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/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/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/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/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"; + } +} diff --git a/contracts/atomic-swap/src/lib.rs b/contracts/atomic-swap/src/lib.rs index 8699918..0d92eb3 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,35 @@ 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 +441,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)); + } +} 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 && ( + + )} +
+ ); +} 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 +} 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); + }); +});