Withdrawals
How an XMR withdrawal is priced, why the network fee comes out of the customer's amount, what happens when outputs are still protocol-locked, and the two states that are deliberately never refunded.
An XMR withdrawal is signed by the customer's own wallet file, not by a platform hot wallet. There is no gas payer on this chain: Monero's network fee is paid out of the same outputs being spent. The master wallet's only role in a withdrawal is to be the destination for your platform fee.
That has a consequence you should decide on before you enable the chain: your
platform's XMR revenue accrues into master_wallet, one withdrawal at a time,
in the same transaction that pays the customer.
The money flow, exactly
The user asks to withdraw amount. Their ECO wallet is debited
amount + withdrawalFee, where withdrawalFee is the number you configured on
the XMR token record. That is the only debit — the Monero network fee is never
added on top of it.
Then, when the queue picks the row up:
| Quantity | Where it comes from | Where it goes |
|---|---|---|
amount − networkFee |
The user's wallet | The destination address |
networkFee |
The user's wallet, out of amount |
Monero miners |
max(0, withdrawalFee − networkFee) |
The user's wallet | The XMR master wallet, as a second destination in the same transaction |
Read the third row again. The platform's profit is your configured fee minus
the actual network fee. Set the token's withdrawal fee below what Monero
charges and the platform earns nothing on every withdrawal — silently, with no
error anywhere. Set it well above and the difference is transferred to
master_wallet on each send.
The wallet must therefore hold amount + adminProfit in unlocked balance,
not merely amount. The admin destination is spent from the same outputs.
If there is no enabled ecosystem_master_wallet row for chain XMR and the
token's currency, the addon logs a warning, sets the admin profit to zero and
sends the withdrawal anyway. Customers are unaffected. You simply collect
nothing, on every withdrawal, until somebody notices.
Create the XMR master wallet during install, and check it is status: true.
Two-pass fee calculation
Monero fees depend on the transaction the wallet actually builds, so the addon does not guess twice.
-
Estimate.
get_fee_estimateon the daemon gives a fee-per-byte table; the addon multiplies by an assumed 2,000-byte transaction. This is used only to sanity-check thatamountcan cover a fee at all — a withdrawal smaller than the estimated fee is rejected outright. -
Build a real transaction and do not send it. A
transfercall withdo_not_relay: true, carrying both destinations, returns the exact fee for the exact set of outputs the wallet would spend. -
Recompute with the exact number. The customer receives
amount − actualNetworkFee; the admin destination getswithdrawalFee − actualNetworkFee. -
Relay for real. A second
transfer, this time withoutdo_not_relay, with the final destinations.
The estimate in step 1 is also what the withdrawal form shows the user, via the fee estimation branch in the Ecosystem withdrawal endpoint. It is indicative; the number that lands is the one from step 2.
Address validation
The destination is checked against the prefix set for XMR_NETWORK before
anything is queued — mainnet 4/8, stagenet 5/7, testnet 9/A/B. A
mismatch is a 400 with "Invalid Monero address".
Note that this validates the prefix only. A well-formed string with the right
first character passes validation and fails later at the wallet RPC. And if
XMR_NETWORK disagrees with the network monerod actually runs, every genuine
address is rejected — see
Configuring the RPC connection.
Locked outputs: the hold, not a failure
Monero locks received outputs for roughly ten blocks — about twenty minutes — at the protocol level. This is independent of the addon's six-confirmation rule for crediting deposits, and it is the single most common reason a withdrawal does not go out immediately.
When the wallet's unlocked balance is short of amount + adminProfit, the addon
does not fail the withdrawal. It sets the row back to PENDING with a
description naming the unlocked and total balances — "Funds are locked (x/y XMR
unlocked). Waiting to process." — and hands it back to the withdrawal queue's
watchdog to retry later.
The same treatment applies when the do_not_relay pass itself reports "not
enough unlocked money" while the total balance is sufficient.
The hold is bounded. Two hours after the transaction row was created, a still-locked withdrawal fails cleanly and is refunded rather than being held indefinitely.
The handler does not schedule its own retry. Re-entering through the queue is what guarantees a single owner for the row — a self-scheduled retry could run alongside a queue-driven one and broadcast the payment twice.
The two states that are never refunded
Most withdrawal failures mark the row FAILED and refund the customer. Two do
not, on purpose.
TIMEOUT — the relay response was lost. The final transfer is issued with
exactly one attempt and a 120-second budget. If it times out, or the connection
resets, the transaction may already be on the Monero network. Retrying would
double-spend platform funds; refunding would pay the customer twice. So the row
is set to TIMEOUT with "Withdrawal broadcast status unknown (response lost).
Manual review required", an admin notification is raised, and nothing else
happens automatically.
Resolve it by hand. Open master_wallet or the user's wallet in a Monero client
and look for an outgoing transfer matching the amount and destination around
that timestamp. If it exists, set the row COMPLETED with the transaction hash.
If it does not, refund.
Stale PROCESSING with no transaction hash. The queue's recovery pass finds
withdrawals stuck in PROCESSING for more than five minutes. For UTXO chains it
can check the chain and decide. For Monero — as for Solana, TON, Tron and EVM —
the transaction hash is only written after the broadcast succeeds, so a crash
in that window is indistinguishable from a crash before it. The recovery pass
logs the row and leaves it alone rather than risking a second broadcast. Manual
review, again.
Both of these are correct, and both mean an operator has to look. Watch for them rather than assuming the queue self-heals.
Idempotency and the failure guard
Two guards make the retry paths safe:
- Before sending, the handler re-reads the transaction. If it is already
COMPLETED, or already carries atrxId, it stops. Several drivers can target the same id — the watchdog, a prior attempt — and only one may send. - Every failure update is written with
where { id, trxId: null }. A row that has a transaction hash can never be flipped toFAILED, so a customer whose coins are already on-chain cannot also be refunded. If the update affects zero rows, the handler logs that and treats the withdrawal as done.
Recording the platform fee
After a completed send, the addon records the collected profit as an
adminProfit entry against chain XMR, wallet type ECO, type WITHDRAW,
referencing the transaction id. The generic withdrawal queue deliberately skips
its own profit recording for Monero, because the split between network fee and
platform profit is only known inside the Monero handler.
The description on that record carries all three numbers — total fee charged, actual network fee, profit — which is what you want when reconciling a month of XMR withdrawals against the master wallet's balance.
What to check when a withdrawal is slow
Before escalating, in this order:
- Is the row
PENDINGwith a "Funds are locked" description? Then it is working. Wait; the queue will retry. - Is
monerodsynced? Fee estimation comes from it, and it is the first thing to fail. - Is the wallet's unlocked balance short? Total balance is not the number that matters.
- Has the wallet been dormant for over six hours? The first open then takes up to fifteen minutes of sync before anything else happens.
- Is another operation ahead of it in the queue? Every wallet operation on this chain is serialised through one wallet RPC.