From 9fd12cd4edbbea3b0b85cdaf181ed51a901f98db Mon Sep 17 00:00:00 2001 From: Bertug Date: Thu, 25 Jun 2026 00:26:10 +0300 Subject: [PATCH 1/3] =?UTF-8?q?feat(x402):=20first-class=20Solana=20(SVM)?= =?UTF-8?q?=20support=20=E2=80=94=20partial-signed=20tx,=20fee=20payer,=20?= =?UTF-8?q?verify/settle?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds Solana to the existing x402 rail without a new rail type. EVM and Solana differ only in client authorization (no EIP-3009 — the client builds and partially signs a real SPL transfer, leaving the fee-payer slot for the facilitator) and a couple of requirement fields. Rail (src/rail-adapters/x402-rail.ts): - X402RailConfig.feePayer + extra.feePayer injected for Solana challenges - x402Version auto-defaults to 2 for SVM (the "exact" scheme is a v2 scheme) - discoverFeePayer(): pulls the fee payer from the facilitator's /supported Client signer (examples/x402-solana-recovery/sign-payload.mjs): - buildSolanaPaymentPayload(): ComputeBudget(limit) + ComputeBudget(price<=5) + TransferChecked + Memo(nonce), partial-signed, base64 -> x402 v2 payload - @solana/web3.js + @solana/spl-token dynamically imported (dev-only) Tests (verify and settle exercised separately, success AND failure each): - x402-solana-rail.test.mjs: challenge shape, fee-payer discovery, verify/settle success + failure paths (facilitator stubbed via fake fetch) - x402-solana-sign.test.mjs: offline signer — payload is v2, fee-payer slot empty (partial sign), tx carries the 4 expected instructions Full suite: 228 passing. Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 3 +- examples/x402-solana-recovery/NOTES.md | 79 ++ .../x402-solana-recovery/sign-payload.mjs | 184 ++++ package-lock.json | 873 ++++++++++++++++++ package.json | 4 +- src/__tests__/x402-solana-rail.test.mjs | 276 ++++++ src/__tests__/x402-solana-sign.test.mjs | 168 ++++ src/rail-adapters/x402-rail.ts | 94 +- 8 files changed, 1669 insertions(+), 12 deletions(-) create mode 100644 examples/x402-solana-recovery/NOTES.md create mode 100644 examples/x402-solana-recovery/sign-payload.mjs create mode 100644 src/__tests__/x402-solana-rail.test.mjs create mode 100644 src/__tests__/x402-solana-sign.test.mjs diff --git a/README.md b/README.md index 9dc9eeb..a0eaec4 100644 --- a/README.md +++ b/README.md @@ -98,7 +98,8 @@ Current version: `0.3.0-beta.0`. | 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 | Experimental | +| x402 (EVM) | Experimental | +| x402 (Solana / SVM) | Experimental; SVM "exact" scheme, facilitator verify/settle ([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 new file mode 100644 index 0000000..55b8641 --- /dev/null +++ b/examples/x402-solana-recovery/NOTES.md @@ -0,0 +1,79 @@ +# x402 on Solana (SVM) — Toolgate Integration Notes + +Toolgate's `X402RailAdapter` is rail-agnostic and already speaks the x402 +challenge/verify/settle flow. EVM and Solana differ only in **how the client +authorizes payment** and a couple of **requirement fields**. This integration +adds first-class Solana (SVM "exact" scheme) support without a new rail. + +## EVM vs Solana — what actually changes + +| | EVM (existing) | Solana / SVM (this integration) | +| --- | --- | --- | +| Authorization | EIP-3009 `transferWithAuthorization`, EIP-712 signature | Client builds + **partially signs** a real SPL transfer tx | +| Gas | none (3009) | client holds no SOL; **facilitator is fee payer** | +| Uniqueness | nonce in the authorization | **Memo instruction** (random nonce or seller memo) | +| x402 version | 1 | **2** (the SVM exact scheme is defined for v2) | +| Settlement | facilitator `/settle` | facilitator co-signs fee-payer slot, then submits | + +## End-to-end flow + +1. **Challenge** — `X402RailAdapter.createChallenge()` emits an x402 v2 + `PaymentRequirements` with: + - `network`: the `solana:` CAIP-2 id + - `asset`: the SPL USDC **mint** (auto-resolved from `SOLANA_USDC_ADDRESSES`) + - `payTo`: base58 recipient + - `extra.feePayer`: the facilitator's fee payer (set via + `X402RailConfig.feePayer`, or fetched with `adapter.discoverFeePayer()` + from the facilitator's `GET /supported`) + +2. **Sign (client)** — `examples/x402-solana-recovery/sign-payload.mjs` + `buildSolanaPaymentPayload()` builds a v0 transaction: + `ComputeBudget(limit) + ComputeBudget(price ≤ 5) + TransferChecked + Memo`, + with `payerKey = feePayer`. The client `partialSign`s its own slot, leaving + the fee-payer signature empty, and base64-encodes it into + `payload.transaction`. + +3. **Verify** — `adapter.verifyPayment(proof, { actionId })` POSTs to the + facilitator `/verify`. Validation only — nothing settles yet. + +4. **Execute** — Toolgate runs the paid tool. + +5. **Settle** — `adapter.settlePayment(proof, { actionId })` POSTs to `/settle`; + the facilitator signs the fee-payer slot and submits to a validator. The + 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. + +## Configuring the rail for Solana + +```ts +import { X402RailAdapter } from "@tkorkmaz/toolgate"; + +const rail = new X402RailAdapter({ + payTo: "GsbwXfJraMomNxBcjYLcG3mxkBUiyWXAB32fGbSMQRdW", // base58 + network: { kind: "solana", caip2: "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1" }, // devnet + facilitatorUrl: "https://facilitator.payai.network", // Solana-first, no API key + // feePayer: "...", // hardcode, or: +}); +await rail.discoverFeePayer(); // pulls extra.feePayer from /supported +``` + +### Facilitators that support Solana + +- **PayAI** — Solana-first, single drop-in endpoint, no API key. +- **Coinbase CDP** — Base + Solana; free tier (~1k tx/mo). +- **Self-hosted (Kora)** — run your own signer node / facilitator. + +## Tests + +- `src/__tests__/x402-solana-rail.test.mjs` — challenge shape, fee-payer + discovery, and **verify/settle success + failure paths** (facilitator stubbed + via a fake `fetch`; no network). +- `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. diff --git a/examples/x402-solana-recovery/sign-payload.mjs b/examples/x402-solana-recovery/sign-payload.mjs new file mode 100644 index 0000000..56aa913 --- /dev/null +++ b/examples/x402-solana-recovery/sign-payload.mjs @@ -0,0 +1,184 @@ +/** + * x402 Solana (SVM) client-side signer. + * + * 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). + * + * 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. + */ +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=120000] + * @param {number} [args.computeUnitPrice=1] microLamports/CU (clamped to ≤ 5) + */ +export async function buildSolanaPaymentPayload({ + challenge, + payerSecretKey, + rpcUrl, + blockhash, + memo, + computeUnitLimit = 120_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"); + + const paymentPayload = { + x402Version: 2, + scheme: requirement.scheme ?? "exact", + network: requirement.network, + payload: { transaction: serialized }, + }; + + return { paymentPayload, transaction, memo: memoText }; +} diff --git a/package-lock.json b/package-lock.json index 4a0d21e..252982d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,8 @@ "license": "MIT", "devDependencies": { "@modelcontextprotocol/sdk": "^1.29.0", + "@solana/spl-token": "^0.4.14", + "@solana/web3.js": "^1.98.4", "@x402/core": "^2.14.0", "@x402/evm": "^2.14.0", "better-sqlite3": "^12.11.1", @@ -45,6 +47,16 @@ "dev": true, "license": "MIT" }, + "node_modules/@babel/runtime": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@hono/node-server": { "version": "1.19.14", "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.14.tgz", @@ -180,6 +192,378 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/@solana/buffer-layout": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@solana/buffer-layout/-/buffer-layout-4.0.1.tgz", + "integrity": "sha512-E1ImOIAD1tBZFRdjeM4/pzTiTApC0AOBGwyAMS4fwIodCWArzJ3DWdoh8cKxeFM2fElkxBh2Aqts1BPC373rHA==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer": "~6.0.3" + }, + "engines": { + "node": ">=5.10" + } + }, + "node_modules/@solana/buffer-layout-utils": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/@solana/buffer-layout-utils/-/buffer-layout-utils-0.2.0.tgz", + "integrity": "sha512-szG4sxgJGktbuZYDg2FfNmkMi0DYQoVjN2h7ta1W1hPrwzarcFLBq9UpX1UjNXsNpT9dn+chgprtWGioUAr4/g==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@solana/buffer-layout": "^4.0.0", + "@solana/web3.js": "^1.32.0", + "bigint-buffer": "^1.1.5", + "bignumber.js": "^9.0.1" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/@solana/buffer-layout/node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, + "node_modules/@solana/codecs": { + "version": "2.0.0-rc.1", + "resolved": "https://registry.npmjs.org/@solana/codecs/-/codecs-2.0.0-rc.1.tgz", + "integrity": "sha512-qxoR7VybNJixV51L0G1RD2boZTcxmwUWnKCaJJExQ5qNKwbpSyDdWfFJfM5JhGyKe9DnPVOZB+JHWXnpbZBqrQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@solana/codecs-core": "2.0.0-rc.1", + "@solana/codecs-data-structures": "2.0.0-rc.1", + "@solana/codecs-numbers": "2.0.0-rc.1", + "@solana/codecs-strings": "2.0.0-rc.1", + "@solana/options": "2.0.0-rc.1" + }, + "peerDependencies": { + "typescript": ">=5" + } + }, + "node_modules/@solana/codecs-core": { + "version": "2.0.0-rc.1", + "resolved": "https://registry.npmjs.org/@solana/codecs-core/-/codecs-core-2.0.0-rc.1.tgz", + "integrity": "sha512-bauxqMfSs8EHD0JKESaNmNuNvkvHSuN3bbWAF5RjOfDu2PugxHrvRebmYauvSumZ3cTfQ4HJJX6PG5rN852qyQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@solana/errors": "2.0.0-rc.1" + }, + "peerDependencies": { + "typescript": ">=5" + } + }, + "node_modules/@solana/codecs-data-structures": { + "version": "2.0.0-rc.1", + "resolved": "https://registry.npmjs.org/@solana/codecs-data-structures/-/codecs-data-structures-2.0.0-rc.1.tgz", + "integrity": "sha512-rinCv0RrAVJ9rE/rmaibWJQxMwC5lSaORSZuwjopSUE6T0nb/MVg6Z1siNCXhh/HFTOg0l8bNvZHgBcN/yvXog==", + "dev": true, + "license": "MIT", + "dependencies": { + "@solana/codecs-core": "2.0.0-rc.1", + "@solana/codecs-numbers": "2.0.0-rc.1", + "@solana/errors": "2.0.0-rc.1" + }, + "peerDependencies": { + "typescript": ">=5" + } + }, + "node_modules/@solana/codecs-numbers": { + "version": "2.0.0-rc.1", + "resolved": "https://registry.npmjs.org/@solana/codecs-numbers/-/codecs-numbers-2.0.0-rc.1.tgz", + "integrity": "sha512-J5i5mOkvukXn8E3Z7sGIPxsThRCgSdgTWJDQeZvucQ9PT6Y3HiVXJ0pcWiOWAoQ3RX8e/f4I3IC+wE6pZiJzDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@solana/codecs-core": "2.0.0-rc.1", + "@solana/errors": "2.0.0-rc.1" + }, + "peerDependencies": { + "typescript": ">=5" + } + }, + "node_modules/@solana/codecs-strings": { + "version": "2.0.0-rc.1", + "resolved": "https://registry.npmjs.org/@solana/codecs-strings/-/codecs-strings-2.0.0-rc.1.tgz", + "integrity": "sha512-9/wPhw8TbGRTt6mHC4Zz1RqOnuPTqq1Nb4EyuvpZ39GW6O2t2Q7Q0XxiB3+BdoEjwA2XgPw6e2iRfvYgqty44g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@solana/codecs-core": "2.0.0-rc.1", + "@solana/codecs-numbers": "2.0.0-rc.1", + "@solana/errors": "2.0.0-rc.1" + }, + "peerDependencies": { + "fastestsmallesttextencoderdecoder": "^1.0.22", + "typescript": ">=5" + } + }, + "node_modules/@solana/errors": { + "version": "2.0.0-rc.1", + "resolved": "https://registry.npmjs.org/@solana/errors/-/errors-2.0.0-rc.1.tgz", + "integrity": "sha512-ejNvQ2oJ7+bcFAYWj225lyRkHnixuAeb7RQCixm+5mH4n1IA4Qya/9Bmfy5RAAHQzxK43clu3kZmL5eF9VGtYQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^5.3.0", + "commander": "^12.1.0" + }, + "bin": { + "errors": "bin/cli.mjs" + }, + "peerDependencies": { + "typescript": ">=5" + } + }, + "node_modules/@solana/options": { + "version": "2.0.0-rc.1", + "resolved": "https://registry.npmjs.org/@solana/options/-/options-2.0.0-rc.1.tgz", + "integrity": "sha512-mLUcR9mZ3qfHlmMnREdIFPf9dpMc/Bl66tLSOOWxw4ml5xMT2ohFn7WGqoKcu/UHkT9CrC6+amEdqCNvUqI7AA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@solana/codecs-core": "2.0.0-rc.1", + "@solana/codecs-data-structures": "2.0.0-rc.1", + "@solana/codecs-numbers": "2.0.0-rc.1", + "@solana/codecs-strings": "2.0.0-rc.1", + "@solana/errors": "2.0.0-rc.1" + }, + "peerDependencies": { + "typescript": ">=5" + } + }, + "node_modules/@solana/spl-token": { + "version": "0.4.14", + "resolved": "https://registry.npmjs.org/@solana/spl-token/-/spl-token-0.4.14.tgz", + "integrity": "sha512-u09zr96UBpX4U685MnvQsNzlvw9TiY005hk1vJmJr7gMJldoPG1eYU5/wNEyOA5lkMLiR/gOi9SFD4MefOYEsA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@solana/buffer-layout": "^4.0.0", + "@solana/buffer-layout-utils": "^0.2.0", + "@solana/spl-token-group": "^0.0.7", + "@solana/spl-token-metadata": "^0.1.6", + "buffer": "^6.0.3" + }, + "engines": { + "node": ">=16" + }, + "peerDependencies": { + "@solana/web3.js": "^1.95.5" + } + }, + "node_modules/@solana/spl-token-group": { + "version": "0.0.7", + "resolved": "https://registry.npmjs.org/@solana/spl-token-group/-/spl-token-group-0.0.7.tgz", + "integrity": "sha512-V1N/iX7Cr7H0uazWUT2uk27TMqlqedpXHRqqAbVO2gvmJyT0E0ummMEAVQeXZ05ZhQ/xF39DLSdBp90XebWEug==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@solana/codecs": "2.0.0-rc.1" + }, + "engines": { + "node": ">=16" + }, + "peerDependencies": { + "@solana/web3.js": "^1.95.3" + } + }, + "node_modules/@solana/spl-token-metadata": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/@solana/spl-token-metadata/-/spl-token-metadata-0.1.6.tgz", + "integrity": "sha512-7sMt1rsm/zQOQcUWllQX9mD2O6KhSAtY1hFR2hfFwgqfFWzSY9E9GDvFVNYUI1F0iQKcm6HmePU9QbKRXTEBiA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@solana/codecs": "2.0.0-rc.1" + }, + "engines": { + "node": ">=16" + }, + "peerDependencies": { + "@solana/web3.js": "^1.95.3" + } + }, + "node_modules/@solana/spl-token/node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, + "node_modules/@solana/web3.js": { + "version": "1.98.4", + "resolved": "https://registry.npmjs.org/@solana/web3.js/-/web3.js-1.98.4.tgz", + "integrity": "sha512-vv9lfnvjUsRiq//+j5pBdXig0IQdtzA0BRZ3bXEP4KaIyF1CcaydWqgyzQgfZMNIsWNWmG+AUHwPy4AHOD6gpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.25.0", + "@noble/curves": "^1.4.2", + "@noble/hashes": "^1.4.0", + "@solana/buffer-layout": "^4.0.1", + "@solana/codecs-numbers": "^2.1.0", + "agentkeepalive": "^4.5.0", + "bn.js": "^5.2.1", + "borsh": "^0.7.0", + "bs58": "^4.0.1", + "buffer": "6.0.3", + "fast-stable-stringify": "^1.0.0", + "jayson": "^4.1.1", + "node-fetch": "^2.7.0", + "rpc-websockets": "^9.0.2", + "superstruct": "^2.0.2" + } + }, + "node_modules/@solana/web3.js/node_modules/@solana/codecs-core": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@solana/codecs-core/-/codecs-core-2.3.0.tgz", + "integrity": "sha512-oG+VZzN6YhBHIoSKgS5ESM9VIGzhWjEHEGNPSibiDTxFhsFWxNaz8LbMDPjBUE69r9wmdGLkrQ+wVPbnJcZPvw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@solana/errors": "2.3.0" + }, + "engines": { + "node": ">=20.18.0" + }, + "peerDependencies": { + "typescript": ">=5.3.3" + } + }, + "node_modules/@solana/web3.js/node_modules/@solana/codecs-numbers": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@solana/codecs-numbers/-/codecs-numbers-2.3.0.tgz", + "integrity": "sha512-jFvvwKJKffvG7Iz9dmN51OGB7JBcy2CJ6Xf3NqD/VP90xak66m/Lg48T01u5IQ/hc15mChVHiBm+HHuOFDUrQg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@solana/codecs-core": "2.3.0", + "@solana/errors": "2.3.0" + }, + "engines": { + "node": ">=20.18.0" + }, + "peerDependencies": { + "typescript": ">=5.3.3" + } + }, + "node_modules/@solana/web3.js/node_modules/@solana/errors": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@solana/errors/-/errors-2.3.0.tgz", + "integrity": "sha512-66RI9MAbwYV0UtP7kGcTBVLxJgUxoZGm8Fbc0ah+lGiAw17Gugco6+9GrJCV83VyF2mDWyYnYM9qdI3yjgpnaQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^5.4.1", + "commander": "^14.0.0" + }, + "bin": { + "errors": "bin/cli.mjs" + }, + "engines": { + "node": ">=20.18.0" + }, + "peerDependencies": { + "typescript": ">=5.3.3" + } + }, + "node_modules/@solana/web3.js/node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, + "node_modules/@solana/web3.js/node_modules/commander": { + "version": "14.0.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.3.tgz", + "integrity": "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/@swc/helpers": { + "version": "0.5.23", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.23.tgz", + "integrity": "sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.8.0" + } + }, + "node_modules/@types/connect": { + "version": "3.4.38", + "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", + "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/node": { "version": "25.6.0", "resolved": "https://registry.npmjs.org/@types/node/-/node-25.6.0.tgz", @@ -190,6 +574,23 @@ "undici-types": "~7.19.0" } }, + "node_modules/@types/uuid": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-10.0.0.tgz", + "integrity": "sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/ws": { + "version": "7.4.7", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-7.4.7.tgz", + "integrity": "sha512-JQbbmxZTZehdc2iszGKs5oC3NFnjeay7mtAWrdt7qNtAVK0g19muApzAy4bm9byz79xa2ZnO/BOBC2R8RC5Lww==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@x402/core": { "version": "2.14.0", "resolved": "https://registry.npmjs.org/@x402/core/-/core-2.14.0.tgz", @@ -248,6 +649,19 @@ "node": ">= 0.6" } }, + "node_modules/agentkeepalive": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/agentkeepalive/-/agentkeepalive-4.6.0.tgz", + "integrity": "sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "humanize-ms": "^1.2.1" + }, + "engines": { + "node": ">= 8.0.0" + } + }, "node_modules/ajv": { "version": "8.20.0", "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", @@ -283,6 +697,16 @@ } } }, + "node_modules/base-x": { + "version": "3.0.11", + "resolved": "https://registry.npmjs.org/base-x/-/base-x-3.0.11.tgz", + "integrity": "sha512-xz7wQ8xDhdyP7tQxwdteLYeFfS68tSMNCZ/Y37WJ4bhGfKPpqEIlmIyueQHqOyoPhE6xNUqjzRr8ra0eF9VRvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, "node_modules/base64-js": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", @@ -319,6 +743,30 @@ "node": "20.x || 22.x || 23.x || 24.x || 25.x || 26.x" } }, + "node_modules/bigint-buffer": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/bigint-buffer/-/bigint-buffer-1.1.5.tgz", + "integrity": "sha512-trfYco6AoZ+rKhKnxA0hgX0HAbVP/s808/EuDSe2JDzUnCp/xAsli35Orvk67UrTEcwuxZqYZDmfA2RXJgxVvA==", + "dev": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "bindings": "^1.3.0" + }, + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/bignumber.js": { + "version": "9.3.1", + "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz", + "integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, "node_modules/bindings": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", @@ -341,6 +789,13 @@ "readable-stream": "^3.4.0" } }, + "node_modules/bn.js": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-5.2.3.tgz", + "integrity": "sha512-EAcmnPkxpntVL+DS7bO1zhcZNvCkxqtkd0ZY53h06GNQ3DEkkGZ/gKgmDv6DdZQGj9BgfSPKtJJ7Dp1GPP8f7w==", + "dev": true, + "license": "MIT" + }, "node_modules/body-parser": { "version": "2.2.2", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", @@ -366,6 +821,28 @@ "url": "https://opencollective.com/express" } }, + "node_modules/borsh": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/borsh/-/borsh-0.7.0.tgz", + "integrity": "sha512-CLCsZGIBCFnPtkNnieW/a8wmreDmfUtjU2m9yHrzPXIlNbqVs0AQrSatSG6vdNYUqdc83tkQi2eHfF98ubzQLA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "bn.js": "^5.2.0", + "bs58": "^4.0.0", + "text-encoding-utf-8": "^1.0.2" + } + }, + "node_modules/bs58": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/bs58/-/bs58-4.0.1.tgz", + "integrity": "sha512-Ok3Wdf5vOIlBrgCvTq96gBkJw+JUEzdBgyaza5HLtPm7yTHkjRy8+JzNyHF7BHa0bNWOQIp3m5YF0nnFcOIKLw==", + "dev": true, + "license": "MIT", + "dependencies": { + "base-x": "^3.0.2" + } + }, "node_modules/buffer": { "version": "5.7.1", "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", @@ -391,6 +868,21 @@ "ieee754": "^1.1.13" } }, + "node_modules/bufferutil": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bufferutil/-/bufferutil-4.1.0.tgz", + "integrity": "sha512-ZMANVnAixE6AWWnPzlW2KpUrxhm9woycYvPOo67jWHyFowASTEd9s+QN1EIMsSDtwhIxN4sWE1jotpuDUIgyIw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "dependencies": { + "node-gyp-build": "^4.3.0" + }, + "engines": { + "node": ">=6.14.2" + } + }, "node_modules/bytes": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", @@ -432,6 +924,19 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/chalk": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, "node_modules/chownr": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", @@ -439,6 +944,16 @@ "dev": true, "license": "ISC" }, + "node_modules/commander": { + "version": "12.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz", + "integrity": "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/content-disposition": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", @@ -560,6 +1075,19 @@ "node": ">=4.0.0" } }, + "node_modules/delay": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/delay/-/delay-5.0.0.tgz", + "integrity": "sha512-ReEBKkIfe4ya47wlPYf/gu5ib6yUG0/Aez0JQZQz94kiWtRQvZIQbTiehsnwHvLSWJnQdhVeqYue7Id1dKr0qw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/depd": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", @@ -655,6 +1183,23 @@ "node": ">= 0.4" } }, + "node_modules/es6-promise": { + "version": "4.2.8", + "resolved": "https://registry.npmjs.org/es6-promise/-/es6-promise-4.2.8.tgz", + "integrity": "sha512-HJDGx5daxeIvxdBxvG2cb9g4tEvwIk3i8+nhX0yGrYmZUzbkdg8QbDevheDB8gd0//uPj4c1EQua8Q+MViT0/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/es6-promisify": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/es6-promisify/-/es6-promisify-5.0.0.tgz", + "integrity": "sha512-C+d6UdsYDk0lMebHNR4S2NybQMMngAOnOwYBQjTOiv0MkoJMP0Myw2mgpDLBcpfCmRLxyFqYhS/CfOENq4SJhQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es6-promise": "^4.0.3" + } + }, "node_modules/escape-html": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", @@ -775,6 +1320,15 @@ "express": ">= 4.11" } }, + "node_modules/eyes": { + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/eyes/-/eyes-0.1.8.tgz", + "integrity": "sha512-GipyPsXO1anza0AOZdy69Im7hGFCNB7Y/NGjDlZGJ3GJJLtwNSb2vrzYrTYJRrRloVx7pl+bhUaTB8yiccPvFQ==", + "dev": true, + "engines": { + "node": "> 0.1.90" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -782,6 +1336,13 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-stable-stringify": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fast-stable-stringify/-/fast-stable-stringify-1.0.0.tgz", + "integrity": "sha512-wpYMUmFu5f00Sm0cj2pfivpmawLZ0NKdviQ4w9zJeR8JVtOpOxHmLaJuj0vxvGqMJQWyP/COUkF75/57OKyRag==", + "dev": true, + "license": "MIT" + }, "node_modules/fast-uri": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz", @@ -799,6 +1360,14 @@ ], "license": "BSD-3-Clause" }, + "node_modules/fastestsmallesttextencoderdecoder": { + "version": "1.0.22", + "resolved": "https://registry.npmjs.org/fastestsmallesttextencoderdecoder/-/fastestsmallesttextencoderdecoder-1.0.22.tgz", + "integrity": "sha512-Pb8d48e+oIuY4MaM64Cd7OW1gt4nxCHs7/ddPPZ/Ic3sg8yVGM7O9wDvZ7us6ScaUupzM+pfBolwtYhN1IxBIw==", + "dev": true, + "license": "CC0-1.0", + "peer": true + }, "node_modules/file-uri-to-path": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", @@ -981,6 +1550,16 @@ "url": "https://opencollective.com/express" } }, + "node_modules/humanize-ms": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/humanize-ms/-/humanize-ms-1.2.1.tgz", + "integrity": "sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.0.0" + } + }, "node_modules/iconv-lite": { "version": "0.7.2", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", @@ -1067,6 +1646,16 @@ "dev": true, "license": "ISC" }, + "node_modules/isomorphic-ws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/isomorphic-ws/-/isomorphic-ws-4.0.1.tgz", + "integrity": "sha512-BhBvN2MBpWTaSHdWRb/bwdZJ1WaehQ2L1KngkCkfLUGF0mAWAT1sQUQacEmQ0jXkFw/czDXPNQSL5u2/Krsz1w==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "ws": "*" + } + }, "node_modules/isows": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/isows/-/isows-1.0.7.tgz", @@ -1083,6 +1672,85 @@ "ws": "*" } }, + "node_modules/jayson": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/jayson/-/jayson-4.3.0.tgz", + "integrity": "sha512-AauzHcUcqs8OBnCHOkJY280VaTiCm57AbuO7lqzcw7JapGj50BisE3xhksye4zlTSR1+1tAz67wLTl8tEH1obQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/connect": "^3.4.33", + "@types/node": "^12.12.54", + "@types/ws": "^7.4.4", + "commander": "^2.20.3", + "delay": "^5.0.0", + "es6-promisify": "^5.0.0", + "eyes": "^0.1.8", + "isomorphic-ws": "^4.0.1", + "json-stringify-safe": "^5.0.1", + "stream-json": "^1.9.1", + "uuid": "^8.3.2", + "ws": "^7.5.10" + }, + "bin": { + "jayson": "bin/jayson.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/jayson/node_modules/@types/node": { + "version": "12.20.55", + "resolved": "https://registry.npmjs.org/@types/node/-/node-12.20.55.tgz", + "integrity": "sha512-J8xLz7q2OFulZ2cyGTLE1TbbZcjpno7FaN6zdJNrgAdrJ+DZzh/uFR6YrTb4C+nXakvud8Q4+rbhoIWlYQbUFQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/jayson/node_modules/commander": { + "version": "2.20.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/jayson/node_modules/utf-8-validate": { + "version": "5.0.10", + "resolved": "https://registry.npmjs.org/utf-8-validate/-/utf-8-validate-5.0.10.tgz", + "integrity": "sha512-Z6czzLq4u8fPOyx7TU6X3dvUZVvoJmxSQ+IcrlmagKhilxlhZgxPK6C5Jqbkw1IDUmFTM+cz9QDnnLTwDz/2gQ==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "node-gyp-build": "^4.3.0" + }, + "engines": { + "node": ">=6.14.2" + } + }, + "node_modules/jayson/node_modules/ws": { + "version": "7.5.11", + "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.11.tgz", + "integrity": "sha512-zS54Oen9bITtp7kp2XM3AydrCIq1D+HwJOuH+c+e4LfpL/lotP5osijd+UoMnxwAam1GN8R4KtLAyIrIcBNpiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.3.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": "^5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/jose": { "version": "6.2.3", "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.3.tgz", @@ -1107,6 +1775,13 @@ "dev": true, "license": "BSD-2-Clause" }, + "node_modules/json-stringify-safe": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", + "integrity": "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==", + "dev": true, + "license": "ISC" + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -1234,6 +1909,40 @@ "node": ">=10" } }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, + "node_modules/node-gyp-build": { + "version": "4.8.4", + "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz", + "integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==", + "dev": true, + "license": "MIT", + "optional": true, + "bin": { + "node-gyp-build": "bin.js", + "node-gyp-build-optional": "optional.js", + "node-gyp-build-test": "build-test.js" + } + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -1505,6 +2214,79 @@ "node": ">= 18" } }, + "node_modules/rpc-websockets": { + "version": "9.3.9", + "resolved": "https://registry.npmjs.org/rpc-websockets/-/rpc-websockets-9.3.9.tgz", + "integrity": "sha512-2iQDaTB4g5fDB2ihrTFSJSibCEuxaRi1q7qTW7ZO9/M5/TC+ToHA4D9/ffNLEbAoHNNrcdeP05oATNk44SKZXA==", + "dev": true, + "license": "LGPL-3.0-only", + "dependencies": { + "@swc/helpers": "^0.5.11", + "@types/uuid": "^10.0.0", + "@types/ws": "^8.2.2", + "buffer": "^6.0.3", + "eventemitter3": "^5.0.1", + "uuid": "^14.0.0", + "ws": "^8.5.0" + }, + "funding": { + "type": "paypal", + "url": "https://paypal.me/kozjak" + }, + "optionalDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": "^6.0.0" + } + }, + "node_modules/rpc-websockets/node_modules/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/rpc-websockets/node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, + "node_modules/rpc-websockets/node_modules/uuid": { + "version": "14.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-14.0.1.tgz", + "integrity": "sha512-6ZxzVpzDXDa3bJWaHilVayA+BH/1zmxCJoVgvmqJnid/gPoKHxUrS/aC/T6LGQtNHT+XHG9fXPJB4d+IrU30Ew==", + "dev": true, + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist-node/bin/uuid" + } + }, "node_modules/safe-buffer": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", @@ -1756,6 +2538,23 @@ "node": ">= 0.8" } }, + "node_modules/stream-chain": { + "version": "2.2.5", + "resolved": "https://registry.npmjs.org/stream-chain/-/stream-chain-2.2.5.tgz", + "integrity": "sha512-1TJmBx6aSWqZ4tx7aTpBDXK0/e2hhcNSTV8+CbFJtDjbb+I1mZ8lHit0Grw9GRT+6JbIrrDd8esncgBi8aBXGA==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/stream-json": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/stream-json/-/stream-json-1.9.1.tgz", + "integrity": "sha512-uWkjJ+2Nt/LO9Z/JyKZbMusL8Dkh97uUBTv3AJQ74y07lVahLY4eEFsPsE97pxYBwr8nnjMAIch5eqI0gPShyw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "stream-chain": "^2.2.5" + } + }, "node_modules/string_decoder": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", @@ -1790,6 +2589,16 @@ "node": ">=12.*" } }, + "node_modules/superstruct": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/superstruct/-/superstruct-2.0.2.tgz", + "integrity": "sha512-uV+TFRZdXsqXTL2pRvujROjdZQ4RAlBUS5BTh9IGm+jTqQntYThciG/qu57Gs69yjnVUSqdxF9YLmSnpupBW9A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/tar-fs": { "version": "2.1.4", "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz", @@ -1820,6 +2629,12 @@ "node": ">=6" } }, + "node_modules/text-encoding-utf-8": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/text-encoding-utf-8/-/text-encoding-utf-8-1.0.2.tgz", + "integrity": "sha512-8bw4MY9WjdsD2aMtO0OzOCY3pXGYNx2d2FfHRVUKkiCPDWjKuOlhLVASS+pD7VkLTVjW268LYJHwsnPFlBpbAg==", + "dev": true + }, "node_modules/toidentifier": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", @@ -1830,6 +2645,20 @@ "node": ">=0.6" } }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "dev": true, + "license": "MIT" + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD" + }, "node_modules/tunnel-agent": { "version": "0.6.0", "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", @@ -1907,6 +2736,21 @@ "node": ">= 0.8" } }, + "node_modules/utf-8-validate": { + "version": "6.0.6", + "resolved": "https://registry.npmjs.org/utf-8-validate/-/utf-8-validate-6.0.6.tgz", + "integrity": "sha512-q3l3P9UtEEiAHcsgsqTgf9PPjctrDWoIXW3NpOHFdRDbLvu4DLIcxHangJ4RLrWkBcKjmcs/6NkerI8T/rE4LA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "dependencies": { + "node-gyp-build": "^4.3.0" + }, + "engines": { + "node": ">=6.14.2" + } + }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", @@ -1914,6 +2758,17 @@ "dev": true, "license": "MIT" }, + "node_modules/uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "deprecated": "uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).", + "dev": true, + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/vary": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", @@ -1955,6 +2810,24 @@ } } }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "dev": true, + "license": "BSD-2-Clause" + }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", diff --git a/package.json b/package.json index 798781b..6e4b7fc 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__/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__/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": [ @@ -86,6 +86,8 @@ }, "devDependencies": { "@modelcontextprotocol/sdk": "^1.29.0", + "@solana/spl-token": "^0.4.14", + "@solana/web3.js": "^1.98.4", "@x402/core": "^2.14.0", "@x402/evm": "^2.14.0", "better-sqlite3": "^12.11.1", diff --git a/src/__tests__/x402-solana-rail.test.mjs b/src/__tests__/x402-solana-rail.test.mjs new file mode 100644 index 0000000..846ec69 --- /dev/null +++ b/src/__tests__/x402-solana-rail.test.mjs @@ -0,0 +1,276 @@ +/** + * x402 Solana (SVM) Rail Adapter Tests + * + * Covers the Solana-specific surface of X402RailAdapter without touching the + * network or a real validator — the facilitator's /verify, /settle, and + * /supported endpoints are stubbed via a fake global.fetch. + * + * Verify and settle are exercised SEPARATELY, and each is tested for BOTH the + * success and the failure path (per-call asserts), because in x402 the two + * phases fail independently: a payment can verify yet fail to settle on-chain. + * + * 1. createChallenge → Solana payment requirement shape (network, asset mint, + * payTo, x402Version=2, extra.feePayer) + * 2. createChallenge → omits feePayer when not configured + * 3. discoverFeePayer → reads facilitator /supported and caches it + * 4. verifyPayment SUCCESS → facilitator says valid → VerificationResult + * 5. verifyPayment FAIL → facilitator says invalid → null + * 6. verifyPayment FAIL → facilitator non-200 → null + * 7. settlePayment SUCCESS → facilitator settles → SettlementResult + txHash + * 8. settlePayment FAIL → facilitator success:false → null + * 9. settlePayment FAIL → facilitator throws/network error → null + * 10. settle → sends x402Version 2 + SVM requirement body to facilitator + * + * Run: node --test src/__tests__/x402-solana-rail.test.mjs + */ + +import { describe, it, beforeEach, afterEach } from "node:test"; +import assert from "node:assert/strict"; +import { X402RailAdapter } from "../../dist/rail-adapters/x402-rail.js"; + +// Solana devnet CAIP-2 + its auto-detected USDC mint (from SOLANA_USDC_ADDRESSES) +const SOLANA_DEVNET = "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1"; +const SOLANA_DEVNET_USDC = "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU"; +const PAY_TO = "GsbwXfJraMomNxBcjYLcG3mxkBUiyWXAB32fGbSMQRdW"; +const FEE_PAYER = "5eykt4UsFv8P8NJdTREpY1vzqKqZKvdpDq9TC5GtoY8N"; +const FACILITATOR = "https://facilitator.example.test"; + +// ─── fetch stub plumbing ────────────────────────────────── + +const realFetch = globalThis.fetch; +let fetchCalls; // [{ url, body }] +let fetchHandler; // (url, init) => { ok, json } + +function installFetch() { + fetchCalls = []; + globalThis.fetch = async (url, init) => { + const body = init?.body ? JSON.parse(init.body) : undefined; + fetchCalls.push({ url: String(url), body }); + const res = await fetchHandler(String(url), body); + return { + ok: res.ok ?? true, + async json() { + return res.json ?? {}; + }, + }; + }; +} + +function restoreFetch() { + globalThis.fetch = realFetch; +} + +function makeAdapter(overrides = {}) { + return new X402RailAdapter({ + payTo: PAY_TO, + network: { kind: "solana", caip2: SOLANA_DEVNET }, + facilitatorUrl: FACILITATOR, + feePayer: FEE_PAYER, + ...overrides, + }); +} + +const CHALLENGE_PARAMS = { + callerId: "agent_sol_1", + amount: 0.05, + currency: "usd", + toolName: "premium_search", + publisherKey: "tg_pub_test", +}; + +// A minimal x402 SVM proof — the rail forwards payload.transaction verbatim, +// so the exact bytes don't matter for the adapter-level contract. +const SOL_PROOF = { + rail: "x402", + x402PaymentPayload: { + x402Version: 2, + scheme: "exact", + network: SOLANA_DEVNET, + payload: { transaction: "BASE64_PARTIALLY_SIGNED_TX" }, + }, +}; + +// ─── Challenge shape ────────────────────────────────────── + +describe("x402 Solana — createChallenge", () => { + it("builds an SVM payment requirement with mint, payTo, v2 and feePayer", async () => { + const adapter = makeAdapter(); + const action = await adapter.createChallenge(CHALLENGE_PARAMS); + + assert.equal(action.rail, "x402"); + assert.ok(action.actionId, "actionId should be present"); + assert.equal( + action.x402PaymentRequired.x402Version, + 2, + "Solana forces x402Version 2 (SVM exact scheme)", + ); + + const req = action.x402PaymentRequired.accepts[0]; + assert.equal(req.scheme, "exact"); + assert.equal(req.network, SOLANA_DEVNET, "network is the Solana caip2"); + assert.equal( + req.asset, + SOLANA_DEVNET_USDC, + "asset auto-resolves to devnet USDC mint", + ); + assert.equal(req.payTo, PAY_TO); + assert.equal( + req.maxAmountRequired, + "50000", + "0.05 USDC at 6 decimals = 50000 atomic units", + ); + assert.equal( + req.extra.feePayer, + FEE_PAYER, + "fee payer is surfaced to the client via extra.feePayer", + ); + }); + + it("omits extra.feePayer when none is configured/discovered", async () => { + const adapter = makeAdapter({ feePayer: undefined }); + const action = await adapter.createChallenge(CHALLENGE_PARAMS); + const req = action.x402PaymentRequired.accepts[0]; + assert.equal( + req.extra.feePayer, + undefined, + "no feePayer key when not configured", + ); + }); +}); + +// ─── Fee payer discovery ────────────────────────────────── + +describe("x402 Solana — discoverFeePayer", () => { + beforeEach(installFetch); + afterEach(restoreFetch); + + it("reads the fee payer from facilitator /supported and caches it", async () => { + const adapter = makeAdapter({ feePayer: undefined }); + fetchHandler = async (url) => { + assert.ok(url.endsWith("/supported"), "hits /supported endpoint"); + return { + ok: true, + json: { + kinds: [ + { network: "eip155:8453", extra: {} }, + { network: SOLANA_DEVNET, extra: { feePayer: FEE_PAYER } }, + ], + }, + }; + }; + + const discovered = await adapter.discoverFeePayer(); + assert.equal(discovered, FEE_PAYER); + assert.equal(adapter.feePayer, FEE_PAYER, "cached on the adapter"); + + // Now feePayer flows into subsequent challenges + const action = await adapter.createChallenge(CHALLENGE_PARAMS); + assert.equal(action.x402PaymentRequired.accepts[0].extra.feePayer, FEE_PAYER); + }); + + it("returns null when the facilitator advertises no SVM fee payer", async () => { + const adapter = makeAdapter({ feePayer: undefined }); + fetchHandler = async () => ({ + ok: true, + json: { kinds: [{ network: "eip155:8453", extra: {} }] }, + }); + assert.equal(await adapter.discoverFeePayer(), null); + }); +}); + +// ─── verifyPayment — success AND failure, in isolation ──── + +describe("x402 Solana — verifyPayment", () => { + let adapter; + let actionId; + + beforeEach(async () => { + installFetch(); + adapter = makeAdapter(); + const action = await adapter.createChallenge(CHALLENGE_PARAMS); + actionId = action.actionId; + }); + afterEach(restoreFetch); + + it("SUCCESS: facilitator validates the proof → VerificationResult", async () => { + fetchHandler = async (url) => { + assert.ok(url.endsWith("/verify"), "verify hits /verify (not /settle)"); + return { ok: true, json: { isValid: true, payer: PAY_TO } }; + }; + + const result = await adapter.verifyPayment(SOL_PROOF, { actionId }); + assert.ok(result, "verify should return a result"); + assert.equal(result.verified, true); + assert.equal(result.rail, "x402"); + assert.equal(result.amount, 0.05, "atomic amount decoded back to 0.05"); + assert.equal(result.currency, "usd"); + assert.equal(fetchCalls.length, 1, "verify does NOT call settle"); + }); + + it("FAIL: facilitator rejects the proof → null", async () => { + fetchHandler = async () => ({ ok: true, json: { isValid: false } }); + const result = await adapter.verifyPayment(SOL_PROOF, { actionId }); + assert.equal(result, null, "invalid proof yields null, not a throw"); + }); + + it("FAIL: facilitator returns non-200 → null", async () => { + fetchHandler = async () => ({ ok: false, json: {} }); + const result = await adapter.verifyPayment(SOL_PROOF, { actionId }); + assert.equal(result, null); + }); +}); + +// ─── settlePayment — success AND failure, in isolation ──── + +describe("x402 Solana — settlePayment", () => { + let adapter; + let actionId; + + beforeEach(async () => { + installFetch(); + adapter = makeAdapter(); + const action = await adapter.createChallenge(CHALLENGE_PARAMS); + actionId = action.actionId; + }); + afterEach(restoreFetch); + + it("SUCCESS: facilitator settles on-chain → SettlementResult with txHash", async () => { + const SIG = + "5wHu1qwD4kT2example9signatureBase58oNSolanaDevnet11111111111"; + fetchHandler = async (url, body) => { + assert.ok(url.endsWith("/settle"), "settle hits /settle endpoint"); + assert.equal(body.x402Version, 2, "settle body carries SVM v2"); + // SVM v2 requirement body forwarded to facilitator + assert.equal(body.paymentRequirements.network, SOLANA_DEVNET); + assert.equal(body.paymentRequirements.asset, SOLANA_DEVNET_USDC); + assert.equal(body.paymentRequirements.extra.feePayer, FEE_PAYER); + return { ok: true, json: { success: true, transaction: SIG } }; + }; + + const result = await adapter.settlePayment(SOL_PROOF, { actionId }); + assert.ok(result, "settle should return a result"); + assert.equal(result.settled, true); + assert.equal(result.rail, "x402"); + assert.equal(result.txHash, SIG, "Solana tx signature surfaced as txHash"); + assert.equal(result.amount, 0.05); + assert.equal( + adapter.pendingCount, + 0, + "successful settle clears the pending requirement", + ); + }); + + it("FAIL: facilitator reports success:false → null", async () => { + fetchHandler = async () => ({ ok: true, json: { success: false } }); + const result = await adapter.settlePayment(SOL_PROOF, { actionId }); + assert.equal(result, null, "failed settlement yields null"); + }); + + it("FAIL: facilitator network error → null (settlement uncertain)", async () => { + fetchHandler = async () => { + throw new Error("ECONNRESET"); + }; + const result = await adapter.settlePayment(SOL_PROOF, { actionId }); + assert.equal(result, null, "network error is swallowed into null"); + }); +}); diff --git a/src/__tests__/x402-solana-sign.test.mjs b/src/__tests__/x402-solana-sign.test.mjs new file mode 100644 index 0000000..a1f72e4 --- /dev/null +++ b/src/__tests__/x402-solana-sign.test.mjs @@ -0,0 +1,168 @@ +/** + * x402 Solana client-side signer tests (offline — no RPC, no validator). + * + * Proves the "Solana sign method + its output" half of the integration: given a + * Toolgate 402 challenge, the signer produces a base64 partially-signed SVM + * transaction wrapped as an x402 v2 payment payload, with the fee-payer slot + * left empty for the facilitator. + * + * A fixed blockhash is injected so the test never touches the network. + * + * Run: node --test src/__tests__/x402-solana-sign.test.mjs + */ + +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { Keypair, VersionedTransaction } from "@solana/web3.js"; +import { + buildSolanaPaymentPayload, + extractSolanaRequirement, +} from "../../examples/x402-solana-recovery/sign-payload.mjs"; + +const SOLANA_DEVNET = "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1"; +const DEVNET_USDC = "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU"; + +function makeChallenge({ feePayer, payTo, amount = "50000" }) { + return { + x402PaymentRequired: { + x402Version: 2, + accepts: [ + { + scheme: "exact", + network: SOLANA_DEVNET, + maxAmountRequired: amount, + resource: "toolgate://tg_pub_test/premium_search", + description: "Payment for premium_search", + payTo, + asset: DEVNET_USDC, + maxTimeoutSeconds: 300, + extra: { feePayer }, + }, + ], + }, + }; +} + +describe("x402 Solana signer", () => { + it("produces an x402 v2 SVM payload from a 402 challenge", async () => { + const payer = Keypair.generate(); + const recipient = Keypair.generate(); + const facilitator = Keypair.generate(); + // Any 32-byte base58 string is a structurally valid blockhash for signing. + const blockhash = Keypair.generate().publicKey.toBase58(); + + const { paymentPayload, transaction, memo } = + await buildSolanaPaymentPayload({ + challenge: makeChallenge({ + feePayer: facilitator.publicKey.toBase58(), + payTo: recipient.publicKey.toBase58(), + }), + payerSecretKey: payer.secretKey, + blockhash, + }); + + // ── payload envelope ── + assert.equal(paymentPayload.x402Version, 2); + assert.equal(paymentPayload.scheme, "exact"); + assert.equal(paymentPayload.network, SOLANA_DEVNET); + assert.ok( + typeof paymentPayload.payload.transaction === "string", + "transaction is a base64 string", + ); + assert.ok(memo.length > 0, "a memo nonce was attached"); + + // ── deserialize and inspect the actual transaction ── + const bytes = Buffer.from(paymentPayload.payload.transaction, "base64"); + const decoded = VersionedTransaction.deserialize(bytes); + const keys = decoded.message.staticAccountKeys.map((k) => k.toBase58()); + + assert.equal( + keys[0], + facilitator.publicKey.toBase58(), + "account[0] is the fee payer (the facilitator), not the client", + ); + assert.equal( + decoded.message.compiledInstructions.length, + 4, + "2 compute-budget + 1 transferChecked + 1 memo = 4 instructions", + ); + + // Round-trip equality with the returned VersionedTransaction + assert.equal( + Buffer.from(transaction.serialize({ requireAllSignatures: false })).toString( + "base64", + ), + paymentPayload.payload.transaction, + ); + }); + + it("leaves the fee-payer signature empty (partial sign)", async () => { + const payer = Keypair.generate(); + const facilitator = Keypair.generate(); + const blockhash = Keypair.generate().publicKey.toBase58(); + + const { transaction } = await buildSolanaPaymentPayload({ + challenge: makeChallenge({ + feePayer: facilitator.publicKey.toBase58(), + payTo: Keypair.generate().publicKey.toBase58(), + }), + payerSecretKey: payer.secretKey, + blockhash, + }); + + // Two required signers: [0]=feePayer (empty), [1]=client (filled). + assert.equal(transaction.signatures.length, 2); + const feePayerSig = transaction.signatures[0]; + const clientSig = transaction.signatures[1]; + assert.ok( + feePayerSig.every((b) => b === 0), + "fee-payer signature slot is all zeros (facilitator signs at /settle)", + ); + assert.ok( + clientSig.some((b) => b !== 0), + "client signature is present", + ); + }); + + it("rejects a non-Solana or malformed requirement", async () => { + await assert.rejects( + () => + buildSolanaPaymentPayload({ + challenge: { x402PaymentRequired: { accepts: [{}] } }, + payerSecretKey: Keypair.generate().secretKey, + blockhash: Keypair.generate().publicKey.toBase58(), + }), + /usable Solana x402 payment requirement/, + ); + }); + + it("requires extra.feePayer to be present", async () => { + const challenge = makeChallenge({ + feePayer: undefined, + payTo: Keypair.generate().publicKey.toBase58(), + }); + // strip feePayer entirely + delete challenge.x402PaymentRequired.accepts[0].extra.feePayer; + + await assert.rejects( + () => + buildSolanaPaymentPayload({ + challenge, + payerSecretKey: Keypair.generate().secretKey, + blockhash: Keypair.generate().publicKey.toBase58(), + }), + /missing extra\.feePayer/, + ); + }); + + it("extractSolanaRequirement reads the first accepts entry", () => { + const req = extractSolanaRequirement( + makeChallenge({ + feePayer: Keypair.generate().publicKey.toBase58(), + payTo: Keypair.generate().publicKey.toBase58(), + }), + ); + assert.equal(req.network, SOLANA_DEVNET); + assert.equal(req.asset, DEVNET_USDC); + }); +}); diff --git a/src/rail-adapters/x402-rail.ts b/src/rail-adapters/x402-rail.ts index 780afa8..382f1ff 100644 --- a/src/rail-adapters/x402-rail.ts +++ b/src/rail-adapters/x402-rail.ts @@ -29,12 +29,28 @@ export interface X402RailConfig { */ network: X402Network; - /** x402 protocol version. Default: 1. Set to 2 for v2 features (sessions, discovery). */ + /** + * x402 protocol version. Default: 1 for EVM, 2 for Solana (SVM). + * The SVM "exact" scheme is defined for x402Version 2, so Solana networks + * are forced to v2 unless you override this explicitly. + */ x402Version?: 1 | 2; /** Payment scheme. Default: "exact" */ scheme?: string; + /** + * Solana fee payer (base58 pubkey) that co-signs and sponsors the settle + * transaction. REQUIRED for Solana/SVM payments: the client builds a + * partially-signed SPL transfer and leaves the fee-payer signature empty, + * the facilitator fills it in at /settle. Surfaced to the client via + * `PaymentRequirements.extra.feePayer`. + * + * Most facilitators expose their fee payer from `GET /supported`; call + * `discoverFeePayer()` to fetch it instead of hardcoding. Ignored for EVM. + */ + feePayer?: string; + /** * Facilitator URL for payment verification and settlement. * @@ -106,6 +122,16 @@ export class X402RailAdapter implements RailAdapter { this.config = config; } + /** True when this adapter is configured for a Solana/SVM network. */ + private get isSolana(): boolean { + return this.config.network.kind === "solana"; + } + + /** Resolved x402 protocol version (SVM "exact" scheme requires v2). */ + private get x402Version(): number { + return this.config.x402Version ?? (this.isSolana ? 2 : 1); + } + async createChallenge(params: ChallengeParams): Promise { const decimals = this.config.network.decimals ?? 6; const amountAtomic = String( @@ -118,6 +144,18 @@ export class X402RailAdapter implements RailAdapter { this.config.resourceUrl ?? `toolgate://${params.publisherKey}/${params.toolName}`; + const extra: Record = { + toolgate_caller_id: params.callerId, + toolgate_tool: params.toolName, + toolgate_publisher: params.publisherKey, + }; + + // SVM "exact" requires the facilitator's fee payer so the client can build + // a partially-signed transaction that leaves the fee-payer slot empty. + if (this.isSolana && this.config.feePayer) { + extra.feePayer = this.config.feePayer; + } + const paymentRequirement: X402PaymentRequirement = { scheme: this.config.scheme ?? "exact", network: this.config.network.caip2, @@ -127,11 +165,7 @@ export class X402RailAdapter implements RailAdapter { payTo: this.config.payTo, asset: this.getAssetAddress(), maxTimeoutSeconds: timeout, - extra: { - toolgate_caller_id: params.callerId, - toolgate_tool: params.toolName, - toolgate_publisher: params.publisherKey, - }, + extra, }; // Store for later verify/settle @@ -152,7 +186,7 @@ export class X402RailAdapter implements RailAdapter { rail: "x402", actionId, x402PaymentRequired: { - x402Version: this.config.x402Version ?? 1, + x402Version: this.x402Version, accepts: [paymentRequirement], }, expiresAt: Math.floor(Date.now() / 1000) + timeout, @@ -184,7 +218,7 @@ export class X402RailAdapter implements RailAdapter { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ - x402Version: this.config.x402Version ?? 1, + x402Version: this.x402Version, paymentPayload: proof.x402PaymentPayload, paymentRequirements: facilitatorRequirements, }), @@ -239,7 +273,7 @@ export class X402RailAdapter implements RailAdapter { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ - x402Version: this.config.x402Version ?? 1, + x402Version: this.x402Version, paymentPayload: proof.x402PaymentPayload, paymentRequirements: facilitatorRequirements, }), @@ -292,7 +326,7 @@ export class X402RailAdapter implements RailAdapter { private toFacilitatorPaymentRequirements( requirements: X402PaymentRequirement, ): Record { - if ((this.config.x402Version ?? 1) >= 2) { + if (this.x402Version >= 2) { return { scheme: requirements.scheme, network: requirements.network, @@ -341,6 +375,46 @@ export class X402RailAdapter implements RailAdapter { ); } + /** + * Discover the Solana fee payer from the facilitator's `GET /supported` + * endpoint and cache it on this adapter (so subsequent challenges include + * `extra.feePayer`). No-op for EVM. Returns the resolved fee payer, or null + * if the facilitator does not advertise one for this network. + * + * The x402 `/supported` response lists payment kinds; SVM entries carry the + * fee payer under `extra.feePayer`. We match on the configured caip2 network. + */ + async discoverFeePayer(): Promise { + if (!this.isSolana) return null; + if (this.config.feePayer) return this.config.feePayer; + + try { + const response = await fetch(`${this.config.facilitatorUrl}/supported`); + if (!response.ok) return null; + + const body = (await response.json()) as { + kinds?: Array<{ + network?: string; + extra?: { feePayer?: string }; + }>; + }; + + const match = body.kinds?.find( + (k) => k.network === this.config.network.caip2 && k.extra?.feePayer, + ); + const feePayer = match?.extra?.feePayer ?? null; + if (feePayer) this.config.feePayer = feePayer; + return feePayer; + } catch { + return null; + } + } + + /** The configured/discovered Solana fee payer, if any. */ + get feePayer(): string | undefined { + return this.config.feePayer; + } + /** Get pending requirements count (for testing/monitoring) */ get pendingCount(): number { return this.pending.size; From bd54ec07d6424b59478e633c340fc360708d32b2 Mon Sep 17 00:00:00 2001 From: Bertug Date: Thu, 25 Jun 2026 01:31:23 +0300 Subject: [PATCH 2/3] =?UTF-8?q?feat(x402-solana):=20verified=20on=20devnet?= =?UTF-8?q?=20=E2=80=94=20fix=20SVM=20payload=20shape=20+=20add=20devnet?= =?UTF-8?q?=20runner?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ran the full rail end-to-end against PayAI + Solana devnet and corrected the client signer to match the live SVM "exact" v2 wire format. The rail's own verify/settle bodies were already correct — fixes are client-side only. Confirmed devnet settle (err: None), fee paid by the facilitator's fee payer (not the client): tx 5JeSK1je6xrt3HouPUSKheawqiwJhVPtSufqyzNgyqCLBKSZ11Krvty... Signer (examples/x402-solana-recovery/sign-payload.mjs): - PaymentPayload now embeds the agreed requirement under `accepted`, with the amount as an atomic STRING field `amount` (facilitator rejected the old shape) - compute-unit limit default 120k -> 30k (facilitators reject too-high limits; too-low fails simulation), price still clamped to <= 5 Add examples/x402-solana-recovery/devnet-settle.mjs — self-transfer devnet smoke test (discover feePayer -> challenge -> partial-sign -> /verify -> /settle), prints the explorer link. Local payer keypair path is gitignored. Tests updated for the new payload envelope. Full suite: 228 passing. Co-Authored-By: Claude Opus 4.8 (1M context) --- .gitignore | 3 + examples/x402-solana-recovery/NOTES.md | 31 +++ .../x402-solana-recovery/devnet-settle.mjs | 177 ++++++++++++++++++ .../x402-solana-recovery/sign-payload.mjs | 22 ++- src/__tests__/x402-solana-sign.test.mjs | 8 +- 5 files changed, 235 insertions(+), 6 deletions(-) create mode 100644 examples/x402-solana-recovery/devnet-settle.mjs diff --git a/.gitignore b/.gitignore index dd6e803..30d8f1f 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,6 @@ node_modules/ dist/ *.log .DS_Store + +# Local devnet payer keypair written by examples/x402-solana-recovery/devnet-settle.mjs +.devnet-payer.json diff --git a/examples/x402-solana-recovery/NOTES.md b/examples/x402-solana-recovery/NOTES.md index 55b8641..2d1ffc2 100644 --- a/examples/x402-solana-recovery/NOTES.md +++ b/examples/x402-solana-recovery/NOTES.md @@ -66,6 +66,37 @@ await rail.discoverFeePayer(); // pulls extra.feePayer from /supported - **Coinbase CDP** — Base + Solana; free tier (~1k tx/mo). - **Self-hosted (Kora)** — run your own signer node / facilitator. +## Verified on devnet + +Run end-to-end against PayAI + Solana devnet with `devnet-settle.mjs` +(self-transfer smoke test; fund the printed address once at +https://faucet.circle.com → "Solana Devnet"): + +```bash +node examples/x402-solana-recovery/devnet-settle.mjs +# … /verify → VALID ✅ /settle → SETTLED ✅ +# tx: 5JeSK1je6xrt3HouPUSKheawqiwJhVPtSufqyzNgyqCLBKSZ11KrvtyE5PoxQKEzMKCNfyRTuezVuv39j93TqdGx +``` + +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. + +### Protocol details learned from a live facilitator + +The SVM "exact" v2 wire format is stricter than the EVM path. Getting verify to +pass required: + +- The `paymentPayload` must embed the agreed requirement under **`accepted`**, + and the amount there is an **atomic string** field named **`amount`** (not + `maxAmountRequired`). The signer emits this shape. +- The transaction's **compute-unit limit is bounded**: too high (~50k+) is + rejected (`compute_limit_too_high`), and too low (≤10k) fails simulation. The + signer defaults to 30k, with compute-unit price clamped to ≤ 5. +- The fee payer must be account[0] and a non-participant in the transfer. + +The rail's `verify`/`settle` request bodies were already correct for v2 — the +fixes were entirely client-side in the signer. + ## Tests - `src/__tests__/x402-solana-rail.test.mjs` — challenge shape, fee-payer diff --git a/examples/x402-solana-recovery/devnet-settle.mjs b/examples/x402-solana-recovery/devnet-settle.mjs new file mode 100644 index 0000000..4a48aa1 --- /dev/null +++ b/examples/x402-solana-recovery/devnet-settle.mjs @@ -0,0 +1,177 @@ +/** + * x402 Solana DEVNET end-to-end settle. + * + * Real run against a live facilitator (default: PayAI) and Solana devnet: + * 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. + * + * Env: + * 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) + * X402_FACILITATOR_URL default: https://facilitator.payai.network + * SOLANA_RPC_URL default: https://api.devnet.solana.com + * PAY_TO optional recipient override (default: self) + * AMOUNT_USDC default: 0.001 + * + * Usage: + * node examples/x402-solana-recovery/devnet-settle.mjs + */ + +import { readFile, writeFile } from "node:fs/promises"; +import { Connection, Keypair, PublicKey } from "@solana/web3.js"; +import { + getAssociatedTokenAddressSync, + getAccount, +} from "@solana/spl-token"; +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 FACILITATOR = + process.env.X402_FACILITATOR_URL ?? "https://facilitator.payai.network"; +const RPC_URL = process.env.SOLANA_RPC_URL ?? "https://api.devnet.solana.com"; +const KEYPAIR_PATH = + process.env.PAYER_KEYPAIR_PATH ?? "./.devnet-payer.json"; +const AMOUNT_USDC = Number(process.env.AMOUNT_USDC ?? "0.001"); + +function parseSecret(raw) { + raw = raw.trim(); + if (raw.startsWith("[")) return Uint8Array.from(JSON.parse(raw)); + // base58 + return Keypair.fromSecretKey(bs58Decode(raw)).secretKey; +} + +// Tiny base58 decoder (avoid adding bs58 dep) +function bs58Decode(str) { + const ALPHABET = + "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz"; + const bytes = [0]; + for (const ch of str) { + const value = ALPHABET.indexOf(ch); + if (value === -1) throw new Error("bad base58 char"); + let carry = value; + for (let i = 0; i < bytes.length; i++) { + carry += bytes[i] * 58; + bytes[i] = carry & 0xff; + carry >>= 8; + } + while (carry) { + bytes.push(carry & 0xff); + carry >>= 8; + } + } + for (const ch of str) { + if (ch === "1") bytes.push(0); + else break; + } + return Uint8Array.from(bytes.reverse()); +} + +async function loadOrCreatePayer() { + if (process.env.SOLANA_PAYER_SECRET) { + return Keypair.fromSecretKey(parseSecret(process.env.SOLANA_PAYER_SECRET)); + } + try { + const raw = await readFile(KEYPAIR_PATH, "utf8"); + return Keypair.fromSecretKey(Uint8Array.from(JSON.parse(raw))); + } catch { + const kp = Keypair.generate(); + await writeFile(KEYPAIR_PATH, JSON.stringify(Array.from(kp.secretKey))); + return kp; + } +} + +async function main() { + const payer = await loadOrCreatePayer(); + const payTo = process.env.PAY_TO + ? new PublicKey(process.env.PAY_TO) + : payer.publicKey; + + const mint = new PublicKey(DEVNET_USDC); + const payerAta = getAssociatedTokenAddressSync(mint, payer.publicKey); + const connection = new Connection(RPC_URL, "confirmed"); + + console.log("── x402 Solana devnet 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? ── + let balance = 0n; + try { + const acct = await getAccount(connection, payerAta); + balance = acct.amount; + } catch { + /* ATA does not exist yet */ + } + 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."); + process.exit(2); + } + + // ── Rail: discover fee payer + build challenge ── + const rail = new X402RailAdapter({ + payTo: payTo.toBase58(), + network: { kind: "solana", caip2: DEVNET_CAIP2 }, + facilitatorUrl: FACILITATOR, + }); + + const feePayer = await rail.discoverFeePayer(); + console.log("Fee payer :", feePayer ?? "(none advertised!)"); + if (!feePayer) process.exit(1); + + const action = await rail.createChallenge({ + callerId: payer.publicKey.toBase58(), + amount: AMOUNT_USDC, + currency: "usd", + toolName: "devnet_smoke", + publisherKey: "tg_devnet", + }); + + // ── Sign (partial) ── + const { paymentPayload, memo } = await buildSolanaPaymentPayload({ + challenge: action, + payerSecretKey: payer.secretKey, + rpcUrl: RPC_URL, + }); + console.log("Memo nonce :", memo); + + const proof = { rail: "x402", x402PaymentPayload: paymentPayload }; + const context = { actionId: action.actionId }; + + // ── Verify ── + const verified = await rail.verifyPayment(proof, context); + console.log("\n/verify →", verified ? "VALID ✅" : "INVALID ❌"); + if (!verified) process.exit(1); + + // ── Settle ── + const settled = await rail.settlePayment(proof, context); + if (!settled) { + console.log("/settle → FAILED ❌ (settlement uncertain)"); + process.exit(1); + } + console.log("/settle → SETTLED ✅"); + console.log("tx :", settled.txHash); + console.log( + "explorer :", + `https://explorer.solana.com/tx/${settled.txHash}?cluster=devnet`, + ); +} + +main().catch((err) => { + console.error("\n✖", err?.message ?? err); + process.exit(1); +}); diff --git a/examples/x402-solana-recovery/sign-payload.mjs b/examples/x402-solana-recovery/sign-payload.mjs index 56aa913..f75655f 100644 --- a/examples/x402-solana-recovery/sign-payload.mjs +++ b/examples/x402-solana-recovery/sign-payload.mjs @@ -81,7 +81,9 @@ export function extractSolanaRequirement(challenge) { * @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=120000] + * @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({ @@ -90,7 +92,7 @@ export async function buildSolanaPaymentPayload({ rpcUrl, blockhash, memo, - computeUnitLimit = 120_000, + computeUnitLimit = 30_000, computeUnitPrice = 1, }) { const { web3, splToken } = await loadSolana(); @@ -173,10 +175,22 @@ export async function buildSolanaPaymentPayload({ transaction.serialize({ requireAllSignatures: false }), ).toString("base64"); - const paymentPayload = { - x402Version: 2, + // 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 }, }; diff --git a/src/__tests__/x402-solana-sign.test.mjs b/src/__tests__/x402-solana-sign.test.mjs index a1f72e4..479c906 100644 --- a/src/__tests__/x402-solana-sign.test.mjs +++ b/src/__tests__/x402-solana-sign.test.mjs @@ -63,8 +63,12 @@ describe("x402 Solana signer", () => { // ── payload envelope ── assert.equal(paymentPayload.x402Version, 2); - assert.equal(paymentPayload.scheme, "exact"); - assert.equal(paymentPayload.network, SOLANA_DEVNET); + // x402 v2 embeds the accepted requirement; amount is an atomic STRING. + assert.equal(paymentPayload.accepted.scheme, "exact"); + assert.equal(paymentPayload.accepted.network, SOLANA_DEVNET); + assert.equal(paymentPayload.accepted.amount, "50000"); + assert.equal(paymentPayload.accepted.asset, DEVNET_USDC); + assert.equal(paymentPayload.accepted.extra.feePayer, facilitator.publicKey.toBase58()); assert.ok( typeof paymentPayload.payload.transaction === "string", "transaction is a base64 string", From 95762b1c7aab7c24353f50e8cc024ce81d820bde Mon Sep 17 00:00:00 2001 From: Bertug Date: Thu, 25 Jun 2026 01:36:29 +0300 Subject: [PATCH 3/3] docs(x402-solana): record verified real cross-account devnet transfer Beyond the self-transfer smoke test, ran a real payer -> recipient transfer on devnet via PAY_TO: moved exactly 0.001 USDC (payer 20 -> 19.999, recipient 20 -> 20.001), gas paid by the facilitator, err: None (tx de6S852jpFTJ1hHLNMBPAaWqrkkMXzjq8XqPbbCf3LD4s1Mm6ZDAU3ah6dxUZueyN19U...). Also documents that the SVM "exact" fixed instruction layout means the recipient's token account must exist before settle. Co-Authored-By: Claude Opus 4.8 (1M context) --- examples/x402-solana-recovery/NOTES.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/examples/x402-solana-recovery/NOTES.md b/examples/x402-solana-recovery/NOTES.md index 2d1ffc2..f3c15dc 100644 --- a/examples/x402-solana-recovery/NOTES.md +++ b/examples/x402-solana-recovery/NOTES.md @@ -81,6 +81,13 @@ 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. +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 +(tx `de6S852jpFTJ1hHLNMBPAaWqrkkMXzjq8XqPbbCf3LD4s1Mm6ZDAU3ah6dxUZueyN19U2FXP58E7CstHGctSncG`). +Note: the SVM "exact" scheme has a fixed instruction layout (no ATA creation), +so the recipient's token account must already exist before settle. + ### Protocol details learned from a live facilitator The SVM "exact" v2 wire format is stricter than the EVM path. Getting verify to