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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,6 @@ dist/
*.log
.DS_Store
.history/

# Local devnet payer keypair written by examples/x402-solana-recovery/devnet-settle.mjs
.devnet-payer.json
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,8 @@ Current version: `0.3.0-beta.1`.
| SQLite / D1 ledger | Local and single-process paths |
| Stripe test mode | Validated with configured test credentials |
| Stripe production | Beta; validate your webhook and deployment path |
| x402 | 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 |
Expand Down
117 changes: 117 additions & 0 deletions examples/x402-solana-recovery/NOTES.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
# 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:<genesisHash>` 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.

## 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.

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
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
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.
177 changes: 177 additions & 0 deletions examples/x402-solana-recovery/devnet-settle.mjs
Original file line number Diff line number Diff line change
@@ -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);
});
Loading
Loading