diff --git a/.gitignore b/.gitignore index 31f3737..29878b5 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,4 @@ dist/ # Local devnet payer keypair written by examples/x402-solana-recovery/devnet-settle.mjs .devnet-payer.json +.mainnet-payer.json diff --git a/README.md b/README.md index 279e432..1ee88f7 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,7 @@ x402, MPP, wallets, webhooks, hosted APIs, or environment variables. - Metering hooks for usage-based tools. - Execution traces for paid tool calls. - Prepaid recovery when execution fails after charge. +- Settlement recovery: retry with backoff, a durable (in-memory or SQLite/D1) queue, optional on-chain confirmation, and a scheduled `reconcileSettlements()` loop — uncertain settlements are recovered, never silently lost. - Optional Stripe, x402, and MPP rail adapters. ## Minimal usage @@ -98,9 +99,9 @@ Current version: `0.3.0-beta.1`. | SQLite / D1 ledger | Local and single-process paths | | Stripe test mode | Validated with configured test credentials | | Stripe production | Beta; validate your webhook and deployment path | -| x402 (EVM) | Experimental | -| x402 (Solana / SVM) | Experimental; SVM "exact" scheme, facilitator verify/settle ([notes](examples/x402-solana-recovery/NOTES.md)) | -| x402 mainnet | Not tested | +| x402 (EVM) | Beta; EIP-712 USDC domain auto-injected, verified on Base mainnet (gasless via facilitator) | +| x402 (Solana / SVM) | Beta; SVM "exact" scheme, packaged signer, full MCP verify/settle e2e, verified on devnet **and mainnet** (gasless) ([notes](examples/x402-solana-recovery/NOTES.md)) | +| x402 mainnet | Smoke-tested with small live settles: EVM (Base) and Solana | | MPP | Mocked / spec-path unless verified with real `mppx` integration | | Multi-instance production | Requires durable idempotency; future work | diff --git a/examples/x402-solana-recovery/NOTES.md b/examples/x402-solana-recovery/NOTES.md index f3c15dc..03c9193 100644 --- a/examples/x402-solana-recovery/NOTES.md +++ b/examples/x402-solana-recovery/NOTES.md @@ -43,13 +43,19 @@ adds first-class Solana (SVM "exact" scheme) support without a new rail. Solana tx signature is surfaced as `SettlementResult.txhash`. Verify and settle are **independent failure domains**: a payment can verify yet -fail to settle on-chain. Toolgate's recovery/trace layer makes that -`settlement_uncertain` state explicit instead of collapsing it into one bit. +fail to settle on-chain. Tollgate's recovery/trace layer makes that +`settlement_uncertain` state explicit instead of collapsing it into one bit — +and **recoverable**: the MCP adapter retries settlement with backoff, then +queues anything still unconfirmed on the gate's pending-settlement store. A +later `gate.reconcileSettlements()` pass drains the queue so a transient +facilitator/RPC outage never silently loses a payment. ## Configuring the rail for Solana +Server side — wrap the paid tool and advertise the Solana challenge: + ```ts -import { X402RailAdapter } from "@tkorkmaz/toolgate"; +import { X402RailAdapter } from "@niceberglabs/tollgate"; const rail = new X402RailAdapter({ payTo: "GsbwXfJraMomNxBcjYLcG3mxkBUiyWXAB32fGbSMQRdW", // base58 @@ -60,6 +66,20 @@ const rail = new X402RailAdapter({ await rail.discoverFeePayer(); // pulls extra.feePayer from /supported ``` +Client side — the signer ships in the package (install `@solana/web3.js` and +`@solana/spl-token` to use it): + +```ts +import { buildSolanaPaymentPayload } from "@niceberglabs/tollgate"; + +const { paymentPayload } = await buildSolanaPaymentPayload({ + challenge, // the 402 / x402PaymentRequired block + payerSecretKey, // 64-byte Uint8Array + rpcUrl: "https://api.devnet.solana.com", +}); +// retry the tool call with paymentPayload as the x402 proof +``` + ### Facilitators that support Solana - **PayAI** — Solana-first, single drop-in endpoint, no API key. @@ -81,6 +101,11 @@ node examples/x402-solana-recovery/devnet-settle.mjs A confirmed devnet settle (`err: None`) had its **fee paid by the facilitator's fee payer, not the client** — the gasless SVM design working as intended. +**Mainnet smoke (real USDC):** the same flow settled on Solana **mainnet-beta** +via PayAI, self-transfer, `err: None`, fee paid by the facilitator +(tx `3d9k5PACqnSqYk42xMjyvkdzZZNfDPjysRyHGVzpxxCYu1womD6eMAGQx2neZcNCerLNkbjDoy15Y31pdqysaLTn`). +Run it with `SOLANA_NETWORK=mainnet` (see `devnet-settle.mjs`). + A real cross-account transfer (set `PAY_TO` to a second funded wallet) moved exactly 0.001 USDC payer → recipient on devnet, gas paid by the facilitator: payer 20 → 19.999, recipient 20 → 20.001 @@ -112,6 +137,11 @@ fixes were entirely client-side in the signer. - `src/__tests__/x402-solana-sign.test.mjs` — offline signer: asserts the produced payload is x402 v2, the fee-payer slot is empty (partial sign), and the serialized tx carries the 4 expected instructions. - -`@solana/web3.js` and `@solana/spl-token` are needed only for the client-side -signer (dynamically imported, dev-only) — the core SDK install stays light. +- `src/__tests__/x402-solana-e2e.test.mjs` — full lifecycle through the MCP + adapter against a fake in-process facilitator: 402 discovery → sign → verify → + credit → execute → settle, asserting the trace records `rail_payment_verified` + and `rail_payment_settled` with the on-chain tx signature. + +`@solana/web3.js` and `@solana/spl-token` are optional peer dependencies, needed +only for the client-side signer (`buildSolanaPaymentPayload`, dynamically +imported) — the core SDK install stays light for callers that never sign on Solana. diff --git a/examples/x402-solana-recovery/devnet-settle.mjs b/examples/x402-solana-recovery/devnet-settle.mjs index 4a48aa1..341d565 100644 --- a/examples/x402-solana-recovery/devnet-settle.mjs +++ b/examples/x402-solana-recovery/devnet-settle.mjs @@ -1,24 +1,30 @@ /** - * x402 Solana DEVNET end-to-end settle. + * x402 Solana end-to-end settle (devnet by default, mainnet opt-in). * - * Real run against a live facilitator (default: PayAI) and Solana devnet: + * Real run against a live facilitator (default: PayAI): * discoverFeePayer → createChallenge → sign (partial) → /verify → /settle * * It is a SELF-TRANSFER smoke test by default (payTo = payer), so you only need - * ONE funded account: the payer's devnet USDC ATA. Fund it once at - * https://faucet.circle.com (select "Solana Devnet") with the printed address. + * ONE funded account: the payer's USDC ATA. On devnet, fund it at + * https://faucet.circle.com ("Solana Devnet"). On MAINNET, fund the printed + * address with a small amount of real USDC — self-transfer means no net loss + * (the facilitator pays the gas), so ~0.01 USDC is plenty. * * Env: + * SOLANA_NETWORK "devnet" (default) or "mainnet" * SOLANA_PAYER_SECRET base58 or JSON-array secret key. If unset, a keypair * is generated and written to PAYER_KEYPAIR_PATH. - * PAYER_KEYPAIR_PATH default: ./.devnet-payer.json (gitignored scratch) + * PAYER_KEYPAIR_PATH default: ./.-payer.json (gitignored scratch) * X402_FACILITATOR_URL default: https://facilitator.payai.network - * SOLANA_RPC_URL default: https://api.devnet.solana.com + * SOLANA_RPC_URL default: cluster public RPC for the chosen network + * NETWORK_CAIP2 override the CAIP-2 network id + * USDC_MINT override the USDC mint * PAY_TO optional recipient override (default: self) * AMOUNT_USDC default: 0.001 * * Usage: - * node examples/x402-solana-recovery/devnet-settle.mjs + * node examples/x402-solana-recovery/devnet-settle.mjs # devnet + * SOLANA_NETWORK=mainnet SOLANA_RPC_URL=... node …/devnet-settle.mjs # mainnet */ import { readFile, writeFile } from "node:fs/promises"; @@ -30,14 +36,35 @@ import { import { X402RailAdapter } from "../../dist/rail-adapters/x402-rail.js"; import { buildSolanaPaymentPayload } from "./sign-payload.mjs"; -const DEVNET_CAIP2 = "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1"; -const DEVNET_USDC = "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU"; +const NETWORK = (process.env.SOLANA_NETWORK ?? "devnet").toLowerCase(); +const IS_MAINNET = NETWORK === "mainnet" || NETWORK === "mainnet-beta"; + +const NETWORKS = { + devnet: { + caip2: "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1", + usdc: "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU", + rpc: "https://api.devnet.solana.com", + cluster: "devnet", + }, + mainnet: { + caip2: "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp", + usdc: "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", + rpc: "https://api.mainnet-beta.solana.com", + cluster: "mainnet-beta", + }, +}; +const NET = IS_MAINNET ? NETWORKS.mainnet : NETWORKS.devnet; + +const NETWORK_CAIP2 = process.env.NETWORK_CAIP2 ?? NET.caip2; +const USDC_MINT = process.env.USDC_MINT ?? NET.usdc; +const CLUSTER = NET.cluster; const FACILITATOR = process.env.X402_FACILITATOR_URL ?? "https://facilitator.payai.network"; -const RPC_URL = process.env.SOLANA_RPC_URL ?? "https://api.devnet.solana.com"; +const RPC_URL = process.env.SOLANA_RPC_URL ?? NET.rpc; const KEYPAIR_PATH = - process.env.PAYER_KEYPAIR_PATH ?? "./.devnet-payer.json"; + process.env.PAYER_KEYPAIR_PATH ?? + (IS_MAINNET ? "./.mainnet-payer.json" : "./.devnet-payer.json"); const AMOUNT_USDC = Number(process.env.AMOUNT_USDC ?? "0.001"); function parseSecret(raw) { @@ -93,18 +120,18 @@ async function main() { ? new PublicKey(process.env.PAY_TO) : payer.publicKey; - const mint = new PublicKey(DEVNET_USDC); + const mint = new PublicKey(USDC_MINT); const payerAta = getAssociatedTokenAddressSync(mint, payer.publicKey); const connection = new Connection(RPC_URL, "confirmed"); - console.log("── x402 Solana devnet settle ──"); + console.log(`── x402 Solana ${CLUSTER} settle ──`); console.log("Payer :", payer.publicKey.toBase58()); console.log("Payer USDC ATA:", payerAta.toBase58()); console.log("Pay to :", payTo.toBase58()); console.log("Facilitator :", FACILITATOR); console.log("Amount :", AMOUNT_USDC, "USDC"); - // ── Preflight: does the payer hold devnet USDC? ── + // ── Preflight: does the payer hold USDC? ── let balance = 0n; try { const acct = await getAccount(connection, payerAta); @@ -115,17 +142,23 @@ async function main() { const needed = BigInt(Math.round(AMOUNT_USDC * 1e6)); console.log("Balance :", Number(balance) / 1e6, "USDC"); if (balance < needed) { - console.log("\n⚠️ Not funded. Fund this address with devnet USDC:"); - console.log(" 1) Open https://faucet.circle.com"); - console.log(' 2) Network "Solana Devnet", paste:', payer.publicKey.toBase58()); - console.log(" 3) Re-run this script."); + if (IS_MAINNET) { + console.log("\n⚠️ Not funded. Send a small amount of real USDC to:"); + console.log(" ", payer.publicKey.toBase58()); + console.log(" (self-transfer → no net loss; facilitator pays gas). Then re-run."); + } else { + console.log("\n⚠️ Not funded. Fund this address with devnet USDC:"); + console.log(" 1) Open https://faucet.circle.com"); + console.log(' 2) Network "Solana Devnet", paste:', payer.publicKey.toBase58()); + console.log(" 3) Re-run this script."); + } process.exit(2); } // ── Rail: discover fee payer + build challenge ── const rail = new X402RailAdapter({ payTo: payTo.toBase58(), - network: { kind: "solana", caip2: DEVNET_CAIP2 }, + network: { kind: "solana", caip2: NETWORK_CAIP2 }, facilitatorUrl: FACILITATOR, }); @@ -167,7 +200,7 @@ async function main() { console.log("tx :", settled.txHash); console.log( "explorer :", - `https://explorer.solana.com/tx/${settled.txHash}?cluster=devnet`, + `https://explorer.solana.com/tx/${settled.txHash}?cluster=${CLUSTER}`, ); } diff --git a/examples/x402-solana-recovery/sign-payload.mjs b/examples/x402-solana-recovery/sign-payload.mjs index f75655f..7180743 100644 --- a/examples/x402-solana-recovery/sign-payload.mjs +++ b/examples/x402-solana-recovery/sign-payload.mjs @@ -1,198 +1,15 @@ /** - * x402 Solana (SVM) client-side signer. + * x402 Solana (SVM) client-side signer — example entry point. * - * Turns a Toolgate 402 challenge into an x402 "exact" payment payload for - * Solana. This is the Solana counterpart to examples/x402-testnet-recovery/ - * sign-payload.mjs (which signs EIP-3009 authorizations for EVM). + * The implementation now ships in the package itself: + * import { buildSolanaPaymentPayload } from "@niceberglabs/tollgate"; * - * Solana has no EIP-712 / EIP-3009. The SVM "exact" scheme instead has the - * client build and PARTIALLY sign a real SPL transfer transaction, leaving the - * fee-payer signature empty for the facilitator to fill at /settle: - * - * 1. ComputeBudget: set unit limit + unit price (price ≤ 5 microLamports/CU) - * 2. SPL TransferChecked: payer ATA → recipient ATA, exact atomic amount - * 3. Memo: a random nonce (or seller-provided memo) for payment uniqueness - * fee payer = requirement.extra.feePayer (the facilitator), NOT the client - * - * The client signs only its own slot (partialSign), serializes with - * requireAllSignatures:false, and base64-encodes the result into - * payload.transaction. - * - * Heavy Solana deps are imported dynamically so the core SDK install stays - * light — install @solana/web3.js and @solana/spl-token to use this helper. - */ - -import { randomBytes } from "node:crypto"; - -const MEMO_PROGRAM_ID = "MemoSq4gqABAXKb96qnH8TysNcWxMyWCqXgDLGmfcHr"; - -/** Max compute-unit price the SVM "exact" scheme allows (microLamports/CU). */ -const MAX_CU_PRICE = 5; - -async function loadSolana() { - try { - const [web3, splToken] = await Promise.all([ - import("@solana/web3.js"), - import("@solana/spl-token"), - ]); - return { web3, splToken }; - } catch (cause) { - throw new Error( - "x402 Solana signing requires @solana/web3.js and @solana/spl-token.\n" + - "Install them: npm install @solana/web3.js @solana/spl-token", - { cause }, - ); - } -} - -/** - * Pull the first payment requirement out of a Toolgate 402 response, whether it - * arrives as the raw x402PaymentRequired block or a Toolgate settlement entry. + * This file just re-exports it from the built output so the local examples and + * scenarios (devnet-settle.mjs, tests) keep importing from one place. Install + * @solana/web3.js and @solana/spl-token to actually sign. */ -export function extractSolanaRequirement(challenge) { - const block = - challenge?.x402PaymentRequired ?? - challenge?.paymentRequired?.x402Challenge ?? - challenge; - const accepts = block?.accepts ?? challenge?.accepts; - const requirement = Array.isArray(accepts) ? accepts[0] : block; - - if (!requirement?.asset || !requirement?.payTo || !requirement?.network) { - throw new Error( - "Challenge does not contain a usable Solana x402 payment requirement " + - "(missing asset/payTo/network).", - ); - } - if (!String(requirement.network).startsWith("solana:")) { - throw new Error( - `Requirement network "${requirement.network}" is not a Solana network.`, - ); - } - return requirement; -} - -/** - * Build a base64, partially-signed x402 SVM payment payload from a 402 - * challenge. Returns { paymentPayload, transaction, memo }. - * - * @param {object} args - * @param {object} args.challenge Toolgate 402 response (or x402 block) - * @param {Uint8Array} args.payerSecretKey Client wallet secret key (64 bytes) - * @param {string} [args.rpcUrl] RPC endpoint to fetch a recent blockhash - * @param {string} [args.blockhash] Explicit blockhash (skips RPC; for tests) - * @param {string} [args.memo] Override memo (defaults to a random nonce) - * @param {number} [args.computeUnitLimit=30000] Bounded by the SVM "exact" - * scheme: facilitators reject limits that are too high (~50k+) and a transfer - * needs more than ~10k, so the default sits comfortably in between. - * @param {number} [args.computeUnitPrice=1] microLamports/CU (clamped to ≤ 5) - */ -export async function buildSolanaPaymentPayload({ - challenge, - payerSecretKey, - rpcUrl, - blockhash, - memo, - computeUnitLimit = 30_000, - computeUnitPrice = 1, -}) { - const { web3, splToken } = await loadSolana(); - const { - Connection, - Keypair, - PublicKey, - TransactionInstruction, - TransactionMessage, - VersionedTransaction, - ComputeBudgetProgram, - } = web3; - const { - getAssociatedTokenAddressSync, - createTransferCheckedInstruction, - } = splToken; - - const requirement = extractSolanaRequirement(challenge); - - if (!requirement.extra?.feePayer) { - throw new Error( - "Solana requirement is missing extra.feePayer — the facilitator must " + - "advertise a fee payer (X402RailConfig.feePayer / discoverFeePayer()).", - ); - } - - const payer = Keypair.fromSecretKey(payerSecretKey); - const mint = new PublicKey(requirement.asset); - const recipient = new PublicKey(requirement.payTo); - const feePayer = new PublicKey(requirement.extra.feePayer); - const decimals = requirement.extra?.decimals ?? 6; - const amount = BigInt(requirement.maxAmountRequired ?? requirement.amount); - - const sourceAta = getAssociatedTokenAddressSync(mint, payer.publicKey); - // allowOwnerOffCurve:true so a PDA recipient (program-owned payTo) is allowed. - const destAta = getAssociatedTokenAddressSync(mint, recipient, true); - - const memoText = memo ?? randomBytes(16).toString("hex"); - - const instructions = [ - ComputeBudgetProgram.setComputeUnitLimit({ units: computeUnitLimit }), - ComputeBudgetProgram.setComputeUnitPrice({ - microLamports: Math.min(computeUnitPrice, MAX_CU_PRICE), - }), - createTransferCheckedInstruction( - sourceAta, - mint, - destAta, - payer.publicKey, - amount, - decimals, - ), - new TransactionInstruction({ - keys: [], - programId: new PublicKey(MEMO_PROGRAM_ID), - data: Buffer.from(memoText, "utf8"), - }), - ]; - - let recentBlockhash = blockhash; - if (!recentBlockhash) { - if (!rpcUrl) { - throw new Error("Provide either `blockhash` or `rpcUrl` to sign."); - } - const connection = new Connection(rpcUrl, "confirmed"); - ({ blockhash: recentBlockhash } = await connection.getLatestBlockhash()); - } - - const message = new TransactionMessage({ - payerKey: feePayer, // facilitator sponsors fees + signs at /settle - recentBlockhash, - instructions, - }).compileToV0Message(); - - const transaction = new VersionedTransaction(message); - // Sign ONLY the client's slot — the fee-payer signature stays empty. - transaction.sign([payer]); - - const serialized = Buffer.from( - transaction.serialize({ requireAllSignatures: false }), - ).toString("base64"); - - // The x402 v2 PaymentPayload embeds the `accepted` requirement the client - // agreed to. Facilitators validate the on-chain transaction against it, and - // expect the amount as an atomic STRING under `accepted.amount`. - const accepted = { - scheme: requirement.scheme ?? "exact", - network: requirement.network, - amount: String(requirement.maxAmountRequired ?? requirement.amount), - asset: requirement.asset, - payTo: requirement.payTo, - maxTimeoutSeconds: requirement.maxTimeoutSeconds ?? 300, - extra: requirement.extra, - }; - - const paymentPayload = { - x402Version: 2, - accepted, - payload: { transaction: serialized }, - }; - return { paymentPayload, transaction, memo: memoText }; -} +export { + buildSolanaPaymentPayload, + extractSolanaRequirement, +} from "../../dist/rail-adapters/x402-solana-signer.js"; diff --git a/landing/index.html b/landing/index.html index bc97892..6e109bf 100644 --- a/landing/index.html +++ b/landing/index.html @@ -421,8 +421,8 @@

Status

SQLite / D1 ledgerLocal and single-process paths Stripe test modeValidated with configured test credentials Stripe productionBeta; validate your webhook and deployment path - x402 — EVM (Base, Polygon, Arbitrum, Optimism, Ethereum)Experimental; mainnet not tested - x402 — Solana / SVMExperimental; verified on devnet (gasless via x402 facilitators), mainnet not tested + x402 — EVM (Base, Polygon, Arbitrum, Optimism, Ethereum)Beta; EIP-712 USDC domain auto-injected, verified on Base mainnet (gasless) + x402 — Solana / SVMBeta; packaged signer + full MCP verify/settle e2e, verified on devnet and mainnet (gasless via x402 facilitators) MPPMocked / spec-path unless verified with real mppx integration diff --git a/package.json b/package.json index 4f6d3dc..3247dc6 100644 --- a/package.json +++ b/package.json @@ -39,7 +39,7 @@ "scenario:x402-testnet:challenge": "npm run build && node examples/x402-testnet-recovery/challenge.mjs", "scenario:x402-testnet:sign": "npm run build && node examples/x402-testnet-recovery/challenge.mjs | node examples/x402-testnet-recovery/sign-payload.mjs", "scenario:x402-testnet": "npm run build && node integrations/x402-testnet/scenario.mjs", - "test": "npm run build && node --test --test-force-exit src/__tests__/paidTool.test.mjs src/__tests__/mcp-adapter.test.mjs src/__tests__/stripe.test.mjs src/__tests__/webhook-handler.test.mjs src/__tests__/db-ledger.test.mjs src/__tests__/rail-adapter.test.mjs src/__tests__/x402-solana-rail.test.mjs src/__tests__/x402-solana-sign.test.mjs src/__tests__/policy.test.mjs src/__tests__/protocol-compliance.test.mjs src/__tests__/idempotency.test.mjs src/__tests__/concurrency.test.mjs src/__tests__/db-idempotency.integration.test.mjs src/__tests__/trace.test.mjs src/__tests__/firecrawl-integration.test.mjs src/__tests__/local-first.test.mjs", + "test": "npm run build && node --test --test-force-exit src/__tests__/paidTool.test.mjs src/__tests__/mcp-adapter.test.mjs src/__tests__/stripe.test.mjs src/__tests__/webhook-handler.test.mjs src/__tests__/db-ledger.test.mjs src/__tests__/rail-adapter.test.mjs src/__tests__/x402-evm-rail.test.mjs src/__tests__/x402-solana-rail.test.mjs src/__tests__/x402-solana-sign.test.mjs src/__tests__/x402-solana-e2e.test.mjs src/__tests__/policy.test.mjs src/__tests__/protocol-compliance.test.mjs src/__tests__/idempotency.test.mjs src/__tests__/concurrency.test.mjs src/__tests__/db-idempotency.integration.test.mjs src/__tests__/trace.test.mjs src/__tests__/settlement-recovery.test.mjs src/__tests__/firecrawl-integration.test.mjs src/__tests__/local-first.test.mjs", "prepublishOnly": "npm run typecheck && npm test" }, "keywords": [ @@ -69,6 +69,8 @@ "url": "https://github.com/niceberginc/tollgate/issues" }, "peerDependencies": { + "@solana/spl-token": ">=0.4.0", + "@solana/web3.js": ">=1.90.0", "@x402/core": ">=0.1.0", "mppx": ">=0.1.0", "stripe": ">=14.0.0" @@ -82,6 +84,12 @@ }, "@x402/core": { "optional": true + }, + "@solana/web3.js": { + "optional": true + }, + "@solana/spl-token": { + "optional": true } }, "devDependencies": { diff --git a/src/__tests__/settlement-recovery.test.mjs b/src/__tests__/settlement-recovery.test.mjs new file mode 100644 index 0000000..7c11f29 --- /dev/null +++ b/src/__tests__/settlement-recovery.test.mjs @@ -0,0 +1,371 @@ +/** + * Settlement-uncertainty recovery (Phase B). + * + * Unit: settleWithRetry backoff, the in-memory pending store, and the reconciler. + * Integration: a flaky x402 rail whose settle fails after execution gets queued + * by the MCP adapter, then drained by gate.reconcileSettlements(). + * + * Run: node --test src/__tests__/settlement-recovery.test.mjs + */ + +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { + ToolGate, + InMemoryLedger, + createMcpAdapter, + usd, + settleWithRetry, + InMemoryPendingSettlementStore, + DbPendingSettlementStore, + SettlementReconciler, + startSettlementReconciler, +} from "../../dist/index.js"; +import { tryCreateSqliteClient } from "./_sqlite-client.mjs"; + +const noSleep = async () => {}; +const SETTLEMENT = { + settled: true, + rail: "x402", + txHash: "0xSETTLED", + amount: 0.05, + currency: "usd", + receiptId: "0xSETTLED", +}; + +/** A settlePayment that fails (null/throw) `failTimes` times, then succeeds. */ +function flakySettler({ failTimes, mode = "null" }) { + let calls = 0; + return { + rail: "x402", + calls: () => calls, + async settlePayment() { + calls++; + if (calls <= failTimes) { + if (mode === "throw") throw new Error("facilitator_timeout"); + return null; + } + return SETTLEMENT; + }, + }; +} + +// ─── settleWithRetry ────────────────────────────────────── + +describe("settleWithRetry", () => { + it("absorbs transient failures and settles", async () => { + const a = flakySettler({ failTimes: 2 }); + const out = await settleWithRetry(a, {}, undefined, { + retries: 3, + sleep: noSleep, + }); + assert.equal(out.result?.txHash, "0xSETTLED"); + assert.equal(out.attempts, 3, "two failures + one success"); + }); + + it("gives up after the retry budget, surfacing attempts + lastError", async () => { + const a = flakySettler({ failTimes: 99, mode: "throw" }); + const out = await settleWithRetry(a, {}, undefined, { + retries: 2, + sleep: noSleep, + }); + assert.equal(out.result, null); + assert.equal(out.attempts, 3, "1 + 2 retries"); + assert.equal(out.lastError, "facilitator_timeout"); + }); +}); + +// ─── store + reconciler ─────────────────────────────────── + +describe("InMemoryPendingSettlementStore", () => { + it("enqueues, updates, and removes", async () => { + const store = new InMemoryPendingSettlementStore(); + await store.enqueue({ id: "a", rail: "x402", proof: {} }); + await store.enqueue({ id: "b", rail: "x402", proof: {} }); + assert.equal(store.size, 2); + await store.update("a", { attempts: 5, lastError: "x" }); + assert.equal((await store.get("a")).attempts, 5); + await store.remove("b"); + assert.equal(store.size, 1); + assert.equal((await store.list()).length, 1); + }); +}); + +describe("SettlementReconciler", () => { + it("drains the queue when the adapter settles", async () => { + const store = new InMemoryPendingSettlementStore(); + await store.enqueue({ id: "a", rail: "x402", proof: {} }); + await store.enqueue({ id: "b", rail: "x402", proof: {} }); + + const adapter = flakySettler({ failTimes: 0 }); + const reconciler = new SettlementReconciler(() => adapter, store, { + sleep: noSleep, + }); + const r = await reconciler.reconcileOnce(); + + assert.equal(r.settled.length, 2); + assert.equal(r.remaining, 0); + assert.equal(store.size, 0); + }); + + it("keeps failing entries queued with attempts accumulated", async () => { + const store = new InMemoryPendingSettlementStore(); + await store.enqueue({ id: "a", rail: "x402", proof: {} }); + + const adapter = flakySettler({ failTimes: 99, mode: "throw" }); + const reconciler = new SettlementReconciler(() => adapter, store, { + retries: 1, + sleep: noSleep, + }); + + const r1 = await reconciler.reconcileOnce(); + assert.equal(r1.settled.length, 0); + assert.equal(r1.failures[0].error, "facilitator_timeout"); + assert.equal(r1.remaining, 1); + assert.equal((await store.get("a")).attempts, 2); + + await reconciler.reconcileOnce(); + assert.equal((await store.get("a")).attempts, 4, "attempts accumulate across passes"); + }); + + it("flags items whose rail has no adapter", async () => { + const store = new InMemoryPendingSettlementStore(); + await store.enqueue({ id: "a", rail: "x402", proof: {} }); + const reconciler = new SettlementReconciler(() => undefined, store, { + sleep: noSleep, + }); + const r = await reconciler.reconcileOnce(); + assert.equal(r.failures[0].error, "no_adapter_for_rail"); + assert.equal(r.remaining, 1); + }); +}); + +// ─── integration: MCP settle fails → queued → reconciled ── + +function buildGate(rail, settleRetry) { + const gate = new ToolGate({ + publisherKey: "tg_recovery", + ledger: new InMemoryLedger(), + paymentRails: ["x402"], + railAdapters: [rail], + }); + const mcp = createMcpAdapter(gate, { + getCallerId: () => "c1", + settleRetry, + }); + const tool = mcp.paidTool("t", { + description: "paid", + price: usd("0.05"), + onPaymentFailed: "block", + inputSchema: { type: "object", properties: { requestId: { type: "string" } } }, + idempotencyKey: (args) => `t:${args.requestId}`, + handler: async () => ({ ok: true }), + }); + return { gate, tool }; +} + +describe("recovery loop through TollGate + MCP adapter", () => { + it("queues an unsettled payment and reconciles it later", async () => { + // settle fails twice; MCP retries once (2 attempts) → both fail → queued + const rail = { + ...flakySettler({ failTimes: 2 }), + async verifyPayment() { + return { + verified: true, + rail: "x402", + amount: 0.05, + currency: "usd", + receiptId: "vrfy", + }; + }, + }; + const { gate, tool } = buildGate(rail, { retries: 1, sleep: noSleep }); + + const res = await tool.handler( + { requestId: "r1" }, + { + _meta: { + tollgate: { x402Payment: { x402Version: 2 }, x402ActionId: "act-1" }, + }, + }, + ); + + assert.notEqual(res.isError, true, "tool executed"); + const queued = await gate.pendingSettlements.list(); + assert.equal(queued.length, 1, "unsettled payment was queued"); + assert.equal(queued[0].id, "act-1"); + assert.equal(queued[0].rail, "x402"); + + // The trace marks it uncertain + queued. + const trace = await gate.traces.findByIdempotencyKey("t:r1"); + const events = trace.events.map((e) => e.event); + assert.ok(events.includes("settlement_uncertain")); + + // Now reconcile — the 3rd settle attempt succeeds. + const result = await gate.reconcileSettlements({ retries: 0, sleep: noSleep }); + assert.equal(result.settled.length, 1); + assert.equal(result.settled[0].settlement.txHash, "0xSETTLED"); + assert.equal(result.remaining, 0); + assert.equal((await gate.pendingSettlements.list()).length, 0); + }); +}); + +// ─── B+ : on-chain confirmation (two-phase) ─────────────── + +describe("SettlementReconciler with a ChainConfirmer", () => { + it("settles once, awaits confirmation, then dequeues — never re-settling", async () => { + const store = new InMemoryPendingSettlementStore(); + await store.enqueue({ id: "x", rail: "x402", proof: {} }); + + const adapter = flakySettler({ failTimes: 0 }); // settles → txHash 0xSETTLED + let confirmCalls = 0; + const confirmer = { + isConfirmed: async () => { + confirmCalls++; + return confirmCalls >= 2; // not confirmed on first check + }, + }; + const reconciler = new SettlementReconciler(() => adapter, store, { + sleep: noSleep, + confirmer, + }); + + const r1 = await reconciler.reconcileOnce(); + assert.equal(r1.settled.length, 0); + assert.equal(r1.pendingConfirmation.length, 1); + assert.equal(r1.remaining, 1); + assert.equal(adapter.calls(), 1, "settled exactly once"); + assert.equal((await store.get("x")).submittedTxHash, "0xSETTLED"); + + const r2 = await reconciler.reconcileOnce(); + assert.equal(r2.settled.length, 1); + assert.equal(r2.settled[0].txHash, "0xSETTLED"); + assert.equal(r2.remaining, 0); + assert.equal( + adapter.calls(), + 1, + "second pass only confirmed — did NOT re-submit the tx", + ); + }); +}); + +// ─── B+ : durable SQLite store ──────────────────────────── + +describe("DbPendingSettlementStore (sqlite)", () => { + it("persists with JSON round-trip; update preserves enqueuedAt", async (t) => { + const db = await tryCreateSqliteClient(); + if (!db) { + t.skip("better-sqlite3 unavailable"); + return; + } + await DbPendingSettlementStore.applySchema(db); + const store = new DbPendingSettlementStore(db); + + await store.enqueue({ + id: "a", + rail: "x402", + proof: { rail: "x402", x402PaymentPayload: { foo: 1 } }, + context: { actionId: "act-a" }, + toolName: "search", + callerId: "c1", + amount: 0.05, + }); + + const got = await store.get("a"); + assert.equal(got.rail, "x402"); + assert.deepEqual(got.proof, { rail: "x402", x402PaymentPayload: { foo: 1 } }); + assert.equal(got.context.actionId, "act-a"); + assert.equal(got.amount, 0.05); + const enqueuedAt = got.enqueuedAt; + + await store.update("a", { + attempts: 3, + submittedTxHash: "0xabc", + lastError: "boom", + }); + const updated = await store.get("a"); + assert.equal(updated.attempts, 3); + assert.equal(updated.submittedTxHash, "0xabc"); + assert.equal(updated.lastError, "boom"); + assert.equal(updated.enqueuedAt, enqueuedAt, "enqueuedAt preserved"); + + await store.enqueue({ id: "b", rail: "x402", proof: {} }); + assert.equal((await store.list()).length, 2); + await store.remove("a"); + const rest = await store.list(); + assert.equal(rest.length, 1); + assert.equal(rest[0].id, "b"); + }); + + it("reconciles a durable queue end-to-end", async (t) => { + const db = await tryCreateSqliteClient(); + if (!db) { + t.skip("better-sqlite3 unavailable"); + return; + } + await DbPendingSettlementStore.applySchema(db); + const store = new DbPendingSettlementStore(db); + await store.enqueue({ id: "q", rail: "x402", proof: {} }); + + const adapter = flakySettler({ failTimes: 1 }); + const reconciler = new SettlementReconciler(() => adapter, store, { + retries: 0, + sleep: noSleep, + }); + + const r1 = await reconciler.reconcileOnce(); // 1 attempt, fails → stays + assert.equal(r1.remaining, 1); + const r2 = await reconciler.reconcileOnce(); // succeeds → dequeued + assert.equal(r2.settled.length, 1); + assert.equal(r2.remaining, 0); + }); +}); + +// ─── B+ : scheduled loop ────────────────────────────────── + +describe("startSettlementReconciler", () => { + const empty = { + settled: [], + pendingConfirmation: [], + failures: [], + remaining: 0, + }; + + it("ticks on demand and stops cleanly", async () => { + let count = 0; + const handle = startSettlementReconciler( + async () => { + count++; + return empty; + }, + { intervalMs: 5 }, + ); + await handle.tick(); + await handle.tick(); + assert.ok(count >= 2); + handle.stop(); + const after = count; + await new Promise((r) => setTimeout(r, 20)); + assert.equal(count, after, "no ticks after stop()"); + }); + + it("skips overlapping ticks", async () => { + let active = 0; + let maxActive = 0; + const handle = startSettlementReconciler( + async () => { + active++; + maxActive = Math.max(maxActive, active); + await new Promise((r) => setTimeout(r, 10)); + active--; + return empty; + }, + { intervalMs: 1 }, + ); + const p1 = handle.tick(); + const p2 = handle.tick(); // running → skipped (resolves null) + await Promise.all([p1, p2]); + handle.stop(); + assert.equal(maxActive, 1, "no overlapping reconcile passes"); + }); +}); diff --git a/src/__tests__/x402-evm-rail.test.mjs b/src/__tests__/x402-evm-rail.test.mjs new file mode 100644 index 0000000..d7b6c44 --- /dev/null +++ b/src/__tests__/x402-evm-rail.test.mjs @@ -0,0 +1,109 @@ +/** + * x402 EVM Rail Adapter — EIP-712 domain injection. + * + * The EVM "exact" scheme (EIP-3009) requires the token's EIP-712 domain + * (name + version) in PaymentRequirements.extra, or the facilitator rejects + * verify with `invalid_exact_evm_missing_eip712_domain`. These tests assert the + * rail injects it for known USDC networks, honours an explicit override, and + * forwards it to the facilitator — all offline (fetch stubbed). + * + * Run: node --test src/__tests__/x402-evm-rail.test.mjs + */ + +import { describe, it, beforeEach, afterEach } from "node:test"; +import assert from "node:assert/strict"; +import { + X402RailAdapter, + EVM_USDC_ADDRESSES, +} from "../../dist/rail-adapters/x402-rail.js"; + +const BASE = "eip155:8453"; +const BASE_SEPOLIA = "eip155:84532"; +const PAY_TO = "0x179516864079FA14a0a718cF767cCDc2483324b6"; +const FACILITATOR = "https://facilitator.example.test"; + +const PARAMS = { + callerId: "0xCaller", + amount: 0.001, + currency: "usd", + toolName: "evm_tool", + publisherKey: "tg_evm", +}; + +function makeAdapter(overrides = {}) { + return new X402RailAdapter({ + payTo: PAY_TO, + network: { kind: "evm", caip2: BASE }, + facilitatorUrl: FACILITATOR, + x402Version: 2, + ...overrides, + }); +} + +describe("x402 EVM — EIP-712 domain in challenge", () => { + it("auto-injects USD Coin v2 for Base mainnet USDC", async () => { + const action = await makeAdapter().createChallenge(PARAMS); + const req = action.x402PaymentRequired.accepts[0]; + assert.equal(req.asset, EVM_USDC_ADDRESSES[BASE]); + assert.equal(req.extra.name, "USD Coin"); + assert.equal(req.extra.version, "2"); + }); + + it("uses USDC v2 for Base Sepolia testnet", async () => { + const action = await makeAdapter({ + network: { kind: "evm", caip2: BASE_SEPOLIA }, + }).createChallenge(PARAMS); + const req = action.x402PaymentRequired.accepts[0]; + assert.equal(req.extra.name, "USDC"); + assert.equal(req.extra.version, "2"); + }); + + it("honours an explicit eip712Domain override (custom token)", async () => { + const action = await makeAdapter({ + network: { kind: "evm", caip2: "eip155:99999", asset: "0xabc" }, + eip712Domain: { name: "MyToken", version: "1" }, + }).createChallenge(PARAMS); + const req = action.x402PaymentRequired.accepts[0]; + assert.equal(req.extra.name, "MyToken"); + assert.equal(req.extra.version, "1"); + }); + + it("does not attach feePayer for EVM", async () => { + const action = await makeAdapter().createChallenge(PARAMS); + assert.equal( + action.x402PaymentRequired.accepts[0].extra.feePayer, + undefined, + ); + }); +}); + +describe("x402 EVM — domain forwarded to the facilitator", () => { + const realFetch = globalThis.fetch; + let captured; + + beforeEach(() => { + captured = []; + globalThis.fetch = async (url, init) => { + captured.push({ url: String(url), body: JSON.parse(init.body) }); + return { ok: true, async json() { return { isValid: true, payer: PAY_TO }; } }; + }; + }); + afterEach(() => { + globalThis.fetch = realFetch; + }); + + it("verify sends extra.name/version in paymentRequirements", async () => { + const adapter = makeAdapter(); + const action = await adapter.createChallenge(PARAMS); + const proof = { rail: "x402", x402PaymentPayload: { x402Version: 2 } }; + + const result = await adapter.verifyPayment(proof, { + actionId: action.actionId, + }); + assert.ok(result?.verified); + const sent = captured.find((c) => c.url.endsWith("/verify")); + assert.ok(sent, "verify was called"); + assert.equal(sent.body.paymentRequirements.extra.name, "USD Coin"); + assert.equal(sent.body.paymentRequirements.extra.version, "2"); + }); +}); diff --git a/src/__tests__/x402-solana-e2e.test.mjs b/src/__tests__/x402-solana-e2e.test.mjs new file mode 100644 index 0000000..a16a894 --- /dev/null +++ b/src/__tests__/x402-solana-e2e.test.mjs @@ -0,0 +1,221 @@ +/** + * x402 Solana — end-to-end through the MCP adapter (offline, CI-friendly). + * + * Exercises the full paid-tool lifecycle on the Solana rail without a real + * validator: a fake in-process facilitator answers /supported, /verify, and + * /settle over localhost, so X402RailAdapter's real fetch calls run, and the + * MCP adapter's built-in verify → credit → execute → settle path is driven with + * the packaged Solana signer. + * + * 1. 402 discovery: a paid MCP tool with no balance returns an x402 Solana + * challenge in _meta (network, mint, actionId). + * 2. Sign: the packaged buildSolanaPaymentPayload() turns it into a + * partial-signed SVM payload. + * 3. Pay: retrying with the proof in _meta.tollgate verifies, credits, + * executes, and settles — the trace shows rail_payment_verified + + * rail_payment_settled with the on-chain tx signature. + * + * Run: node --test src/__tests__/x402-solana-e2e.test.mjs + */ + +import { describe, it, before, after } from "node:test"; +import assert from "node:assert/strict"; +import http from "node:http"; +import { once } from "node:events"; +import { Keypair } from "@solana/web3.js"; +import { + ToolGate, + InMemoryLedger, + createMcpAdapter, + X402RailAdapter, + buildSolanaPaymentPayload, + usd, +} from "../../dist/index.js"; + +const DEVNET_CAIP2 = "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1"; +const DEVNET_USDC = "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU"; +const SETTLE_SIG = + "5wHu1qwD4kT2example9signatureBase58oNSolanaDevnet11111111111"; + +const payer = Keypair.generate(); +const recipient = Keypair.generate(); +const feePayer = Keypair.generate(); +const fixedBlockhash = Keypair.generate().publicKey.toBase58(); + +// ─── Fake in-process x402 facilitator ───────────────────── + +let server; +let facilitatorUrl; +const facilitatorCalls = []; + +function jsonBody(req) { + return new Promise((resolve) => { + const chunks = []; + req.on("data", (c) => chunks.push(c)); + req.on("end", () => + resolve(chunks.length ? JSON.parse(Buffer.concat(chunks).toString()) : {}), + ); + }); +} + +before(async () => { + server = http.createServer(async (req, res) => { + const send = (obj) => { + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify(obj)); + }; + if (req.method === "GET" && req.url.endsWith("/supported")) { + facilitatorCalls.push("supported"); + return send({ + kinds: [ + { + x402Version: 2, + scheme: "exact", + network: DEVNET_CAIP2, + extra: { feePayer: feePayer.publicKey.toBase58() }, + }, + ], + }); + } + const body = await jsonBody(req); + if (req.url.endsWith("/verify")) { + facilitatorCalls.push("verify"); + // A real facilitator validates the partial-signed tx here. + return send({ isValid: true, payer: payer.publicKey.toBase58() }); + } + if (req.url.endsWith("/settle")) { + facilitatorCalls.push("settle"); + return send({ success: true, transaction: SETTLE_SIG, payer: payer.publicKey.toBase58() }); + } + res.writeHead(404); + res.end(); + }); + server.listen(0); + await once(server, "listening"); + facilitatorUrl = `http://127.0.0.1:${server.address().port}`; +}); + +after(() => server?.close()); + +// ─── Harness ────────────────────────────────────────────── + +async function buildGate() { + const ledger = new InMemoryLedger(); // zero balance + const rail = new X402RailAdapter({ + payTo: recipient.publicKey.toBase58(), + network: { kind: "solana", caip2: DEVNET_CAIP2 }, + facilitatorUrl, + }); + // Pull the fee payer from /supported so challenges carry extra.feePayer. + await rail.discoverFeePayer(); + + const gate = new ToolGate({ + publisherKey: "tg_sol_e2e", + ledger, + paymentRails: ["x402"], + railAdapters: [rail], + }); + const mcp = createMcpAdapter(gate, { getCallerId: () => "agent_sol" }); + + const tool = mcp.paidTool("premium_search", { + description: "Premium search, paid per call.", + price: usd("0.05"), + onPaymentFailed: "block", + inputSchema: { + type: "object", + properties: { query: { type: "string" }, requestId: { type: "string" } }, + required: ["query", "requestId"], + }, + idempotencyKey: (args) => `premium_search:${args.requestId}`, + handler: async (args) => ({ tier: "premium", query: args.query }), + }); + + return { gate, rail, tool }; +} + +// ─── Tests ──────────────────────────────────────────────── + +describe("x402 Solana — MCP end-to-end", () => { + it("returns an x402 Solana challenge when the caller has no balance", async () => { + const { tool } = await buildGate(); + + const res = await tool.handler( + { query: "vector dbs", requestId: "disc-1" }, + {}, + ); + + assert.equal(res.isError, true, "no balance → payment required"); + assert.equal(res._meta.tollgate.paymentRequired, true); + const challenge = res._meta.x402; + assert.ok(challenge, "challenge block present in _meta.x402"); + assert.equal(challenge.x402Version, 2, "Solana forces x402 v2"); + const req = challenge.accepts[0]; + assert.equal(req.network, DEVNET_CAIP2); + assert.equal(req.asset, DEVNET_USDC, "asset is the devnet USDC mint"); + assert.equal( + req.extra.feePayer, + feePayer.publicKey.toBase58(), + "fee payer discovered from /supported and surfaced to the client", + ); + assert.ok(res._meta.tollgate.x402ActionId, "actionId present for retry"); + }); + + it("verifies, executes, and settles when retried with a signed proof", async () => { + const { tool, gate } = await buildGate(); + + // 1) discover the challenge + const challengeRes = await tool.handler( + { query: "vector dbs", requestId: "disc-2" }, + {}, + ); + const challenge = challengeRes._meta.x402; + const actionId = challengeRes._meta.tollgate.x402ActionId; + + // 2) sign it (offline — fixed blockhash, no RPC) + const { paymentPayload } = await buildSolanaPaymentPayload({ + challenge: { x402PaymentRequired: challenge }, + payerSecretKey: payer.secretKey, + blockhash: fixedBlockhash, + }); + + // 3) pay: retry with the proof in _meta (fresh requestId → fresh idem key) + const paidRes = await tool.handler( + { query: "vector dbs", requestId: "pay-2" }, + { + _meta: { + tollgate: { x402Payment: paymentPayload, x402ActionId: actionId }, + }, + }, + ); + + assert.notEqual(paidRes.isError, true, "paid call should succeed"); + const payload = JSON.parse(paidRes.content[0].text); + assert.equal(payload.tier, "premium"); + assert.equal(payload.query, "vector dbs"); + + // facilitator saw verify then settle + assert.ok(facilitatorCalls.includes("verify")); + assert.ok(facilitatorCalls.includes("settle")); + + // 4) the trace records verify + on-chain settle with the tx signature + const trace = await gate.traces.findByIdempotencyKey( + "premium_search:pay-2", + ); + assert.ok(trace, "trace exists for the paid call"); + assert.equal(trace.handlerStatus, "success"); + const events = trace.events.map((e) => e.event); + assert.ok( + events.includes("rail_payment_verified"), + "verify event recorded", + ); + assert.ok( + events.includes("rail_payment_settled"), + "settle event recorded", + ); + assert.equal( + trace.provider?.traceId, + SETTLE_SIG, + "on-chain tx signature attached to the trace", + ); + }); +}); diff --git a/src/index.ts b/src/index.ts index 760f282..a7c381e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -49,6 +49,27 @@ export type { // Phase 2: Trace store export { InMemoryTraceStore } from "./trace-store.js"; +// Phase B: Settlement-uncertainty recovery (retry + queue + reconcile) +export { + settleWithRetry, + InMemoryPendingSettlementStore, + DbPendingSettlementStore, + DB_PENDING_SETTLEMENT_SCHEMA, + SettlementReconciler, + startSettlementReconciler, +} from "./settlement-recovery.js"; +export type { + SettleRetryOptions, + SettleAttemptOutcome, + PendingSettlement, + PendingSettlementInput, + PendingSettlementStore, + ChainConfirmer, + ReconcileResult, + ReconcilerOptions, + ReconcilerLoopHandle, +} from "./settlement-recovery.js"; + // MCP Adapter export { McpAdapter, createMcpAdapter } from "./mcp-adapter.js"; @@ -58,12 +79,19 @@ export { MppRailAdapter, X402RailAdapter, EVM_USDC_ADDRESSES, + EVM_USDC_EIP712_DOMAINS, SOLANA_USDC_ADDRESSES, + buildSolanaPaymentPayload, + extractSolanaRequirement, } from "./rail-adapters/index.js"; export type { StripeRailConfig, MppRailConfig, X402RailConfig, + BuildSolanaPaymentInput, + BuildSolanaPaymentResult, + SolanaPaymentPayload, + SolanaPaymentRequirement, } from "./rail-adapters/index.js"; // Stripe Integration (Phase 1) diff --git a/src/mcp-adapter.ts b/src/mcp-adapter.ts index 74689d9..dc5ae24 100644 --- a/src/mcp-adapter.ts +++ b/src/mcp-adapter.ts @@ -1,5 +1,7 @@ import type { TollGate } from "./tollgate.js"; import { usd } from "./money.js"; +import { settleWithRetry } from "./settlement-recovery.js"; +import type { SettleRetryOptions } from "./settlement-recovery.js"; import type { PaidToolConfig, ToolCallResult, @@ -41,6 +43,13 @@ export interface McpAdapterConfig { * Default: true */ includeMeta?: boolean; + + /** + * Retry/backoff policy for on-chain settlement after execution. A settlement + * that still fails is queued on the gate's pending-settlement store for + * `reconcileSettlements()`. Default: 3 retries with exponential backoff. + */ + settleRetry?: SettleRetryOptions; } // ─── MCP Paid Tool Config ────────────────────────────────── @@ -117,6 +126,7 @@ export class McpAdapter { getCallerId: config?.getCallerId ?? defaultGetCallerId, defaultCallerId: config?.defaultCallerId ?? "anonymous", includeMeta: config?.includeMeta ?? true, + settleRetry: config?.settleRetry ?? {}, }; } @@ -301,11 +311,16 @@ export class McpAdapter { }); } - // Settle on-chain after successful execution + // Settle on-chain after successful execution — with retry/backoff, and + // queue for reconciliation if it still doesn't confirm. if (result.success && railAdapter?.settlePayment && railProof) { - const settlement = await railAdapter - .settlePayment(railProof, verificationContext) - .catch(() => null); + const { result: settlement, attempts, lastError } = + await settleWithRetry( + railAdapter, + railProof, + verificationContext, + this.config.settleRetry, + ); if (settlement) { await this.annotateTrace(idempotencyKey, { @@ -320,15 +335,31 @@ export class McpAdapter { metadata: { receiptId: settlement.receiptId, txHash: settlement.txHash, + attempts, }, }, }); - } else if (railProof.rail === "x402") { + } else { + // Verified + executed but unsettled: queue it so a later + // reconcileSettlements() pass can retry instead of losing the payment. + await this.gate + .enqueueSettlement({ + id: verificationContext?.actionId ?? idempotencyKey, + rail: railProof.rail, + proof: railProof, + context: verificationContext, + toolName: name, + callerId, + amount: verifiedAmount, + lastError, + }) + .catch(() => undefined); await this.annotateTrace(idempotencyKey, { failureClass: "settlement_uncertain", event: { event: "settlement_uncertain", - detail: "x402 facilitator did not confirm settlement", + detail: `${railProof.rail} settlement unconfirmed after ${attempts} attempt(s); queued for reconciliation`, + metadata: { attempts, lastError, queued: true }, }, }); } diff --git a/src/rail-adapters/index.ts b/src/rail-adapters/index.ts index b319578..ca72773 100644 --- a/src/rail-adapters/index.ts +++ b/src/rail-adapters/index.ts @@ -7,6 +7,18 @@ export type { MppRailConfig } from "./mpp-rail.js"; export { X402RailAdapter, EVM_USDC_ADDRESSES, + EVM_USDC_EIP712_DOMAINS, SOLANA_USDC_ADDRESSES, } from "./x402-rail.js"; export type { X402RailConfig } from "./x402-rail.js"; + +export { + buildSolanaPaymentPayload, + extractSolanaRequirement, +} from "./x402-solana-signer.js"; +export type { + BuildSolanaPaymentInput, + BuildSolanaPaymentResult, + SolanaPaymentPayload, + SolanaPaymentRequirement, +} from "./x402-solana-signer.js"; diff --git a/src/rail-adapters/x402-rail.ts b/src/rail-adapters/x402-rail.ts index c304052..2d1c478 100644 --- a/src/rail-adapters/x402-rail.ts +++ b/src/rail-adapters/x402-rail.ts @@ -51,6 +51,15 @@ export interface X402RailConfig { */ feePayer?: string; + /** + * EIP-712 token domain (name + version) for EVM payments, surfaced to the + * facilitator via `PaymentRequirements.extra` so it can verify the EIP-3009 + * signature. Auto-detected from `EVM_USDC_EIP712_DOMAINS` for known USDC + * networks; set this only for a non-USDC asset or a custom token. Ignored for + * Solana. + */ + eip712Domain?: { name: string; version: string }; + /** * Facilitator URL for payment verification and settlement. * @@ -85,6 +94,25 @@ export const EVM_USDC_ADDRESSES: Record = { "eip155:10": "0x0b2C639c533813f4Aa9D7837CAf62653d097Ff85", // Optimism }; +/** + * EIP-712 domain (name + version) for USDC on each EVM network. The facilitator + * needs this in `PaymentRequirements.extra` to verify the EIP-3009 signature — + * without it, verify fails with `invalid_exact_evm_missing_eip712_domain`. + * Circle's USDC uses name "USD Coin" v2 on mainnets; Base Sepolia testnet USDC + * uses "USDC" v2. + */ +export const EVM_USDC_EIP712_DOMAINS: Record< + string, + { name: string; version: string } +> = { + "eip155:8453": { name: "USD Coin", version: "2" }, // Base mainnet + "eip155:84532": { name: "USDC", version: "2" }, // Base Sepolia + "eip155:1": { name: "USD Coin", version: "2" }, // Ethereum mainnet + "eip155:137": { name: "USD Coin", version: "2" }, // Polygon + "eip155:42161": { name: "USD Coin", version: "2" }, // Arbitrum + "eip155:10": { name: "USD Coin", version: "2" }, // Optimism +}; + /** USDC mint addresses for Solana networks (SPL Token) */ export const SOLANA_USDC_ADDRESSES: Record = { "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp": @@ -156,6 +184,19 @@ export class X402RailAdapter implements RailAdapter { extra.feePayer = this.config.feePayer; } + // EVM "exact" (EIP-3009) requires the token's EIP-712 domain so the + // facilitator can verify the authorization signature. Auto-detect for known + // USDC networks; otherwise honour an explicitly configured domain. + if (!this.isSolana) { + const domain = + this.config.eip712Domain ?? + EVM_USDC_EIP712_DOMAINS[this.config.network.caip2]; + if (domain) { + extra.name = domain.name; + extra.version = domain.version; + } + } + const paymentRequirement: X402PaymentRequirement = { scheme: this.config.scheme ?? "exact", network: this.config.network.caip2, diff --git a/src/rail-adapters/x402-solana-signer.ts b/src/rail-adapters/x402-solana-signer.ts new file mode 100644 index 0000000..0ae82d6 --- /dev/null +++ b/src/rail-adapters/x402-solana-signer.ts @@ -0,0 +1,229 @@ +import { randomBytes } from "node:crypto"; +import type { VersionedTransaction } from "@solana/web3.js"; + +// ─── x402 Solana (SVM) client-side signer ───────────────── +// +// Turns a Tollgate 402 challenge into an x402 "exact" payment payload for +// Solana. Solana has no EIP-712 / EIP-3009; the SVM "exact" scheme instead has +// the client build and PARTIALLY sign a real SPL transfer, leaving the fee-payer +// signature empty for the facilitator to fill at /settle: +// +// 1. ComputeBudget: set unit limit + unit price (price ≤ 5 microLamports/CU) +// 2. SPL TransferChecked: payer ATA → recipient ATA, exact atomic amount +// 3. Memo: a random nonce (or seller-provided memo) for payment uniqueness +// fee payer = requirement.extra.feePayer (the facilitator), NOT the client +// +// @solana/web3.js and @solana/spl-token are imported dynamically and declared as +// optional peer dependencies, so installing Tollgate stays light for callers +// that never sign on Solana. + +const MEMO_PROGRAM_ID = "MemoSq4gqABAXKb96qnH8TysNcWxMyWCqXgDLGmfcHr"; + +/** Max compute-unit price the SVM "exact" scheme allows (microLamports/CU). */ +const MAX_CU_PRICE = 5; + +/** A facilitator-validated x402 v2 payment payload, ready for the X-PAYMENT header. */ +export interface SolanaPaymentPayload { + x402Version: 2; + accepted: Record; + payload: { transaction: string }; +} + +export interface SolanaPaymentRequirement { + scheme?: string; + network: string; + maxAmountRequired?: string; + amount?: string; + asset: string; + payTo: string; + resource?: string; + description?: string; + maxTimeoutSeconds?: number; + extra?: { feePayer?: string; decimals?: number; [k: string]: unknown }; +} + +export interface BuildSolanaPaymentInput { + /** Tollgate 402 response, the raw x402 block, or a single requirement. */ + challenge: unknown; + /** Client wallet secret key (64-byte Uint8Array). */ + payerSecretKey: Uint8Array; + /** RPC endpoint used to fetch a recent blockhash (omit if `blockhash` is given). */ + rpcUrl?: string; + /** Explicit recent blockhash — skips RPC (useful for tests/offline signing). */ + blockhash?: string; + /** Override the memo (defaults to a random 16-byte hex nonce). */ + memo?: string; + /** + * Compute-unit limit. Bounded by the SVM "exact" scheme: facilitators reject + * limits that are too high (~50k+) and a transfer needs more than ~10k, so + * the default sits comfortably between. + */ + computeUnitLimit?: number; + /** Compute-unit price in microLamports/CU (clamped to ≤ 5). */ + computeUnitPrice?: number; +} + +export interface BuildSolanaPaymentResult { + paymentPayload: SolanaPaymentPayload; + transaction: VersionedTransaction; + memo: string; +} + +async function loadSolana() { + try { + const [web3, splToken] = await Promise.all([ + import("@solana/web3.js"), + import("@solana/spl-token"), + ]); + return { web3, splToken }; + } catch (cause) { + throw new Error( + "x402 Solana signing requires @solana/web3.js and @solana/spl-token.\n" + + "Install them: npm install @solana/web3.js @solana/spl-token", + { cause }, + ); + } +} + +/** + * Pull the first payment requirement out of a Tollgate 402 response, whether it + * arrives as the raw x402PaymentRequired block or a Tollgate settlement entry. + */ +export function extractSolanaRequirement( + challenge: unknown, +): SolanaPaymentRequirement { + const c = challenge as Record | undefined; + const block = + c?.x402PaymentRequired ?? c?.paymentRequired?.x402Challenge ?? c; + const accepts = block?.accepts ?? c?.accepts; + const requirement = Array.isArray(accepts) ? accepts[0] : block; + + if (!requirement?.asset || !requirement?.payTo || !requirement?.network) { + throw new Error( + "Challenge does not contain a usable Solana x402 payment requirement " + + "(missing asset/payTo/network).", + ); + } + if (!String(requirement.network).startsWith("solana:")) { + throw new Error( + `Requirement network "${requirement.network}" is not a Solana network.`, + ); + } + return requirement as SolanaPaymentRequirement; +} + +/** + * Build a base64, partially-signed x402 SVM payment payload from a 402 + * challenge. The client signs only its own slot; the fee-payer signature stays + * empty for the facilitator to fill at /settle. + */ +export async function buildSolanaPaymentPayload( + input: BuildSolanaPaymentInput, +): Promise { + const { + challenge, + payerSecretKey, + rpcUrl, + blockhash, + memo, + computeUnitLimit = 30_000, + computeUnitPrice = 1, + } = input; + + const { web3, splToken } = await loadSolana(); + const { + Connection, + Keypair, + PublicKey, + TransactionInstruction, + TransactionMessage, + VersionedTransaction, + ComputeBudgetProgram, + } = web3; + const { getAssociatedTokenAddressSync, createTransferCheckedInstruction } = + splToken; + + const requirement = extractSolanaRequirement(challenge); + + if (!requirement.extra?.feePayer) { + throw new Error( + "Solana requirement is missing extra.feePayer — the facilitator must " + + "advertise a fee payer (X402RailConfig.feePayer / discoverFeePayer()).", + ); + } + + const payer = Keypair.fromSecretKey(payerSecretKey); + const mint = new PublicKey(requirement.asset); + const recipient = new PublicKey(requirement.payTo); + const feePayer = new PublicKey(requirement.extra.feePayer); + const decimals = requirement.extra?.decimals ?? 6; + const amount = BigInt(requirement.maxAmountRequired ?? requirement.amount!); + + const sourceAta = getAssociatedTokenAddressSync(mint, payer.publicKey); + // allowOwnerOffCurve:true so a PDA recipient (program-owned payTo) is allowed. + const destAta = getAssociatedTokenAddressSync(mint, recipient, true); + + const memoText = memo ?? randomBytes(16).toString("hex"); + + const instructions = [ + ComputeBudgetProgram.setComputeUnitLimit({ units: computeUnitLimit }), + ComputeBudgetProgram.setComputeUnitPrice({ + microLamports: Math.min(computeUnitPrice, MAX_CU_PRICE), + }), + createTransferCheckedInstruction( + sourceAta, + mint, + destAta, + payer.publicKey, + amount, + decimals, + ), + new TransactionInstruction({ + keys: [], + programId: new PublicKey(MEMO_PROGRAM_ID), + data: Buffer.from(memoText, "utf8"), + }), + ]; + + let recentBlockhash = blockhash; + if (!recentBlockhash) { + if (!rpcUrl) { + throw new Error("Provide either `blockhash` or `rpcUrl` to sign."); + } + const connection = new Connection(rpcUrl, "confirmed"); + ({ blockhash: recentBlockhash } = await connection.getLatestBlockhash()); + } + + const message = new TransactionMessage({ + payerKey: feePayer, // facilitator sponsors fees + signs at /settle + recentBlockhash, + instructions, + }).compileToV0Message(); + + const transaction = new VersionedTransaction(message); + // Sign ONLY the client's slot — the fee-payer signature stays empty. + transaction.sign([payer]); + + const serialized = Buffer.from( + transaction.serialize(), + ).toString("base64"); + + // The x402 v2 PaymentPayload embeds the `accepted` requirement the client + // agreed to. Facilitators validate the on-chain transaction against it and + // expect the amount as an atomic STRING under `accepted.amount`. + const accepted = { + scheme: requirement.scheme ?? "exact", + network: requirement.network, + amount: String(requirement.maxAmountRequired ?? requirement.amount), + asset: requirement.asset, + payTo: requirement.payTo, + maxTimeoutSeconds: requirement.maxTimeoutSeconds ?? 300, + extra: requirement.extra, + }; + + return { + paymentPayload: { x402Version: 2, accepted, payload: { transaction: serialized } }, + transaction, + memo: memoText, + }; +} diff --git a/src/settlement-recovery.ts b/src/settlement-recovery.ts new file mode 100644 index 0000000..70e1a47 --- /dev/null +++ b/src/settlement-recovery.ts @@ -0,0 +1,479 @@ +import type { + PaymentRail, + PaymentProof, + VerificationContext, + SettlementResult, + RailAdapter, +} from "./types.js"; +import type { DbClient } from "./db-ledger.js"; + +// ─── Settlement Recovery ────────────────────────────────── +// +// In x402-style rails, settlement happens AFTER execution and can fail +// independently (RPC blip, facilitator timeout) while the payment was already +// verified and the caller credited. A bare `settlePayment` call that returns +// null leaves money authorized but unsettled — the provider isn't paid. +// +// This module turns that "settlement_uncertain" dead-end into a recovery loop: +// 1. settleWithRetry — absorb transient failures with bounded backoff. +// 2. PendingSettlementStore — durably queue what still didn't settle. +// 3. SettlementReconciler — drain the queue later until it does. + +export interface SettleRetryOptions { + /** Extra attempts after the first (default 3 → up to 4 tries). */ + retries?: number; + /** Base backoff in ms (default 250). */ + baseDelayMs?: number; + /** Backoff ceiling in ms (default 4000). */ + maxDelayMs?: number; + /** Injectable sleep (tests pass a no-op). */ + sleep?: (ms: number) => Promise; +} + +export interface SettleAttemptOutcome { + result: SettlementResult | null; + attempts: number; + lastError?: string; +} + +const defaultSleep = (ms: number) => + new Promise((resolve) => setTimeout(resolve, ms)); + +/** + * Call `adapter.settlePayment` repeatedly until it returns a settlement or the + * retry budget is exhausted. A thrown error or a null result both count as a + * failed attempt. Returns the outcome plus how many attempts were spent. + */ +export async function settleWithRetry( + adapter: Pick, + proof: PaymentProof, + context: VerificationContext | undefined, + options: SettleRetryOptions = {}, +): Promise { + const retries = options.retries ?? 3; + const base = options.baseDelayMs ?? 250; + const max = options.maxDelayMs ?? 4000; + const sleep = options.sleep ?? defaultSleep; + + let attempts = 0; + let lastError: string | undefined; + + while (attempts <= retries) { + attempts++; + try { + const result = (await adapter.settlePayment?.(proof, context)) ?? null; + if (result) return { result, attempts, lastError }; + lastError = "settle_returned_null"; + } catch (error) { + lastError = error instanceof Error ? error.message : String(error); + } + if (attempts <= retries) { + await sleep(Math.min(max, base * 2 ** (attempts - 1))); + } + } + + return { result: null, attempts, lastError }; +} + +// ─── Pending settlement store ───────────────────────────── + +export interface PendingSettlement { + /** Stable id — the rail actionId when available, else generated. */ + id: string; + rail: PaymentRail; + proof: PaymentProof; + context?: VerificationContext; + toolName?: string; + callerId?: string; + amount?: number; + attempts: number; + lastError?: string; + /** + * Tx hash returned by a successful settle that is awaiting on-chain + * confirmation. When set, the reconciler re-checks confirmation instead of + * re-submitting — so a landed-but-unconfirmed payment is never double-settled. + */ + submittedTxHash?: string; + enqueuedAt: number; + updatedAt: number; +} + +/** + * Confirms that a settlement tx actually landed on-chain. Implement per chain + * (EVM: `eth_getTransactionReceipt`; Solana: `getSignatureStatuses`). Lets the + * reconciler promote a "submitted" settlement to "confirmed". + */ +export interface ChainConfirmer { + isConfirmed(rail: PaymentRail, txHash: string): Promise; +} + +export type PendingSettlementInput = Omit< + PendingSettlement, + "attempts" | "enqueuedAt" | "updatedAt" +> & { attempts?: number }; + +export interface PendingSettlementStore { + enqueue(item: PendingSettlementInput): Promise; + list(): Promise; + get(id: string): Promise; + remove(id: string): Promise; + update(id: string, patch: Partial): Promise; +} + +export class InMemoryPendingSettlementStore implements PendingSettlementStore { + private items = new Map(); + + async enqueue(item: PendingSettlementInput): Promise { + const now = Date.now(); + const existing = this.items.get(item.id); + this.items.set(item.id, { + ...item, + attempts: item.attempts ?? existing?.attempts ?? 0, + enqueuedAt: existing?.enqueuedAt ?? now, + updatedAt: now, + }); + } + + async list(): Promise { + return Array.from(this.items.values()).sort( + (a, b) => a.enqueuedAt - b.enqueuedAt, + ); + } + + async get(id: string): Promise { + return this.items.get(id) ?? null; + } + + async remove(id: string): Promise { + this.items.delete(id); + } + + async update(id: string, patch: Partial): Promise { + const existing = this.items.get(id); + if (!existing) return; + this.items.set(id, { ...existing, ...patch, updatedAt: Date.now() }); + } + + /** Count of queued settlements (testing/monitoring). */ + get size(): number { + return this.items.size; + } +} + +// ─── Reconciler ─────────────────────────────────────────── + +export interface ReconcileResult { + settled: Array<{ id: string; settlement?: SettlementResult; txHash?: string }>; + /** Settled but not yet confirmed on-chain — kept queued for re-check. */ + pendingConfirmation: Array<{ id: string; txHash?: string }>; + failures: Array<{ id: string; error: string }>; + remaining: number; +} + +export interface ReconcilerOptions extends SettleRetryOptions { + /** Optional on-chain confirmer; when present, only confirmed settles dequeue. */ + confirmer?: ChainConfirmer; +} + +/** + * Drains a PendingSettlementStore by retrying each queued settlement through its + * rail adapter. Settled entries are removed; still-failing entries stay queued + * with their attempt count and last error updated for the next pass. + * + * With a {@link ChainConfirmer}, settlement becomes two-phase: a successful + * settle records its tx hash and stays queued until the tx is confirmed + * on-chain — and a queued item that already has a tx hash is re-checked for + * confirmation instead of being settled again. + */ +export class SettlementReconciler { + private confirmer?: ChainConfirmer; + + constructor( + private resolveAdapter: (rail: PaymentRail) => RailAdapter | undefined, + private store: PendingSettlementStore, + private options: ReconcilerOptions = {}, + ) { + this.confirmer = options.confirmer; + } + + async reconcileOnce(): Promise { + const items = await this.store.list(); + const settled: ReconcileResult["settled"] = []; + const pendingConfirmation: ReconcileResult["pendingConfirmation"] = []; + const failures: ReconcileResult["failures"] = []; + + for (const item of items) { + // Phase 2: a prior pass already settled this; just confirm on-chain. + if (item.submittedTxHash && this.confirmer) { + const ok = await this.confirmer + .isConfirmed(item.rail, item.submittedTxHash) + .catch(() => false); + if (ok) { + settled.push({ id: item.id, txHash: item.submittedTxHash }); + await this.store.remove(item.id); + } else { + pendingConfirmation.push({ id: item.id, txHash: item.submittedTxHash }); + await this.store.update(item.id, { lastError: "awaiting_confirmation" }); + } + continue; + } + + const adapter = this.resolveAdapter(item.rail); + if (!adapter?.settlePayment) { + failures.push({ id: item.id, error: "no_adapter_for_rail" }); + await this.store.update(item.id, { lastError: "no_adapter_for_rail" }); + continue; + } + + const outcome = await settleWithRetry( + adapter, + item.proof, + item.context, + this.options, + ); + + if (!outcome.result) { + failures.push({ + id: item.id, + error: outcome.lastError ?? "settle_failed", + }); + await this.store.update(item.id, { + attempts: item.attempts + outcome.attempts, + lastError: outcome.lastError, + }); + continue; + } + + const txHash = outcome.result.txHash; + if (this.confirmer && txHash) { + const ok = await this.confirmer + .isConfirmed(item.rail, txHash) + .catch(() => false); + if (ok) { + settled.push({ id: item.id, settlement: outcome.result, txHash }); + await this.store.remove(item.id); + } else { + // Landed but unconfirmed — remember the tx so we don't re-submit. + pendingConfirmation.push({ id: item.id, txHash }); + await this.store.update(item.id, { + attempts: item.attempts + outcome.attempts, + submittedTxHash: txHash, + lastError: "awaiting_confirmation", + }); + } + } else { + settled.push({ id: item.id, settlement: outcome.result, txHash }); + await this.store.remove(item.id); + } + } + + return { + settled, + pendingConfirmation, + failures, + remaining: (await this.store.list()).length, + }; + } +} + +// ─── Scheduled reconciler loop ──────────────────────────── + +export interface ReconcilerLoopHandle { + /** Stop the loop. */ + stop(): void; + /** Run one pass immediately (also used internally on each tick). */ + tick(): Promise; +} + +/** + * Run `reconcile` on a fixed interval until stopped. Overlapping ticks are + * skipped (a slow pass won't pile up). Returns a handle with `stop()` and a + * `tick()` for manual/triggered runs. + */ +export function startSettlementReconciler( + reconcile: () => Promise, + options: { + intervalMs: number; + onResult?: (result: ReconcileResult) => void; + onError?: (error: unknown) => void; + }, +): ReconcilerLoopHandle { + let running = false; + let stopped = false; + + const tick = async (): Promise => { + if (running || stopped) return null; + running = true; + try { + const result = await reconcile(); + options.onResult?.(result); + return result; + } catch (error) { + options.onError?.(error); + return null; + } finally { + running = false; + } + }; + + const timer = setInterval(tick, options.intervalMs); + timer.unref?.(); + + return { + stop() { + stopped = true; + clearInterval(timer); + }, + tick, + }; +} + +// ─── Durable (D1 / Turso / SQLite) pending store ────────── + +/** + * SQL migration for the durable pending-settlement queue. Run once against your + * D1/Turso/SQLite database (mirrors DB_IDEMPOTENCY_SCHEMA conventions). + */ +export const DB_PENDING_SETTLEMENT_SCHEMA = [ + `CREATE TABLE IF NOT EXISTS tg_pending_settlements ( + id TEXT PRIMARY KEY, + rail TEXT NOT NULL, + proof TEXT NOT NULL, + context TEXT, + tool_name TEXT, + caller_id TEXT, + amount REAL, + attempts INTEGER NOT NULL DEFAULT 0, + last_error TEXT, + submitted_tx_hash TEXT, + enqueued_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL + )`, + `CREATE INDEX IF NOT EXISTS tg_pending_settlements_enqueued_at + ON tg_pending_settlements (enqueued_at)`, +]; + +interface PendingSettlementRow { + id: string; + rail: string; + proof: string; + context: string | null; + tool_name: string | null; + caller_id: string | null; + amount: number | null; + attempts: number; + last_error: string | null; + submitted_tx_hash: string | null; + enqueued_at: number; + updated_at: number; +} + +/** + * Durable, cross-instance pending-settlement store backed by a SQL database. + * The multi-instance counterpart to {@link InMemoryPendingSettlementStore}: a + * settlement queued by one worker survives restarts and can be reconciled by + * any worker sharing the database. + */ +export class DbPendingSettlementStore implements PendingSettlementStore { + constructor(private db: DbClient) {} + + static async applySchema(db: DbClient): Promise { + for (const sql of DB_PENDING_SETTLEMENT_SCHEMA) { + await db.prepare(sql).run(); + } + } + + private rowToItem(row: PendingSettlementRow): PendingSettlement { + return { + id: row.id, + rail: row.rail as PaymentRail, + proof: JSON.parse(row.proof) as PaymentProof, + context: row.context + ? (JSON.parse(row.context) as VerificationContext) + : undefined, + toolName: row.tool_name ?? undefined, + callerId: row.caller_id ?? undefined, + amount: row.amount ?? undefined, + attempts: row.attempts, + lastError: row.last_error ?? undefined, + submittedTxHash: row.submitted_tx_hash ?? undefined, + enqueuedAt: row.enqueued_at, + updatedAt: row.updated_at, + }; + } + + async enqueue(item: PendingSettlementInput): Promise { + const now = Date.now(); + // Upsert: on conflict, keep the original enqueued_at, refresh the rest. + await this.db + .prepare( + `/* tg_settle_upsert */ + INSERT INTO tg_pending_settlements + (id, rail, proof, context, tool_name, caller_id, amount, attempts, + last_error, submitted_tx_hash, enqueued_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(id) DO UPDATE SET + rail = excluded.rail, + proof = excluded.proof, + context = excluded.context, + tool_name = excluded.tool_name, + caller_id = excluded.caller_id, + amount = excluded.amount, + attempts = excluded.attempts, + last_error = excluded.last_error, + submitted_tx_hash = excluded.submitted_tx_hash, + updated_at = excluded.updated_at`, + ) + .bind( + item.id, + item.rail, + JSON.stringify(item.proof), + item.context ? JSON.stringify(item.context) : null, + item.toolName ?? null, + item.callerId ?? null, + item.amount ?? null, + item.attempts ?? 0, + item.lastError ?? null, + item.submittedTxHash ?? null, + now, + now, + ) + .run(); + } + + async list(): Promise { + const { results } = await this.db + .prepare( + `/* tg_settle_list */ + SELECT * FROM tg_pending_settlements ORDER BY enqueued_at ASC`, + ) + .all(); + return results.map((r) => this.rowToItem(r)); + } + + async get(id: string): Promise { + const row = await this.db + .prepare( + `/* tg_settle_get */ SELECT * FROM tg_pending_settlements WHERE id = ?`, + ) + .bind(id) + .first(); + return row ? this.rowToItem(row) : null; + } + + async remove(id: string): Promise { + await this.db + .prepare( + `/* tg_settle_delete */ DELETE FROM tg_pending_settlements WHERE id = ?`, + ) + .bind(id) + .run(); + } + + async update(id: string, patch: Partial): Promise { + const existing = await this.get(id); + if (!existing) return; + const merged = { ...existing, ...patch }; + await this.enqueue(merged); // upsert preserves enqueued_at on conflict + } +} diff --git a/src/tollgate.ts b/src/tollgate.ts index 81fc816..a1b309f 100644 --- a/src/tollgate.ts +++ b/src/tollgate.ts @@ -28,6 +28,16 @@ import { resolvePriceInput, } from "./money.js"; import { determineRecovery, getCapabilities } from "./recovery.js"; +import { + InMemoryPendingSettlementStore, + SettlementReconciler, +} from "./settlement-recovery.js"; +import type { + PendingSettlementStore, + PendingSettlementInput, + SettleRetryOptions, + ReconcileResult, +} from "./settlement-recovery.js"; import { InMemoryLedger } from "./ledger.js"; import { InMemoryIdempotencyStore } from "./idempotency.js"; import { InMemoryTraceStore } from "./trace-store.js"; @@ -52,6 +62,7 @@ export class TollGate { paymentMode: PaymentMode; idempotencyStore: IdempotencyStore; traceStore: TraceStore; + pendingSettlementStore: PendingSettlementStore; idempotencyTtlSeconds: number; }; @@ -76,6 +87,8 @@ export class TollGate { idempotencyStore: config.idempotencyStore ?? new InMemoryIdempotencyStore(), traceStore: config.traceStore ?? new InMemoryTraceStore(), + pendingSettlementStore: + config.pendingSettlementStore ?? new InMemoryPendingSettlementStore(), idempotencyTtlSeconds: config.idempotencyTtlSeconds ?? 3600, waitForInProgressMs: config.waitForInProgressMs ?? 5000, }; @@ -125,6 +138,35 @@ export class TollGate { return this.config.railAdapters?.find((a) => a.rail === rail); } + /** Access the pending-settlement store (settlements awaiting reconciliation) */ + get pendingSettlements(): PendingSettlementStore { + return this.config.pendingSettlementStore; + } + + /** + * Queue a verified-but-unsettled payment for later reconciliation. Called when + * settlement fails after execution (settlement_uncertain) so it isn't lost. + */ + async enqueueSettlement(item: PendingSettlementInput): Promise { + await this.config.pendingSettlementStore.enqueue(item); + } + + /** + * Drain the pending-settlement queue: retry each queued settlement through its + * rail adapter, removing the ones that settle and keeping the rest for a later + * pass. Run this on a timer or after a facilitator outage. + */ + async reconcileSettlements( + options?: SettleRetryOptions, + ): Promise { + const reconciler = new SettlementReconciler( + (rail) => this.getRailAdapter(rail), + this.config.pendingSettlementStore, + options, + ); + return reconciler.reconcileOnce(); + } + /** Access the trace store (for querying execution history) */ get traces(): TraceStore { return this.config.traceStore; diff --git a/src/types.ts b/src/types.ts index c6029b7..38944f7 100644 --- a/src/types.ts +++ b/src/types.ts @@ -30,6 +30,12 @@ export interface TollGateConfig { idempotencyStore?: IdempotencyStore; /** Execution trace store. Default: InMemoryTraceStore */ traceStore?: TraceStore; + /** + * Store for settlements that were verified + executed but failed to settle + * on-chain (settlement_uncertain). Drained later via `reconcileSettlements()`. + * Default: InMemoryPendingSettlementStore. + */ + pendingSettlementStore?: import("./settlement-recovery.js").PendingSettlementStore; /** * Default idempotency TTL in seconds. After this, same key = new execution. * Default: 3600 (1 hour).