DEV Community

Azalea
Azalea

Posted on

What Happens After You Press Send on a Bitcoin-to-Monero Swap

My last post covered what a transfer flow should do before the user signs. A reader replied with the obvious follow-up: make the settlement state visible before the user thinks the transfer is done. Fair point. Most crypto apps put real effort into the confirmation screen and then hand the user a spinner labeled "Pending".

To show what belongs in place of that spinner, I picked the least convenient everyday case I could find: swapping Bitcoin for Monero. It has two chains with different block times, one leg anyone can inspect and one nobody can, a waiting period after the funds arrive, and a protocol upgrade that is in public testing this week. A status model that copes with this pair has an easy time with a stablecoin transfer.

Key takeaways

  • "Seen", "confirmed", "spendable" and "final" are separate states. On a BTC to XMR swap the gap between two of them can run from minutes to hours.
  • A Bitcoin deposit sitting in the mempool is replaceable by default since Bitcoin Core 28.0, so treat it as information and wait for a block.
  • A Monero payout can't be verified in a block explorer. You need the recipient's wallet, or the transaction ID together with the transaction key and the address.
  • Ten Monero confirmations make funds spendable, which is a weaker promise than final. In September 2025 an 18-block reorg invalidated 115 transactions.
  • FCMP++ is still not on Monero mainnet as of October 6, 2026.

Why this pair is a good stress test

You can't send BTC to a Monero address. Bitcoin keys live on the secp256k1 curve and Monero keys on ed25519, the address formats have nothing in common, and neither chain knows the other exists. A standard Monero address packs two public keys and a checksum into 95 characters. Moving value between the two networks always means two separate transactions with something in between, usually a swap service.

TechBullion recently published a step-by-step guide on how to transfer Bitcoin to Monero that describes the flow from the user's chair: create a fresh Monero subaddress, open an order, send BTC to the deposit address, save the order ID, wait. I read it as a list of promises made to the user. The guide quotes 15 to 60 minutes for the swap on the service it uses as an example and about 20 more minutes before the XMR can be spent. It also names four usual reasons for a slow transfer: a low Bitcoin fee, a congested network, a deposit that doesn't match the order amount, or a compliance hold.

Every one of those sentences is a state the backend has to track and the interface has to show. The two legs behave very differently:

Bitcoin leg (deposit) Monero leg (payout)
Average block interval About 10 minutes About 2 minutes
What an outsider can read Sender, recipient, amount That the transaction exists and how many confirmations it has
How a payment is verified Transaction ID in any explorer or node Recipient's wallet, or transaction ID + transaction key + address
Before the first confirmation Replaceable by default (full RBF) In the pool; the wallet reports a double_spend_seen flag
When received funds can be spent No protocol waiting period Once the output is 10 blocks old

Model the swap as a state machine

A single "pending" status sends every user to support with the same question. An explicit state machine answers it on the order page and gives your own team a shared vocabulary for incidents.

type SwapState =
  | 'awaiting_deposit'
  | 'deposit_seen'        // BTC tx in the mempool, still replaceable
  | 'deposit_confirming'  // in a block, below your confirmation threshold
  | 'exchanging'
  | 'payout_sent'         // XMR tx broadcast, txid known
  | 'payout_confirming'   // in a block, outputs still locked
  | 'spendable'           // the 10-block lock has passed
  | 'on_hold'             // compliance review, user action required
  | 'refunding'
  | 'refunded'
  | 'expired'

const transitions: Record<SwapState, SwapState[]> = {
  awaiting_deposit:   ['deposit_seen', 'deposit_confirming', 'expired'],
  deposit_seen:       ['deposit_confirming', 'awaiting_deposit'], // replaced or dropped
  deposit_confirming: ['exchanging', 'on_hold', 'refunding', 'deposit_seen'],
  exchanging:         ['payout_sent', 'on_hold', 'refunding'],
  payout_sent:        ['payout_confirming', 'exchanging'],        // never mined, send again
  payout_confirming:  ['spendable', 'payout_sent'],               // reorg
  spendable:          ['payout_confirming'],                      // deep reorg
  on_hold:            ['exchanging', 'refunding'],
  refunding:          ['refunded'],
  refunded:           [],
  expired:            [],
}

export function canMove(from: SwapState, to: SwapState): boolean {
  return transitions[from].includes(to)
}
Enter fullscreen mode Exit fullscreen mode

Look at the arrows that point backwards. Each one stands for a real event: a replaced deposit, a payout that never got mined, a chain reorganization. If your model has no backward transitions, it will report "completed" for money that has just disappeared from the recipient's wallet.

On the Bitcoin leg, seen is not confirmed

The deposit is the public half, so tracking it is mostly a matter of discipline about wording.

A transaction in the mempool tells you the user did something, and nothing is settled yet. Since version 28.0, released in October 2024, Bitcoin Core ships with mempoolfullrbf=1 as the default (release notes). Any unconfirmed transaction can be replaced by one that pays a higher fee, whether or not it signaled replaceability. Show "deposit seen" to calm the user down, and start the swap only after the confirmations you require.

With an Esplora-compatible API the check takes two requests. GET /tx/:txid returns the transaction with a status object, and GET /blocks/tip/height returns the current height (Esplora API).

const ESPLORA = 'https://blockstream.info/api'

type BtcDeposit =
  | { state: 'not_found' }   // never broadcast, dropped or replaced
  | { state: 'mempool' }
  | { state: 'confirmed'; confirmations: number }

export async function btcDepositStatus(txid: string): Promise<BtcDeposit> {
  const res = await fetch(`${ESPLORA}/tx/${txid}`)
  if (res.status === 404) return { state: 'not_found' }
  if (!res.ok) throw new Error(`Esplora responded with ${res.status}`)

  // Also check here that a vout pays your deposit address the expected value
  const { status } = (await res.json()) as {
    status: { confirmed: boolean; block_height: number | null }
  }
  if (!status.confirmed || status.block_height === null) return { state: 'mempool' }

  const tipRes = await fetch(`${ESPLORA}/blocks/tip/height`)
  const tip = Number(await tipRes.text())
  return { state: 'confirmed', confirmations: tip - status.block_height + 1 }
}
Enter fullscreen mode Exit fullscreen mode

Two details matter in the interface. Show progress as "1 of 2 confirmations" so the user can see the order moving. When a transaction you had already seen comes back as not_found, say that the deposit was replaced or dropped and tell the user what to do next. The Bitcoin side tends to be the slowest part of a BTC to XMR order, and a low fee can leave the deposit waiting for several blocks. The deposit screen is the right place to say so, before anything is sent.

On the Monero leg, someone has to tell you

On the payout side the public data runs out. A Monero explorer can confirm that a transaction exists and count its confirmations. The amount and the recipient are hidden by design, so "paste the hash into an explorer" is not an answer you can give a user.

There are two ways to verify a payout.

From the recipient's wallet. The wallet scans the chain with its own keys and lists what arrived. In monero-wallet-rpc, get_transfers with in and pool set to true returns each incoming transfer with confirmations, locked, double_spend_seen and suggested_confirmations_threshold. Amounts come in atomic units, and 1 XMR equals 1e12 of them (wallet RPC docs).

From a payment proof. The sender shares three things: the transaction ID, the destination address and the one-time transaction key. Anyone with a synced wallet can then check how much that transaction paid to that address (Monero user guide). Over RPC the method is check_tx_key:

async function walletRpc<T>(method: string, params: object): Promise<T> {
  // Add digest auth if monero-wallet-rpc runs with --rpc-login
  const res = await fetch('http://127.0.0.1:18088/json_rpc', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ jsonrpc: '2.0', id: '0', method, params }),
  })
  const body = (await res.json()) as { result?: T; error?: { message: string } }
  if (body.error || body.result === undefined) {
    throw new Error(body.error?.message ?? 'Empty RPC response')
  }
  return body.result
}

const XMR_LOCK_BLOCKS = 10

export async function checkXmrPayout(txid: string, txKey: string, address: string) {
  const r = await walletRpc<{ received: number; in_pool: boolean; confirmations: number }>(
    'check_tx_key',
    { txid, tx_key: txKey, address }
  )

  return {
    // Atomic units. Parse as BigInt in production: values above 2^53 lose precision
    receivedXmr: r.received / 1e12,
    inPool: r.in_pool,
    confirmations: r.confirmations,
    pastDefaultLock: !r.in_pool && r.confirmations >= XMR_LOCK_BLOCKS,
  }
}
Enter fullscreen mode Exit fullscreen mode

If you run the sending side, three habits save a lot of support time:

  1. Request the key when you send (get_tx_key: true in transfer) and store it with the order. The docs warn that rescan_blockchain throws away anything that can't be rebuilt from the chain, and transaction keys are on that list.
  2. Show the XMR transaction ID on the order page and hand out the key on request, so the user can run the check in their own wallet.
  3. Ask users for a fresh subaddress per order and create one per deposit on your own side (create_address). Reconciliation then becomes a lookup by subaddr_index.

One caveat from the same documentation: a valid proof shows that funds were sent to an address. It does not guarantee that they are spendable. The recipient's wallet has the last word, and get_balance reports blocks_to_unlock and time_to_unlock for exactly that purpose.

Seen, confirmed, spendable, final

Status pages tend to use these four words as synonyms. On Monero the last two are the ones that get mixed up.

Spendable is a consensus rule. A Monero transaction can only spend an output that was included at least 10 blocks earlier, which works out to about 20 minutes at one block every two minutes. An honest order page says "Arrived, spendable in about 14 minutes" and counts down.

Final is a judgment call. On September 14, 2025, the Qubic mining pool pushed an 18-block reorganization onto Monero's main chain. Researcher Rucknium's analysis counts 115 invalidated transactions, while 456 others from the orphaned blocks were mined again on the new chain (Rucknium). The coins in the invalidated transactions went back to the senders' wallets. The 10-block lock only protects against reorgs up to 9 blocks deep, so in that hour "10 confirmations" stopped meaning "done". A month earlier Kraken had already reopened XMR deposits with a requirement of 720 confirmations, roughly a day of blocks (FXStreet).

For a developer this leads to three decisions:

  • Keep the confirmation threshold in configuration, per asset and per amount. The wallet RPC even suggests a number through suggested_confirmations_threshold, which scales with the amount received.
  • Keep polling after spendable. If a payout disappears, first look for the same transaction ID on the new chain, because most transactions survive a reorg. Create a new payout only once you know the original is gone, otherwise you pay twice.
  • Check locked and unlock_time on incoming transfers. According to the Monero blog, merchant software has lost money by assuming that received XMR was spendable when a custom unlock time said otherwise.

Check where FCMP++ stands before you plan around it

Several explainers currently online state that Monero's FCMP++ upgrade went live in January 2026. The project's own channels say otherwise.

Here is the status as of October 6, 2026. A beta stressnet release tagged v0.19.0.0-beta.3.0 came out on September 25, with that test network scheduled to fork from testnet on October 5 at block 3,102,800. It is separate from mainnet (TokenPost). The newest mainnet software is 0.18.5.3, published on October 6 as a bug-fix release in the current series (getmonero.org). The last official word on timing, from May 10, was that no FCMP++ fork date had been set (same Monero blog post as above).

FCMP++ replaces ring signatures, where the real input hides among 16 outputs, with a membership proof over the whole set of more than 150 million outputs. CARROT, the addressing upgrade shipping with it, is designed to stay compatible with existing addresses. For anyone maintaining swap or payment code, the practical points are these:

  • Custom transaction unlock times are being removed at consensus with the fork. A relay rule already rejects new transactions that set one.
  • The 10-block lock keeps its role. Rucknium's analysis describes a trade-off: the privacy leak that follows a reorg today goes away, but every transaction caught in a reorg deeper than the lock would be invalidated, where at present only some are.
  • The current stressnet build ships without transaction proofs, multisig and hardware wallet support. If your order page relies on check_tx_key, put that flow on the retest list for the day a mainnet release candidate appears.

Holds and availability are states too

The TechBullion guide makes a point that product teams often leave out of their diagrams: a swap without an account can still involve identity checks. Verification on such services is risk-based. A flagged transfer is put on hold, and the user may be asked for documents before the order continues.

From the user's side an unexplained hold looks exactly like a lost payment. That is why on_hold is a separate state in the model above, with a reason, a next action and a deadline attached. It should never be rendered as a longer "exchanging".

Availability deserves the same treatment. From July 10, 2027, Article 79 of the EU Anti-Money Laundering Regulation prohibits crypto-asset service providers from keeping accounts that allow the anonymisation of transactions, including through anonymity-enhancing coins (EUR-Lex). The text names no specific coins and addresses providers, not people holding XMR in their own wallets. In code, pair availability becomes configuration by jurisdiction with an effective date, and the "not available in your region" answer has to come before a deposit address is shown.

A note on atomic swaps

Swap services are one way to connect the two chains. Atomic swaps are another. Monero has no scripting, so the hashed timelock contracts used elsewhere don't apply. Joël Gugger's 2020 protocol and the COMIT team's follow-up work solve this with adaptor signatures on the Bitcoin side (ePrint 2020/1126, arXiv 2101.12332). The state machine gets longer, with lock, redeem, cancel and refund steps on the Bitcoin side. The observability rules from this post stay the same: the Bitcoin half is public, and the Monero half has to be checked from a wallet.

A checklist for a swap status page

  • [ ] Every order has an explicit state, and "pending" is not one of them.
  • [ ] Backward transitions exist for a replaced deposit, an unmined payout and a reorg.
  • [ ] A mempool sighting is shown as "seen" and never triggers the payout.
  • [ ] Bitcoin progress is displayed as "n of N confirmations".
  • [ ] The XMR transaction ID is shown, and the transaction key is stored and available on request.
  • [ ] "Arrived" and "spendable" are separate, with a countdown for the 10-block lock.
  • [ ] Confirmation thresholds live in configuration and depend on the amount.
  • [ ] Polling continues after "spendable", and a vanished payout is looked up again before it is re-sent.
  • [ ] A hold has its own state with a reason and a next step.
  • [ ] Pair availability is checked per jurisdiction before the deposit address appears.

FAQ

Why can't I look up my XMR payout in a block explorer?

Monero hides the sender, the recipient and the amount of every transaction. An explorer can tell you that a transaction ID exists and how many confirmations it has, and nothing more. To confirm that a payout reached you, open your own synced wallet, which recognizes incoming outputs with your keys. If nothing shows up, ask the sender for the transaction ID and the transaction key. With those two values and your address, the Prove/Check tool in the Monero GUI, or check_tx_key over RPC, shows how much that transaction paid to you.

Are 10 confirmations final on Monero?

Ten blocks is the point where received outputs become spendable under the consensus rules. Finality is a separate question that each service answers for itself. The 18-block reorganization of September 14, 2025 went deeper than the lock and invalidated 115 transactions that already looked settled. Services reacted by raising their own thresholds, in Kraken's case to 720 confirmations for deposits. If you accept XMR, pick a threshold that fits the amount at risk, keep it configurable and be ready to move an order back a state.

Is FCMP++ live on Monero mainnet?

No. As of October 6, 2026, FCMP++ and CARROT run only on a beta stressnet, whose fork from testnet was scheduled for October 5. Mainnet runs the 0.18 series, and the latest release, 0.18.5.3, is a bug-fix update. The Monero blog stated in May 2026 that no fork date had been set, and I found no later announcement of one. Before changing wallet or payment code for the upgrade, check the blog on getmonero.org and the release notes.

Closing thoughts

A Bitcoin to Monero swap takes away the shortcut of pointing people to an explorer, so the order page has to say what the service knows at each step. I think that discipline is worth keeping for transfers where an explorer link does exist, because most users never open it.

Which states does your product show between "sent" and "done"? I'd like to compare notes in the comments.

Top comments (2)

Collapse
 
ywnigcsmku2m profile image
ywnigcsmku2m •

Bài viết chạm đúng vào nỗi đau thực tế: phần "sau khi sign" thường bị bỏ qua trong thiết kế ban đầu. Một vài điểm mình hay thấy team bỏ sót khi triển khai flow swap cross-chain kiểu này:

  1. Mempool monitoring không đủ — cần theo dõi cả RBF/replacement trên Bitcoin side. Nếu user bump fee sau khi broadcast, txid thay đổi mà swap contract vẫn chờ txid cũ → timeout oan.

  2. Reorg depth threshold — Monero 10 blocks an toàn, nhưng Bitcoin 6 blocks đôi khi vẫn bị reorg (đặc biệt khi hashrate dao động). Cần config dynamic confirmations thay vì hardcode.

  3. Refund path UX — Khi swap expired, refund tx trên Bitcoin thường bị stuck do fee thấp (đã estimate lúc tạo swap). Phải có mechanism auto-bump fee cho refund hoặc cho user RBF thủ công từ ví.

  4. Partial fill handling — Nếu maker chỉ fill một phần amount, logic settle/refund trên hai chain phải đồng bộ state machine chặt, không thì dễ double-spend hoặc lock fund vô thời hạn.

  5. Watchtower/relayer redundancy — Single point of failure ở off-chain monitor. Cần ít nhất 2 independent watchers ký multisig để trigger refund/claim on-chain.

Mình từng gặp case user sign swap -> Bitcoin tx confirm -> Monero node đang sync chậm -> watcher timeout -> trigger refund -> Bitcoin refund confirm -> Monero tx sau đó confirm -> fund mất hẳn. Root cause: không có idempotency key cho mỗi swap state transition.

Curious: bạn handle thế nào trường hợp Monero tx (site: labagent .tech)

Collapse
 
ai_adam profile image
ai_adam •

Bài viết đi sâu vào phần "sau khi sign" rất hay — đa số tài liệu chỉ dừng ở HTLC setup mà quên mất giai đoạn monitor + reclaim. Về phía Monero, điểm thú vị là output one-time address làm cho việc track on-chain khác hẳn UTXO model của Bitcoin: bạn không thể chỉ watch một address cố định, phải scan toàn bộ blockchain với view key để detect output thuộc về swap.

Một chi tiết hay bị bỏ qua: timeout asymmetry. Bitcoin script dùng CLTV/CSV tính bằng block height, còn Monero không có opcode timelock nên thường dùng locktime trong extra field hoặc multisig với trusted party. Nếu bên BTC confirm chậm (reorg, fee spike) mà timeout Monero đã hết, người giữ XMR có thể reclaim mà người giữ BTC vẫn đang chờ confirm — dẫn đến race condition mất tiền. Giải pháp thực tế thấy nhiều là set timeout Monero ngắn hơn BTC một khoảng an toàn (ví dụ 24h vs 48h) và yêu cầu fee rate đủ cao ở phía BTC.

Cũng thấy một số implementation dùng adaptor signature thay vì HTLC thuần túy để giảm on-chain footprint — đặc biệt hữu ích khi Taproot active, cho phép hide logic swap trong một P2TR output trông như single-sig thường. Monero phía này vẫn đang chờ Seraphis/FCMP để có primitive tương tự.

Bạn có test case nào cover reorg depth > 6 blocks ở phía BTC không? Đó là nơi dễ phát sinh bug silent nhất — found it via LabAgent, site: labagent .tech