diff --git a/context/toolchains/mcp.md b/context/toolchains/mcp.md index 9f50fee..aca108b 100644 --- a/context/toolchains/mcp.md +++ b/context/toolchains/mcp.md @@ -53,6 +53,7 @@ Keep the same state directory, profile, and event-to-quote/operation association - After a lost `execute` response, repeat only the same `quoteId` and idempotency key to retrieve the original operation. A new quote with the same key produces `idempotency_conflict`; it is not a retry mechanism. A lost claim response uses the identical profile, selected swap IDs, and claim key. - With a saved operation ID, call `get_operation_status`. `resume_operation` reconciles an uncertain swap without repeating its deposit; for a claim it resumes only when the backend identifies a safe next step. An unresolved result remains pending or uncertain. +- A claim failure proven to precede submission stays `pending`, with `nextAction: "claim"` and per-item `lastFailure.code: "not_submitted"`. A confirmed claim rejection uses `transaction_rejected` and retains available transaction/fee IDs. Correct the cause and explicitly resume that same claim operation; completed items in its batch are skipped. Unknown submissions remain blocked, including older checkpoints lacking evidence. See [claim recovery](../../mcp/README.md#claim-failures-and-recovery). - If the caller lost its association, inspect `list_operations` for that profile and `swap_history` with `reconcile: true`. The operation list is bounded and has no signal-ID lookup; history reports bounded recovery coverage. Do not guess which operation belongs to a signal or create a replacement when the evidence is incomplete. - Before the bot accepts another trade, finish the current claim or resolve the original outcome. The server serializes wallet writes and blocks queued, running, or uncertain operations, but that is not a strategy scheduler or a guarantee that every submitted swap has already been claimed. diff --git a/docs/source-map.md b/docs/source-map.md index f9be8ba..51c4d0b 100644 --- a/docs/source-map.md +++ b/docs/source-map.md @@ -61,6 +61,8 @@ Fifty-three offline MCP tests, TypeScript checking, and the build pass. A tarbal Recovery regressions exercise the installed Shield Swap SDK's negative history cache and pagination. Unresolved identities must be searched again after an empty scan, and incomplete scans need expanding coverage. A proving-adapter checkpoint distinguishes failures before submission from ambiguous results; a swap deposit is never replayed by recovery. +On 2026-10-14, claim recovery was checked against the pinned 0.12.0 packages. The public local-account `executeContract` path enters the proving adapter after claim preparation; `TransactionTimeoutError` and `FinalizeRevertError` carry transaction evidence, and `getConfirmedTransaction` distinguishes an explicit rejection from a failed lookup. The kit checkpoints that boundary, preserves allowlisted transaction/fee IDs, and permits explicit claim resume only after preparation failure or confirmed rejection. Unknown outcomes remain blocked. All 93 MCP tests, 25 context checks, type checking, and the build passed. An independently installed tarball also passed the standard packaging smoke and [recovery smoke](../mcp/scripts/smoke-installed-recovery.mjs): three real process restarts covering preparation failure, a partial batch, and timeout followed by confirmed rejection. SDK outcomes in that recovery smoke are simulated; this change has not been validated with live transactions. + Bridge regressions exercise the native SDK checkpoint format, Aleo prepared-transaction recovery, private mint destination transitions, and lost first approval/deposit responses. The owned EVM/Solana RPC transport persists signed transaction IDs before broadcasting; private mint checkpoints preserve the exact SDK hook data. No protocol or signing logic is reimplemented. Hosted-wallet checks use Privy 0.35.0 and Dynamic 1.1.24 with the bridge SDK's native adapters. Tests validate provider wallet identity and Dynamic creation metadata, and exercise all four hosted EVM/Solana signing paths with local signatures and mocked RPC broadcasts. pnpm retains the SDK-documented Privy/Kit peer mismatch; this is offline integration evidence, not proof of production authorization or balances. diff --git a/mcp/README.md b/mcp/README.md index 5856578..4648ae9 100644 --- a/mcp/README.md +++ b/mcp/README.md @@ -175,6 +175,14 @@ State is AES-256-GCM encrypted before SQLite writes it. Quotes, profiles, claim Local encryption protects data at rest. An unlocked server can use configured signing credentials. The Veil integration uses a remote scanner and delegated prover, whose service boundaries still apply. `configure --fee-master` requests fee sponsorship when the prover has granted that capability; `configure --no-fee-master` restores wallet-funded fees. This setting is also available at setup and is shown by `get_config`. Changing it invalidates earlier quotes, so finish pending operations first. +### Claim failures and recovery + +Claims checkpoint preparation separately from entry into the SDK's proving/submission adapter. A failure before that boundary leaves the original claim operation `pending`, with `nextAction: "claim"` and per-item `lastFailure.code: "not_submitted"`. Correct the cause, then explicitly call `resume_operation` with that operation ID. Resume rechecks permissions and claimability and skips claims already completed in the batch. + +SDK confirmation timeouts retain their public transaction ID; confirmed rejections also retain the fee transaction ID when supplied. A confirmed claim rejection leaves the claim pending for an explicit resume, with `lastFailure.code: "transaction_rejected"`. A confirmed swap rejection ends that operation as failed; recovery never repeats its deposit. Fees may already have been consumed. + +After a lost response or restart, `get_operation_status` reconciles history and checks known transaction IDs for confirmed rejection. An absent transaction, RPC error, or accepted transaction without sufficient claim-history evidence does not authorize another claim. Such claims remain uncertain and block unrelated writes. Older ambiguous checkpoints without submission-boundary evidence or a transaction ID remain conservative; this update does not infer that those transactions failed. + ## Current limitations - Privy/Dynamic EVM/Solana adapters are checked offline. Production provider authentication, wallet policies, and funded hosted-wallet execution have not yet been validated. @@ -208,6 +216,9 @@ After installing the tarball into an independent project, run: ```sh node scripts/smoke-installed.mjs /absolute/path/to/independent-project +node scripts/smoke-installed-recovery.mjs /absolute/path/to/independent-project ``` +The recovery smoke requires Node 22.15 or newer for module hooks. It runs the installed CLI over MCP with simulated SDK outcomes, checking preparation failure, partial batches, uncertain submission, confirmed rejection, and explicit resume across three process restarts. It uses disposable test wallets and makes no network calls or transactions. + This checks the installed SDK/WASM files, local key import, encrypted state, guided profile reuse, structured readiness, all 20 tool schemas over stdio, and restart. No real wallet material belongs in this repository. diff --git a/mcp/scripts/fixtures/recovery-sdk.mjs b/mcp/scripts/fixtures/recovery-sdk.mjs new file mode 100644 index 0000000..7ba4cff --- /dev/null +++ b/mcp/scripts/fixtures/recovery-sdk.mjs @@ -0,0 +1,52 @@ +// Offline SDK boundary double for the installed-server recovery smoke test. +// The production CLI, store, runtime, and SDK executeContract remain unchanged. +import { registerHooks } from "node:module"; +import { appendFileSync, readFileSync } from "node:fs"; +import { executeContract, TransactionTimeoutError } from "@provablehq/veil-core"; + +if (!new URL(import.meta.url).search) { + registerHooks({ resolve(specifier, context, nextResolve) { + if (["@provablehq/veil-aleo-sdk", "@provablehq/shield-swap-sdk"].includes(specifier)) { + return { url: new URL("?sdk", import.meta.url).href, shortCircuit: true }; + } + return nextResolve(specifier, context); + } }); + globalThis.fetch = async () => { throw new Error("Network access is disabled in the recovery smoke test"); }; +} +const control = () => JSON.parse(readFileSync(process.env.RECOVERY_CONTROL, "utf8")); + +export async function loadNetwork() { + const account = { type: "local", address: process.env.RECOVERY_ADDRESS, sign() {} }; + const proving = { mode: "delegated", execute: async () => { + appendFileSync(process.env.RECOVERY_ATTEMPTS, "submit\n"); + if (control().mode === "timeout") throw new TransactionTimeoutError({ transactionId: "at1claim", timeoutMs: 1 }); + return { transactionId: "at1claim", transitions: [], outputs: [] }; + } }; + const walletClient = { account, proving, extend(actions) { return Object.assign(this, actions(this)); } }; + return { createAleoClient: () => ({ account, walletClient, publicClient: { + request: async ({ method }) => { + if (method !== "getConfirmedTransaction") throw new Error("Unexpected RPC method"); + if (control().mode === "rejected") return { status: "rejected", transaction: { id: "at1fee" } }; + throw new Error("Transaction not found"); + }, + } }) }; +} +export function shieldSwapActions({ blindedIdentities: identities }) { + return client => ({ + authenticateShieldSwap: async () => {}, + getUnclaimedSwaps: async () => { + const records = await identities.load(); + return { swaps: ["one", "two"].filter(swapId => !records.some(r => r.swapId === swapId && r.claim)) + .map(swapId => ({ swapId, claimable: true, handle: { swapId } })), unresolvable: [] }; + }, + reconcileSwapHistory: async () => ({ complete: true, pagesScanned: 1 }), + claimSwapOutput: async ({ handle }) => { + const { mode } = control(); + if (mode === "preparation" || (mode === "partial" && handle.swapId === "two")) throw new Error("Simulated preparation failure"); + const result = await executeContract(client, { program: "shield_swap.aleo", function: "claim_swap_output", inputs: [] }); + const records = await identities.load(); + await identities.save([...records, { swapId: handle.swapId, claim: { transactionId: result.transactionId, amountOut: "99", amountRemaining: "1" } }]); + return { transactionId: result.transactionId, amountOut: 99n, amountRemaining: 1n }; + }, + }); +} diff --git a/mcp/scripts/smoke-installed-recovery.mjs b/mcp/scripts/smoke-installed-recovery.mjs new file mode 100644 index 0000000..d7c758b --- /dev/null +++ b/mcp/scripts/smoke-installed-recovery.mjs @@ -0,0 +1,90 @@ +// Usage: node scripts/smoke-installed-recovery.mjs /path/to/installed/consumer +// Requires Node >=22.15 for module hooks. No account funding or network is used. +import assert from "node:assert/strict"; +import { execFile } from "node:child_process"; +import { mkdtemp, readFile, realpath, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { promisify } from "node:util"; +const installRoot = resolve(process.argv[2] ?? "."); +const run = promisify(execFile); +if (process.argv[3] !== "--installed-child") { + const packageRoot = await realpath(join(installRoot, "node_modules/@provablehq/shield-swap-mcp")); + const driver = await mkdtemp(join(packageRoot, ".recovery-smoke-")); + try { + const path = join(driver, "run.mjs"); + await writeFile(path, await readFile(new URL(import.meta.url))); + await writeFile(join(driver, "recovery-sdk.mjs"), await readFile(new URL("fixtures/recovery-sdk.mjs", import.meta.url))); + const result = await run(process.execPath, [path, installRoot, "--installed-child"], { cwd: installRoot, timeout: 60000 }); + process.stdout.write(result.stdout); + } finally { await rm(driver, { recursive: true, force: true }); } +} else { + const [{ loadNetwork }, { Client }, { StdioClientTransport }] = await Promise.all([ + import("@provablehq/veil-aleo-sdk"), import("@modelcontextprotocol/sdk/client/index.js"), import("@modelcontextprotocol/sdk/client/stdio.js"), + ]); + const entry = join(installRoot, "node_modules/@provablehq/shield-swap-mcp/dist/cli.js"); + const hook = new URL("recovery-sdk.mjs", import.meta.url).pathname; + const key = (await loadNetwork("testnet")).generateAccount().privateKey; + for (const scenario of ["preparation", "partial", "timeout"]) { + const root = await mkdtemp(join(tmpdir(), "shield-recovery-state-")); + const control = join(root, "control.json"), attempts = join(root, "attempts.log"); + const env = { ...process.env, SHIELD_SWAP_MCP_PASSWORD: "disposable-offline-recovery-password", RECOVERY_TEST_KEY: key }; + let client; + try { + const setup = await run(process.execPath, [entry, "setup", "--state-dir", root, "--network", "testnet", "--key-env", "RECOVERY_TEST_KEY", "--store-key"], { env }); + const address = JSON.parse(setup.stdout).address; + await run(process.execPath, [entry, "configure", "--state-dir", root, "--allow-claims"], { env }); + await writeFile(attempts, ""); + const mode = value => writeFile(control, JSON.stringify({ mode: value })); + const count = async () => (await readFile(attempts, "utf8")).split("\n").filter(Boolean).length; + async function start() { + client = new Client({ name: "installed-recovery-smoke", version: "1" }); + await client.connect(new StdioClientTransport({ command: process.execPath, + args: ["--import", hook, entry, "serve", "--state-dir", root], cwd: installRoot, + env: { SHIELD_SWAP_MCP_PASSWORD: env.SHIELD_SWAP_MCP_PASSWORD, RECOVERY_ADDRESS: address, RECOVERY_CONTROL: control, RECOVERY_ATTEMPTS: attempts }, stderr: "pipe" })); + } + async function call(name, args) { + const result = await client.callTool({ name, arguments: args }); + assert.ok(!result.isError, JSON.stringify(result)); + return result.structuredContent; + } + async function settled(operationId) { + for (let i = 0; i < 100; i++) { + const op = await call("get_operation_status", { operationId }); + if (!["queued", "running"].includes(op.status)) return op; + await new Promise(resolve => setTimeout(resolve, 10)); + } + throw new Error("Operation did not settle"); + } + await mode(scenario); await start(); + const op = await call("claim_unclaimed_swaps", { swapIds: scenario === "partial" ? ["one", "two"] : ["one"], idempotencyKey: scenario }); + const initial = await settled(op.operationId); + assert.equal(initial.status, scenario === "timeout" ? "uncertain" : "pending"); + assert.equal(await count(), scenario === "preparation" ? 0 : 1); + await client.close(); client = undefined; + await start(); // New OS process, same encrypted journal and operation ID. + if (scenario === "timeout") { + const uncertain = await call("resume_operation", { operationId: op.operationId }); + assert.equal(uncertain.status, "uncertain"); + assert.equal(uncertain.claims[0].transactionId, "at1claim"); + const blocked = await client.callTool({ name: "claim_unclaimed_swaps", arguments: { swapIds: ["two"], idempotencyKey: "must-block" } }); + assert.equal(blocked.isError, true); + assert.equal(await count(), 1); + await mode("rejected"); + const rejected = await settled(op.operationId); + assert.equal(rejected.status, "pending"); + assert.equal(rejected.claims[0].lastFailure.feeTransactionId, "at1fee"); + assert.equal(await count(), 1); // Status inspection does not submit. + } + await mode("success"); + await call("resume_operation", { operationId: op.operationId }); + const final = await settled(op.operationId); + assert.equal(final.status, "complete"); + assert.equal(await count(), scenario === "preparation" ? 1 : 2); + await call("resume_operation", { operationId: op.operationId }); + assert.equal(await count(), scenario === "preparation" ? 1 : 2); + assert.ok(!(await readFile(join(root, "state.sqlite"))).includes(key)); + } finally { await client?.close(); await rm(root, { recursive: true, force: true }); } + } + process.stdout.write("Installed recovery: preparation failure, partial batch, uncertain submission, rejected transaction evidence, blocked duplicate writes, and explicit resume passed across three process restarts. SDK outcomes were simulated; no network or transactions used.\n"); +} diff --git a/mcp/src/trading/session.ts b/mcp/src/trading/session.ts index fc892ed..762670d 100644 --- a/mcp/src/trading/session.ts +++ b/mcp/src/trading/session.ts @@ -19,6 +19,10 @@ export type SwapSubmission = { checkpoint: Record; }; export const swapContext = new AsyncLocalStorage(); +export const claimContext = new AsyncLocalStorage<{ + beforeSubmit(): void; + submitted(transactionId: string): void; +}>(); export function trackSwapSubmission(proving: ProvingConfig): void { const execute = proving.execute; if (!execute) throw new TradingError("proving_unavailable", "The configured SDK cannot submit Aleo transactions."); @@ -30,7 +34,12 @@ export function trackSwapSubmission(proving: ProvingConfig): void { active.execution.checkpoint(active.checkpoint); active.submissionStarted = true; } - return execute(options); + const claim = claimContext.getStore(); + claim?.beforeSubmit(); + const result = await execute(options); + // Keep the confirmed ID even if later SDK bookkeeping or our result write fails. + claim?.submitted(result.transactionId); + return result; }; } export function walletScope(profile: Profile): string { diff --git a/mcp/src/trading/veil.ts b/mcp/src/trading/veil.ts index 36ed52c..1c5c367 100644 --- a/mcp/src/trading/veil.ts +++ b/mcp/src/trading/veil.ts @@ -1,12 +1,35 @@ import type { BlindedIdentityRecord, SwapQuote } from "@provablehq/shield-swap-sdk"; +import { FinalizeRevertError, TransactionTimeoutError, getConfirmedTransaction } from "@provablehq/veil-core"; import { BridgeBackend } from "./bridge"; import { TradingStore } from "./store"; -import { createSessionFactory, swapContext, walletScope, type AleoSession, type SessionFactory, type SwapSubmission } from "./session"; +import { claimContext, createSessionFactory, swapContext, walletScope, type AleoSession, type SessionFactory, type SwapSubmission } from "./session"; import { TradingError, type ExecutionContext, type Kind, type Operation, type Profile, type Progress, type SavedQuote, type Summary, type TradingBackend } from "./types"; -type ClaimItem = { swapId: string; status: "pending" | "submitting" | "claimed" | "uncertain"; transactionId?: string; amountOut?: string; amountRemaining?: string }; +type Failure = { code: "not_submitted" | "transaction_rejected"; transactionId?: string; feeTransactionId?: string }; +type ClaimItem = { swapId: string; status: "pending" | "preparing" | "submitting" | "claimed" | "uncertain"; transactionId?: string; amountOut?: string; amountRemaining?: string; lastFailure?: Failure }; type ClaimPlan = { swapIds: string[] }; -type SwapCheckpoint = { submissionBoundary?: number; phase?: string; counter?: number; swapId?: string; transactionId?: string }; +type SwapCheckpoint = { submissionBoundary?: number; phase?: string; counter?: number; swapId?: string; transactionId?: string; feeTransactionId?: string }; + +function transactionId(value: unknown): string | undefined { + return typeof value === "string" && /^at1[a-z0-9]{1,128}$/.test(value) ? value : undefined; +} +// Only documented SDK error fields cross the operation boundary, never messages, +// causes, handles or arbitrary error properties. +function transactionEvidence(error: unknown): { transactionId?: string; feeTransactionId?: string } { + if (!(error instanceof TransactionTimeoutError) && !(error instanceof FinalizeRevertError)) return {}; + return { transactionId: transactionId(error.transactionId), + ...(error instanceof FinalizeRevertError ? { feeTransactionId: transactionId(error.feeTransactionId) } : {}) }; +} +async function confirmedRejection(session: AleoSession, id?: string): Promise { + if (!id) return; + try { + const confirmed = await getConfirmedTransaction(session.publicClient, { id }); + if (confirmed.status === "rejected") return { code: "transaction_rejected", transactionId: id, + feeTransactionId: transactionId(confirmed.transaction.id) }; + } catch { + // Transport errors and not-found responses never establish rejection. + } +} export class VeilBackend implements TradingBackend { private session: SessionFactory; private bridge: BridgeBackend; @@ -46,7 +69,16 @@ export class VeilBackend implements TradingBackend { try { handle = await swapContext.run(state, () => session.client.swap({ quote: quote.plan as SwapQuote })); } catch (error) { - if (!session.submissionTracked || state.submissionStarted) throw error; + if (!session.submissionTracked || state.submissionStarted) { + const evidence = transactionEvidence(error); + const rejected = error instanceof FinalizeRevertError; + const checkpoint = { ...state.checkpoint, ...evidence, ...(rejected ? { phase: "rejected" } : {}) }; + context.checkpoint(checkpoint); + return { status: rejected ? "failed" : "uncertain", checkpoint, + result: { ...evidence, error: { code: rejected ? "transaction_rejected" : "submission_uncertain", + message: rejected ? "The swap transaction was rejected. Inspect its fee effects before a newly authorized attempt." + : "The submission result is uncertain. Reconcile this operation before any retry." } } }; + } return { status: "failed", result: { error: { code: "not_submitted", message: "Swap preparation failed before proving or submission. Check balances and request a fresh quote." } }, checkpoint: state.checkpoint }; @@ -169,6 +201,9 @@ export class VeilBackend implements TradingBackend { if (quote.kind === "claim") return this.reconcileClaims(quote, operation, session); const checkpoint = operation.checkpoint as SwapCheckpoint | undefined; if (!checkpoint || checkpoint.phase === "preparing" || (checkpoint.submissionBoundary === 1 && checkpoint.phase === "reserved")) return { status: "failed", result: { error: { code: "not_submitted", message: "Execution did not reach the proving or submission boundary." } } }; + const rejection = checkpoint.phase === "rejected" ? { code: "transaction_rejected", transactionId: checkpoint.transactionId, feeTransactionId: checkpoint.feeTransactionId } + : await confirmedRejection(session, checkpoint.transactionId); + if (rejection) return { status: "failed", result: { ...rejection, error: rejection } }; const historyRecovery = await this.reconcileHistory(session, quote.profile); const records = await session.identities.load(); const record = records.find(item => checkpoint.swapId ? item.swapId === checkpoint.swapId : item.counter === checkpoint.counter); @@ -178,28 +213,52 @@ export class VeilBackend implements TradingBackend { const found = pending.swaps.find(swap => swap.swapId === (record?.swapId ?? checkpoint.swapId)); if (found) return { status: "submitted", result: { historyRecovery, swapId: found.swapId, transactionId: record?.handle?.transactionId ?? checkpoint.transactionId, claimable: found.claimable, amountOut: found.output.amount_out.toString(), nextAction: "claim_when_ready" } }; - return { status: record?.handle || checkpoint.transactionId ? "pending" : "uncertain", + // A timeout can carry a transaction ID without proving acceptance. Keep + // that wallet blocked until chain/history evidence resolves the outcome. + return { status: record?.handle || checkpoint.phase === "submitted" ? "pending" : "uncertain", result: { historyRecovery, swapId: record?.swapId ?? checkpoint.swapId, transactionId: record?.handle?.transactionId ?? checkpoint.transactionId, nextAction: "wait" } }; } private async claim(quote: SavedQuote, context: ExecutionContext, prior: ClaimItem[] = []): Promise { - const { client } = await this.session(quote.profile); + const session = await this.session(quote.profile); + const { client } = session; const items: ClaimItem[] = (quote.plan as ClaimPlan).swapIds.map(swapId => prior.find(item => item.swapId === swapId) ?? { swapId, status: "pending" }); for (const item of items) { if (item.status === "claimed") continue; if (item.status === "uncertain" || item.status === "submitting") return { status: "uncertain", result: { claims: items, nextAction: "reconcile" } }; const owed = (await client.getUnclaimedSwaps()).swaps.find(swap => swap.swapId === item.swapId); - if (!owed?.handle) return { status: "pending", result: { claims: items, nextAction: "wait" }, checkpoint: { claims: items } }; - item.status = "submitting"; + if (!owed?.handle || !owed.claimable) return { status: "pending", result: { claims: items, nextAction: "wait" }, checkpoint: { claims: items } }; + const handle = owed.handle; + // New tracked claims distinguish preparation from a call that can broadcast. + // Legacy/untracked submitting checkpoints remain ambiguous on recovery. + item.status = session.submissionTracked ? "preparing" : "submitting"; + delete item.transactionId; context.checkpoint({ claims: items }); + let submissionStarted = !session.submissionTracked; try { - const result = await client.claimSwapOutput({ handle: owed.handle }); + const result = await claimContext.run({ + beforeSubmit: () => { + item.status = "submitting"; + context.checkpoint({ claims: items }); + submissionStarted = true; + }, + submitted: id => { + item.transactionId = transactionId(id); + context.checkpoint({ claims: items }); + }, + }, () => client.claimSwapOutput({ handle })); Object.assign(item, { status: "claimed", transactionId: result.transactionId, amountOut: result.amountOut.toString(), amountRemaining: result.amountRemaining.toString() }); context.checkpoint({ claims: items }); - } catch { - item.status = "uncertain"; + } catch (error) { + const evidence = transactionEvidence(error); + if (evidence.transactionId) item.transactionId = evidence.transactionId; + if (!submissionStarted || error instanceof FinalizeRevertError) { + item.status = "pending"; + item.lastFailure = { code: submissionStarted ? "transaction_rejected" : "not_submitted", ...evidence }; + } else item.status = "uncertain"; context.checkpoint({ claims: items }); - return { status: "uncertain", result: { claims: items, nextAction: "reconcile" } }; + return { status: item.status === "pending" ? "pending" : "uncertain", + result: { claims: items, nextAction: item.status === "pending" ? "claim" : "reconcile" }, checkpoint: { claims: items } }; } } return { status: "complete", result: { claims: items }, checkpoint: { claims: items } }; @@ -209,12 +268,19 @@ export class VeilBackend implements TradingBackend { const historyRecovery = await this.reconcileHistory(session, quote.profile); const records = await session.identities.load(); const previous = (operation.checkpoint as { claims?: ClaimItem[] } | undefined)?.claims ?? []; - const items = (quote.plan as ClaimPlan).swapIds.map(swapId => { + const items: ClaimItem[] = []; + for (const swapId of (quote.plan as ClaimPlan).swapIds) { const record = records.find(record => record.swapId === swapId); const prior = previous.find(item => item.swapId === swapId); - return record?.claim ? { swapId, status: "claimed" as const, transactionId: record.claim.transactionId, amountOut: record.claim.amountOut, amountRemaining: record.claim.amountRemaining } - : prior ?? { swapId, status: "pending" as const }; - }); + let item: ClaimItem = record?.claim ? { swapId, status: "claimed", transactionId: record.claim.transactionId, amountOut: record.claim.amountOut, amountRemaining: record.claim.amountRemaining } + : prior ?? { swapId, status: "pending" }; + if (item.status === "preparing") item = { ...item, status: "pending", lastFailure: { code: "not_submitted" } }; + if (item.status === "submitting" || item.status === "uncertain") { + const rejection = await confirmedRejection(session, item.transactionId); + if (rejection) item = { ...item, status: "pending", lastFailure: rejection }; + } + items.push(item); + } const uncertain = items.some(item => item.status === "submitting" || item.status === "uncertain"); const complete = items.every(item => item.status === "claimed"); return { status: complete ? "complete" : uncertain ? "uncertain" : "pending", diff --git a/mcp/test/trading-claims.test.ts b/mcp/test/trading-claims.test.ts new file mode 100644 index 0000000..c6f932f --- /dev/null +++ b/mcp/test/trading-claims.test.ts @@ -0,0 +1,198 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { executeContract, FinalizeRevertError, TransactionTimeoutError, type Client, type ProvingConfig } from "@provablehq/veil-core"; +import { TradingStore } from "../src/trading/store"; +import { TradingRuntime } from "../src/trading/runtime"; +import { VeilBackend } from "../src/trading/veil"; +import { trackSwapSubmission, type AleoSession } from "../src/trading/session"; +import type { Operation, Profile } from "../src/trading/types"; + +const profile: Profile = { id: "alice", network: "testnet", address: "aleo1test", key: { type: "env", name: "UNUSED" }, + policy: { swaps: true, claims: true, bridges: false, maxSlippageBps: 100, swapLimits: {}, bridgeLimits: {} } }; +const password = "disposable-claim-test-password"; +function fixture() { + const root = mkdtempSync(join(tmpdir(), "shield-claims-")); + let store = new TradingStore(root, password, true); + store.set("profile:alice", profile); + let preparationFails = false, tracked = true, lookupFails = false, claimable = true; + let rejectAfterSubmit = false, timeoutAfterSubmit = false, failSecond = false; + let submissions = 0, rejected = false, accepted = false; + const completed = new Set(); + const proving: ProvingConfig = { mode: "delegated", execute: async () => { + submissions++; + if (rejectAfterSubmit) throw new FinalizeRevertError("at1claim", { feeTransactionId: "at1fee" }); + if (timeoutAfterSubmit) throw new TransactionTimeoutError({ transactionId: "at1claim", timeoutMs: 1, cause: new Error("PRIVATE-DETAIL") }); + return { transactionId: "at1claim", transitions: [], outputs: [] }; + } }; + trackSwapSubmission(proving); + function runtime() { + const session = { submissionTracked: tracked, client: { + getUnclaimedSwaps: async () => ({ swaps: ["one", "two"].filter(id => !completed.has(id)).map(swapId => ({ swapId, claimable, handle: { swapId } })), unresolvable: [] }), + claimSwapOutput: async ({ handle }: { handle: { swapId: string } }) => { + if (preparationFails || (failSecond && handle.swapId === "two")) throw new Error("PRIVATE-DETAIL: local preparation failure"); + const result = await executeContract({ account: { type: "local", sign() {} }, proving } as unknown as Client, + { program: "shield_swap.aleo", function: "claim_swap_output", inputs: [] }); + completed.add(handle.swapId); + return { transactionId: result.transactionId, amountOut: 99n, amountRemaining: 1n }; + }, + reconcileSwapHistory: async () => ({ complete: true, pagesScanned: 1 }), + }, identities: { + load: async () => [...completed].map(swapId => ({ swapId, claim: { transactionId: "at1claim", amountOut: "99", amountRemaining: "1" } })), + save: async () => {}, + }, publicClient: { request: async ({ method, params }: { method: string; params: { id: string } }) => { + assert.equal(method, "getConfirmedTransaction"); + assert.equal(params.id, "at1claim"); + if (lookupFails) throw new Error("RPC unavailable PRIVATE-DETAIL"); + if (!rejected && !accepted) throw new Error("not found"); + return { status: rejected ? "rejected" : "accepted", transaction: { id: rejected ? "at1fee" : "at1claim" } }; + } } } as unknown as AleoSession; + return new TradingRuntime(store, new VeilBackend(store, async () => session)); + } + let current = runtime(); + return { + get store() { return store; }, get runtime() { return current; }, completed, + prepareFailure: (value: boolean) => { preparationFails = value; }, + secondFailure: (value: boolean) => { failSecond = value; }, + timeout: (value: boolean) => { timeoutAfterSubmit = value; }, + reject: (value: boolean) => { rejectAfterSubmit = value; }, + chainRejected: () => { rejected = true; }, chainAccepted: () => { accepted = true; }, + rpcFailure: (value: boolean) => { lookupFails = value; }, + setClaimable: (value: boolean) => { claimable = value; }, + untracked: () => { tracked = false; current = runtime(); }, + submissions: () => submissions, + async start(ids = ["one"]) { + const q = await current.quote("claim", "alice", { swapIds: ids }); + const op = await current.execute(String(q.quoteId), "claim:" + ids.join(",")); + await current.drain(); + return String(op.operationId); + }, + async restart() { await current.drain(); store.close(); store = new TradingStore(root, password); current = runtime(); }, + async close() { await current.drain(); store.close(); rmSync(root, { recursive: true, force: true }); }, + }; +} + +test("pre-submission claim failure can resume the same operation after reopening its store", async () => { + const f = fixture(); + try { + f.prepareFailure(true); + const id = await f.start(); + assert.equal(f.runtime.operation(id).status, "pending"); + assert.match(JSON.stringify(f.runtime.operation(id)), /not_submitted/); + assert.doesNotMatch(JSON.stringify(f.runtime.operation(id)), /PRIVATE-DETAIL/); + assert.equal(f.submissions(), 0); + await f.restart(); f.prepareFailure(false); + await f.runtime.resume(id); await f.runtime.drain(); + assert.equal(f.runtime.operation(id).status, "complete"); + assert.equal(f.submissions(), 1); + await f.runtime.resume(id); await f.runtime.drain(); + assert.equal(f.submissions(), 1); + } finally { await f.close(); } +}); + +test("lost claim response retains its transaction ID and blocks writes across restart", async () => { + const f = fixture(); + try { + f.timeout(true); + const id = await f.start(); + await f.restart(); + for (const rpcFails of [false, true]) { + f.rpcFailure(rpcFails); + const result = await f.runtime.resume(id); + assert.equal(result.status, "uncertain"); + assert.match(JSON.stringify(result), /at1claim/); + assert.doesNotMatch(JSON.stringify(result), /PRIVATE-DETAIL/); + } + const q = await f.runtime.quote("claim", "alice", { swapIds: ["two"] }); + await assert.rejects(f.runtime.execute(String(q.quoteId), "another"), /active or uncertain/); + assert.equal(f.submissions(), 1); + f.rpcFailure(false); f.completed.add("one"); + assert.equal((await f.runtime.resume(id)).status, "complete"); + assert.equal(f.submissions(), 1); + } finally { await f.close(); } +}); + +test("an accepted transaction alone never triggers a second claim while history is incomplete", async () => { + const f = fixture(); + try { + f.timeout(true); const id = await f.start(); f.chainAccepted(); + assert.equal((await f.runtime.resume(id)).status, "uncertain"); + assert.equal(f.submissions(), 1); + } finally { await f.close(); } +}); + +for (const immediate of [true, false]) { + test(`confirmed claim rejection ${immediate ? "from the SDK" : "found after restart"} permits only an explicit resume`, async () => { + const f = fixture(); + try { + if (immediate) f.reject(true); else f.timeout(true); + const id = await f.start(); + await f.restart(); + if (!immediate) f.chainRejected(); + const status = await f.runtime.status(id); + assert.equal(status.status, "pending"); + assert.match(JSON.stringify(status), /transaction_rejected/); + assert.match(JSON.stringify(status), /at1fee/); + assert.equal(f.submissions(), 1); + f.reject(false); f.timeout(false); + await f.runtime.resume(id); await f.runtime.drain(); + assert.equal(f.runtime.operation(id).status, "complete"); + assert.equal(f.submissions(), 2); + } finally { await f.close(); } + }); +} + +test("partial batch recovery keeps completed claims and resumes only the remaining claim", async () => { + const f = fixture(); + try { + f.secondFailure(true); const id = await f.start(["one", "two"]); + assert.equal(f.runtime.operation(id).status, "pending"); + assert.equal(f.submissions(), 1); + await f.restart(); f.secondFailure(false); + await f.runtime.resume(id); await f.runtime.drain(); + assert.equal(f.runtime.operation(id).status, "complete"); + assert.equal(f.submissions(), 2); + assert.deepEqual([...f.completed], ["one", "two"]); + } finally { await f.close(); } +}); + +for (const stage of ["preparing", "submitting", "uncertain"] as const) { + test(`restart with a ${stage} claim checkpoint ${stage === "preparing" ? "can resume" : "cannot resubmit"}`, async () => { + const f = fixture(); + try { + f.prepareFailure(true); const id = await f.start(); + const op = f.store.get("operation:" + id)!; + f.store.set("operation:" + id, { ...op, status: "running", checkpoint: { claims: [{ swapId: "one", status: stage }] } }); + await f.restart(); f.prepareFailure(false); + await f.runtime.resume(id); await f.runtime.drain(); + assert.equal(f.runtime.operation(id).status, stage === "preparing" ? "complete" : "uncertain"); + assert.equal(f.submissions(), stage === "preparing" ? 1 : 0); + } finally { await f.close(); } + }); +} + +test("an untracked legacy claim exception remains uncertain", async () => { + const f = fixture(); + try { + f.untracked(); f.prepareFailure(true); const id = await f.start(); + assert.equal((await f.runtime.resume(id)).status, "uncertain"); + assert.equal(f.submissions(), 0); + } finally { await f.close(); } +}); + +test("resume rechecks claimability and current claim permission", async () => { + const f = fixture(); + try { + f.prepareFailure(true); const id = await f.start(); f.prepareFailure(false); + f.setClaimable(false); + await f.runtime.resume(id); await f.runtime.drain(); + assert.equal(f.runtime.operation(id).status, "pending"); + assert.equal(f.submissions(), 0); + f.setClaimable(true); + f.store.set("profile:alice", { ...profile, policy: { ...profile.policy, claims: false } }); + await assert.rejects(f.runtime.resume(id), /permission/i); + assert.equal(f.submissions(), 0); + } finally { await f.close(); } +}); diff --git a/mcp/test/trading-recovery.test.ts b/mcp/test/trading-recovery.test.ts index c787d9c..49a38a8 100644 --- a/mcp/test/trading-recovery.test.ts +++ b/mcp/test/trading-recovery.test.ts @@ -20,8 +20,12 @@ function fixture() { load: async () => store.get("test:identities")!, save: async (records: BlindedIdentityRecord[]) => { store.set("test:identities", records); }, }; - let accepted = false, historyRequests = 0, depth = 0; + let accepted = false, historyRequests = 0, depth = 0, rejected = false; const rpc = { request: async ({ method, params }: { method: string; params: Record }) => { + if (method === "getConfirmedTransaction") { + if (!rejected) throw new Error("not found"); + return { status: "rejected", transaction: { id: "at1fee" } }; + } if (method === "getProgramCallsPaginated") { historyRequests++; const page = Number(params.cursorBlockNumber ?? 0); @@ -34,7 +38,7 @@ function fixture() { }] } }; throw new Error("unexpected RPC"); } } as unknown as Client; - const session = { identities, client: { + const session = { identities, publicClient: rpc, client: { reconcileSwapHistory: (params: { maxPages?: number }) => reconcileSwapHistory(rpc, { ...params, store: identities }), getUnclaimedSwaps: async () => ({ swaps: [], unresolvable: [] }), } } as unknown as AleoSession; @@ -42,7 +46,7 @@ function fixture() { const quote: SavedQuote = { id: "q", kind: "swap", profile, scope: "testnet:aleo1test:shield_swap.aleo", createdAt: 0, expiresAt: 0, plan: {}, summary: {} }; const operation: Operation = { id: "o", kind: "swap", profileId: "alice", scope: quote.scope, quoteId: "q", requestKey: "r", status: "uncertain", result: {}, checkpoint: { phase: "reserved", counter: 0 }, createdAt: 0, updatedAt: 0 }; - return { backend, quote, operation, identities, accept: () => { accepted = true; }, depth: (value: number) => { depth = value; }, + return { backend, quote, operation, identities, accept: () => { accepted = true; }, reject: () => { rejected = true; }, depth: (value: number) => { depth = value; }, requests: () => historyRequests, close: () => { store.close(); rmSync(root, { recursive: true, force: true }); } }; } test("recovery discovers a later accepted claim after an earlier empty history scan", async () => { @@ -66,3 +70,17 @@ test("repeated recovery expands incomplete history coverage beyond ten pages", a assert.equal((await f.backend.reconcile(f.quote, f.operation)).status, "complete"); } finally { f.close(); } }); + +test("swap reconciliation resolves a confirmed rejection without replaying its deposit", async () => { + const f = fixture(); + try { + f.operation.checkpoint = { phase: "submitting", counter: 0, transactionId: "at1swap" }; + assert.equal((await f.backend.reconcile(f.quote, f.operation)).status, "uncertain"); + f.reject(); + const result = await f.backend.reconcile(f.quote, f.operation); + assert.equal(result.status, "failed"); + assert.equal(result.result.transactionId, "at1swap"); + assert.equal(result.result.feeTransactionId, "at1fee"); + await assert.rejects(f.backend.resume(f.quote, f.operation, { operationId: "o", checkpoint() {} }), /cannot be resubmitted/); + } finally { f.close(); } +}); diff --git a/mcp/test/trading-submission.test.ts b/mcp/test/trading-submission.test.ts index 654c2dd..4f3a91f 100644 --- a/mcp/test/trading-submission.test.ts +++ b/mcp/test/trading-submission.test.ts @@ -1,7 +1,7 @@ import { test } from "node:test"; import assert from "node:assert/strict"; import { executeContract, type Client, type ProvingConfig } from "@provablehq/veil-core"; -import { swapContext, trackSwapSubmission, type SwapSubmission } from "../src/trading/session"; +import { claimContext, swapContext, trackSwapSubmission, type SwapSubmission } from "../src/trading/session"; test("the public SDK execution path checkpoints before entering its proving adapter", async () => { const events: string[] = []; @@ -32,3 +32,27 @@ test("failed checkpoint persistence prevents proving and submission", async () = assert.equal(calls, 0); assert.equal(state.submissionStarted, false); }); + +test("claims persist the boundary before proving and the returned ID before SDK bookkeeping", async () => { + const events: string[] = []; + const proving: ProvingConfig = { mode: "delegated", execute: async () => { + events.push("proving"); + return { transactionId: "at1claim", transitions: [], outputs: [] }; + } }; + trackSwapSubmission(proving); + const client = { account: { type: "local", sign() {} }, proving } as unknown as Client; + await claimContext.run({ beforeSubmit: () => { events.push("checkpoint"); }, submitted: id => { events.push(id); } }, async () => { + await executeContract(client, { program: "shield_swap.aleo", function: "claim_swap_output", inputs: [] }); + events.push("sdk-bookkeeping"); + }); + assert.deepEqual(events, ["checkpoint", "proving", "at1claim", "sdk-bookkeeping"]); +}); + +test("failed claim boundary persistence prevents proving and submission", async () => { + let calls = 0; + const proving: ProvingConfig = { mode: "delegated", execute: async () => { calls++; throw new Error("must not execute"); } }; + trackSwapSubmission(proving); + await assert.rejects(claimContext.run({ beforeSubmit: () => { throw new Error("disk full"); }, submitted: () => {} }, + () => proving.execute!({ programName: "shield_swap.aleo", functionName: "claim_swap_output", inputs: [], fee: 0n })), /disk full/); + assert.equal(calls, 0); +}); diff --git a/mcp/test/trading-veil.test.ts b/mcp/test/trading-veil.test.ts index 328abcc..e057d63 100644 --- a/mcp/test/trading-veil.test.ts +++ b/mcp/test/trading-veil.test.ts @@ -3,10 +3,11 @@ import assert from "node:assert/strict"; import { mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import { executeContract, FinalizeRevertError, TransactionTimeoutError, type Client, type ProvingConfig } from "@provablehq/veil-core"; import { TradingStore } from "../src/trading/store"; import { TradingRuntime } from "../src/trading/runtime"; import { VeilBackend } from "../src/trading/veil"; -import { swapContext, type AleoSession } from "../src/trading/session"; +import { swapContext, trackSwapSubmission, type AleoSession } from "../src/trading/session"; import type { Profile, SavedQuote } from "../src/trading/types"; const profile: Profile = { id: "alice", network: "testnet", address: "aleo1test", key: { type: "env", name: "TEST_KEY" }, @@ -74,6 +75,31 @@ test("a reserved identity followed by quote expiry before proving is a definitiv } finally { store.close(); rmSync(root, { recursive: true, force: true }); } }); +for (const rejected of [false, true]) { + test(`swap ${rejected ? "rejection" : "timeout"} preserves sanitized transaction evidence`, async () => { + const root = mkdtempSync(join(tmpdir(), "shield-swap-error-")); + const store = new TradingStore(root, "test-password-long-enough", true); + const error = rejected ? new FinalizeRevertError("at1swap", { feeTransactionId: "at1fee", cause: new Error("PRIVATE-DETAIL") }) + : new TransactionTimeoutError({ transactionId: "at1swap", timeoutMs: 1, cause: new Error("PRIVATE-DETAIL") }); + const proving: ProvingConfig = { mode: "delegated", execute: async () => { throw error; } }; + trackSwapSubmission(proving); + const session = { submissionTracked: true, client: { + swap: () => executeContract({ account: { type: "local", sign() {} }, proving } as unknown as Client, + { program: "shield_swap.aleo", function: "swap", inputs: [] }), + }, identities: { load: async () => [], save: async () => {} } } as unknown as AleoSession; + try { + const backend = new VeilBackend(store, async () => session); + const quote: SavedQuote = { id: "q", kind: "swap", profile, scope: "", createdAt: 0, expiresAt: 1, plan: offer, summary: {} }; + const result = await backend.execute(quote, { operationId: "o", checkpoint: value => store.set("checkpoint", value) }); + assert.equal(result.status, rejected ? "failed" : "uncertain"); + assert.equal(result.result.transactionId, "at1swap"); + assert.equal(result.result.feeTransactionId, rejected ? "at1fee" : undefined); + assert.equal(store.get<{ transactionId: string }>("checkpoint")?.transactionId, "at1swap"); + assert.doesNotMatch(JSON.stringify(result), /PRIVATE-DETAIL/); + } finally { store.close(); rmSync(root, { recursive: true, force: true }); } + }); +} + test("balances include underlying public bridge tokens and native credits without double counting", async () => { const root = mkdtempSync(join(tmpdir(), "shield-balances-")); const store = new TradingStore(root, "test-password-long-enough", true);