Skip to content

fix(asb): serialize Monero locks and refund unfundable swaps - #1193

Open
louislaugier wants to merge 1 commit into
eigenwallet:masterfrom
louislaugier:fix/serialize-monero-lock-phase
Open

fix(asb): serialize Monero locks and refund unfundable swaps#1193
louislaugier wants to merge 1 commit into
eigenwallet:masterfrom
louislaugier:fix/serialize-monero-lock-phase

Conversation

@louislaugier

@louislaugier louislaugier commented Aug 19, 2026

Copy link
Copy Markdown

Problem

Two swaps running at the same time can both select the same Monero output while building their lock transactions. wallet2 only marks an output spent after its lock transaction is relayed, and monero-sys has no reserve/freeze API, so the second lock becomes a permanent double-spend that monerod rejects, and that swap hangs until the cancel timelock (the recurring "xmr is refundable" reports).

A related case: when concurrent swaps together need more XMR than the maker holds, even a serialized second swap can be forced to reselect the first swap's freshly spent outputs, build a lock that double-spends, and then retry the rejected publish forever (the publish retry has no deadline), wedging until the cancel timelock.

Fix

  1. Serialize the construct-to-first-publish window with a process-wide async mutex, so at most one unpublished lock exists at a time. Overlapping swaps that are each fundable then pick different outputs and both succeed. A max-hold deadline releases the mutex if a swap wedges on a rejected publish, so one stuck swap cannot starve the others.
  2. Under that guard, before constructing, check the wallet's unlocked balance against what the lock needs. If a concurrent swap already consumed the shared balance, fail with a permanent error that routes to an early Bitcoin refund (no Monero is locked, so this is safe) instead of building a doomed double-spend that wedges the swap.

Testing

concurrent_bobs_before_xmr_lock_proof_sent and concurrent_bobs_after_xmr_lock_proof_sent (both swaps funded, both redeem), a new concurrent_bobs_insufficient_xmr (maker funded for only one of two concurrent swaps; the winner redeems, the loser refunds early rather than wedging), and happy_path all pass locally.

Mainnet: two wedges followed end to end, two distinct failure signatures

Running this change on a live maker (deployed 2026-08-21), I followed two wedges to their endings. They split the problem cleanly:

Swap 5eabdea8 (2026-08-26) — insufficient balance, missed by the pre-check. Wallet held 13.156 XMR against a 13.963 XMR lock; the pre-check still passed (it never logged its refusal — a stale unlocked_balance() read), 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 held 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, and 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 (locked, redeemed) during the wedge — the serialization half doing exactly its job. Neither wedge ever locked Monero; both takers were refunded on time.

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. Happy to wire it in, and to extract the lock phase into a testable struct per the review comment.
  • The two end-states want different exits. Insufficient funds routes to the early Bitcoin refund this PR adds. Sufficient-funds-but-stale-selection would have been saved by rebuilding the lock tx on rejection — the direction feat(asb): rebuild xmr lock tx on confirmed double spend  #1142 explores. The two are complementary, not competing.

Relationship to #1142

#1142 explores rebuilding the lock transaction after a confirmed double-spend; the discussion there leaned toward aborting into a clean end state rather than rebuilding. This PR takes that route for the unfundable case — no Monero is ever locked, so there is nothing to rebuild. The 0abd1dcd wedge above shows the other case is real too: with ample funds and a stale selection, a rebuild succeeds where any abort forfeits a fillable swap. Its is_key_image_spent signal slots directly into this PR's early-refund trigger either way.

Related

AI disclosure: I used Claude (Fable 5 and Opus 5) to help investigate and write this change. I understand the code, tested it, and am responsible for it.

@louislaugier
louislaugier force-pushed the fix/serialize-monero-lock-phase branch from be1a996 to a415bd3 Compare August 19, 2026 01:11
@louislaugier
louislaugier force-pushed the fix/serialize-monero-lock-phase branch from a415bd3 to f732309 Compare August 19, 2026 19:10
@louislaugier louislaugier changed the title fix(asb): serialize the Monero lock phase across swaps fix(asb): serialize Monero locks and refund unfundable swaps Aug 19, 2026
@louislaugier

Copy link
Copy Markdown
Author

Update: reproduced this exact wedge on a live mainnet ASB today and deployed the full change (mutex + balance pre-check -> early refund). Details + an honest best-effort caveat (wallet2 lag; is_key_image_spent as the reliable follow-up) added to the PR description above.

@binarybaron

Copy link
Copy Markdown

Lets extract MONERO_LOCK_PHASE into a testable struct such that we don't have to put as much logic directly into the state machine.

@louislaugier
louislaugier force-pushed the fix/serialize-monero-lock-phase branch 2 times, most recently from c289b25 to 934a1bf Compare August 26, 2026 11:43
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>
@louislaugier
louislaugier force-pushed the fix/serialize-monero-lock-phase branch from 934a1bf to 91a1ed0 Compare August 27, 2026 01:27
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.

2 participants