Skip to content

feat(asb): rebuild xmr lock tx on confirmed double spend - #1142

Draft
binarybaron wants to merge 6 commits into
masterfrom
trusted-daemon-double-spend-rebuild
Draft

feat(asb): rebuild xmr lock tx on confirmed double spend #1142
binarybaron wants to merge 6 commits into
masterfrom
trusted-daemon-double-spend-rebuild

Conversation

@binarybaron

Copy link
Copy Markdown

No description provided.

@binarybaron binarybaron changed the title feat(asb): rebuild Monero lock tx on confirmed double spend with trusted daemon feat(asb): rebuild xmr lock tx on confirmed double spend Jun 25, 2026
@binarybaron

Copy link
Copy Markdown
Author

@Einliterflasche Should we really rebuild the TX here? We could also just go into SafelyAborted (or a similar end state, e.g SafelyAbortedAfterXmrLockDoubleSpend to preserve the state data).

@binarybaron

Copy link
Copy Markdown
Author

bugbot run

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 473b299. Configure here.


return Ok(AliceState::BtcLocked { state3: state3.clone() });
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pool presence skips double-spend check

High Severity

Confirmed-input double-spend recovery runs only inside !is_present, but is_transaction_present is true while the lock tx is still in the mempool. If inputs are already SpentInBlockchain via another tx, the swap still advances to XmrLockTransactionSent and can wait forever for confirmations that never arrive.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 473b299. Configure here.

binarybaron and others added 3 commits July 29, 2026 16:03
…ng the lock tx

Before Alice constructs her Monero lock transaction, scan the shared
wallet (the view pair Bob and Alice both know) from a recorded restore
height and abort the swap if it already received any output, on-chain
or in the mempool. This guards against ever locking Monero twice for
the same swap, complementing the double-spend rebuild path which
re-enters BtcLocked and now re-runs this check with a fresh restore
height.

- swap-machine: new AliceState::XmrReadyToLock carries the restore
  height captured in BtcLocked so a resumed swap scans the full window
  in which an earlier lock could have landed.
- monero-wallet-ng: new empty module scans blocks in batches of 10
  plus the mempool with a naive Scanner over the shared view pair.
- The check runs once per construction attempt and again before
  falling back to BtcEarlyRefundable after the lock retry timeout.
@Einliterflasche
Einliterflasche force-pushed the trusted-daemon-double-spend-rebuild branch from 473b299 to 089114a Compare July 31, 2026 13:40
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Jul 31, 2026

Copy link
Copy Markdown

Deploying eigenwallet-docs with  Cloudflare Pages  Cloudflare Pages

Latest commit: 17d7164
Status: ✅  Deploy successful!
Preview URL: https://bd99cab0.eigenwallet-docs.pages.dev
Branch Preview URL: https://trusted-daemon-double-spend.eigenwallet-docs.pages.dev

View logs

@binarybaron

Copy link
Copy Markdown
Author

@Einliterflasche Is this good to merge ?

@binarybaron

Copy link
Copy Markdown
Author

This is not good to merge.

@louislaugier

louislaugier commented Aug 19, 2026

Copy link
Copy Markdown

Sharing a real-world reproduction of this on a live mainnet ASB today, in case the failure mode is useful.

Two takers opened swaps ~70s apart. The maker had 43 unreserved XMR; the swaps needed 9.65 and 38.07 XMR, so together (47.7) they over-committed. Both were accepted (the quote-time reservation is defeated here: quotes are cached by {min,max} only, and a swap reserves 0 XMR until it reaches BtcLockTransactionSeen, so two concurrent same-size requests both see the full balance). The small swap reached the Monero lock phase first, constructed and relayed its 9.65 lock. The big swap then constructed a 38.07 lock, and because wallet2's balance/spend state does not yet reflect the freshly relayed (unconfirmed) lock, construct_multi_destination_tx happily re-selected the small swap's inputs. Its publish is now rejected by a healthy monerod (empty-reason TransactionRejected) and retries forever: the publish retry(..., None, None) in XmrLockTransactionConstructed has no deadline, so the only escape is the cancel timelock. The taker refunds, but the swap is wedged for the whole timelock window.

The takeaway: wallet2's own balance/scan lags the relay, so it can't reliably catch the over-commit before constructing — a daemon-level is_key_image_spent query is the authoritative signal, and that is the piece worth keeping from this PR.

Where it could go from here: #1193 takes the abort-not-rebuild route raised earlier in this thread. It serializes the construct->first-relay window (so concurrent same-balance swaps pick different outputs — no double-spend when funds suffice) and routes the genuinely-unfundable swap to an early Bitcoin refund instead of building a doomed lock (no Monero is locked, so nothing to rebuild). The is_key_image_spent check from here would slot straight into #1193's early-refund trigger, replacing its best-effort balance pre-check and giving the reliable signal without the rebuild. So rather than two competing approaches, it reads like #1193's prevention + clean abort, with this PR's key-image query as the authoritative trigger. Happy to wire that up if that is the direction you prefer.

AI disclosure: I used Claude Fable 5 to help investigate and write this. I understand it and am responsible for it.

louislaugier added a commit to louislaugier/core that referenced this pull request Aug 27, 2026
Two overlapping swaps could select the same Monero output when building
their lock transactions. wallet2 only marks an output spent once its
lock tx is relayed, and monero-sys has no reserve API, so the second
swap's lock tx becomes a permanent double-spend that monerod rejects
forever -- the swap then hangs until the cancel timelock and the taker
is refunded ("xmr is refundable").

A related case: when concurrent swaps together need more XMR than the
maker holds, even a serialized swap can be forced to reselect a sibling's
freshly spent outputs, build a double-spend, and then retry the rejected
publish forever, since the publish has no deadline.

Serialize the construct-through-first-publish window with a process-wide
async mutex so at most one unpublished lock tx exists at a time;
overlapping swaps that are each fundable pick different outputs and both
succeed. A max-hold deadline releases the mutex if a swap wedges on a
rejected publish. Under the guard, before constructing, check that the
unlocked balance covers the lock and fail with a permanent error (an
early Bitcoin refund; no Monero was locked, so it is safe) if a sibling
already took the shared balance, instead of building a doomed lock.

Two mainnet wedges were then followed end to end on a live maker running
this change, and they split the failure into two distinct signatures:

Swap 5eabdea8 (2026-08-26) -- insufficient balance, missed. Wallet
13.156 XMR against a 13.963 XMR lock; the pre-check still PASSED (it
never logged its refusal), the publish was rejected with an empty-reason
TransactionRejected, and the swap retried that same transaction until
the cancel timelock, ~4.2h. The balance recovering mid-wedge (20.29 XMR
unreserved for 33 minutes) changed nothing.

Swap 0abd1dcd (2026-08-27) -- ample balance, unreachable by any balance
check. Wallet 41.3 XMR unlocked against a 6.85 XMR lock; the pre-check
was RIGHT to pass, but the constructed tx had selected outputs freshly
spent by an earlier swap that wallet2 had not yet reflected. monerod
rejected that one fixed transaction 799 times over 3.9h until the
cancel timelock. Meanwhile the max-hold deadline released the mutex and
sibling swap 4f885739 constructed with fresh outputs and completed
end-to-end DURING the wedge -- the serialization half doing exactly its
job. Neither wedge ever locked Monero; both takers were refunded.

What this pins down: the construct-time gate must operate on OUTPUTS,
not balance -- the daemon-side is_key_image_spent query, as the primary
check. And the two end-states want different exits: insufficient funds
routes to the early Bitcoin refund this patch adds, while
sufficient-funds-stale-selection would have been SAVED by rebuilding the
lock tx on rejection -- the direction eigenwallet#1142 explores, complementary to
this patch rather than superseded by it.

Verified with the concurrent_bobs_before_xmr_lock_proof_sent,
concurrent_bobs_after_xmr_lock_proof_sent, a new
concurrent_bobs_insufficient_xmr, and happy_path integration tests.

Refs eigenwallet#120

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants