From 5c957b82df059d471b39f9d99a90533e353e88fd Mon Sep 17 00:00:00 2001 From: Bertug Date: Thu, 25 Jun 2026 15:08:43 +0300 Subject: [PATCH 1/3] feat(x402-solana): package the signer + full MCP verify/settle e2e (Beta on devnet) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Moves Solana from "experimental rail + example" to a packaged, end-to-end Beta. - Ship the client signer in the package: src/rail-adapters/x402-solana-signer.ts exports buildSolanaPaymentPayload + extractSolanaRequirement (also re-exported from the root). @solana/web3.js and @solana/spl-token become OPTIONAL peer dependencies, dynamically imported — install stays light for non-Solana callers. The example sign-payload.mjs now re-exports from the package. - Add src/__tests__/x402-solana-e2e.test.mjs: drives the MCP adapter's built-in verify -> credit -> execute -> settle path on the Solana rail against a fake in-process facilitator (localhost; no validator). Asserts the 402 Solana challenge, the signed retry, and a trace carrying rail_payment_verified + rail_payment_settled with the on-chain tx signature. - Status bumped Experimental -> Beta on devnet (README + landing). Mainnet still untested. Full suite: 230 passing. Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 2 +- examples/x402-solana-recovery/NOTES.md | 29 ++- .../x402-solana-recovery/sign-payload.mjs | 203 +--------------- landing/index.html | 2 +- package.json | 10 +- src/__tests__/x402-solana-e2e.test.mjs | 221 +++++++++++++++++ src/index.ts | 6 + src/rail-adapters/index.ts | 11 + src/rail-adapters/x402-solana-signer.ts | 229 ++++++++++++++++++ 9 files changed, 513 insertions(+), 200 deletions(-) create mode 100644 src/__tests__/x402-solana-e2e.test.mjs create mode 100644 src/rail-adapters/x402-solana-signer.ts diff --git a/README.md b/README.md index 279e432..60f9036 100644 --- a/README.md +++ b/README.md @@ -99,7 +99,7 @@ Current version: `0.3.0-beta.1`. | 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 (Solana / SVM) | Beta on devnet; SVM "exact" scheme, packaged signer, full MCP verify/settle e2e ([notes](examples/x402-solana-recovery/NOTES.md)). Mainnet not tested | | x402 mainnet | Not tested | | 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..3ed2e0f 100644 --- a/examples/x402-solana-recovery/NOTES.md +++ b/examples/x402-solana-recovery/NOTES.md @@ -48,8 +48,10 @@ fail to settle on-chain. Toolgate's recovery/trace layer makes that ## 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 +62,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. @@ -112,6 +128,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/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..5c12feb 100644 --- a/landing/index.html +++ b/landing/index.html @@ -422,7 +422,7 @@

Status

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 — Solana / SVMBeta on devnet; packaged signer + full MCP verify/settle e2e (gasless via x402 facilitators), mainnet not tested MPPMocked / spec-path unless verified with real mppx integration diff --git a/package.json b/package.json index 4f6d3dc..8a99dd4 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-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__/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__/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..0e1908e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -59,11 +59,17 @@ export { X402RailAdapter, EVM_USDC_ADDRESSES, 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/rail-adapters/index.ts b/src/rail-adapters/index.ts index b319578..ba67b07 100644 --- a/src/rail-adapters/index.ts +++ b/src/rail-adapters/index.ts @@ -10,3 +10,14 @@ export { 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-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, + }; +} From c673ae319a688a5ab7621275141353f3b0a21251 Mon Sep 17 00:00:00 2001 From: Bertug Date: Thu, 25 Jun 2026 15:19:57 +0300 Subject: [PATCH 2/3] feat(x402-solana): make the settle runner network-agnostic (devnet + mainnet) devnet-settle.mjs now honors SOLANA_NETWORK=devnet|mainnet (plus NETWORK_CAIP2 / USDC_MINT / SOLANA_RPC_URL overrides), resolving the CAIP-2 id, USDC mint, default RPC, and explorer cluster per network. Mainnet uses a real-USDC funding prompt instead of the faucet. Self-transfer default keeps the smoke test zero-net-cost. Gitignore the mainnet payer keypair too. Co-Authored-By: Claude Opus 4.8 (1M context) --- .gitignore | 1 + .../x402-solana-recovery/devnet-settle.mjs | 73 ++++++++++++++----- 2 files changed, 54 insertions(+), 20 deletions(-) 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/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}`, ); } From c99dcb1f38584e23af6befb00b167d4d35c19808 Mon Sep 17 00:00:00 2001 From: Bertug Date: Thu, 25 Jun 2026 15:25:32 +0300 Subject: [PATCH 3/3] =?UTF-8?q?docs(x402-solana):=20mainnet=20smoke=20veri?= =?UTF-8?q?fied=20=E2=80=94=20bump=20status=20to=20Beta?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ran the same flow on Solana mainnet-beta with real USDC via PayAI: self-transfer, err: None, fee paid by the facilitator (tx 3d9k5PACqnSqYk42xMjyvkdzZZNfDPjysRyHGVzpxxCYu1womD6eMAGQx2neZcNCerLNkbjDoy15Y31...). Status (README + landing): Solana x402 now "Beta; verified on devnet and mainnet". Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 2 +- examples/x402-solana-recovery/NOTES.md | 5 +++++ landing/index.html | 2 +- 3 files changed, 7 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 60f9036..d54edfc 100644 --- a/README.md +++ b/README.md @@ -99,7 +99,7 @@ Current version: `0.3.0-beta.1`. | Stripe test mode | Validated with configured test credentials | | Stripe production | Beta; validate your webhook and deployment path | | x402 (EVM) | Experimental | -| x402 (Solana / SVM) | Beta on devnet; SVM "exact" scheme, packaged signer, full MCP verify/settle e2e ([notes](examples/x402-solana-recovery/NOTES.md)). Mainnet not tested | +| 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 | Not tested | | 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 3ed2e0f..eb62381 100644 --- a/examples/x402-solana-recovery/NOTES.md +++ b/examples/x402-solana-recovery/NOTES.md @@ -97,6 +97,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 diff --git a/landing/index.html b/landing/index.html index 5c12feb..878da21 100644 --- a/landing/index.html +++ b/landing/index.html @@ -422,7 +422,7 @@

Status

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 / SVMBeta on devnet; packaged signer + full MCP verify/settle e2e (gasless via x402 facilitators), mainnet not tested + 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