Ecosystem payouts have stopped — master wallet balance and gas
Diagnose and fix on-chain withdrawals failing for want of native gas — where the master wallet balance is shown, why a drained wallet still reads healthy, and what the queue refunds versus what it strands.
Every ecosystem (on-chain) withdrawal is paid by a master wallet you have to keep funded with native coin. The platform holds the key; the coin is yours to top up. When a chain's master wallet runs dry, withdrawals fail one at a time with a gas error buried in the log, the customer is refunded, and there is no banner on the dashboard that says the chain is out of gas.
This runbook is the diagnosis and the fix. If payouts have stopped across every
chain at once, this is probably not your fault line — check
the scheduler console first, because a stopped cron app
produces the same symptom on all chains simultaneously.
Every recovery path in this runbook is guarded so that a withdrawal whose coins have already left the wallet is never marked FAILED and never refunded. Do not work around those guards by editing rows by hand — a refund on top of a broadcast transaction pays the customer twice, from your float.
Symptom
- On-chain withdrawals on one chain fail while every other chain is fine.
- Customers report the withdrawal was "cancelled" and see the money back in their
balance. The transaction row is
FAILEDwith a description that endsTransaction failed: Failed to estimate gas. pm2 logs cronandpm2 logs backendcarryWITHDRAWandWALLETlines around each attempt.- Token withdrawals on that chain fail; native-coin withdrawals may still work.
That last point is the tell. The master wallet is the gas payer: it signs and pays for the transfer of a token held in a customer's custodial wallet. A chain whose only active assets are native never touches it.
Where the balance is shown
-
Admin → Ecosystem (
/admin/ecosystem) — the overview.The gas signal is the caption of the Chains Not Ready tile, not a tile of its own. The tile's number counts blocked and degraded chains; its caption reads "{count} with an empty gas payer" whenever any chain is flagged
gasEmpty, and "of N chains carrying custody" when none is. So a chain can be out of gas while the tile still shows zero — read the caption, not the figure. The tile clicks through to the blockchain list.Below, the chain table's Gas column shows each chain's master-wallet balance: the figure turns warning-coloured with a fuel icon and the words Out of gas for any chain flagged
gasEmpty.A chain is flagged only where the master wallet actually signs — EVM and custom EVM chains with at least one active non-native token. That is deliberate; flagging a native-only chain would be noise.
-
Admin → Ecosystem → Wallets → Master Wallets (
/admin/ecosystem/wallet/master) — the list itself.Columns:
chain,currency,address,balance(8 decimal places),lastIndexandid. This is where you copy the address to send coin to. -
Admin → Ecosystem → Wallets → Custodial Wallets (
/admin/ecosystem/wallet/custodial) — the per-user wallets.Columns:
address,chain,network,status,masterWalletId,createdAt. Each custodial wallet names the master wallet that derived it, so this is how you confirm which master wallet a given customer's payout depends on. The row is viewable and editable; it is not deletable.
The balance refresher fetches each master wallet's live balance and writes it
back — unless the figure it read is exactly zero, in which case it returns
without writing and without caching. A master wallet that drains completely
therefore keeps showing its last non-zero figure in the balance column and on
the overview indefinitely.
So a chain can be genuinely empty while the table shows a small positive number,
and the gasEmpty flag (which compares that stored number against the floor) will
not fire. Read the balance on a block explorer, not from this column, when you
are diagnosing a gas failure. The address is in the address column.
The refresher also caches per wallet in Redis under wallet:{id}:balance for five
minutes, so a top-up you just made can take that long to appear even when it does
get written.
The two endpoints with no screen
Both of these exist and answer; neither is called by any admin page. Reach them with an API key or from the server.
The transactions endpoint is the fastest way to prove a top-up landed, and to see what the gas payer has actually been spending, without leaving the platform.
Two funding actions, also API-only
A custodial wallet row can be funded directly. Both routes sign with the master wallet's key, so both fail for the same reason a payout does when there is no gas.
recipient and amount in the smallest unit.Nothing in the admin panel calls either route — the custodial wallet screen is a
plain data table with no transfer action. They are documented here because they
are the only in-platform way to move funds off a specific custodial wallet, and
because they are gated on access.ecosystem.custodial.wallet: anybody who can
open the custodial wallet list can also call them.
How a payout actually fails
The chain of events for a token withdrawal on an EVM chain:
- The withdrawal queue picks the row up and flips it
PENDING → PROCESSING(guarded, so two workers cannot both own it). getGasPayer(chain, provider)loads the chain's master wallet, decrypts its private key and builds a signer. All three ways it can fail — no master wallet for the chain, a wallet row with nodata, or a decryption failure — sit inside onetrywhosecatchre-throws the same generic 500 "Withdrawal failed - please try again later". Which one it was is only in the log, asMaster wallet not found for chain: XorMaster wallet data not found for chain: XunderWALLET. Do not go looking for those strings in an API response; they never leave the server.getAdjustedGasPrice(provider)reads the network fee data and pads it by 20%. On failure it logsFailed to adjust gas priceand throws "Failed to adjust gas price".estimateGas(transaction, provider)simulates the transfer and pads the estimate by 20%. This is the one that fires when the wallet is dry. It logsFailed to estimate gas for transaction, logs the revert reason separately underGASwhen the node returns one, and throws "Failed to estimate gas".- The queue catches it, marks the row
FAILEDand refunds the customer.
Every one of those is a generic 500 by design, so the customer-facing failure email says nothing about your gas balance. The distinguishing detail is in the log:
pm2 logs cron --lines 300 | grep -iE "WITHDRAW|WALLET|GAS|ECOSYSTEM"An estimate failure is usually insufficient native balance, but not always — a
paused token contract, a blocked address or a bad allowance produce the same
message. Read the revert-reason line under GAS before you conclude it is gas,
and confirm the balance on the explorer.
What the queue does with a failure
The recovery logic is careful, and knowing which branch you are in tells you whether the customer has their money back.
| Branch | When | Result |
|---|---|---|
| Fail and refund | The ordinary case, including a gas failure | Row set FAILED, wallet refunded, failure email sent, in-app notification created |
| Ownership collision (409) | The guarded PENDING → PROCESSING flip affected 0 rows — another worker already owns it |
Dropped from the local queue with no state change. Never failed, never refunded — the owner may be mid-broadcast |
| Not found (404) | The row or its ECO wallet vanished from this process's lookups | No state change. Failing here would mark it FAILED without refunding |
| Requeued | The handler deliberately deferred (for example XMR funds still protocol-locked) | Left PENDING for the watchdog to retry. No email, no refund |
| Unknown status | Broadcast happened but could not be confirmed | Set TIMEOUT, admin notified, not refunded. Needs a human |
The refund path itself is guarded twice. Before writing FAILED it re-reads the
row and refuses outright if the status is already COMPLETED or a trxId is
present; then the FAILED write itself carries where: { trxId: null }, so a
withdrawal that broadcast between the read and the write is still protected. After
the write it re-reads once more and skips the refund if a hash appeared. The
refund is idempotent on its own key (eco_refund_<id>), so a duplicate attempt
throws and is logged rather than paying twice.
What recovers an orphaned row
Two things sweep for withdrawals that were debited and then orphaned:
recoverEcoWithdrawalsAtBoot()runs once at startup, on the scheduling process only, before the cron workers begin. It sweeps every pending ecosystem withdrawal with no age filter, so rows orphaned by a previous process lifetime are picked up in order. It is bounded: past a deadline the server carries on starting and the sweep finishes in the background, so an unreachable explorer cannot hold the boot. Gated on theecosystemextension being enabled, so disabling the addon really does stop it.ecosystemWithdrawRecon(every 5 minutes) re-enqueues rows created more than three minutes ago that the in-memory queue is not tracking — the crash between the wallet debit and the queue draining.processPendingEcoWithdrawals(every 30 minutes) runs the same sweep as a second net; the two share one in-flight pass so they cannot double-probe the same rows.
A stale PROCESSING row that already carries a broadcast hash (trxId) is
promoted straight to COMPLETED with no re-broadcast and no chain lookup —
the hash only ever gets written after a successful broadcast, so it is itself the
proof that the coins left — and its UTXO inputs are settled.
A PROCESSING row with no hash is left alone until it is five minutes old
(STALE_PROCESSING_MS) — before that a live worker may still own it. Past that,
it is handled by chain, and the two cases are not alike:
- UTXO chains (BTC, LTC, DOGE, DASH). These handlers persist a pre-broadcast
intent in
txHashPending, so the sweep has something to ask the chain about. If that transaction is found, the row is promoted toCOMPLETEDwith no re-broadcast. If it is not found, the sweep waits 30 minutes before concluding it was never broadcast — provider lag is not evidence — and only then reverts it toPENDING, guarded ontrxId: null. A UTXO row that never got as far as writing an intent has nothing to check and is reverted straight away. - Every other chain (EVM, SOL, TON, TRON, XMR) — the chains this runbook is
about. There is no pre-broadcast intent to check, and the hash is written
only after the network accepts the transaction, so a crash in that window
leaves a row that may already be on-chain. The sweep never reverts it. It
logs
Cannot verify broadcast on-chain for this chain — NOT auto-reverting (double-broadcast risk). Manual review required.and moves on, leaving the rowPROCESSINGfor the review queue below. Nothing automated will ever clear it.
UTXO chains behave differently
BTC, LTC, DOGE and DASH withdrawals are batched: several withdrawals ride one
on-chain transaction. A lone withdrawal is parked for a batching window so
companions can join it, and a same-chain parked companion is released to ride with
it. hasQueuedUtxoCompanion() is the check that decides whether to park or go now.
Failure isolation, in order:
- One member fails identifiably → that member alone is failed and refunded, and the rest are retried as a batch.
- The whole batch fails with more than one member → every member is retried individually, and each result is settled per member. A member that already carries a broadcast hash is skipped and left for the recovery sweep.
- A single-member failure is classified exactly as a non-batched one.
A partial batch failure is reported per member, not rolled back. So on a UTXO
chain you can legitimately see one withdrawal FAILED and refunded while three
others from the same attempt completed. Post-broadcast side effects (the
confirmation email and the platform-fee record) are run outside every retry
boundary and never throw — a failed email after a successful broadcast must not
re-enter a path that would re-broadcast.
Fix it
-
Confirm it is gas, on the explorer. Copy the address from
/admin/ecosystem/wallet/masterfor the failing chain and read its live native balance. Do not trust thebalancecolumn for this — see the warning above. -
Send native coin to that address. From an exchange withdrawal, or any wallet you control. The
currencycolumn names the coin — it is the chain's native asset, not the token being withdrawn. -
Give it a block or two, then refresh the stored figure.
curl -s -H "Authorization: Bearer $ADMIN_TOKEN" \ http://127.0.0.1:4000/api/admin/ecosystem/wallet/master/balance | head -c 500Or simply wait: the value on the overview updates when something writes it, and the per-wallet Redis cache expires after five minutes.
-
Retry the affected withdrawals. They were failed and refunded, so the customer has to submit again — there is no admin "retry" that re-runs a
FAILEDecosystem withdrawal. Rows that are stillPENDINGare picked up byecosystemWithdrawReconwithin five minutes with no action from you. -
Clear the review queue. Anything that ended
TIMEOUTwas not refunded and needs a decision — and so does anything stillPROCESSINGwith no hash on a non-UTXO chain, which no sweep will ever resolve. See below.
The review queue
The Ecosystem overview counts payouts that need a human and ranks them so the rows nothing will ever resolve on its own come first:
| Reason | Means | Safe to retry? |
|---|---|---|
unknown-outcome |
Broadcast happened, outcome could not be confirmed. Status TIMEOUT. |
No. Establish on the explorer whether the coins moved. |
stranded |
PROCESSING for more than 5 minutes with no transaction hash, on a non-UTXO chain |
No, and nothing else will either. The sweep refuses to touch exactly these rows — it cannot prove the transaction was never broadcast, so reverting would risk paying twice. Establish on the explorer whether the coins moved. |
broadcast |
A hash exists | No. The coins have left. |
in-flight |
A worker may still own it | Wait. |
stranded and unknown-outcome are the two the page counts as needing a person,
and they are ranked to the top of the queue for that reason. Read stranded as
"nothing will ever move this row but you" — it is not a row waiting on a retry.
A stale UTXO row is deliberately not labelled stranded; it stays in-flight,
because on those chains the recovery sweep can verify it against the chain and
resolve it without help.
The 5-minute stranding threshold mirrors the withdrawal queue's own
STALE_PROCESSING_MS, so the page and the watchdog agree about which rows are
abandoned.
The decisions themselves are taken on Admin → Finance → Withdrawals
(/admin/finance/withdraw/log), which accepts an Approve or Reject on rows in
PENDING and PROCESSING — a payout stuck mid-flight is exactly the row that
needs resolving.
Set a floor so "empty" is not the alarm
The gasEmpty flag compares the stored balance against a floor that defaults to
zero. Zero is a fact; anything else is a guess, and a good guess is per-chain —
0.01 is generous on Polygon and nothing on Ethereum. The result is that by default
you are told a chain is out of gas only once it is completely out, which is one
failed withdrawal too late.
Set your own floor in .env:
The value in force is echoed back in the dashboard payload's thresholds.gasFloor,
so the page can say which floor it is applying.
The daily check
- Open Admin → Ecosystem. Read the caption under Chains Not Ready: if it says "N with an empty gas payer", that N is today's work. The tile's own number counts blocked and degraded chains and can be zero while N is not.
- Scan the chain table's Gas column for Out of gas markers, and for any figure below one working day of payouts on that chain.
- Check the payouts-waiting-on-a-person count. Nothing should age past a day.
- Once a week, read a chain's master wallet transaction history through the endpoint above and confirm the burn rate matches your expectation. A sudden rise is either volume or a fee spike; both change how often you top up.
Key management, and what it cannot do
The master wallet's private key is stored encrypted. The vault is unlocked by
supplying the passphrase, which decrypts the key held in ENCRYPTED_ENCRYPTION_KEY
using ENCRYPTION_KEY_PASSPHRASE-derived material and holds the result in memory
for the life of the process.
When both .env values are present the vault unlocks itself at boot. When they
are not, the Ecosystem overview shows a destructive The vault is locked alert
with an Initiate vault button, and nothing signs on any chain — no withdrawal
completes and no new deposit address is derived.
passphrase. Returns 500 "Failed to set encryption key" when the passphrase does not decrypt the stored key.It can: unlock the vault for the running process by supplying the passphrase, so signing resumes without a restart.
It cannot: rotate a key, change a passphrase, export a private key, or
re-encrypt anything. There is no rotation screen and no rotation endpoint. The
encryption key is set once, at install, and lives in .env.
The unlock is per process and in memory only. Restarting the backend re-locks
the vault unless .env carries both values, and unlocking through this route does
not persist anything. Note also that on the standard three-app deployment the
cron process is a separate process — unlocking through the admin panel reaches
the web process, not the scheduler.
Losing ENCRYPTED_ENCRYPTION_KEY or ENCRYPTION_KEY_PASSPHRASE is an
unrecoverable loss of every custodial wallet key. Back them up outside the
database — see Backup and restore.