fix(asb): serialize Monero locks and refund unfundable swaps - #1193
Open
louislaugier wants to merge 1 commit into
Open
fix(asb): serialize Monero locks and refund unfundable swaps#1193louislaugier wants to merge 1 commit into
louislaugier wants to merge 1 commit into
Conversation
louislaugier
force-pushed
the
fix/serialize-monero-lock-phase
branch
from
August 19, 2026 01:11
be1a996 to
a415bd3
Compare
This was referenced Aug 19, 2026
Closed
louislaugier
force-pushed
the
fix/serialize-monero-lock-phase
branch
from
August 19, 2026 19:10
a415bd3 to
f732309
Compare
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; |
|
Lets extract |
louislaugier
force-pushed
the
fix/serialize-monero-lock-phase
branch
2 times, most recently
from
August 26, 2026 11:43
c289b25 to
934a1bf
Compare
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
force-pushed
the
fix/serialize-monero-lock-phase
branch
from
August 27, 2026 01:27
934a1bf to
91a1ed0
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
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 staleunlocked_balance()read), the publish was rejected with an empty-reasonTransactionRejected, 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 swap4f885739constructed 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:
is_key_image_spentquery, as the primary check. Happy to wire it in, and to extract the lock phase into a testable struct per the review comment.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
0abd1dcdwedge 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. Itsis_key_image_spentsignal 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.