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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,4 @@ dist/

# Local devnet payer keypair written by examples/x402-solana-recovery/devnet-settle.mjs
.devnet-payer.json
.mainnet-payer.json
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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; 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 |
Expand Down
34 changes: 30 additions & 4 deletions examples/x402-solana-recovery/NOTES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
Expand All @@ -81,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
Expand Down Expand Up @@ -112,6 +133,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.
73 changes: 53 additions & 20 deletions examples/x402-solana-recovery/devnet-settle.mjs
Original file line number Diff line number Diff line change
@@ -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: ./.<network>-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";
Expand All @@ -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) {
Expand Down Expand Up @@ -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);
Expand All @@ -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,
});

Expand Down Expand Up @@ -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}`,
);
}

Expand Down
Loading
Loading