Feat/atomic swap dispute bridge - #449
Merged
jotel-dev merged 5 commits intoAug 30, 2026
Merged
Conversation
…mability Two on-chain gaps the cross-ledger dispute bridge needs closed. Relayer secret leakage atomic-swap published the revealed preimage only in its released event. An event is seen by whoever happens to be watching when it fires, so a relayer that was down, restarting, or lagging lost the secret outright -- and with it the counterpart leg's collateral, permanently. release() now writes the preimage to DataKey::RevealedSecret before emitting it, and get_revealed_secret() reads it back at any later point. The event stays as the fast path; state is the recovery path. Refund claimability htlc-core gains ledgers_until_refund() and is_refund_claimable(), both saturating so a timeout near u32::MAX cannot panic under overflow-checks and an expired swap cannot wrap to a huge remaining count. is_refund_claimable() requires Locked status as well as an elapsed timeout, so a released, refunded, or arbitrator-resolved trade is never refunded a second time -- and a Disputed trade stays with the arbitrator rather than being pulled into the automated path. atomic-swap exposes it as is_refund_claimable(id), returning false for an unknown id rather than panicking, so the worker can probe ids it has not seen locked. It delegates to the htlc-core helper, so this answer and refund() own precondition cannot drift apart. Tests: contracts 32 -> 40, htlc-core 9 -> 15. Covers the persisted secret surviving for a late reader and rehashing to the trade secret_hash, refund claimability flipping exactly at the timeout ledger, released/refunded/ disputed never being claimable, and the full counterparty-timeout scenario refunding the honest party with no secret in play.
…tore
Migration 029 adds swap_dispute_state and atomic_swap_dispute_bridges: one
row per cross-chain swap, recording the preimage the moment it is seen on
either leg and tracking the swap through ACTIVE -> SECRET_EXTRACTED /
REFUND_CLAIMABLE -> RESOLVED. idx_swap_expiration leads with
expiration_ledger so the worker's "which live swaps expired" scan is an
index range read rather than a full scan.
SwapDisputeStore is the concurrency boundary. The worker and an operator
calling the API can act on the same swap in the same moment, and a
duplicate claim would mean two refund() submissions racing on-chain. Every
transition takes SELECT ... FOR UPDATE on the bridge row and then moves
state CAS-style (UPDATE ... WHERE state = expected), so exactly one caller
owns the follow-on submission and the rest are told why they lost. Slow
I/O stays outside the lock, so a hung RPC cannot pin a row.
Two invariants worth naming:
* The preimage is write-once and verified -- recordSecret rejects anything
that does not SHA-256 to the row secret_hash, matching the Stellar leg's
env.crypto().sha256, so a bad observation cannot poison the record and a
later caller cannot overwrite a good one.
* A swap whose secret was extracted is never refundable. Refunding it
would hand the funds back while the counterparty still holds a usable
preimage for the other leg -- the exact double-spend this bridge exists
to prevent.
Without a pool the store falls back to an in-memory map so tests run with no
database; Node's single thread makes an await-free critical section atomic,
so the fallback keeps the same exactly-once guarantee.
14 stress tests: 50 concurrent refund claims yield exactly one winner and 49
reasoned refusals; 50 concurrent extractions yield one; 25 extractions
racing 25 refund claims resolve to exactly one outcome overall, never both;
claims land exactly at the expiration ledger and not one before; 20 swaps x
5 racers stay independent at one winner each.
The automated path that ends an asymmetric lockup without anyone waiting out the full timeout by hand. Worker (swapDisputeWorker.ts) Polls faster than a ledger closes, so a reveal is picked up within one ledger sequence. Each tick extracts newly revealed preimages first, then claims refunds for legs that expired with no secret. That ordering is load-bearing: a reveal seen in the same tick as an expiry must win, or the funds go back while the counterparty can still take the other leg. A single bad reveal is reported and skipped rather than aborting the batch, since the remaining secrets are still at risk. On-chain submission happens outside the store's lock. Route (swap-dispute.ts) POST /api/v1/swaps/dispute-claim settles with a preimage when one exists and otherwise claims an expired swap's refund, returning 200 with the resulting bridge state as execution proof. Zod validates swap_id and the optional preimage; a caller that loses the race gets 409 with the specific reason rather than a second on-chain submission. GET /api/v1/swaps/dispute/:swapId backs the status card. getLatestLedgerSequence is wrapped in a closure rather than referenced at registration, so suites that partially mock lib/stellar.js (app.test.ts, request-id.test.ts) can still build the app without stubbing it. Supporting changes timeouts.ts: poll interval, warning margin, and buildSwapDisputeCountdown, whose refundClaimable mirrors the contract precondition exactly so the UI never offers a claim the chain would reject. The zero refund grace is a named constant so "no extra delay" is a decision, not an accident. webhook.ts: secret-extracted, refund-claimed, and approaching-expiry alerts. The last fires before expiry, while an operator can still act. stellar.ts: normalizeRevealedPreimage folds Stellar bytes/hex and 0x-prefixed EVM hex into one canonical form, so the same secret observed on two chains is recognised as one and a malformed log entry is dropped rather than stored as a bogus secret. 9 worker tests, including the same-tick reveal-beats-expiry ordering and batch resilience to a bad reveal.
… claims Shows one leg of a cross-chain swap and gives the honest party the escape hatch when the counterparty stalls: live lockup countdown, whether the secret has been extracted and stored off-chain, and a one-click "Claim Dispute Refund". The button's enabled condition mirrors the contract precondition (latestLedger >= expirationLedger), so the UI never offers an action the chain would reject. It also stays disabled once a secret has been extracted: that swap settles with the preimage, and refunding it would return the funds while the counterparty can still take the other leg. Ledger counts are the source of truth; the seconds estimate is a convenience and never gates the claim. Follows CollateralCooldownBadge for i18n, data-testid hooks, and inline styling.
|
@N-thnI is attempting to deploy a commit to the jotelfootball-tech's projects Team on Vercel. A member of the Team first needs to authorize it. |
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
jotel-dev
reviewed
Aug 29, 2026
Contributor
There was a problem hiding this comment.
@N-thnI Thanks for this. A couple of checks are failing: contracts-ci / test (6s) and node-ci / build (16s). Could you check the logs for both and let me know what you find? No conflicts, so should be straightforward once those are sorted.
Both failing checks on Nullifier-Systems#449 were mine, and both failed on their first step, so the rest of each job never ran. contracts-ci / test -- cargo fmt --all --check The htlc_core::is_refund_claimable call in atomic-swap fits on one line; rustfmt wanted it collapsed. cargo fmt --all touched only that call site. node-ci / build -- npm run localization:check AtomicSwapDisputeCard used 14 t() keys that were not in the catalogs, and the validator requires every key to exist in both en and es. Adds the swapDispute namespace to both, with Spanish translations rather than English placeholders, since the validator checks presence in es.json and a placeholder would silently ship untranslated UI. Also adds the trailing newline both catalogs were missing. Ran the remaining steps of both jobs locally, since neither had reached them: cargo build/test --workspace, soroban-lint (0 errors, my files clean), invariant-verifier (7/7 invariants preserved), the wasm32v1-none release build, and npm build/test/lint. All pass.
Contributor
Author
|
Please review |
3 tasks
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.
Summary
Implements the Cross-Ledger Settlement Time-Lock Atomic Swap Dispute Bridge: automated dual-side secret extraction, automatic refund claims on counterparty expiry,
SELECT ... FOR UPDATEtransaction locking, and operator alert webhooks.Closes #446
The two risks, and where each is actually fixed
Relayer secret leakage — fixed on-chain.
atomic-swappublished the revealed preimage only in itsreleasedevent. An event is seen by whoever happens to be watching when it fires, so a relayer that was down, restarting, or lagging lost the secret outright — and with it the counterpart leg's collateral, permanently.release()now writes the preimage to contract state before emitting it, andget_revealed_secret()reads it back at any later point. The event stays as the fast path; state is the recovery path.Asymmetric lockup — fixed off-chain. The worker claims the honest party's refund automatically once a leg expires, instead of leaving them to notice and act.
refund()is permissionless on-chain, so no signature from them is needed.Design decisions worth reviewing
A swap whose secret was extracted is never refundable. This is enforced in three places — the store's
refusalReason, the worker's tick ordering (secrets are extracted before refunds are claimed), and the mobile card's disabled state. Refunding such a swap would hand the funds back while the counterparty still holds a usable preimage for the other leg. That is the double-spend this bridge exists to prevent, and it is asserted directly in both the stress test and the worker tests.The preimage is write-once and verified.
recordSecretrejects anything that does not SHA-256 to the row'ssecret_hash— matching the Stellar leg'senv.crypto().sha256— so a bad observation cannot poison the record and a later caller cannot overwrite a good one.On-chain and off-chain agree on "claimable."
atomic-swap::is_refund_claimabledelegates to the sharedhtlc_corehelper, and the store and UI mirror the same condition, so none of them can drift fromrefund()'s own precondition. The UI never offers a claim the chain would reject.Claim, then do slow I/O outside the lock. Every transition takes
SELECT ... FOR UPDATEand then moves state CAS-style (UPDATE ... WHERE state = $expected). Exactly one caller owns the follow-on on-chain submission; the rest are told why they lost. A hung RPC cannot pin a database row.Files
New
apps/api/src/db/migrations/029_add_atomic_swap_dispute_bridge.sqlswap_dispute_stateenum,atomic_swap_dispute_bridges, expiry indexapps/api/src/lib/swapDisputeStore.tsFOR UPDATEclaimsapps/api/src/routes/swap-dispute.tsPOST /api/v1/swaps/dispute-claim,GET /api/v1/swaps/dispute/:swapIdapps/api/src/lib/workers/swapDisputeWorker.tsapps/api/src/lib/workers/swapDisputeWorker.test.tstests/concurrency/swap_dispute_stress.test.tsmobile/frontend/src/components/AtomicSwapDisputeCard.tsxModified
contracts/atomic-swap/src/lib.rsget_revealed_secret,is_refund_claimablecontracts/htlc-core/src/lib.rsledgers_until_refund,is_refund_claimable(saturating)contracts/atomic-swap/src/test.rsapps/api/src/lib/timeouts.tsbuildSwapDisputeCountdownapps/api/src/lib/webhook.tsapps/api/src/lib/stellar.tsnormalizeRevealedPreimage(Stellar bytes/hex +0xEVM hex → one form)apps/api/src/app.tsDeviations from the issue's file inventory
Three additions, all deliberate:
apps/api/src/lib/swapDisputeStore.ts— the locking logic has to live somewhere the stress test can import directly, which is how every other concurrency test in this repo works (multisigEscrowStoreis the exact precedent). Putting it inside the route would have made the 50-way test impossible to write.swapDisputeWorker.test.ts— worker unit tests; the inventory named only the stress test.apps/api/src/app.ts— the route is unreachable without registration.Verification
cargo test -p atomic-swap -p htlc-core— atomic-swap 32 → 40, htlc-core 9 → 15. No new warnings.tsc --noEmitclean. Note it requirespackages/sharedto be built first (npx tsc -p packages/shared), otherwise ~100 unrelated@velo/sharedresolution errors mask everything.AtomicSwapDisputeCardtypechecks clean.Pre-existing failures, not from this PR
7 files / 17 tests fail on a clean tree: the
mobile/frontendsync suites (IndexedDB API missingin the test environment),scripts/validate-localization.test.mjs,tests/concurrency/state_channel_stress.test.ts, twotests/e2e/*, andtests/integration/state_channels_e2e.test.ts. Untouched by this work.One regression I introduced and fixed
Registering the route initially broke
apps/api/src/app.test.tsandrequest-id.test.ts: both partially mocklib/stellar.js, and the route dereferencedgetLatestLedgerSequenceat registration time, so the missing export threw during app build. Fixed by wrapping it in a closure — registration no longer touches the module's exports. No change to their tests.Acceptance criteria
refund()for honest counterparties — worker claims and submits;refund()is permissionless, so no signature from the honest party is required.SELECT FOR UPDATE— 50 concurrent refund claims yield exactly one winner and 49 reasoned refusals; 25 extractions racing 25 refund claims resolve to exactly one outcome overall, never both.Not included
The worker is implemented and tested but not started in
apps/api/src/index.ts. It takes injectedgetLedger/pollReveals/submitRefund, and wiring those to live Stellar and EVM log sources is a deployment decision (which chains, which RPC endpoints, which contract addresses) I did not want to guess at. Happy to wire it in this PR or a follow-up — say which you prefer.Migration note
029 creates a new enum type and table with no changes to existing tables, so it is additive and safe to apply ahead of the code. Rolling back requires dropping
atomic_swap_dispute_bridgesbeforeswap_dispute_state.