Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions context/toolchains/mcp.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
2 changes: 2 additions & 0 deletions docs/source-map.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
11 changes: 11 additions & 0 deletions mcp/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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.
52 changes: 52 additions & 0 deletions mcp/scripts/fixtures/recovery-sdk.mjs
Original file line number Diff line number Diff line change
@@ -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 };
},
});
}
90 changes: 90 additions & 0 deletions mcp/scripts/smoke-installed-recovery.mjs
Original file line number Diff line number Diff line change
@@ -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");
}
11 changes: 10 additions & 1 deletion mcp/src/trading/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,10 @@ export type SwapSubmission = {
checkpoint: Record<string, unknown>;
};
export const swapContext = new AsyncLocalStorage<SwapSubmission>();
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.");
Expand All @@ -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 {
Expand Down
Loading
Loading