Skip to content

Feat/atomic swap dispute bridge - #449

Merged
jotel-dev merged 5 commits into
Nullifier-Systems:mainfrom
N-thnI:feat/atomic-swap-dispute-bridge
Aug 30, 2026
Merged

Feat/atomic swap dispute bridge#449
jotel-dev merged 5 commits into
Nullifier-Systems:mainfrom
N-thnI:feat/atomic-swap-dispute-bridge

Conversation

@N-thnI

@N-thnI N-thnI commented Aug 29, 2026

Copy link
Copy Markdown
Contributor

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 UPDATE transaction locking, and operator alert webhooks.

Closes #446

The two risks, and where each is actually fixed

Relayer secret leakage — fixed on-chain. 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 contract state 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.

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. recordSecret rejects anything that does not SHA-256 to the row's 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.

On-chain and off-chain agree on "claimable." atomic-swap::is_refund_claimable delegates to the shared htlc_core helper, and the store and UI mirror the same condition, so none of them can drift from refund()'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 UPDATE and 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

File Purpose
apps/api/src/db/migrations/029_add_atomic_swap_dispute_bridge.sql swap_dispute_state enum, atomic_swap_dispute_bridges, expiry index
apps/api/src/lib/swapDisputeStore.ts The concurrency boundary — all FOR UPDATE claims
apps/api/src/routes/swap-dispute.ts POST /api/v1/swaps/dispute-claim, GET /api/v1/swaps/dispute/:swapId
apps/api/src/lib/workers/swapDisputeWorker.ts Secret extraction + automated refund claims
apps/api/src/lib/workers/swapDisputeWorker.test.ts Worker unit tests
tests/concurrency/swap_dispute_stress.test.ts 50-way concurrency stress test
mobile/frontend/src/components/AtomicSwapDisputeCard.tsx Lockup status + one-click claim

Modified

File Change
contracts/atomic-swap/src/lib.rs Persist revealed secret; get_revealed_secret, is_refund_claimable
contracts/htlc-core/src/lib.rs ledgers_until_refund, is_refund_claimable (saturating)
contracts/atomic-swap/src/test.rs 8 new tests
apps/api/src/lib/timeouts.ts Poll interval, warning margin, buildSwapDisputeCountdown
apps/api/src/lib/webhook.ts Secret-extracted, refund-claimed, approaching-expiry alerts
apps/api/src/lib/stellar.ts normalizeRevealedPreimage (Stellar bytes/hex + 0x EVM hex → one form)
apps/api/src/app.ts Route registration

Deviations 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 (multisigEscrowStore is 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

  • Contracts: cargo test -p atomic-swap -p htlc-core — atomic-swap 32 → 40, htlc-core 9 → 15. No new warnings.
  • API types: tsc --noEmit clean. Note it requires packages/shared to be built first (npx tsc -p packages/shared), otherwise ~100 unrelated @velo/shared resolution errors mask everything.
  • Monorepo suite: passing 644 → 667 (+23, exactly the new tests). Failures are identical to baseline — same 7 files, same 17 tests, verified by stashing and re-running.
  • Mobile: AtomicSwapDisputeCard typechecks clean.

Pre-existing failures, not from this PR

7 files / 17 tests fail on a clean tree: the mobile/frontend sync suites (IndexedDB API missing in the test environment), scripts/validate-localization.test.mjs, tests/concurrency/state_channel_stress.test.ts, two tests/e2e/*, and tests/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.ts and request-id.test.ts: both partially mock lib/stellar.js, and the route dereferenced getLatestLedgerSequence at 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

  • Automated worker extracts on-chain secret preimages within 1 ledger sequence — poll interval defaults to 5s, deliberately shorter than a ~6s ledger close, so at least one scan lands per ledger.
  • Expired swaps automatically trigger refund() for honest counterparties — worker claims and submits; refund() is permissionless, so no signature from the honest party is required.
  • Concurrent claim attempts resolved safely via 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 injected getLedger / 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_bridges before swap_dispute_state.

N-thnI added 4 commits August 29, 2026 23:28
…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.
@vercel

vercel Bot commented Aug 29, 2026

Copy link
Copy Markdown

@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.

@vercel

vercel Bot commented Aug 29, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
velo Ready Ready Preview Aug 30, 2026 1:31pm
velo-frontend Ready Ready Preview Aug 30, 2026 1:31pm

@jotel-dev jotel-dev left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

@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.
@N-thnI

N-thnI commented Aug 29, 2026

Copy link
Copy Markdown
Contributor Author

Please review

@jotel-dev jotel-dev left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

All checks pass, no conflicts. Reviewed and looks good — approving. Thanks for the contribution! @N-thnI

@jotel-dev
jotel-dev merged commit 595c29a into Nullifier-Systems:main Aug 30, 2026
5 checks passed
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.

[FEAT] Cross-Ledger Settlement Time-Lock Atomic Swap Dispute Bridge

2 participants