MMashDiv

Deposits and confirmations

How a customer's XMR deposit address comes into existence, how the addon detects an incoming transfer without an explorer, the one wallet RPC every backend process shares under a lock, the identity and ownership checks that refuse a wrong credit, the six-confirmation rule, and the monitor lifecycle.

18 min readUpdated 1 October 2026deposits, confirmations, wallet-rpc, monitoring, subaddresses, session-lock

Monero deposit detection has no explorer, no webhook and no address index. The only thing on earth that can tell you a transaction belongs to an address is a wallet holding that address's view key. So the addon does the only thing it can: it keeps a wallet file per customer and asks each one, repeatedly, whether anything arrived.

Everything below follows from that.

One wallet file per customer wallet

The first time a user opens the XMR deposit page, the platform finds or creates their ECO wallet for XMR and then, because the address map has no XMR entry yet, calls create_wallet on monero-wallet-rpc with the ECO wallet's UUID as the filename. It reads back the primary address and the mnemonic, then closes the file, opens it again by name and asks again. Only if both answers match does it encrypt the mnemonic with the Ecosystem vault key and write the address map entry and a wallet_data row. A mismatch stores nothing, so a customer can never be handed another wallet's address.

GET/api/ecosystem/wallet/{currency}
Returns the user's ECO wallet for a currency, creating and backfilling addresses as needed

If the file already exists — an earlier attempt created it and then failed before anything was stored — create_wallet answers "already exists", and the platform adopts that file once it has proved it the same way, and against the address already recorded for the wallet if there is one. For master_wallet that adoption is logged as an ALERT, because it brings back keys an operator may have meant to retire.

So your --wallet-dir accumulates one wallet per customer who has ever viewed the XMR deposit page. That is the design, and it is why the wallet directory is the single most important thing to back up on this chain — there is no HD seed these can be re-derived from.

Every operation uses account_index: 0. The addon never calls create_address, and it never generates an integrated address with a payment ID. A user gets one address, permanently, and it is a standard address beginning with 4 on mainnet.

This is why the model is one wallet per user rather than one wallet with many subaddresses: subaddress accounting would need a payment-ID or index mapping the platform does not keep.

One wallet RPC, shared by every process

monero-wallet-rpc holds one open wallet at a time, and every wallet call — get_transfers, get_balance, transfer — is answered by whichever wallet is open when it arrives. The default deployment runs two backend processes that both drive it: the web process (backend in PM2: the live deposit monitor, new withdrawals as they are requested, wallet creation and the admin's balance reads) and the cron process (cron: the background deposit scanner, the withdrawal watchdog — which runs every retry, and any withdrawal still waiting three minutes after it was requested — and the dormant-wallet refresh). A pool-backing send runs in whichever process dispatched the settlement — the cron process for the settlement job, the web process when an administrator presses Settle now.

So every session — open a wallet, prove it, read or send, close it — holds one Redis key for that wallet RPC, across every process:

Part What it is
Key xmr:wallet-rpc:<host:port>:session, where host:port comes from XMR_WALLET_RPC_URL
Value <session number>:<hostname>:<pid>:<random id> — who holds it
Taken with SET NX, expiring after 30 seconds
Renewed every 10 seconds, only while the key still holds this session's value
Released by deleting it only if it still holds this session's value
Session number xmr:wallet-rpc:<host:port>:fence, incremented on every attempt to take the lock, so numbers only ever grow

A session whose renewal fails, or that has not renewed for 25 seconds, is marked lost and sends nothing more; no session may run longer than 30 minutes. If Redis is unreachable, no session starts at all — no deposit is checked and no withdrawal is sent until it is back. If a wallet call times out on the backend's side, the wallet RPC may still be running it, so the session keeps the lock until the wallet RPC answers get_version again (at most 15 minutes, with an ALERT if it never does) before it lets the next session in.

Each kind of session waits a different time for the lock:

Session Waits at most If it does not get the lock
Live deposit check 2 minutes That round is skipped — neither a retry nor an empty check
Background scan of one wallet 60 seconds Nothing is read; the scanner comes back to the wallet on its normal schedule
Withdrawal 90 seconds Requeued, never failed — see Withdrawals
Pool-backing send 20 minutes Refused, and marked as never broadcast for the pool-backing engine
Pool backing's balance reads, before a send 2 minutes The settlement stops there, unsent, and is marked as never broadcast
A customer's XMR wallet creation, on their first visit to the deposit page 2 minutes per attempt; a customer with no XMR wallet yet gets two attempts, one after the other No address is issued. The page's wallet request fails with HTTP 500 "Failed to generate any addresses for the wallet" once every attempt has been refused; XMR_SESSION_BUSY is only in the backend log, on the WALLET_CREATION and WALLET lines. Opening the page again tries again
Creating the XMR master wallet, reading its transaction history 2 minutes The request fails with XMR_SESSION_BUSY
Admin balance reads of master_wallet 2 minutes The read is skipped and the screen keeps the balance last stored
Dormant-wallet refresh Does not wait Skipped; tried again later

Those are waits for the lock, not for an answer. Each backend process also runs its own Monero sessions strictly one at a time, and a session first waits its turn in that process's queue. A withdrawal's 90 seconds covers that wait as well. Every other session waits there without a limit, and the time in the table starts only when it reaches the front.

A customer's first visit to the XMR deposit page shows it most. The wallet creation is queued in the web process behind whatever Monero work that process already has — live deposit checks, or a withdrawal, whose session can last fifteen minutes on the refresh of a dormant wallet alone. So the request can take far longer than the four minutes that two refused attempts add up to, and it then outlasts the reverse proxy in front of the backend: with the configuration on Nginx, proxy_read_timeout ends it after 300 seconds and the page gets a 504 rather than the 500. The backend does not abandon the creation when that happens. It stays queued and runs when its turn comes, and if it succeeds the address is there the next time the page is opened.

The lock is not trusted alone. Something that ignores it — an older build of the backend, a hand-made curl, another program — can still swap the open wallet. So every session also proves what it is talking to:

  • Identity. Straight after open_wallet, get_address must return exactly the XMR address recorded on that wallet's row (for master_wallet, on the XMR master wallet row). It is asked again immediately before every get_transfers, get_transfer_by_txid, get_balance, transfer and relay_tx. Any difference is refused as XMR_WALLET_IDENTITY — logged as WALLET IDENTITY MISMATCH wallet=… expected=… got=… fence=… — before a single read or spend. A wallet row with no recorded XMR address is refused before anything is opened.
  • Ownership. Every entry in a deposit check's transfer list must name one of the proved wallet's own account-0 addresses, and so must the record fetched for the transfer being credited. One entry that does not is refused as XMR_OWNERSHIP_MISMATCH — logged as OWNERSHIP MISMATCH wallet=… expected=… got=… txid=… fence=… — and nothing proved in that session is credited.

Two consequences for you:

  • Restart every backend process together after an update — with the default PM2 file, pm2 restart backend cron. An old build left running beside a new one does not take the lock. Every process must also name the wallet RPC the same way in XMR_WALLET_RPC_URL, because the key is built from that host and port: localhost:18083 and 127.0.0.1:18083 are two different locks.
  • Never drive the live wallet RPC by hand. An open_wallet or close_wallet of yours swaps the wallet under whatever session is running, and any other wallet call answers from whichever customer's wallet happens to be open. To look inside a wallet, copy its files and open the copy with a second monero-wallet-rpc on another port — Troubleshooting has the commands.

Detection: two paths, one lock

Every check is one wallet-rpc session: open → prove the file → refresh → get_transfers → check every entry → prove each spendable transfer → close. Crediting is database work, so it happens after the session has closed the wallet and let the next session in — and not at all if anything in the session named another wallet. Two things schedule those checks.

The live session monitor

When a user is on the deposit page, the frontend holds a WebSocket to the Ecosystem deposit endpoint, served by the web process. That registers a Monero monitor for their wallet, which joins a global monitoring queue in that process.

The processor then loops: it collects every monitored wallet whose last check was more than five seconds ago, works through them in batches of three (sequentially — the wallet RPC cannot parallelise), and sleeps two seconds between passes. Each check calls get_transfers with in: true and pending: true, and with no height filter — so the in list it gets back is the wallet's whole incoming history, not only what is new. The request never sets pool: true, which is where monero-wallet-rpc lists incoming transfers that are still in the mempool: a deposit becomes visible with its first confirmation, not before.

Every check also leaves two short-lived Redis markers, refreshed while it waits for the lock: xmr:monitoring:<walletId>, which keeps the background scanner in the cron process off that wallet, and xmr:monitoring-active, which stops any dormant-wallet refresh from starting while any wallet is being live-monitored.

The refresh before get_transfers is not optional and not cosmetic. A freshly-opened wallet answers from its on-disk cache, so without an explicit refresh the wallet reports the world as it was at its last sync — which is how a wallet with funds in it reports a zero balance.

The background scanner

Deposits do not stop arriving when the user closes the tab. Ecosystem's background deposit scanner, which runs in the cron process, keeps a working set of deposit addresses in Redis and sweeps them on a rate-limited schedule.

An XMR address joins the scanner's active list when the user opens the deposit page or opens their XMR wallet on the Wallets page, and stays on it for 72 hours after the last such visit (ECOSYSTEM_SCAN_ACTIVE_TTL_MS). Signing in and the wallet list put a customer's other addresses on the slower passive list, but not XMR: it is excluded from that list by default (ECOSYSTEM_SCAN_PASSIVE_SKIP_CHAINS), because the scanner's Monero lane can afford only one pass every twenty seconds.

Customers' addresses are not the only ones on the active list. Pool backing registers its treasury's XMR address there when a settlement into the treasury is dispatched, and again each time it checks one that has not arrived. With Instant Convert installed, the Convert house's deposit addresses are registered too — its XMR address included, once the house holds an XMR wallet — and that addon registers them again on a schedule, so they never drop off.

Monero's rate is deliberately the slowest of any chain: 0.05 passes per second, one wallet sweep every twenty seconds at most, because each pass takes exclusive possession of the single wallet RPC and would otherwise starve live sessions. A sweep is a no-op while a live monitor in any process is checking that wallet, and crediting is idempotent, so the two paths cannot double-credit.

ECOSYSTEM_BACKGROUND_SCAN="false" turns the scanner off platform-wide. Do not do that on an install with Monero enabled — it is the only thing that catches a deposit made after the deposit page was closed.

The six-confirmation rule

A transfer is credited when confirmations >= 6 and the wallet RPC no longer reports it as locked. Nothing lower is credited, ever, and there is no setting that changes the number.

The second condition matters on this chain. A sender can attach an unlock_time to a transfer, and the daemon then refuses to spend those outputs until that height or time is reached, however many confirmations they carry. Until Monero 6.1.8 the monitor read only the confirmation count, so such a transfer was credited the moment it reached six confirmations and the customer could trade or transfer a balance the platform could not yet move. The monitor now keeps polling a confirmed-but-locked transfer and credits it when the locked flag clears and the unlock time has passed.

Coinbase outputs are never credited. The wallet RPC lists a mined output — a solo-mined block, or a P2Pool payout, which anyone can point at a customer's address — in the same incoming list, typed block. It is the wallet's own output, but it is not a deposit: it is skipped, and it does not keep a monitor running while its lock runs down.

Monero blocks target two minutes, so six confirmations is roughly twelve to twenty minutes after the transaction is mined — longer if it sat in the mempool first. Tell your support team that number, because "my deposit is missing" at the eight-minute mark is the most common false alarm on this chain.

Below six confirmations the transfer is not ignored. Each time the confirmation count changes, the addon broadcasts a pending update over the deposit WebSocket carrying the transaction hash, the amount, the fee, the current confirmations and the required six, with status PENDING. That is what drives the progress the user sees. It is a live update only: no transaction row exists until the deposit is credited.

The counter starts at the first confirmation. An incoming transfer that is still in the mempool is not in the lists the check asks for (see The live session monitor above), so the deposit screen shows nothing for it until it is mined — on average a couple of minutes after the sender broadcast it, sometimes much longer.

What the check's pending list does hold is the wallet's own outgoing transfers that are not mined yet — in practice, a withdrawal that customer has just had relayed. If their wallet is being live-monitored at that moment, the entry is broadcast to the deposit screen as a zero-confirmation pending update — again on every check, because a count of zero never registers as already sent — and it keeps the monitor running until the withdrawal is mined. After that no further update comes for it, so an open screen keeps showing zero confirmations until it is reopened. Nothing is ever credited for it: only an in entry, proved again by get_transfer_by_txid as an incoming transfer, can be credited.

What a credited deposit records

Once six confirmations are reached and the transfer is spendable, the addon fetches the transfer's own record with get_transfer_by_txid, checks it names one of the proved wallet's addresses, and — after the session has ended — writes one transaction:

Field Value
type DEPOSIT
status COMPLETED — written directly; there is no PENDING row while a deposit confirms
trxId The Monero transaction id
amount The transfer's piconero amount divided by 10^12, to eight decimal places
fee 0, always. The network fee of the sender's transaction is read from the wallet RPC but not stored on the deposit row
description "Deposit of … XMR from N/A"
metadata.chain XMR
metadata.from N/A
metadata.to The exact address the wallet RPC says received the transfer
  1. A Tron deposit appears already COMPLETED
  2. Amount and fee are DECIMAL(36,18) and arrive as strings

Two of those rows deserve a note.

from is N/A and always will be. Monero transactions do not reveal a sender. If your compliance process expects a source address on every deposit, this chain cannot supply one — decide that before you enable it, not during an audit.

Amounts are stored to eight decimal places although Monero has twelve. The last four digits of a piconero amount are not retained on the transaction record. In practice this only matters for dust; it is not a rounding of the customer's credited balance beyond the eighth place.

The wallet-rpc session that proved the deposit is named in the XMR log line written when the deposit is handed over for crediting ("Transaction … processed successfully (session N)"); it is not stored on the transaction row. That line alone does not prove a credit. It is also written when the credit could not be made at that moment and the deposit was parked for Ecosystem's verification job to book — "Storing … as pending for verification worker", under the DEPOSIT tag — or was refused as already credited. The line that records a credit is the DEPOSIT one: "Deposit … processed and broadcast immediately", or, for a parked one, "Transaction … fully processed and removed from pending".

Crediting is deduplicated three times: an in-memory walletId-txid key with a thirty-minute expiry; a check for any existing DEPOSIT row with the same trxId and the same walletId, whatever its status — so a deposit an operator rejected, or one reversed after a wrong credit, is never credited again; and the ledger credit's own idempotency key, eco_deposit_<txid>_<walletId>. The pairing with walletId is what makes Monero's pay-to-many work — one transaction can pay several of your customers at once, and each wallet must credit it independently.

Monitor lifecycle

The monitor is not tied to the WebSocket session, and this surprises people.

A monitor stops when one of three things happens:

  • Its lists are empty. Three consecutive checks whose in and pending lists are both empty, and the wallet has been monitored for at least ten minutes. Both conditions are required; the ten-minute floor exists because a wallet that has not finished syncing legitimately reports nothing. Because the in list is the wallet's whole incoming history, this stop only ever ends the monitor of a wallet that has never received anything. A wallet with any earlier deposit — credited long ago, or a coinbase output — never returns an empty list, and ends one of the two ways below.
  • It reaches the check cap with nothing in flight. 120 checks since the customer last opened the deposit page. With a check every five seconds plus the time each session takes, that is somewhere past ten minutes on an idle wallet RPC and longer on a busy one.
  • It hits the wall-clock cap. Ninety minutes, unconditionally.

The check cap only applies when nothing is in flight. While a deposit is visible and still confirming, or confirmed but still locked, the monitor keeps running past it until the deposit is credited or the ninety-minute ceiling — because a monitor that stopped at the cap would leave a transaction to confirm with nobody watching, and only the background scanner could still credit it.

A round the session machinery refused — the wallet RPC busy past the two-minute wait, Redis unreachable, a lost lock, an identity or ownership refusal — says nothing about the wallet. It is not counted as a retry or as an empty check (an identity or ownership refusal also resets the run of empty checks), though the ninety-minute cap still applies. A wallet whose row has no recorded XMR address stops being monitored at once, since no later round could prove its file either.

Closing the deposit page does not stop a Monero monitor. Other chains implement a polling stop that the WebSocket teardown calls; the Monero monitor does not, so it runs its own course. That is the correct behaviour here: the user usually closes the tab long before six confirmations.

Background staleness refresh

Separately from any deposit, the addon keeps dormant wallets from drifting too far behind the chain. It picks the single most stale XMR wallet with a positive balance and a recorded address, round-robin, and refreshes it at low priority with a ten-minute budget. "Stale" means six hours since its last refresh.

It runs only where the scheduler runs — the cron process of the default deployment, or the single process of an inline one. Each process says which when its Monero monitoring loop first runs: "Dormant-wallet background refresh runs in this process" or "…is left to the cron process (CRON_MODE=off here)". Even there, a refresh starts only when:

  • that process's monitoring loop is idle — no monitored wallets, nothing queued, no daemon back-off;
  • no wallet is being live-monitored in any process (the xmr:monitoring-active marker), checked again when the session starts;
  • at least three minutes have passed since the last refresh started or finished — or, for the first one, since the loop first came to this step idle;
  • the wallet RPC is free right now. A refresh never waits for the lock; if it is busy, the next attempt comes thirty seconds later.

The loop does not exist until that process has used Monero. The cron process never registers a live deposit monitor, so its loop starts only when its Monero service is built — the first time something in the process uses the chain — and only if the chain is active at that moment. In the cron process that first use is whichever of these comes first:

  • An XMR withdrawal the cron process runs. It picks up PENDING rows three ways: the sweep it makes once at boot, which takes every waiting row whatever its age; the five-minute watchdog; and a thirty-minute job that runs the same pass as the watchdog. So if an XMR withdrawal is waiting when the cron process restarts, the service is built straight away.
  • The deposit scanner sweeping an XMR address on its active list — a customer's, for 72 hours after they last opened the XMR deposit page or their XMR wallet, or the Convert house's on an install with Instant Convert. This needs the scanner: with ECOSYSTEM_BACKGROUND_SCAN="false" no address is registered and nothing is swept.
  • Pool backing reading or sending XMR.

So after a restart the first dormant refresh can come much later than three minutes, and on a quiet install — no XMR address on the scanner's list, nothing queued, no pool backing — it does not run at all. The "Dormant-wallet background refresh runs in this process" line in the cron log is the sign that it is armed.

Opening the XMR deposit page once puts an address on the scanner's list, and the scanner's next sweep of it builds the service — provided the scanner is on, and the chain is active in the cron process at that moment. If the chain was inactive there — the wallet RPC was down when the sweep came, say — the service is built without the loop, and only a restart of the cron process starts it.

The point is latency at the moment it matters. A withdrawal must refresh the customer's wallet file before it can spend from it, and that refresh has to catch up on every block since the file was last synced — which is why a withdrawal on a wallet this process has not refreshed in six hours is given fifteen minutes for it. Without the background refresh, the first withdrawal from a long-dormant wallet would do all of that catching up with a customer watching.

Know its limits before you rely on it:

  • It is slow by design. One refresh, then at least three minutes before the next one starts, counted from when the previous one ended. That is at most twenty wallets an hour — about 120 in the six hours a refresh counts as fresh — and fewer when refreshes are long. An install with more funded XMR wallets than that cannot keep them all within six hours; the loop simply keeps taking the stalest one.
  • A customer on the deposit page stops it. While any wallet is being live-monitored it does not start, and a monitor runs for ten minutes or more after the page was opened. On a busy install it gets few turns.
  • Its memory is per process and does not survive a restart. "Refreshed in the last six hours" is tracked in memory by the process that did the refresh, so after a restart every wallet counts as never refreshed, and the first refresh waits at least three minutes from the moment the loop exists (see above).
  • It shortens the sync, not the budget of a first attempt. In the default two-process deployment a withdrawal's first attempt usually runs in the web process, which counts only its own refreshes — a live deposit check or an earlier withdrawal of that wallet — so it usually opens with the fifteen-minute budget whatever the cron process has done. What the background refresh changes is how many blocks that sync has to cover.

Operating notes

  • Wallet count is a real capacity number. Every customer wallet is a file and every check is an exclusive session on the one wallet RPC, whichever process runs it. A few thousand wallets is fine; the serialisation, not the disk, is what eventually binds.
  • Deposits stop the moment the daemon does. The wallet RPC can be perfectly healthy and still return nothing, because it cannot sync. Monitor monerod, not just the wallet RPC. What deposits depend on is the wallet RPC's own connection to the daemon, through its --daemon-address: a deposit check sends nothing to monerod itself. The backend's connection to the daemon prices and checks withdrawals, so if only that path is broken, deposits keep crediting while new withdrawals are refused.
  • Deposits also stop while Redis is down. No session starts without the lock.
  • XMR is the log tag. Every line the service emits carries it, in the web process's log and the cron process's. See Troubleshooting for what to grep for.