From 6999c94d5d82c967ea23a37de5d9b1adcec60115 Mon Sep 17 00:00:00 2001 From: N-thnI Date: Sat, 29 Aug 2026 23:28:40 +0100 Subject: [PATCH 1/5] feat(contracts): persist revealed HTLC secrets and expose refund claimability 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. --- contracts/atomic-swap/src/lib.rs | 51 +++++++++++ contracts/atomic-swap/src/test.rs | 140 ++++++++++++++++++++++++++++++ contracts/htlc-core/src/lib.rs | 81 +++++++++++++++++ 3 files changed, 272 insertions(+) diff --git a/contracts/atomic-swap/src/lib.rs b/contracts/atomic-swap/src/lib.rs index 8699918..32575a9 100644 --- a/contracts/atomic-swap/src/lib.rs +++ b/contracts/atomic-swap/src/lib.rs @@ -39,6 +39,14 @@ enum DataKey { CrossChainState(BytesN<32>), // evm_tx_hash -> CrossChainTxInfo /// Track if timelock was extended for a trade TimelockExtended(BytesN<32>), + /// Revealed preimage, persisted at `release()` time. + /// + /// The preimage is also published in the `released` event, but an event is + /// only observable by whoever happens to be watching when it fires. A + /// relayer that misses it has no way to recover the secret, and the + /// counterpart leg's collateral becomes unrecoverable. Persisting it makes + /// the secret readable at any later point via `get_revealed_secret`. + RevealedSecret(BytesN<32>), } #[contracterror] @@ -118,6 +126,39 @@ impl AtomicSwapContract { env.storage().persistent().get(&DataKey::Trade(id)) } + /// The preimage revealed by `release()`, or `None` if this leg has not + /// been released. + /// + /// This is the recovery path for the relayer secret-leakage risk: the + /// preimage is normally read from the `released` event, but an event is + /// only seen by whoever is watching at the time. A relayer that was down, + /// restarting, or lagging can read the secret here instead of losing the + /// counterpart leg's collateral. + pub fn get_revealed_secret(env: Env, id: BytesN<32>) -> Option> { + env.storage().persistent().get(&DataKey::RevealedSecret(id)) + } + + /// Whether `refund()` would succeed for this trade at the current ledger. + /// + /// Lets the dispute bridge decide whether to submit without simulating a + /// call that might panic. Returns `false` for an unknown id, and defers to + /// `htlc_core::is_refund_claimable` so this answer and the contract's own + /// precondition can never disagree. + pub fn is_refund_claimable(env: Env, id: BytesN<32>) -> bool { + let Some(state) = env + .storage() + .persistent() + .get::(&DataKey::Trade(id)) + else { + return false; + }; + htlc_core::is_refund_claimable( + state.status, + state.timeout_ledger, + env.ledger().sequence(), + ) + } + /// Cross-chain: Set finality depth (k-confirmations) for an EVM chain. /// Only callable by admin. Used to track reorg risk per chain. pub fn set_chain_finality(env: Env, chain_id: u32, k_confirmations: u32) -> Result<(), Error> { @@ -404,6 +445,16 @@ impl Htlc for AtomicSwapContract { &state.amount, ); + // Persist the preimage before emitting it. The event is the fast path + // for a relayer that is watching; this key is the recovery path for one + // that was not. Without it a missed event means an unrecoverable + // counterpart leg. + let secret_key = DataKey::RevealedSecret(id.clone()); + env.storage().persistent().set(&secret_key, &secret); + env.storage() + .persistent() + .extend_ttl(&secret_key, TTL_EXTEND, TTL_EXTEND); + // The revealed secret is the cross-chain payload: the relayer reads it // from this event and uses it to claim the counterpart HTLC. env.events() diff --git a/contracts/atomic-swap/src/test.rs b/contracts/atomic-swap/src/test.rs index 6454ef9..7e341f2 100644 --- a/contracts/atomic-swap/src/test.rs +++ b/contracts/atomic-swap/src/test.rs @@ -474,3 +474,143 @@ fn arbitrum_l2_vs_ethereum_l1_finality_comparison() { ); assert_eq!(eth_extension, 0); // Sufficient, no extension } + +// ============================================================================ +// Cross-Ledger Settlement Time-Lock Atomic Swap Dispute Bridge +// ============================================================================ +// +// Two guarantees the dispute bridge is built on: +// +// * a revealed preimage stays recoverable even if nobody was watching the +// `released` event when it fired; +// * an expired leg can be refunded by anyone, automatically, without the +// honest party having to wait out a manual dispute. + +/// The preimage is readable from contract state after release, not only from +/// the event log. This is the fix for relayer secret leakage: a relayer that +/// was down when the event fired can still recover the secret and claim the +/// counterpart leg. +#[test] +fn revealed_secret_is_persisted_for_late_readers() { + let f = setup(10_000); + + // Before release there is nothing to read. + assert_eq!(f.client.get_revealed_secret(&f.id), None); + + f.client + .lock(&f.id, &f.seller, &f.buyer, &5_000, &f.secret_hash, &100); + f.client.release(&f.id, &f.secret); + + // After release the preimage is durable state, independent of events. + assert_eq!(f.client.get_revealed_secret(&f.id), Some(f.secret.clone())); +} + +/// The persisted preimage really is the swap's secret — it hashes to the +/// trade's `secret_hash`, so a relayer can use it on the counterpart chain. +#[test] +fn persisted_secret_hashes_to_the_trade_secret_hash() { + let f = setup(10_000); + f.client + .lock(&f.id, &f.seller, &f.buyer, &5_000, &f.secret_hash, &100); + f.client.release(&f.id, &f.secret); + + let extracted = f.client.get_revealed_secret(&f.id).expect("secret stored"); + let rehashed = f.env.crypto().sha256(&extracted.into()).to_bytes(); + assert_eq!(rehashed, f.secret_hash); +} + +/// A refunded leg never reveals a secret — there is nothing to extract, and +/// the bridge must not report one. +#[test] +fn refunded_swap_exposes_no_secret() { + let f = setup(10_000); + f.client + .lock(&f.id, &f.seller, &f.buyer, &5_000, &f.secret_hash, &100); + + f.env.ledger().with_mut(|li| li.sequence_number += 101); + f.client.refund(&f.id); + + assert_eq!(f.client.get_revealed_secret(&f.id), None); +} + +/// `is_refund_claimable` tracks the contract's own timeout precondition +/// exactly, so the bridge never submits a refund the chain would reject. +#[test] +fn refund_claimable_flips_exactly_at_timeout() { + let f = setup(10_000); + f.client + .lock(&f.id, &f.seller, &f.buyer, &5_000, &f.secret_hash, &100); + + // Locked but not yet expired. + assert!(!f.client.is_refund_claimable(&f.id)); + + // One ledger short of the timeout: still not claimable. + f.env.ledger().with_mut(|li| li.sequence_number += 99); + assert!(!f.client.is_refund_claimable(&f.id)); + + // At the timeout ledger it becomes claimable, and refund() agrees. + f.env.ledger().with_mut(|li| li.sequence_number += 1); + assert!(f.client.is_refund_claimable(&f.id)); + f.client.refund(&f.id); + assert_eq!(f.token.balance(&f.buyer), 10_000); +} + +/// A released leg is never refund-claimable, however long ago it settled. +/// Refunding it would return funds the seller has already been paid. +#[test] +fn released_swap_is_never_refund_claimable() { + let f = setup(10_000); + f.client + .lock(&f.id, &f.seller, &f.buyer, &5_000, &f.secret_hash, &100); + f.client.release(&f.id, &f.secret); + + f.env.ledger().with_mut(|li| li.sequence_number += 10_000); + assert!(!f.client.is_refund_claimable(&f.id)); +} + +/// An already-refunded leg is not claimable again — the honest counterparty's +/// automated claim runs at most once. +#[test] +fn refunded_swap_is_not_claimable_again() { + let f = setup(10_000); + f.client + .lock(&f.id, &f.seller, &f.buyer, &5_000, &f.secret_hash, &100); + + f.env.ledger().with_mut(|li| li.sequence_number += 101); + assert!(f.client.is_refund_claimable(&f.id)); + f.client.refund(&f.id); + + assert!(!f.client.is_refund_claimable(&f.id)); +} + +/// An unknown swap id is not claimable, rather than panicking — the worker +/// scans ids it has not necessarily seen locked on this contract. +#[test] +fn unknown_swap_is_not_refund_claimable() { + let f = setup(10_000); + let unknown = BytesN::from_array(&f.env, &[0xABu8; 32]); + assert!(!f.client.is_refund_claimable(&unknown)); + assert_eq!(f.client.get_revealed_secret(&unknown), None); +} + +/// Counterparty-timeout scenario end to end: the counterparty never reveals, +/// the leg expires, and the honest buyer is made whole automatically without +/// anyone holding the secret. +#[test] +fn expired_swap_refunds_honest_party_without_a_secret() { + let f = setup(10_000); + f.client + .lock(&f.id, &f.seller, &f.buyer, &5_000, &f.secret_hash, &100); + assert_eq!(f.token.balance(&f.buyer), 5_000); + + // Counterparty stalls: no reveal on either leg, timeout elapses. + f.env.ledger().with_mut(|li| li.sequence_number += 101); + assert_eq!(f.client.get_revealed_secret(&f.id), None); + assert!(f.client.is_refund_claimable(&f.id)); + + // Permissionless refund — no buyer signature needed. + f.client.refund(&f.id); + + assert_eq!(f.token.balance(&f.buyer), 10_000); + assert_eq!(f.token.balance(&f.seller), 0); +} diff --git a/contracts/htlc-core/src/lib.rs b/contracts/htlc-core/src/lib.rs index 69952e6..ce0a889 100644 --- a/contracts/htlc-core/src/lib.rs +++ b/contracts/htlc-core/src/lib.rs @@ -182,6 +182,32 @@ pub fn is_collateral_locked(deposit_ledger: u32, current_ledger: u32) -> bool { collateral_cooldown_remaining(deposit_ledger, current_ledger) > 0 } +/// Ledgers remaining before an HTLC's permissionless refund unlocks. +/// `0` means `refund()` will pass its timeout precondition right now. +/// +/// Saturating, so a `timeout_ledger` near `u32::MAX` cannot panic under +/// `overflow-checks` and a `current_ledger` past the timeout yields exactly +/// `0` rather than wrapping to a huge remaining count. +pub fn ledgers_until_refund(timeout_ledger: u32, current_ledger: u32) -> u32 { + timeout_ledger.saturating_sub(current_ledger) +} + +/// Whether a swap leg is eligible for an automated refund claim. +/// +/// Two conditions, and both matter: +/// +/// * the timeout has elapsed — the same check `refund()` itself enforces, +/// so this never reports claimable for a call the contract would reject; +/// * the trade is still `Locked` — a released, refunded, or arbitrator- +/// resolved trade must never be refunded again. +/// +/// The dispute bridge calls this before submitting, and the off-chain store +/// mirrors it (`refusalReason` in apps/api/src/lib/swapDisputeStore.ts), so +/// both sides agree on what "claimable" means. +pub fn is_refund_claimable(status: TradeStatus, timeout_ledger: u32, current_ledger: u32) -> bool { + status == TradeStatus::Locked && ledgers_until_refund(timeout_ledger, current_ledger) == 0 +} + #[cfg(test)] mod cooldown_tests { use super::*; @@ -266,3 +292,58 @@ mod fee_math_tests { assert_eq!(net_of(0, 1), Err(FeeMathError::Overflow)); } } + +#[cfg(test)] +mod refund_claim_tests { + use super::*; + + #[test] + fn countdown_reaches_zero_at_the_timeout_ledger() { + assert_eq!(ledgers_until_refund(1_100, 1_000), 100); + assert_eq!(ledgers_until_refund(1_100, 1_099), 1); + assert_eq!(ledgers_until_refund(1_100, 1_100), 0); + } + + #[test] + fn countdown_saturates_instead_of_wrapping() { + // Past the timeout stays 0 rather than wrapping to a huge number, + // which would make an expired swap look like it had ages left. + assert_eq!(ledgers_until_refund(1_100, 5_000), 0); + // And a timeout near u32::MAX must not panic under overflow-checks. + assert_eq!(ledgers_until_refund(u32::MAX, 0), u32::MAX); + } + + #[test] + fn locked_and_expired_is_claimable() { + assert!(is_refund_claimable(TradeStatus::Locked, 1_100, 1_100)); + assert!(is_refund_claimable(TradeStatus::Locked, 1_100, 9_999)); + } + + #[test] + fn locked_but_unexpired_is_not_claimable() { + assert!(!is_refund_claimable(TradeStatus::Locked, 1_100, 1_099)); + } + + #[test] + fn terminal_states_are_never_claimable() { + // Even long past the timeout: refunding any of these would move funds + // that have already been settled. + for status in [ + TradeStatus::Released, + TradeStatus::Refunded, + TradeStatus::Resolved, + ] { + assert!( + !is_refund_claimable(status, 1_100, 9_999), + "{status:?} must never be refund-claimable" + ); + } + } + + #[test] + fn disputed_is_not_claimable_by_the_bridge() { + // A disputed trade belongs to the arbitrator, not to the automated + // refund path — the bridge must not pull it out from under them. + assert!(!is_refund_claimable(TradeStatus::Disputed, 1_100, 9_999)); + } +} From 2d59a2ae739d8d5706c72122b024074e6e51755f Mon Sep 17 00:00:00 2001 From: N-thnI Date: Sat, 29 Aug 2026 23:29:02 +0100 Subject: [PATCH 2/5] feat(api): atomic swap dispute bridge schema and exactly-once claim store 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. --- .../029_add_atomic_swap_dispute_bridge.sql | 64 +++ apps/api/src/lib/swapDisputeStore.ts | 375 ++++++++++++++++++ tests/concurrency/swap_dispute_stress.test.ts | 276 +++++++++++++ 3 files changed, 715 insertions(+) create mode 100644 apps/api/src/db/migrations/029_add_atomic_swap_dispute_bridge.sql create mode 100644 apps/api/src/lib/swapDisputeStore.ts create mode 100644 tests/concurrency/swap_dispute_stress.test.ts diff --git a/apps/api/src/db/migrations/029_add_atomic_swap_dispute_bridge.sql b/apps/api/src/db/migrations/029_add_atomic_swap_dispute_bridge.sql new file mode 100644 index 0000000..0af6b19 --- /dev/null +++ b/apps/api/src/db/migrations/029_add_atomic_swap_dispute_bridge.sql @@ -0,0 +1,64 @@ +BEGIN; + +-- Cross-Ledger Settlement Time-Lock Atomic Swap Dispute Bridge. +-- +-- A cross-chain HTLC swap has two legs. The Stellar leg +-- (contracts/atomic-swap) publishes the revealed preimage in its `released` +-- event precisely so a relayer can claim the counterpart leg on the other +-- chain. Two failure modes follow from that design, and this table exists to +-- close both: +-- +-- Asymmetric lockup — the counterparty reveals on chain A and stalls on +-- chain B. Until now the honest party simply waited out the full timeout +-- with their funds locked and no automated claims path. +-- +-- Relayer secret leakage — the revealed preimage lives in an event log. If +-- the relayer misses that event before the local HTLC expires, the secret +-- is effectively lost and the collateral is unrecoverable. +-- +-- `atomic_swap_dispute_bridges` is one row per swap: it durably records the +-- preimage the moment it is observed on either chain (so a missed event is no +-- longer fatal), and tracks the swap through a small state machine so the +-- worker knows what is still owed. +-- +-- Concurrency: `swapDisputeWorker` and an operator-triggered +-- POST /api/v1/swaps/dispute-claim can act on the same swap at the same +-- moment. Both read and write under `SELECT ... FOR UPDATE` on this row, and +-- state transitions are CAS-style (`UPDATE ... WHERE state = $expected`), so +-- a refund is claimed exactly once no matter how many callers race +-- (tests/concurrency/swap_dispute_stress.test.ts). + +CREATE TYPE swap_dispute_state AS ENUM ( + -- Swap is live; neither side has revealed and the timeout has not passed. + 'ACTIVE', + -- A preimage has been observed on-chain and stored in secret_preimage. + -- The counterpart leg can now be claimed with it. + 'SECRET_EXTRACTED', + -- expiration_ledger has passed with no secret. A short-lived claim state: + -- the caller that wins the CAS into this state is the only one permitted + -- to submit refund() on-chain. + 'REFUND_CLAIMABLE', + -- Terminal. Either the swap settled with the extracted secret or the + -- refund landed. + 'RESOLVED' +); + +CREATE TABLE atomic_swap_dispute_bridges ( + swap_id VARCHAR(64) PRIMARY KEY, + initiator_address VARCHAR(56) NOT NULL, + counterparty_address VARCHAR(56) NOT NULL, + secret_hash VARCHAR(64) NOT NULL, + -- NULL until a preimage is observed on either leg. Written once and never + -- overwritten: the first correct preimage is the only one that matters. + secret_preimage VARCHAR(64) NULL, + expiration_ledger INT NOT NULL, + state swap_dispute_state NOT NULL DEFAULT 'ACTIVE', + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP +); + +-- The worker's hot query is "which live swaps have expired?", i.e. a range +-- scan on expiration_ledger filtered by state. Leading with expiration_ledger +-- keeps that an index range read rather than a full scan as the table grows. +CREATE INDEX idx_swap_expiration ON atomic_swap_dispute_bridges(expiration_ledger, state); + +COMMIT; diff --git a/apps/api/src/lib/swapDisputeStore.ts b/apps/api/src/lib/swapDisputeStore.ts new file mode 100644 index 0000000..571f3c1 --- /dev/null +++ b/apps/api/src/lib/swapDisputeStore.ts @@ -0,0 +1,375 @@ +/** + * Cross-Ledger Settlement Time-Lock Atomic Swap Dispute Bridge — store. + * + * Backs `atomic_swap_dispute_bridges` (migration 029). Two callers act on the + * same swap concurrently: `swapDisputeWorker` scanning for expiries and + * revealed preimages, and an operator hitting + * POST /api/v1/swaps/dispute-claim. Both must be able to run at once without + * ever submitting two refunds for one swap. + * + * The concurrency model mirrors `multisigEscrowStore`: take + * `SELECT ... FOR UPDATE` on the bridge row, then transition state CAS-style + * (`UPDATE ... WHERE state = $expected`). Only the caller whose UPDATE + * actually matched a row owns the follow-on on-chain submission — everyone + * else is told the work is already claimed and does nothing. Slow I/O (the + * Soroban call) happens *outside* the lock, so a stuck RPC cannot pin a + * database row. + * + * Without a pool the store falls back to an in-memory map, so unit and + * concurrency tests run with no database. Node runs this on a single thread, + * so an in-memory critical section that never awaits is atomic by + * construction — the fallback preserves the same exactly-once guarantee the + * SQL path gets from row locks. + */ +import type { Pool, PoolClient } from "pg"; +import { createHash } from "node:crypto"; + +export type SwapDisputeState = + | "ACTIVE" + | "SECRET_EXTRACTED" + | "REFUND_CLAIMABLE" + | "RESOLVED"; + +export interface SwapDisputeBridge { + swapId: string; + initiatorAddress: string; + counterpartyAddress: string; + secretHash: string; + secretPreimage: string | null; + expirationLedger: number; + state: SwapDisputeState; +} + +export interface RegisterSwapInput { + swapId: string; + initiatorAddress: string; + counterpartyAddress: string; + secretHash: string; + expirationLedger: number; +} + +export interface RecordSecretResult { + bridge: SwapDisputeBridge; + /** + * True exactly once, for the caller whose observation first moved the swap + * to SECRET_EXTRACTED. That caller owns claiming the counterpart leg. + */ + claimedForSettlement: boolean; +} + +export interface ClaimRefundResult { + bridge: SwapDisputeBridge; + /** + * True exactly once, for the single caller that moved the swap to + * REFUND_CLAIMABLE. Only that caller may submit refund() on-chain. + */ + claimedForRefund: boolean; + /** Why a claim was refused, when `claimedForRefund` is false. */ + reason: "claimed" | "not_expired" | "secret_already_extracted" | "resolved" | null; +} + +interface BridgeRow { + swap_id: string; + initiator_address: string; + counterparty_address: string; + secret_hash: string; + secret_preimage: string | null; + expiration_ledger: number; + state: SwapDisputeState; +} + +export class SwapDisputeNotFoundError extends Error { + constructor(swapId: string) { + super(`Atomic swap dispute bridge not found: ${swapId}`); + this.name = "SwapDisputeNotFoundError"; + } +} + +export class InvalidPreimageError extends Error { + constructor(swapId: string) { + super(`Preimage does not hash to the swap's secret_hash: ${swapId}`); + this.name = "InvalidPreimageError"; + } +} + +function rowToBridge(row: BridgeRow): SwapDisputeBridge { + return { + swapId: row.swap_id, + initiatorAddress: row.initiator_address, + counterpartyAddress: row.counterparty_address, + secretHash: row.secret_hash, + secretPreimage: row.secret_preimage, + expirationLedger: Number(row.expiration_ledger), + state: row.state, + }; +} + +const SELECT_COLUMNS = `swap_id, initiator_address, counterparty_address, + secret_hash, secret_preimage, expiration_ledger, state`; + +/** + * Verifies a preimage against the swap's secret hash. + * + * The Stellar leg hashes with SHA-256 (`env.crypto().sha256` in + * contracts/atomic-swap), so the check here must be SHA-256 too. Both sides + * are hex; comparison is case-insensitive because chains differ on casing. + */ +export function preimageMatchesHash(preimageHex: string, secretHashHex: string): boolean { + if (!/^[0-9a-fA-F]{64}$/.test(preimageHex)) return false; + const digest = createHash("sha256").update(Buffer.from(preimageHex, "hex")).digest("hex"); + return digest.toLowerCase() === secretHashHex.toLowerCase(); +} + +export class SwapDisputeStore { + private readonly pool: Pool | null; + private readonly memory = new Map(); + + constructor(pool: Pool | null = null) { + this.pool = pool; + } + + /** Registers a swap for monitoring. Idempotent — re-registering is a no-op. */ + async registerSwap(input: RegisterSwapInput): Promise { + if (!this.pool) { + const existing = this.memory.get(input.swapId); + if (existing) return { ...existing }; + const bridge: SwapDisputeBridge = { + swapId: input.swapId, + initiatorAddress: input.initiatorAddress, + counterpartyAddress: input.counterpartyAddress, + secretHash: input.secretHash, + secretPreimage: null, + expirationLedger: input.expirationLedger, + state: "ACTIVE", + }; + this.memory.set(input.swapId, bridge); + return { ...bridge }; + } + + const { rows } = await this.pool.query( + `INSERT INTO atomic_swap_dispute_bridges + (swap_id, initiator_address, counterparty_address, secret_hash, expiration_ledger) + VALUES ($1, $2, $3, $4, $5) + ON CONFLICT (swap_id) DO UPDATE SET swap_id = EXCLUDED.swap_id + RETURNING ${SELECT_COLUMNS}`, + [ + input.swapId, + input.initiatorAddress, + input.counterpartyAddress, + input.secretHash, + input.expirationLedger, + ], + ); + return rowToBridge(rows[0]); + } + + async getBridge(swapId: string): Promise { + if (!this.pool) { + const bridge = this.memory.get(swapId); + return bridge ? { ...bridge } : null; + } + const { rows } = await this.pool.query( + `SELECT ${SELECT_COLUMNS} FROM atomic_swap_dispute_bridges WHERE swap_id = $1`, + [swapId], + ); + return rows[0] ? rowToBridge(rows[0]) : null; + } + + /** + * Swaps whose timeout has passed and which are still ACTIVE — the worker's + * candidate set for automated refund claims. + */ + async listExpiredActive(currentLedger: number, limit = 100): Promise { + if (!this.pool) { + return [...this.memory.values()] + .filter((b) => b.state === "ACTIVE" && currentLedger >= b.expirationLedger) + .slice(0, limit) + .map((b) => ({ ...b })); + } + const { rows } = await this.pool.query( + `SELECT ${SELECT_COLUMNS} + FROM atomic_swap_dispute_bridges + WHERE state = 'ACTIVE' AND expiration_ledger <= $1 + ORDER BY expiration_ledger ASC + LIMIT $2`, + [currentLedger, limit], + ); + return rows.map(rowToBridge); + } + + /** + * Durably records a preimage observed on either leg. + * + * This is the answer to relayer secret leakage: the preimage stops being + * event-log-only the moment it is seen. Write-once — a swap that already + * has a stored preimage keeps the first one, so a later (or malicious) + * caller cannot overwrite it. + * + * Rejects a preimage that does not hash to `secret_hash`, so a bad + * observation can never poison the record. + */ + async recordSecret(swapId: string, preimageHex: string): Promise { + if (!this.pool) return this.recordSecretInMemory(swapId, preimageHex); + + const client = await this.pool.connect(); + try { + await client.query("BEGIN"); + const result = await this.recordSecretLocked(client, swapId, preimageHex); + await client.query("COMMIT"); + return result; + } catch (error) { + await client.query("ROLLBACK").catch(() => undefined); + throw error; + } finally { + client.release(); + } + } + + private async recordSecretLocked( + client: Pick, + swapId: string, + preimageHex: string, + ): Promise { + const { rows } = await client.query( + `SELECT ${SELECT_COLUMNS} + FROM atomic_swap_dispute_bridges WHERE swap_id = $1 FOR UPDATE`, + [swapId], + ); + if (!rows[0]) throw new SwapDisputeNotFoundError(swapId); + const current = rowToBridge(rows[0]); + + if (!preimageMatchesHash(preimageHex, current.secretHash)) { + throw new InvalidPreimageError(swapId); + } + + // Write-once: only an ACTIVE swap with no stored preimage transitions. + const { rows: updated } = await client.query( + `UPDATE atomic_swap_dispute_bridges + SET secret_preimage = $2, state = 'SECRET_EXTRACTED' + WHERE swap_id = $1 AND secret_preimage IS NULL AND state = 'ACTIVE' + RETURNING ${SELECT_COLUMNS}`, + [swapId, preimageHex], + ); + + if (!updated[0]) { + return { bridge: current, claimedForSettlement: false }; + } + return { bridge: rowToBridge(updated[0]), claimedForSettlement: true }; + } + + private recordSecretInMemory(swapId: string, preimageHex: string): RecordSecretResult { + const bridge = this.memory.get(swapId); + if (!bridge) throw new SwapDisputeNotFoundError(swapId); + if (!preimageMatchesHash(preimageHex, bridge.secretHash)) { + throw new InvalidPreimageError(swapId); + } + + if (bridge.secretPreimage !== null || bridge.state !== "ACTIVE") { + return { bridge: { ...bridge }, claimedForSettlement: false }; + } + + bridge.secretPreimage = preimageHex; + bridge.state = "SECRET_EXTRACTED"; + return { bridge: { ...bridge }, claimedForSettlement: true }; + } + + /** + * Claims the right to submit an on-chain refund for an expired swap. + * + * Returns `claimedForRefund: true` for exactly one caller. Everyone else + * gets `false` plus the reason, and must not submit. A swap whose secret + * was extracted is never refundable — it settles instead, and refunding it + * would hand the funds back while the counterparty still holds a usable + * preimage. + */ + async claimRefund(swapId: string, currentLedger: number): Promise { + if (!this.pool) return this.claimRefundInMemory(swapId, currentLedger); + + const client = await this.pool.connect(); + try { + await client.query("BEGIN"); + const result = await this.claimRefundLocked(client, swapId, currentLedger); + await client.query("COMMIT"); + return result; + } catch (error) { + await client.query("ROLLBACK").catch(() => undefined); + throw error; + } finally { + client.release(); + } + } + + private async claimRefundLocked( + client: Pick, + swapId: string, + currentLedger: number, + ): Promise { + const { rows } = await client.query( + `SELECT ${SELECT_COLUMNS} + FROM atomic_swap_dispute_bridges WHERE swap_id = $1 FOR UPDATE`, + [swapId], + ); + if (!rows[0]) throw new SwapDisputeNotFoundError(swapId); + const current = rowToBridge(rows[0]); + + const refusal = refusalReason(current, currentLedger); + if (refusal) return { bridge: current, claimedForRefund: false, reason: refusal }; + + const { rows: updated } = await client.query( + `UPDATE atomic_swap_dispute_bridges + SET state = 'REFUND_CLAIMABLE' + WHERE swap_id = $1 AND state = 'ACTIVE' + RETURNING ${SELECT_COLUMNS}`, + [swapId], + ); + + if (!updated[0]) { + return { bridge: current, claimedForRefund: false, reason: "claimed" }; + } + return { bridge: rowToBridge(updated[0]), claimedForRefund: true, reason: null }; + } + + private claimRefundInMemory(swapId: string, currentLedger: number): ClaimRefundResult { + const bridge = this.memory.get(swapId); + if (!bridge) throw new SwapDisputeNotFoundError(swapId); + + const refusal = refusalReason(bridge, currentLedger); + if (refusal) return { bridge: { ...bridge }, claimedForRefund: false, reason: refusal }; + + bridge.state = "REFUND_CLAIMABLE"; + return { bridge: { ...bridge }, claimedForRefund: true, reason: null }; + } + + /** Marks a swap terminal once its refund or settlement has landed on-chain. */ + async markResolved(swapId: string): Promise { + if (!this.pool) { + const bridge = this.memory.get(swapId); + if (!bridge) throw new SwapDisputeNotFoundError(swapId); + bridge.state = "RESOLVED"; + return { ...bridge }; + } + + const { rows } = await this.pool.query( + `UPDATE atomic_swap_dispute_bridges SET state = 'RESOLVED' + WHERE swap_id = $1 RETURNING ${SELECT_COLUMNS}`, + [swapId], + ); + if (!rows[0]) throw new SwapDisputeNotFoundError(swapId); + return rowToBridge(rows[0]); + } +} + +/** + * Why a swap cannot be claimed for refund right now, or null if it can. + * Shared by the SQL and in-memory paths so both refuse for the same reasons. + */ +function refusalReason( + bridge: SwapDisputeBridge, + currentLedger: number, +): ClaimRefundResult["reason"] { + if (bridge.state === "RESOLVED") return "resolved"; + if (bridge.state === "SECRET_EXTRACTED") return "secret_already_extracted"; + if (bridge.state === "REFUND_CLAIMABLE") return "claimed"; + if (currentLedger < bridge.expirationLedger) return "not_expired"; + return null; +} diff --git a/tests/concurrency/swap_dispute_stress.test.ts b/tests/concurrency/swap_dispute_stress.test.ts new file mode 100644 index 0000000..69e8e7b --- /dev/null +++ b/tests/concurrency/swap_dispute_stress.test.ts @@ -0,0 +1,276 @@ +import { describe, expect, it } from "vitest"; +import { createHash } from "node:crypto"; +import { + SwapDisputeStore, + InvalidPreimageError, + SwapDisputeNotFoundError, +} from "../../apps/api/src/lib/swapDisputeStore.js"; + +/** + * Atomic swap dispute bridge stress test. + * + * The invariant: for any one swap, **at most one** refund is ever claimed and + * **at most one** caller owns the extracted secret — no matter how many + * workers, retries, and operator API calls race on the same swap in the same + * moment. A duplicate refund claim would mean two `refund()` submissions + * racing on-chain; the contract's own status check stops a literal + * double-payout, but the API should never attempt the second, since it is a + * guaranteed-failed transaction against a leg that has already paid out. + * + * Mirrors this repo's other concurrency stress tests + * (tests/concurrency/multisig_release_stress.test.ts, + * tests/concurrency/flash_loan_stress.test.ts): race `Promise.all` against the + * store and assert on its invariants, rather than timing anything + * wall-clock-dependent. + */ +describe("atomic swap dispute bridge vs. concurrent claims", () => { + const SWAP_ID = "a".repeat(64); + const INITIATOR = `GINITIATOR${"X".repeat(46)}`.slice(0, 56); + const COUNTERPARTY = `GCOUNTERPARTY${"X".repeat(43)}`.slice(0, 56); + const EXPIRATION_LEDGER = 1_000; + + const PREIMAGE = "11".repeat(32); + const SECRET_HASH = createHash("sha256") + .update(Buffer.from(PREIMAGE, "hex")) + .digest("hex"); + + async function makeStore(overrides: { expirationLedger?: number } = {}) { + const store = new SwapDisputeStore(); + await store.registerSwap({ + swapId: SWAP_ID, + initiatorAddress: INITIATOR, + counterpartyAddress: COUNTERPARTY, + secretHash: SECRET_HASH, + expirationLedger: overrides.expirationLedger ?? EXPIRATION_LEDGER, + }); + return store; + } + + // ── Refund claims ──────────────────────────────────────────────────────── + + it("exactly one of 50 concurrent refund claims succeeds", async () => { + const store = await makeStore(); + const afterExpiry = EXPIRATION_LEDGER + 1; + + const outcomes = await Promise.all( + Array.from({ length: 50 }, () => store.claimRefund(SWAP_ID, afterExpiry)), + ); + + const winners = outcomes.filter((o) => o.claimedForRefund); + expect(winners).toHaveLength(1); + + // Every loser is told why, so a caller never silently assumes success. + const losers = outcomes.filter((o) => !o.claimedForRefund); + expect(losers).toHaveLength(49); + expect(losers.every((o) => o.reason === "claimed")).toBe(true); + + const bridge = await store.getBridge(SWAP_ID); + expect(bridge?.state).toBe("REFUND_CLAIMABLE"); + }); + + it("a second wave of claims after the first still yields no extra refunds", async () => { + const store = await makeStore(); + const afterExpiry = EXPIRATION_LEDGER + 1; + + const first = await Promise.all( + Array.from({ length: 25 }, () => store.claimRefund(SWAP_ID, afterExpiry)), + ); + const second = await Promise.all( + Array.from({ length: 25 }, () => store.claimRefund(SWAP_ID, afterExpiry)), + ); + + const totalWinners = [...first, ...second].filter((o) => o.claimedForRefund); + expect(totalWinners).toHaveLength(1); + }); + + it("refuses every claim while the swap is still live", async () => { + const store = await makeStore(); + const beforeExpiry = EXPIRATION_LEDGER - 1; + + const outcomes = await Promise.all( + Array.from({ length: 50 }, () => store.claimRefund(SWAP_ID, beforeExpiry)), + ); + + expect(outcomes.every((o) => !o.claimedForRefund)).toBe(true); + expect(outcomes.every((o) => o.reason === "not_expired")).toBe(true); + expect((await store.getBridge(SWAP_ID))?.state).toBe("ACTIVE"); + }); + + it("claims exactly at the expiration ledger, not one before", async () => { + const store = await makeStore(); + + const early = await store.claimRefund(SWAP_ID, EXPIRATION_LEDGER - 1); + expect(early.claimedForRefund).toBe(false); + + const onTime = await store.claimRefund(SWAP_ID, EXPIRATION_LEDGER); + expect(onTime.claimedForRefund).toBe(true); + }); + + // ── Secret extraction ──────────────────────────────────────────────────── + + it("exactly one of 50 concurrent secret extractions claims settlement", async () => { + const store = await makeStore(); + + const outcomes = await Promise.all( + Array.from({ length: 50 }, () => store.recordSecret(SWAP_ID, PREIMAGE)), + ); + + const winners = outcomes.filter((o) => o.claimedForSettlement); + expect(winners).toHaveLength(1); + + const bridge = await store.getBridge(SWAP_ID); + expect(bridge?.state).toBe("SECRET_EXTRACTED"); + expect(bridge?.secretPreimage).toBe(PREIMAGE); + }); + + it("the stored preimage is write-once", async () => { + const store = await makeStore(); + await store.recordSecret(SWAP_ID, PREIMAGE); + + // A second, differently-hashing preimage is rejected outright... + const other = "22".repeat(32); + await expect(store.recordSecret(SWAP_ID, other)).rejects.toBeInstanceOf( + InvalidPreimageError, + ); + + // ...and the original survives untouched. + expect((await store.getBridge(SWAP_ID))?.secretPreimage).toBe(PREIMAGE); + }); + + it("rejects a preimage that does not hash to the swap's secret hash", async () => { + const store = await makeStore(); + await expect(store.recordSecret(SWAP_ID, "33".repeat(32))).rejects.toBeInstanceOf( + InvalidPreimageError, + ); + expect((await store.getBridge(SWAP_ID))?.secretPreimage).toBeNull(); + }); + + // ── The two paths must never both fire ─────────────────────────────────── + + it("an extracted secret makes the swap permanently un-refundable", async () => { + const store = await makeStore(); + await store.recordSecret(SWAP_ID, PREIMAGE); + + // Well past expiry, a refund must still be refused: the counterparty holds + // a usable preimage, so returning the funds would let them take both legs. + const claim = await store.claimRefund(SWAP_ID, EXPIRATION_LEDGER + 10_000); + expect(claim.claimedForRefund).toBe(false); + expect(claim.reason).toBe("secret_already_extracted"); + }); + + it("secret extraction and refund claims racing together resolve to one outcome", async () => { + const store = await makeStore(); + const afterExpiry = EXPIRATION_LEDGER + 1; + + // 25 workers see a reveal at the same instant 25 others see an expiry. + const outcomes = await Promise.all([ + ...Array.from({ length: 25 }, () => + store.recordSecret(SWAP_ID, PREIMAGE).then( + (r) => ({ kind: "secret" as const, claimed: r.claimedForSettlement }), + () => ({ kind: "secret" as const, claimed: false }), + ), + ), + ...Array.from({ length: 25 }, () => + store.claimRefund(SWAP_ID, afterExpiry).then( + (r) => ({ kind: "refund" as const, claimed: r.claimedForRefund }), + () => ({ kind: "refund" as const, claimed: false }), + ), + ), + ]); + + const claimed = outcomes.filter((o) => o.claimed); + + // Exactly one side wins overall — never a secret extraction *and* a refund + // for the same swap, which would be the double-spend this bridge exists to + // prevent. + expect(claimed).toHaveLength(1); + + const bridge = await store.getBridge(SWAP_ID); + expect(["SECRET_EXTRACTED", "REFUND_CLAIMABLE"]).toContain(bridge?.state); + }); + + it("a resolved swap accepts no further claims", async () => { + const store = await makeStore(); + await store.claimRefund(SWAP_ID, EXPIRATION_LEDGER + 1); + await store.markResolved(SWAP_ID); + + const outcomes = await Promise.all( + Array.from({ length: 20 }, () => store.claimRefund(SWAP_ID, EXPIRATION_LEDGER + 1)), + ); + expect(outcomes.every((o) => !o.claimedForRefund)).toBe(true); + expect(outcomes.every((o) => o.reason === "resolved")).toBe(true); + }); + + // ── Isolation and error handling ───────────────────────────────────────── + + it("concurrent claims across many swaps stay independent", async () => { + const store = new SwapDisputeStore(); + const swapIds = Array.from({ length: 20 }, (_, i) => String(i).padStart(64, "0")); + + await Promise.all( + swapIds.map((swapId) => + store.registerSwap({ + swapId, + initiatorAddress: INITIATOR, + counterpartyAddress: COUNTERPARTY, + secretHash: SECRET_HASH, + expirationLedger: EXPIRATION_LEDGER, + }), + ), + ); + + // Five racers per swap, all 20 swaps at once. + const outcomes = await Promise.all( + swapIds.flatMap((swapId) => + Array.from({ length: 5 }, () => + store.claimRefund(swapId, EXPIRATION_LEDGER + 1).then((r) => ({ + swapId, + claimed: r.claimedForRefund, + })), + ), + ), + ); + + // Exactly one winner per swap — 20 in total, one for each distinct id. + const winners = outcomes.filter((o) => o.claimed); + expect(winners).toHaveLength(swapIds.length); + expect(new Set(winners.map((w) => w.swapId)).size).toBe(swapIds.length); + }); + + it("registering the same swap twice is idempotent under concurrency", async () => { + const store = new SwapDisputeStore(); + const inputs = { + swapId: SWAP_ID, + initiatorAddress: INITIATOR, + counterpartyAddress: COUNTERPARTY, + secretHash: SECRET_HASH, + expirationLedger: EXPIRATION_LEDGER, + }; + + const bridges = await Promise.all( + Array.from({ length: 10 }, () => store.registerSwap(inputs)), + ); + + expect(bridges.every((b) => b.swapId === SWAP_ID)).toBe(true); + expect(bridges.every((b) => b.state === "ACTIVE")).toBe(true); + }); + + it("claims against an unknown swap reject rather than inventing a bridge", async () => { + const store = await makeStore(); + await expect( + store.claimRefund("f".repeat(64), EXPIRATION_LEDGER + 1), + ).rejects.toBeInstanceOf(SwapDisputeNotFoundError); + }); + + it("listExpiredActive only returns live, expired swaps", async () => { + const store = await makeStore(); + + expect(await store.listExpiredActive(EXPIRATION_LEDGER - 1)).toHaveLength(0); + expect(await store.listExpiredActive(EXPIRATION_LEDGER)).toHaveLength(1); + + // Once claimed it leaves the worker's candidate set, so the next tick does + // not try to claim it again. + await store.claimRefund(SWAP_ID, EXPIRATION_LEDGER); + expect(await store.listExpiredActive(EXPIRATION_LEDGER)).toHaveLength(0); + }); +}); From d315798ca8cf6335b62e5552cde234b148bee3f9 Mon Sep 17 00:00:00 2001 From: N-thnI Date: Sat, 29 Aug 2026 23:29:24 +0100 Subject: [PATCH 3/5] feat(api): dispute-claim route, secret extraction worker and alerts 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. --- apps/api/src/app.ts | 10 + apps/api/src/lib/stellar.ts | 25 +++ apps/api/src/lib/timeouts.ts | 71 ++++++ apps/api/src/lib/webhook.ts | 108 +++++++++ .../src/lib/workers/swapDisputeWorker.test.ts | 198 +++++++++++++++++ apps/api/src/lib/workers/swapDisputeWorker.ts | 207 ++++++++++++++++++ apps/api/src/routes/swap-dispute.ts | 187 ++++++++++++++++ 7 files changed, 806 insertions(+) create mode 100644 apps/api/src/lib/workers/swapDisputeWorker.test.ts create mode 100644 apps/api/src/lib/workers/swapDisputeWorker.ts create mode 100644 apps/api/src/routes/swap-dispute.ts diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index e2fab06..5af54dd 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -45,6 +45,8 @@ import { collateralRoutes } from "./routes/collateral.js"; import { CollateralGuardStore } from "./lib/collateralGuard.js"; import { multisigEscrowRoutes } from "./routes/multisig-escrow.js"; import { MultisigEscrowStore } from "./lib/multisigEscrowStore.js"; +import { swapDisputeRoutes } from "./routes/swap-dispute.js"; +import { SwapDisputeStore } from "./lib/swapDisputeStore.js"; import { getChatInfrastructure } from "./lib/chat-infrastructure.js"; import { juryArbitrationRoutes } from "./routes/jury-arbitration.js"; @@ -440,6 +442,14 @@ app.register(multisigEscrowRoutes, { prefix: "/api/v1", store: new MultisigEscrowStore(pgPool ?? undefined), }); +// Cross-Ledger Settlement Time-Lock Atomic Swap Dispute Bridge: automated +// secret extraction and refund claims when a cross-chain counterparty stalls. +// Shares the pool so its SELECT ... FOR UPDATE claims coordinate with the +// dispute worker; degrades to an in-memory store in dev like the routes above. +app.register(swapDisputeRoutes, { + prefix: "/api/v1", + store: new SwapDisputeStore(pgPool ?? null), +}); // (#404) Decentralized Jury Dispute Arbitration: commit-reveal voting, // VRF juror selection, and automated escrow resolution with stake slashing. app.register(juryArbitrationRoutes, { prefix: "/api/v1" }); diff --git a/apps/api/src/lib/stellar.ts b/apps/api/src/lib/stellar.ts index e974ac3..91ae462 100644 --- a/apps/api/src/lib/stellar.ts +++ b/apps/api/src/lib/stellar.ts @@ -109,6 +109,31 @@ export async function getLatestLedgerSequence(): Promise { return (await server.getLatestLedger()).sequence; } +/** + * Normalizes a revealed preimage from either leg into lower-case hex. + * + * The Stellar leg surfaces a 32-byte preimage as raw bytes or as hex; EVM logs + * surface it `0x`-prefixed and often upper-cased. The dispute bridge stores + * one canonical form so a preimage observed twice, from two chains, is + * recognised as the same secret rather than written twice. + * + * Returns null for anything that is not exactly 32 bytes, so a malformed log + * entry is dropped rather than persisted as a bogus secret. + */ +export function normalizeRevealedPreimage( + raw: string | Uint8Array | Buffer | null | undefined, +): string | null { + if (raw === null || raw === undefined) return null; + + if (typeof raw !== "string") { + const bytes = Buffer.from(raw); + return bytes.length === 32 ? bytes.toString("hex") : null; + } + + const hex = raw.startsWith("0x") || raw.startsWith("0X") ? raw.slice(2) : raw; + return /^[0-9a-fA-F]{64}$/.test(hex) ? hex.toLowerCase() : null; +} + /** * Issue #420: latest closed ledger sequence with bounded retries. * diff --git a/apps/api/src/lib/timeouts.ts b/apps/api/src/lib/timeouts.ts index 12e95d2..a00693c 100644 --- a/apps/api/src/lib/timeouts.ts +++ b/apps/api/src/lib/timeouts.ts @@ -180,6 +180,77 @@ const ____: unknown = ((): unknown => { */ export const AVERAGE_LEDGER_CLOSE_SECONDS = 6; +// ============================================================================ +// Cross-ledger atomic swap dispute bridge +// ============================================================================ + +/** + * How often the swap dispute worker scans for revealed preimages and expiries. + * + * Deliberately shorter than one ledger close (~6s): the requirement is that a + * revealed secret is extracted within one ledger sequence, so the scan has to + * run at least once per ledger to have a chance of catching it. + * Can be overridden via SWAP_DISPUTE_POLL_INTERVAL_MS. + */ +export const DEFAULT_SWAP_DISPUTE_POLL_INTERVAL_MS = 5_000; + +/** + * Ledgers of margin before a swap's expiry at which it is treated as at-risk + * and operators are alerted. + * + * ~50 ledgers ≈ 5 minutes. The point is to fire while a refund can still be + * organised, not to announce the loss afterwards. + */ +export const SWAP_DISPUTE_WARNING_MARGIN_LEDGERS = 50; + +/** + * Extra ledgers to wait after expiry before claiming an automated refund. + * + * Zero: the on-chain `refund()` already enforces + * `current_ledger >= timeout_ledger`, so waiting longer only prolongs the + * lockup this feature exists to end. Kept as a named constant so the "no extra + * grace" decision is explicit rather than an accident of the code. + */ +export const SWAP_DISPUTE_REFUND_GRACE_LEDGERS = 0; + +/** Lifecycle of one cross-chain swap as tracked by the dispute bridge. */ +export interface SwapDisputeCountdown { + expirationLedger: number; + latestLedger: number; + ledgersUntilExpiry: number; + /** True once the on-chain refund precondition is satisfied. */ + refundClaimable: boolean; + /** True while inside the warning margin but not yet expired. */ + approachingExpiry: boolean; + estimatedSecondsUntilExpiry: number; +} + +/** + * Builds the countdown the dispute card and worker both read from. + * + * `refundClaimable` mirrors the contract's own precondition exactly + * (`latestLedger >= expirationLedger`), so the UI never offers a claim the + * chain would reject. + */ +export function buildSwapDisputeCountdown( + expirationLedger: number, + latestLedger: number, + warningMarginLedgers: number = SWAP_DISPUTE_WARNING_MARGIN_LEDGERS, +): SwapDisputeCountdown { + const ledgersUntilExpiry = Math.max(0, expirationLedger - latestLedger); + const refundClaimable = + latestLedger >= expirationLedger + SWAP_DISPUTE_REFUND_GRACE_LEDGERS; + + return { + expirationLedger, + latestLedger, + ledgersUntilExpiry, + refundClaimable, + approachingExpiry: !refundClaimable && ledgersUntilExpiry <= warningMarginLedgers, + estimatedSecondsUntilExpiry: ledgersUntilExpiry * AVERAGE_LEDGER_CLOSE_SECONDS, + }; +} + /** Public countdown for when permissionless refund becomes available. */ export interface RefundCountdown { timeoutLedger: number; diff --git a/apps/api/src/lib/webhook.ts b/apps/api/src/lib/webhook.ts index 0bd3177..b4bc288 100644 --- a/apps/api/src/lib/webhook.ts +++ b/apps/api/src/lib/webhook.ts @@ -104,3 +104,111 @@ export async function sendRefundCountdownAlert(params: { }, }); } + +/** + * A cross-chain swap's preimage was observed on-chain and stored off-chain. + * + * This is the "secret is safe now" signal: until it fires, the preimage + * exists only in an event log, and missing it means the collateral cannot be + * recovered. Operators want to see this land well before the local timeout. + */ +export async function sendSwapSecretExtractedAlert(params: { + swapId: string; + secretHash: string; + initiator: string; + counterparty: string; + extractedAtLedger: number; + expirationLedger: number; +}): Promise { + const { + swapId, + secretHash, + initiator, + counterparty, + extractedAtLedger, + expirationLedger, + } = params; + await sendWebhookAlert({ + title: "Swap secret extracted", + text: `Preimage for swap \`${swapId}\` extracted at ledger ${extractedAtLedger} and stored off-chain.`, + fields: { + "Swap ID": `\`${swapId}\``, + "Secret hash": `\`${secretHash}\``, + "Extracted at ledger": String(extractedAtLedger), + "Expiration ledger": String(expirationLedger), + "Ledgers to spare": String(Math.max(0, expirationLedger - extractedAtLedger)), + Initiator: `\`${initiator}\``, + Counterparty: `\`${counterparty}\``, + }, + }); +} + +/** + * A swap expired without either side revealing, and an automated refund has + * been claimed for the honest party. + * + * Fired by whichever caller won the `SELECT ... FOR UPDATE` claim, so this + * alert appears exactly once per swap even when the worker and an operator + * race each other. + */ +export async function sendSwapRefundClaimedAlert(params: { + swapId: string; + initiator: string; + counterparty: string; + expirationLedger: number; + latestLedger: number; + txHash?: string | null; +}): Promise { + const { swapId, initiator, counterparty, expirationLedger, latestLedger, txHash } = params; + await sendWebhookAlert({ + title: "Swap refund claimed", + text: `Swap \`${swapId}\` expired without a revealed secret — automated refund claimed.`, + fields: { + "Swap ID": `\`${swapId}\``, + "Expiration ledger": String(expirationLedger), + "Latest ledger": String(latestLedger), + "Ledgers overdue": String(Math.max(0, latestLedger - expirationLedger)), + Initiator: `\`${initiator}\``, + Counterparty: `\`${counterparty}\``, + ...(txHash ? { "Tx hash": `\`${txHash}\`` } : {}), + }, + }); +} + +/** + * A swap is inside the warning margin and still has no revealed secret. + * + * The counterpart to the two alerts above: it fires *before* expiry, while an + * operator can still intervene, rather than reporting a lockup after the fact. + */ +export async function sendSwapExpiryWarningAlert(params: { + swapId: string; + initiator: string; + counterparty: string; + expirationLedger: number; + latestLedger: number; + estimatedSecondsUntilExpiry: number; +}): Promise { + const { + swapId, + initiator, + counterparty, + expirationLedger, + latestLedger, + estimatedSecondsUntilExpiry, + } = params; + const ledgersLeft = Math.max(0, expirationLedger - latestLedger); + const etaMinutes = Math.max(1, Math.round(estimatedSecondsUntilExpiry / 60)); + await sendWebhookAlert({ + title: "Swap approaching expiry", + text: `Swap \`${swapId}\` expires in ${ledgersLeft} ledger(s), about ${etaMinutes} min, with no secret revealed.`, + fields: { + "Swap ID": `\`${swapId}\``, + "Ledgers until expiry": String(ledgersLeft), + "Expiration ledger": String(expirationLedger), + "Latest ledger": String(latestLedger), + Initiator: `\`${initiator}\``, + Counterparty: `\`${counterparty}\``, + }, + }); +} diff --git a/apps/api/src/lib/workers/swapDisputeWorker.test.ts b/apps/api/src/lib/workers/swapDisputeWorker.test.ts new file mode 100644 index 0000000..1c407d2 --- /dev/null +++ b/apps/api/src/lib/workers/swapDisputeWorker.test.ts @@ -0,0 +1,198 @@ +import { describe, expect, it, vi, beforeEach } from "vitest"; +import { createHash } from "node:crypto"; + +const alerts = vi.hoisted(() => ({ + secretExtracted: vi.fn(), + refundClaimed: vi.fn(), + expiryWarning: vi.fn(), +})); + +vi.mock("../webhook.js", () => ({ + sendSwapSecretExtractedAlert: alerts.secretExtracted, + sendSwapRefundClaimedAlert: alerts.refundClaimed, + sendSwapExpiryWarningAlert: alerts.expiryWarning, +})); + +const { SwapDisputeStore } = await import("../swapDisputeStore.js"); +const { runSwapDisputeTick, warnOnApproachingExpiry } = await import( + "./swapDisputeWorker.js" +); + +const SWAP_ID = "b".repeat(64); +const INITIATOR = `GINITIATOR${"X".repeat(46)}`.slice(0, 56); +const COUNTERPARTY = `GCOUNTER${"X".repeat(48)}`.slice(0, 56); +const EXPIRATION_LEDGER = 1_000; + +const PREIMAGE = "44".repeat(32); +const SECRET_HASH = createHash("sha256") + .update(Buffer.from(PREIMAGE, "hex")) + .digest("hex"); + +async function makeStore() { + const store = new SwapDisputeStore(); + await store.registerSwap({ + swapId: SWAP_ID, + initiatorAddress: INITIATOR, + counterpartyAddress: COUNTERPARTY, + secretHash: SECRET_HASH, + expirationLedger: EXPIRATION_LEDGER, + }); + return store; +} + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("swap dispute worker", () => { + it("extracts a revealed preimage and alerts once", async () => { + const store = await makeStore(); + + const summary = await runSwapDisputeTick({ + store, + getLedger: async () => EXPIRATION_LEDGER - 100, + pollReveals: async () => [ + { swapId: SWAP_ID, preimageHex: PREIMAGE, source: "stellar" }, + ], + }); + + expect(summary.secretsExtracted).toBe(1); + expect(alerts.secretExtracted).toHaveBeenCalledTimes(1); + + const bridge = await store.getBridge(SWAP_ID); + expect(bridge?.state).toBe("SECRET_EXTRACTED"); + expect(bridge?.secretPreimage).toBe(PREIMAGE); + }); + + it("does not re-alert for a preimage it already stored", async () => { + const store = await makeStore(); + const options = { + store, + getLedger: async () => EXPIRATION_LEDGER - 100, + pollReveals: async () => [ + { swapId: SWAP_ID, preimageHex: PREIMAGE, source: "stellar" as const }, + ], + }; + + await runSwapDisputeTick(options); + const second = await runSwapDisputeTick(options); + + expect(second.secretsExtracted).toBe(0); + expect(alerts.secretExtracted).toHaveBeenCalledTimes(1); + }); + + it("claims a refund for an expired swap and marks it resolved", async () => { + const store = await makeStore(); + const submitRefund = vi.fn(async () => "tx-hash-1"); + + const summary = await runSwapDisputeTick({ + store, + getLedger: async () => EXPIRATION_LEDGER + 1, + pollReveals: async () => [], + submitRefund, + }); + + expect(summary.refundsClaimed).toBe(1); + expect(submitRefund).toHaveBeenCalledTimes(1); + expect(alerts.refundClaimed).toHaveBeenCalledTimes(1); + expect((await store.getBridge(SWAP_ID))?.state).toBe("RESOLVED"); + }); + + it("does not refund a swap that is not yet expired", async () => { + const store = await makeStore(); + const submitRefund = vi.fn(async () => null); + + const summary = await runSwapDisputeTick({ + store, + getLedger: async () => EXPIRATION_LEDGER - 1, + pollReveals: async () => [], + submitRefund, + }); + + expect(summary.refundsClaimed).toBe(0); + expect(submitRefund).not.toHaveBeenCalled(); + }); + + it("never refunds a swap whose secret landed in the same tick", async () => { + // The ordering guarantee: a reveal seen alongside an expiry must win, or + // the funds go back while the counterparty can still take the other leg. + const store = await makeStore(); + const submitRefund = vi.fn(async () => null); + + const summary = await runSwapDisputeTick({ + store, + getLedger: async () => EXPIRATION_LEDGER + 5, + pollReveals: async () => [ + { swapId: SWAP_ID, preimageHex: PREIMAGE, source: "evm" }, + ], + submitRefund, + }); + + expect(summary.secretsExtracted).toBe(1); + expect(summary.refundsClaimed).toBe(0); + expect(submitRefund).not.toHaveBeenCalled(); + expect((await store.getBridge(SWAP_ID))?.state).toBe("SECRET_EXTRACTED"); + }); + + it("keeps processing the batch when one reveal is bad", async () => { + const store = await makeStore(); + const onError = vi.fn(); + + const summary = await runSwapDisputeTick({ + store, + getLedger: async () => EXPIRATION_LEDGER - 100, + pollReveals: async () => [ + // Unknown swap — must not abort the rest of the batch. + { swapId: "f".repeat(64), preimageHex: PREIMAGE, source: "stellar" }, + { swapId: SWAP_ID, preimageHex: PREIMAGE, source: "stellar" }, + ], + onError, + }); + + expect(onError).toHaveBeenCalledTimes(1); + expect(summary.secretsExtracted).toBe(1); + }); + + it("reports an error rather than storing a preimage that does not hash", async () => { + const store = await makeStore(); + const onError = vi.fn(); + + const summary = await runSwapDisputeTick({ + store, + getLedger: async () => EXPIRATION_LEDGER - 100, + pollReveals: async () => [ + { swapId: SWAP_ID, preimageHex: "99".repeat(32), source: "evm" }, + ], + onError, + }); + + expect(summary.secretsExtracted).toBe(0); + expect(onError).toHaveBeenCalledTimes(1); + expect((await store.getBridge(SWAP_ID))?.secretPreimage).toBeNull(); + }); + + it("warns only for live swaps inside the expiry margin", async () => { + const store = await makeStore(); + const bridge = await store.getBridge(SWAP_ID); + expect(bridge).not.toBeNull(); + + // Far from expiry: no warning. + expect(await warnOnApproachingExpiry([bridge!], EXPIRATION_LEDGER - 500)).toBe(0); + + // Inside the margin: one warning. + expect(await warnOnApproachingExpiry([bridge!], EXPIRATION_LEDGER - 10)).toBe(1); + expect(alerts.expiryWarning).toHaveBeenCalledTimes(1); + + // Already expired is the refund path's business, not a warning. + expect(await warnOnApproachingExpiry([bridge!], EXPIRATION_LEDGER + 1)).toBe(0); + }); + + it("does not warn about a swap whose secret is already stored", async () => { + const store = await makeStore(); + await store.recordSecret(SWAP_ID, PREIMAGE); + const bridge = await store.getBridge(SWAP_ID); + + expect(await warnOnApproachingExpiry([bridge!], EXPIRATION_LEDGER - 10)).toBe(0); + expect(alerts.expiryWarning).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/api/src/lib/workers/swapDisputeWorker.ts b/apps/api/src/lib/workers/swapDisputeWorker.ts new file mode 100644 index 0000000..f767793 --- /dev/null +++ b/apps/api/src/lib/workers/swapDisputeWorker.ts @@ -0,0 +1,207 @@ +/** + * Atomic Swap Secret Extraction Worker. + * + * Watches both legs of every live cross-chain swap and does two things the + * counterparties would otherwise have to do by hand: + * + * 1. **Extract secrets.** When a preimage is revealed on either chain, store + * it off-chain immediately. The Stellar leg publishes it in a `released` + * event and (since this feature) in contract state, but an event is only + * seen by whoever is watching at the time — a relayer that was restarting + * loses it, and with it the counterpart leg's collateral. Persisting on + * first sight is what makes that unrecoverable case recoverable. + * + * 2. **Claim refunds.** When a leg expires with no secret anywhere, claim the + * honest party's refund automatically instead of leaving them to notice + * and act. `refund()` is permissionless on-chain, so the worker needs no + * signature from them. + * + * The poll interval is deliberately shorter than a ledger close, so a reveal + * is picked up within one ledger sequence. + * + * Concurrency: this worker races operators calling + * POST /api/v1/swaps/dispute-claim. Both go through `SwapDisputeStore`'s + * `SELECT ... FOR UPDATE` claims, so on-chain submission happens exactly once + * per swap even when they fire simultaneously. + */ +import { + SwapDisputeStore, + type SwapDisputeBridge, +} from "../swapDisputeStore.js"; +import { + DEFAULT_SWAP_DISPUTE_POLL_INTERVAL_MS, + buildSwapDisputeCountdown, +} from "../timeouts.js"; +import { + sendSwapExpiryWarningAlert, + sendSwapRefundClaimedAlert, + sendSwapSecretExtractedAlert, +} from "../webhook.js"; + +/** One observed preimage reveal, from either chain. */ +export interface ObservedReveal { + swapId: string; + /** 32-byte preimage, hex encoded. */ + preimageHex: string; + /** Which leg it came from — recorded for operator context only. */ + source: "stellar" | "evm"; +} + +export interface SwapDisputeWorkerOptions { + store: SwapDisputeStore; + /** Current chain tip; drives both expiry and warning decisions. */ + getLedger: () => Promise; + /** + * Reveals observed since the last tick, from Stellar event logs and/or EVM + * logs. Injected so the worker stays testable without a chain. + */ + pollReveals: () => Promise; + /** + * Submits the on-chain `refund()` for a swap the worker has exclusively + * claimed. Runs outside the database lock — a hung RPC must not pin a row. + * Returning a tx hash is optional and used only for the alert. + */ + submitRefund?: (bridge: SwapDisputeBridge) => Promise; + pollIntervalMs?: number; + onError?: (error: unknown) => void; + onTick?: (summary: SwapDisputeTickSummary) => void; +} + +export interface SwapDisputeTickSummary { + secretsExtracted: number; + refundsClaimed: number; + warningsSent: number; +} + +/** + * Runs one full pass. Exported separately from the interval so tests can drive + * it deterministically instead of waiting on wall-clock timers. + */ +export async function runSwapDisputeTick( + options: SwapDisputeWorkerOptions, +): Promise { + const { store, getLedger, pollReveals, submitRefund } = options; + const summary: SwapDisputeTickSummary = { + secretsExtracted: 0, + refundsClaimed: 0, + warningsSent: 0, + }; + + const latestLedger = await getLedger(); + + // --- 1. Extract any newly revealed preimages ----------------------------- + // + // Secrets first, deliberately: a swap whose preimage just landed must not be + // refunded in the same tick. Refunding it would return the funds while the + // counterparty still holds a usable secret for the other leg. + const reveals = await pollReveals(); + for (const reveal of reveals) { + try { + const result = await store.recordSecret(reveal.swapId, reveal.preimageHex); + if (!result.claimedForSettlement) continue; + + summary.secretsExtracted += 1; + await sendSwapSecretExtractedAlert({ + swapId: result.bridge.swapId, + secretHash: result.bridge.secretHash, + initiator: result.bridge.initiatorAddress, + counterparty: result.bridge.counterpartyAddress, + extractedAtLedger: latestLedger, + expirationLedger: result.bridge.expirationLedger, + }); + } catch (error) { + // One bad reveal (unknown swap, preimage that does not hash) must not + // stop the rest of the batch — the remaining secrets are still at risk. + options.onError?.(error); + } + } + + // --- 2. Claim refunds for legs that expired with no secret --------------- + const expired = await store.listExpiredActive(latestLedger); + for (const bridge of expired) { + try { + const claim = await store.claimRefund(bridge.swapId, latestLedger); + if (!claim.claimedForRefund) continue; // someone else owns it + + // On-chain submission happens outside the lock. + const txHash = submitRefund ? await submitRefund(claim.bridge) : null; + + summary.refundsClaimed += 1; + await sendSwapRefundClaimedAlert({ + swapId: claim.bridge.swapId, + initiator: claim.bridge.initiatorAddress, + counterparty: claim.bridge.counterpartyAddress, + expirationLedger: claim.bridge.expirationLedger, + latestLedger, + txHash, + }); + + await store.markResolved(claim.bridge.swapId); + } catch (error) { + options.onError?.(error); + } + } + + return summary; +} + +/** + * Warns about swaps inside the expiry margin that still have no secret. + * + * Separate from the tick above because it is advisory: it fires while an + * operator can still intervene, and takes no claim on the swap. + */ +export async function warnOnApproachingExpiry( + bridges: SwapDisputeBridge[], + latestLedger: number, +): Promise { + let sent = 0; + for (const bridge of bridges) { + if (bridge.state !== "ACTIVE" || bridge.secretPreimage) continue; + const countdown = buildSwapDisputeCountdown(bridge.expirationLedger, latestLedger); + if (!countdown.approachingExpiry) continue; + + await sendSwapExpiryWarningAlert({ + swapId: bridge.swapId, + initiator: bridge.initiatorAddress, + counterparty: bridge.counterpartyAddress, + expirationLedger: bridge.expirationLedger, + latestLedger, + estimatedSecondsUntilExpiry: countdown.estimatedSecondsUntilExpiry, + }); + sent += 1; + } + return sent; +} + +/** Starts the polling loop. Returns a stop function. */ +export function startSwapDisputeWorker(options: SwapDisputeWorkerOptions): () => void { + // An explicit option wins; otherwise the env override, but only when it + // parses to a usable positive number — an unset or malformed value must fall + // through to the default rather than becoming NaN and firing continuously. + const envInterval = Number(process.env.SWAP_DISPUTE_POLL_INTERVAL_MS); + const pollIntervalMs = + options.pollIntervalMs ?? + (Number.isFinite(envInterval) && envInterval > 0 + ? envInterval + : DEFAULT_SWAP_DISPUTE_POLL_INTERVAL_MS); + + async function tick(): Promise { + try { + const summary = await runSwapDisputeTick(options); + options.onTick?.(summary); + } catch (error) { + options.onError?.(error); + } + } + + const timer = setInterval(() => { + void tick(); + }, pollIntervalMs); + timer.unref?.(); + + // Fire once immediately without blocking startup. + void tick().catch(() => undefined); + + return () => clearInterval(timer); +} diff --git a/apps/api/src/routes/swap-dispute.ts b/apps/api/src/routes/swap-dispute.ts new file mode 100644 index 0000000..851f573 --- /dev/null +++ b/apps/api/src/routes/swap-dispute.ts @@ -0,0 +1,187 @@ +/** + * Cross-Ledger Settlement Time-Lock Atomic Swap Dispute Bridge — HTTP surface. + * + * A cross-chain swap has two legs on two chains. If the counterparty reveals + * on their leg and stalls on ours — or simply vanishes — the honest party's + * funds sit locked until the timeout with no automated way out. These routes + * are that way out: + * + * POST /swaps/dispute-claim — settle with an extracted secret, or claim an + * automated refund once the leg has expired + * GET /swaps/dispute/:swapId — current bridge state and expiry countdown + * + * Concurrency: the worker and an operator can hit the same swap in the same + * moment. Every state transition goes through `SwapDisputeStore` under + * `SELECT ... FOR UPDATE`, so a refund is claimed exactly once no matter how + * many callers race (tests/concurrency/swap_dispute_stress.test.ts). + */ +import type { FastifyInstance } from "fastify"; +import { z } from "zod"; +import { parseBody } from "../lib/validation.js"; +import { ApiError, ErrorCode } from "../lib/errors.js"; +import { + InvalidPreimageError, + SwapDisputeNotFoundError, + SwapDisputeStore, + type SwapDisputeBridge, +} from "../lib/swapDisputeStore.js"; +import { buildSwapDisputeCountdown } from "../lib/timeouts.js"; +import { getLatestLedgerSequence } from "../lib/stellar.js"; +import { + sendSwapRefundClaimedAlert, + sendSwapSecretExtractedAlert, +} from "../lib/webhook.js"; + +const HEX_64 = /^[0-9a-fA-F]{64}$/; + +const disputeClaimSchema = z.object({ + swap_id: z.string().min(1).max(64), + /** + * Optional: a preimage the caller has observed on the counterpart chain. + * When present the swap settles instead of refunding — a swap whose secret + * is known must never be refunded, or the counterparty could still use that + * preimage to take the other leg after we handed the funds back. + */ + secret_preimage: z.string().regex(HEX_64, "secret_preimage must be 32 hex bytes").optional(), +}); + +export interface SwapDisputeRouteOptions { + store?: SwapDisputeStore; + /** Injectable for tests; defaults to the live chain tip. */ + getLedger?: () => Promise; +} + +function serializeBridge(bridge: SwapDisputeBridge, latestLedger: number) { + const countdown = buildSwapDisputeCountdown(bridge.expirationLedger, latestLedger); + return { + swap_id: bridge.swapId, + initiator_address: bridge.initiatorAddress, + counterparty_address: bridge.counterpartyAddress, + secret_hash: bridge.secretHash, + // The preimage itself is returned only once extracted; it is not a secret + // at that point (it is on-chain), and the counterpart leg needs it. + secret_preimage: bridge.secretPreimage, + expiration_ledger: bridge.expirationLedger, + state: bridge.state, + countdown, + }; +} + +export async function swapDisputeRoutes( + app: FastifyInstance, + opts: SwapDisputeRouteOptions = {}, +) { + const store = opts.store ?? new SwapDisputeStore(); + // Wrapped rather than referenced directly: registration must not touch the + // stellar module's exports, so suites that partially mock it (app.test.ts) + // can still build the app without stubbing every ledger helper. + const getLedger = opts.getLedger ?? (() => getLatestLedgerSequence()); + + app.get<{ Params: { swapId: string } }>("/swaps/dispute/:swapId", async (req) => { + const bridge = await store.getBridge(req.params.swapId); + if (!bridge) { + throw new ApiError(404, ErrorCode.NOT_FOUND, "Swap dispute bridge not found"); + } + return serializeBridge(bridge, await getLedger()); + }); + + /** + * Resolves a stalled swap one of two ways, and never both: + * + * * a preimage was supplied or already extracted → settle with it; + * * the leg has expired with no preimage anywhere → claim the refund. + * + * Returns 200 with the resulting bridge state as execution proof. A caller + * that lost the race gets 409 rather than a second on-chain submission. + */ + app.post("/swaps/dispute-claim", async (req, reply) => { + const body = parseBody(disputeClaimSchema, req.body, reply); + if (!body) return reply; + + const latestLedger = await getLedger(); + + let bridge: SwapDisputeBridge; + try { + const existing = await store.getBridge(body.swap_id); + if (!existing) { + throw new ApiError(404, ErrorCode.NOT_FOUND, "Swap dispute bridge not found"); + } + bridge = existing; + + // --- Settlement path: a secret exists or was just handed to us -------- + const preimage = body.secret_preimage ?? bridge.secretPreimage; + if (preimage) { + const result = await store.recordSecret(body.swap_id, preimage); + if (result.claimedForSettlement) { + await sendSwapSecretExtractedAlert({ + swapId: result.bridge.swapId, + secretHash: result.bridge.secretHash, + initiator: result.bridge.initiatorAddress, + counterparty: result.bridge.counterpartyAddress, + extractedAtLedger: latestLedger, + expirationLedger: result.bridge.expirationLedger, + }); + } + return reply.code(200).send({ + outcome: "secret_extracted", + claimed: result.claimedForSettlement, + ...serializeBridge(result.bridge, latestLedger), + }); + } + + // --- Refund path: expired with no secret anywhere --------------------- + const claim = await store.claimRefund(body.swap_id, latestLedger); + if (!claim.claimedForRefund) { + // Every refusal is a state conflict — asking too early, losing the + // race, or a swap that already settled. The message distinguishes + // them; the status code does not need to. + throw new ApiError( + 409, + ErrorCode.WRONG_STATUS, + refusalMessage(claim.reason, claim.bridge, latestLedger), + ); + } + + await sendSwapRefundClaimedAlert({ + swapId: claim.bridge.swapId, + initiator: claim.bridge.initiatorAddress, + counterparty: claim.bridge.counterpartyAddress, + expirationLedger: claim.bridge.expirationLedger, + latestLedger, + }); + + return reply.code(200).send({ + outcome: "refund_claimed", + claimed: true, + ...serializeBridge(claim.bridge, latestLedger), + }); + } catch (error) { + if (error instanceof SwapDisputeNotFoundError) { + throw new ApiError(404, ErrorCode.NOT_FOUND, error.message); + } + if (error instanceof InvalidPreimageError) { + throw new ApiError(400, ErrorCode.VALIDATION_ERROR, error.message); + } + throw error; + } + }); +} + +/** Human-readable explanation for a refused refund claim. */ +function refusalMessage( + reason: "claimed" | "not_expired" | "secret_already_extracted" | "resolved" | null, + bridge: SwapDisputeBridge, + latestLedger: number, +): string { + switch (reason) { + case "not_expired": + return `Swap has not expired yet: ${bridge.expirationLedger - latestLedger} ledger(s) remaining`; + case "secret_already_extracted": + return "Swap secret was already extracted; settle with the preimage instead of refunding"; + case "resolved": + return "Swap is already resolved"; + case "claimed": + default: + return "Refund has already been claimed for this swap"; + } +} From 53ad1929618bb2e109b52458f12443ab91bc05e3 Mon Sep 17 00:00:00 2001 From: N-thnI Date: Sat, 29 Aug 2026 23:29:39 +0100 Subject: [PATCH 4/5] feat(mobile): AtomicSwapDisputeCard for HTLC lockup status and refund 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. --- .../src/components/AtomicSwapDisputeCard.tsx | 183 ++++++++++++++++++ 1 file changed, 183 insertions(+) create mode 100644 mobile/frontend/src/components/AtomicSwapDisputeCard.tsx diff --git a/mobile/frontend/src/components/AtomicSwapDisputeCard.tsx b/mobile/frontend/src/components/AtomicSwapDisputeCard.tsx new file mode 100644 index 0000000..c040c09 --- /dev/null +++ b/mobile/frontend/src/components/AtomicSwapDisputeCard.tsx @@ -0,0 +1,183 @@ +import { useTranslation } from "react-i18next"; + +/** Approximate Stellar ledger close time, mirrors apps/api AVERAGE_LEDGER_CLOSE_SECONDS. */ +const LEDGER_CLOSE_SECONDS = 6; + +/** Mirrors SWAP_DISPUTE_WARNING_MARGIN_LEDGERS in apps/api/src/lib/timeouts.ts. */ +const DEFAULT_WARNING_MARGIN_LEDGERS = 50; + +/** Mirrors swap_dispute_state in migration 029. */ +export type SwapDisputeState = + | "ACTIVE" + | "SECRET_EXTRACTED" + | "REFUND_CLAIMABLE" + | "RESOLVED"; + +export interface AtomicSwapDisputeCardProps { + swapId: string; + counterpartyAddress: string; + /** Ledger at which this leg's HTLC timeout elapses. */ + expirationLedger: number; + /** Current chain tip. */ + latestLedger: number; + state: SwapDisputeState; + /** Present once a preimage has been extracted from either chain. */ + secretPreimage?: string | null; + /** Ledgers of margin before expiry at which the card starts warning. */ + warningMarginLedgers?: number; + /** Invoked by the claim button. Absent renders the card read-only. */ + onClaimRefund?: () => void; + /** True while a claim is in flight, so the button cannot be double-fired. */ + claiming?: boolean; +} + +function shortenAddress(address: string): string { + return address.length > 12 ? `${address.slice(0, 6)}…${address.slice(-4)}` : address; +} + +/** + * Live status of one leg of a cross-chain HTLC swap, and the honest party's + * escape hatch when the counterparty stalls. + * + * Three things a counterparty needs to see, in order of urgency: + * + * * whether the secret has been extracted — once it has, the swap settles + * and there is nothing to dispute; + * * how long the lockup has left before a refund unlocks; + * * a one-click claim, enabled only once the chain would actually accept it. + * + * The claim button's enabled condition mirrors the contract precondition + * (`latestLedger >= expirationLedger`), so the UI never offers an action the + * chain would reject. + */ +export default function AtomicSwapDisputeCard({ + swapId, + counterpartyAddress, + expirationLedger, + latestLedger, + state, + secretPreimage = null, + warningMarginLedgers = DEFAULT_WARNING_MARGIN_LEDGERS, + onClaimRefund, + claiming = false, +}: AtomicSwapDisputeCardProps) { + const { t } = useTranslation(); + + const ledgersUntilExpiry = Math.max(0, expirationLedger - latestLedger); + const expired = latestLedger >= expirationLedger; + const secretExtracted = state === "SECRET_EXTRACTED" || Boolean(secretPreimage); + const resolved = state === "RESOLVED"; + const approachingExpiry = !expired && ledgersUntilExpiry <= warningMarginLedgers; + + // A swap whose secret is out settles with that preimage — refunding it would + // hand the funds back while the counterparty can still take the other leg. + const refundClaimable = expired && !secretExtracted && !resolved; + const secondsUntilExpiry = ledgersUntilExpiry * LEDGER_CLOSE_SECONDS; + + const statusLabel = resolved + ? t("swapDispute.status.resolved", "Resolved") + : secretExtracted + ? t("swapDispute.status.secretExtracted", "Secret extracted — settling") + : refundClaimable + ? t("swapDispute.status.refundClaimable", "Timed out — refund available") + : approachingExpiry + ? t("swapDispute.status.approachingExpiry", "Expiring soon") + : t("swapDispute.status.active", "Locked — awaiting counterparty"); + + const statusTone = resolved + ? "#6b7280" + : secretExtracted + ? "#047857" + : refundClaimable + ? "#b45309" + : approachingExpiry + ? "#b45309" + : "#1d4ed8"; + + return ( +
+
+
+
+ {t("swapDispute.swapId", "Swap")} +
+ + {shortenAddress(swapId)} + +
+ + {statusLabel} + +
+ +
+ {t("swapDispute.counterparty", "Counterparty")}:{" "} + {shortenAddress(counterpartyAddress)} +
+ + {/* Lockup countdown. Ledger counts are the source of truth; the time + estimate is a convenience and never gates the claim. */} +
+ {expired + ? t("swapDispute.expired", "Lockup expired at ledger {{ledger}}", { + ledger: expirationLedger, + }) + : t( + "swapDispute.countdown", + "{{ledgers}} ledger(s) until refund unlocks (~{{seconds}}s)", + { ledgers: ledgersUntilExpiry, seconds: secondsUntilExpiry }, + )} +
+ + {/* Secret extraction progress — the reassurance that the preimage is + stored off-chain and the counterpart leg is claimable. */} +
+ {secretExtracted + ? t("swapDispute.secretStored", "Secret extracted and stored off-chain") + : t("swapDispute.secretPending", "No secret revealed yet on either chain")} +
+ + {onClaimRefund && ( + + )} +
+ ); +} From 5b40791754c79a4e03716f35974ad564725f1b86 Mon Sep 17 00:00:00 2001 From: N-thnI Date: Sun, 30 Aug 2026 00:04:03 +0100 Subject: [PATCH 5/5] fix(ci): satisfy rustfmt and register swapDispute translation keys Both failing checks on #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. --- contracts/atomic-swap/src/lib.rs | 6 +----- mobile/frontend/src/i18n/locales/en.json | 20 +++++++++++++++++++- mobile/frontend/src/i18n/locales/es.json | 20 +++++++++++++++++++- 3 files changed, 39 insertions(+), 7 deletions(-) diff --git a/contracts/atomic-swap/src/lib.rs b/contracts/atomic-swap/src/lib.rs index 32575a9..0d92eb3 100644 --- a/contracts/atomic-swap/src/lib.rs +++ b/contracts/atomic-swap/src/lib.rs @@ -152,11 +152,7 @@ impl AtomicSwapContract { else { return false; }; - htlc_core::is_refund_claimable( - state.status, - state.timeout_ledger, - env.ledger().sequence(), - ) + htlc_core::is_refund_claimable(state.status, state.timeout_ledger, env.ledger().sequence()) } /// Cross-chain: Set finality depth (k-confirmations) for an EVM chain. diff --git a/mobile/frontend/src/i18n/locales/en.json b/mobile/frontend/src/i18n/locales/en.json index bc67fb7..bc2fdd3 100644 --- a/mobile/frontend/src/i18n/locales/en.json +++ b/mobile/frontend/src/i18n/locales/en.json @@ -514,5 +514,23 @@ "comparePrompt": "Compare this safety number out loud or side-by-side with your peer ({{address}}) to verify end-to-end encryption integrity and prevent machine-in-the-middle attacks.", "fingerprintSubtext": "Signal Double Ratchet Constant-Time Safety Number", "verifiedDone": "Verified & Done" + }, + "swapDispute": { + "cardLabel": "Atomic swap dispute status", + "swapId": "Swap", + "counterparty": "Counterparty", + "expired": "Lockup expired at ledger {{ledger}}", + "countdown": "{{ledgers}} ledger(s) until refund unlocks (~{{seconds}}s)", + "secretStored": "Secret extracted and stored off-chain", + "secretPending": "No secret revealed yet on either chain", + "claimRefund": "Claim Dispute Refund", + "claiming": "Claiming…", + "status": { + "active": "Locked — awaiting counterparty", + "approachingExpiry": "Expiring soon", + "refundClaimable": "Timed out — refund available", + "secretExtracted": "Secret extracted — settling", + "resolved": "Resolved" + } } -} \ No newline at end of file +} diff --git a/mobile/frontend/src/i18n/locales/es.json b/mobile/frontend/src/i18n/locales/es.json index 6b16725..4d75fa0 100644 --- a/mobile/frontend/src/i18n/locales/es.json +++ b/mobile/frontend/src/i18n/locales/es.json @@ -514,5 +514,23 @@ "comparePrompt": "Compara este número de seguridad en voz alta o lado a lado con tu par ({{address}}) para verificar la integridad del cifrado de extremo a extremo y evitar ataques de intermediario.", "fingerprintSubtext": "Número de seguridad de tiempo constante de Signal Double Ratchet", "verifiedDone": "Verificado y listo" + }, + "swapDispute": { + "cardLabel": "Estado de disputa del intercambio atómico", + "swapId": "Intercambio", + "counterparty": "Contraparte", + "expired": "El bloqueo expiró en el ledger {{ledger}}", + "countdown": "{{ledgers}} ledger(s) hasta que se desbloquee el reembolso (~{{seconds}}s)", + "secretStored": "Secreto extraído y almacenado fuera de la cadena", + "secretPending": "Aún no se ha revelado ningún secreto en ninguna cadena", + "claimRefund": "Reclamar reembolso de disputa", + "claiming": "Reclamando…", + "status": { + "active": "Bloqueado — esperando a la contraparte", + "approachingExpiry": "Expira pronto", + "refundClaimable": "Expirado — reembolso disponible", + "secretExtracted": "Secreto extraído — liquidando", + "resolved": "Resuelto" + } } -} \ No newline at end of file +}