From d10fde9788d810af7347f7015c0eed1c76306eec Mon Sep 17 00:00:00 2001 From: skingem <199834904+SkinGem@users.noreply.github.com> Date: Wed, 7 Oct 2026 13:11:30 +0200 Subject: [PATCH] feat(x402): opt-in Solana batch settlement for sol.blockrun.ai sol.blockrun.ai offers `batch-settlement` next to `exact` on Solana mainnet: one USDC deposit opens a payment channel, then each call is paid with a signed voucher instead of an on-chain transfer. This wires the SDK's BatchSvmScheme in, off by default. Safety model. BlockRun's channels are server signed, so the operator can claim up to the whole deposit without another client signature: - CLAWROUTER_SOLANA_BATCH=1 turns it on; CLAWROUTER_SOLANA_BATCH_OPERATORS is empty by default and an empty list keeps every call on exact (fail closed). - maxDeposit equals CLAWROUTER_SOLANA_BATCH_DEPOSIT_USDC (default 1 USDC), so a trusted operator can never hold more than one deposit. - Channel state is persisted to ~/.openclaw/blockrun/solana-batch-channels.json with atomic writes; a torn file is refused, never read as empty. - A claim check (start + every 10 min, and in `doctor`) decodes each channel account and compares the on-chain `settled` watermark with the cumulative this wallet signed. Claimed above signed disables batch for the process. - Any batch failure pays that request with exact: a signing failure, or a 402 for the voucher. A lost response after the voucher was sent is not re-paid. Also: - @x402/* 2.21 -> 2.28 (batch-settlement first ships in 2.28). @x402/svm 2.28 pulls @solana/program-client-core ^6, which nested a second @solana/signers and transactions tree; a scoped override pins it to the root 8.4 copy (svm only imports getAccountMetaFactory, present in 8.x). smoke-dist passes with one copy; dist/cli.js +1.5%. - @x402/core 2.28 adds client spendControls on by default (default assets only, $1 per payment). ClawRouter's own spend policy already governs, so the proxy and doctor clients turn them off to keep payments as they were. - payment-preauth feeds the gateway's answer to the batch scheme (processPaymentResult) so it commits the confirmed cumulative or resyncs, as @x402/fetch does; the exact path is unchanged. Co-Authored-By: Claude Opus 5.5 --- README.md | 1 + docs/configuration.md | 60 +++- package-lock.json | 52 ++- package.json | 13 +- src/commands/policy.test.ts | 3 +- src/config.ts | 15 + src/doctor.ts | 52 ++- src/payment-preauth.ts | 83 ++++- src/polymarket/spend-policy.test.ts | 3 +- src/proxy.ts | 32 +- src/solana-batch.payment.test.ts | 181 ++++++++++ src/solana-batch.test.ts | 360 +++++++++++++++++++ src/solana-batch.ts | 533 ++++++++++++++++++++++++++++ src/spend-control.test.ts | 16 +- 14 files changed, 1351 insertions(+), 53 deletions(-) create mode 100644 src/solana-batch.payment.test.ts create mode 100644 src/solana-batch.test.ts create mode 100644 src/solana-batch.ts diff --git a/README.md b/README.md index e1afaef5..0f884d8e 100644 --- a/README.md +++ b/README.md @@ -777,6 +777,7 @@ For basic usage, no configuration needed. For advanced options: | `CLAWROUTER_DISABLED` | `false` | Disable smart routing | | `CLAWROUTER_DEBUG_HEADERS` | `on` | Set to `off` to suppress `x-clawrouter-*` debug response headers | | `CLAWROUTER_SOLANA_RPC_URL` | `https://api.mainnet-beta.solana.com` | Solana RPC endpoint — balance checks and payment signing | +| `CLAWROUTER_SOLANA_BATCH` | off | Opt-in Solana x402 batch settlement (needs a trusted operator; see the configuration reference) | **Full reference:** [docs/configuration.md](docs/configuration.md) diff --git a/docs/configuration.md b/docs/configuration.md index fb15d270..9e8aad9e 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -21,17 +21,20 @@ Complete reference for ClawRouter configuration options. ## Environment Variables -| Variable | Default | Description | -| --------------------------- | ------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `BLOCKRUN_API_KEY` | - | BlockRun API key (`brk_live_…`). Pays from card-funded account credit via `api.blockrun.ai` instead of a wallet. Takes precedence over every wallet source. | -| `BLOCKRUN_API_BASE_URL` | `https://api.blockrun.ai` | Override the API-key gateway (staging deploys only). | -| `BLOCKRUN_WALLET_KEY` | - | Explicit Base wallet override (hex, 0x-prefixed). | -| `BLOCKRUN_PROXY_PORT` | `8402` | Port for the local x402 proxy server. | -| `CLAWROUTER_SOLANA_RPC_URL` | `https://api.mainnet-beta.solana.com` | Solana RPC endpoint for USDC balance checks. | -| `CLAWROUTER_DISABLED` | `false` | Set to `true` to disable smart routing (pass requests through as-is). | -| `CLAWROUTER_WORKER` | - | Set to `1` to enable Worker Mode (earn USDC by running health checks). | -| `CLAWROUTER_DEBUG_HEADERS` | (on) | Set to `off`/`false`/`0` to suppress the `x-clawrouter-*` debug response headers. | -| `BLOCKRUN_WEB_SEARCH` | (auto-enabled) | Set to `off` to disable BlockRun's Exa web search provider registration. | +| Variable | Default | Description | +| -------------------------------------- | ------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `BLOCKRUN_API_KEY` | - | BlockRun API key (`brk_live_…`). Pays from card-funded account credit via `api.blockrun.ai` instead of a wallet. Takes precedence over every wallet source. | +| `BLOCKRUN_API_BASE_URL` | `https://api.blockrun.ai` | Override the API-key gateway (staging deploys only). | +| `BLOCKRUN_WALLET_KEY` | - | Explicit Base wallet override (hex, 0x-prefixed). | +| `BLOCKRUN_PROXY_PORT` | `8402` | Port for the local x402 proxy server. | +| `CLAWROUTER_SOLANA_RPC_URL` | `https://api.mainnet-beta.solana.com` | Solana RPC endpoint for USDC balance checks. | +| `CLAWROUTER_SOLANA_BATCH` | off | Set to `1` to pay Solana calls through an x402 batch-settlement channel. See below. | +| `CLAWROUTER_SOLANA_BATCH_DEPOSIT_USDC` | `1` | Channel deposit in USDC; also the most a trusted operator can hold. | +| `CLAWROUTER_SOLANA_BATCH_OPERATORS` | - | Comma-separated operator keys trusted for server-signed channels. Empty = batch stays off. | +| `CLAWROUTER_DISABLED` | `false` | Set to `true` to disable smart routing (pass requests through as-is). | +| `CLAWROUTER_WORKER` | - | Set to `1` to enable Worker Mode (earn USDC by running health checks). | +| `CLAWROUTER_DEBUG_HEADERS` | (on) | Set to `off`/`false`/`0` to suppress the `x-clawrouter-*` debug response headers. | +| `BLOCKRUN_WEB_SEARCH` | (auto-enabled) | Set to `off` to disable BlockRun's Exa web search provider registration. | --- @@ -234,6 +237,41 @@ curl -s -m 10 -X POST https://api.mainnet-beta.solana.com \ If that hangs or is refused, point this variable at an endpoint the host can reach. +### CLAWROUTER_SOLANA_BATCH + +Experimental, off by default, Solana chain only. `sol.blockrun.ai` offers two +schemes: `exact` (one on-chain USDC transfer per call) and `batch-settlement` +(one deposit opens a payment channel; each call is then paid with a signed +voucher and the gateway claims vouchers in batches, so calls skip the per-call +transaction). + +BlockRun's channels are **server signed**: the gateway's operator key can claim +up to the whole deposit without another signature from your wallet. So nothing +is trusted by default; you name the operator and the deposit caps the exposure: + +```bash +export CLAWROUTER_SOLANA_BATCH=1 +export CLAWROUTER_SOLANA_BATCH_OPERATORS= +export CLAWROUTER_SOLANA_BATCH_DEPOSIT_USDC=1 # default; the escrow cap equals the deposit +openclaw gateway restart +``` + +- With no operator listed, or an operator the 402 does not advertise, every call + stays on `exact`. +- Any batch failure (signing, RPC, a 402 for the voucher) pays that request with + `exact` instead. A request whose voucher was sent but whose response was lost + is not paid again. +- What your wallet has signed per channel is kept in + `~/.openclaw/blockrun/solana-batch-channels.json` (atomic writes), so a + restart neither loses nor double counts it. Do not delete it while a channel + is open. +- At start and every 10 minutes the proxy reads each channel account on chain + and compares what was claimed (`settled`) with what you signed. Claimed above + signed turns batch off for the process and logs a warning; `clawrouter doctor` + runs the same check and lists each channel. +- The deposit is escrow, not a spend: ClawRouter's spend limits count the + per-call price, not the deposit. + --- ## Wallet Configuration diff --git a/package-lock.json b/package-lock.json index fc01d3ff..f63c57ff 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,10 +17,10 @@ "@scure/bip32": "^2.0.1", "@scure/bip39": "^1.5.0", "@solana/kit": "^8.4.0", - "@x402/core": "^2.9.0", - "@x402/evm": "^2.9.0", - "@x402/fetch": "^2.9.0", - "@x402/svm": "^2.9.0", + "@x402/core": "^2.28.0", + "@x402/evm": "^2.28.0", + "@x402/fetch": "^2.28.0", + "@x402/svm": "^2.28.0", "axios": "^1.18.1", "bs58": "^6.0.0", "https-proxy-agent": "^9.1.0", @@ -4444,49 +4444,63 @@ } }, "node_modules/@x402/core": { - "version": "2.21.0", - "resolved": "https://registry.npmjs.org/@x402/core/-/core-2.21.0.tgz", - "integrity": "sha512-0djKE7V5/JKDMrjRe5he3DoMFzlbVnUcvMmLAb2j6OoAJDamupkFh6fFrXeoHwjkBIxOFUzjGI4FVixz2dMxSA==", + "version": "2.28.0", + "resolved": "https://registry.npmjs.org/@x402/core/-/core-2.28.0.tgz", + "integrity": "sha512-Da/7zKxCKqpBoB4Msc9tFgkAv7tJTD8nG+DSSm+QdXAyJS4PQsmgVSKe17rDJTtlu0pwNbLcOje6MyQ6kaDkig==", "license": "Apache-2.0", "dependencies": { "zod": "^3.24.2" } }, "node_modules/@x402/evm": { - "version": "2.21.0", - "resolved": "https://registry.npmjs.org/@x402/evm/-/evm-2.21.0.tgz", - "integrity": "sha512-VZtPz26IxhfAQbHHmyG7nCY/0jLH9QIgNqRztT1ae93dc9uSGUyn5bofzA3Wxq7lND3WDFir0QAQ2LBqoFeMrg==", + "version": "2.28.0", + "resolved": "https://registry.npmjs.org/@x402/evm/-/evm-2.28.0.tgz", + "integrity": "sha512-BZuoaCKMoPQQuMTa4oibvoyI06WdCpgrzgdicUu8d+5NBMAfWb6LAmSQf1KkeiS8C+25/+2JGB4cHSL0VLGnSA==", "license": "Apache-2.0", "dependencies": { - "@x402/core": "~2.21.0", + "@x402/core": "~2.28.0", "viem": "^2.48.11", "zod": "^3.24.2" } }, "node_modules/@x402/fetch": { - "version": "2.21.0", - "resolved": "https://registry.npmjs.org/@x402/fetch/-/fetch-2.21.0.tgz", - "integrity": "sha512-LEiJ8jjb8fmCNgF1yMjLoVRtrg8gtb/zJgBDLW7t/KPbntDDqgru+nd1OLU1qyyp9D2I7ULYP420eHVyAVZ12w==", + "version": "2.28.0", + "resolved": "https://registry.npmjs.org/@x402/fetch/-/fetch-2.28.0.tgz", + "integrity": "sha512-kF5IjZIz/izg2TD/Zav+SVj0jtC0zBDfE7WcKCnMZWIsZcLCyL2GgvwXCn7CCSsNjpQ/z3Rz4OqTCA4AaeKySQ==", "license": "Apache-2.0", "dependencies": { - "@x402/core": "~2.21.0" + "@x402/core": "~2.28.0" } }, "node_modules/@x402/svm": { - "version": "2.21.0", - "resolved": "https://registry.npmjs.org/@x402/svm/-/svm-2.21.0.tgz", - "integrity": "sha512-PdB+ALLXP5igMXiF8CaU0c7HBX52gs5sfLGlJ4iTtMKk11A1qvge46bIviGt9GcZkwbzBBFOHwkHJVUZum+vQA==", + "version": "2.28.0", + "resolved": "https://registry.npmjs.org/@x402/svm/-/svm-2.28.0.tgz", + "integrity": "sha512-D6+MSi7GG9LP7Lld1/i+A+Rk9ZyET5MmZAZqAyxmIda42tW3mVZjn/RJjGQbQmTKe+lYk8KiKWbeaxoK/x/ArA==", "license": "Apache-2.0", "dependencies": { + "@noble/hashes": "^1.8.0", "@solana-program/compute-budget": "^0.11.0", "@solana-program/token": "^0.9.0", "@solana-program/token-2022": "^0.6.1", - "@x402/core": "~2.21.0" + "@solana/program-client-core": "^6.1.0", + "@x402/core": "~2.28.0" }, "peerDependencies": { "@solana/kit": ">=5.1.0" } }, + "node_modules/@x402/svm/node_modules/@noble/hashes": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/abitype": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/abitype/-/abitype-1.2.3.tgz", diff --git a/package.json b/package.json index fa4eb26b..79459f18 100644 --- a/package.json +++ b/package.json @@ -96,10 +96,10 @@ "@scure/bip32": "^2.0.1", "@scure/bip39": "^1.5.0", "@solana/kit": "^8.4.0", - "@x402/core": "^2.9.0", - "@x402/evm": "^2.9.0", - "@x402/fetch": "^2.9.0", - "@x402/svm": "^2.9.0", + "@x402/core": "^2.28.0", + "@x402/evm": "^2.28.0", + "@x402/fetch": "^2.28.0", + "@x402/svm": "^2.28.0", "axios": "^1.18.1", "bs58": "^6.0.0", "https-proxy-agent": "^9.1.0", @@ -140,7 +140,10 @@ "ip-address": "^10.4.0", "@solana-program/compute-budget": "^0.18.1", "@solana-program/token": "^0.16.1", - "@solana-program/token-2022": "^0.16.1" + "@solana-program/token-2022": "^0.16.1", + "@x402/svm": { + "@solana/program-client-core": "^8.4.0" + } }, "engines": { "node": ">=22" diff --git a/src/commands/policy.test.ts b/src/commands/policy.test.ts index a9ee528e..991cd359 100644 --- a/src/commands/policy.test.ts +++ b/src/commands/policy.test.ts @@ -36,7 +36,8 @@ describe("runPolicyCommand (in-memory store)", () => { expect(run(["set", "blockedPayees", payee]).isError).toBeFalsy(); let signerCalls = 0; - const client = new x402Client(); + // As startProxy builds it: ClawRouter's policy, not the SDK's spendControls. + const client = new x402Client().setSpendControls(false); registerSpendPolicyHook(client, openControl()); client.register(CAIP2_BASE, { scheme: "exact", diff --git a/src/config.ts b/src/config.ts index 02fe8a6d..11e9d8c4 100644 --- a/src/config.ts +++ b/src/config.ts @@ -5,6 +5,8 @@ * Separated from network code to avoid security scanner false positives. */ +import { parseSolanaBatchConfig, type SolanaBatchConfig } from "./solana-batch.js"; + const DEFAULT_PORT = 8402; /** @@ -21,3 +23,16 @@ export const PROXY_PORT = (() => { } return DEFAULT_PORT; })(); + +/** + * Opt-in Solana x402 batch settlement (off by default). Read at call time so + * tests and `doctor` see the current environment. See solana-batch.ts. + */ +export function solanaBatchConfigFromEnv(): SolanaBatchConfig { + return parseSolanaBatchConfig(process["env"]); +} + +/** CLAWROUTER_SOLANA_RPC_URL, the Solana RPC override (undefined = library default). */ +export function solanaRpcUrlFromEnv(): string | undefined { + return process["env"].CLAWROUTER_SOLANA_RPC_URL || undefined; +} diff --git a/src/doctor.ts b/src/doctor.ts index 47677cbf..a9ff1190 100644 --- a/src/doctor.ts +++ b/src/doctor.ts @@ -33,6 +33,8 @@ import { getStats } from "./stats.js"; import { getProxyPort } from "./proxy.js"; import { getSharedSpendControl, registerSpendPolicyHook, SpendControl } from "./spend-control.js"; import { VERSION } from "./version.js"; +import { solanaBatchConfigFromEnv, solanaRpcUrlFromEnv } from "./config.js"; +import type { SolanaBatchReport } from "./solana-batch.js"; // Types interface SystemInfo { @@ -85,6 +87,8 @@ interface DiagnosticResult { wallet: WalletInfo; network: NetworkInfo; logs: LogInfo; + /** Present only when CLAWROUTER_SOLANA_BATCH is set. */ + solanaBatch?: SolanaBatchReport; issues: string[]; } @@ -327,6 +331,26 @@ async function collectLogInfo(): Promise { } } +// Solana batch settlement: config state and a read-only claim check +async function collectSolanaBatchInfo( + apiKeyConfigured: boolean, +): Promise { + const config = solanaBatchConfigFromEnv(); + if (apiKeyConfigured || config.status === "off") return undefined; + const { inspectSolanaBatch, DEFAULT_CHANNEL_STORE } = await import("./solana-batch.js"); + try { + return await inspectSolanaBatch(config, { rpcUrl: solanaRpcUrlFromEnv() }); + } catch (err) { + return { + status: config.status, + message: `claim check failed: ${err instanceof Error ? err.message : String(err)}`, + store: DEFAULT_CHANNEL_STORE, + channels: [], + overclaimed: false, + }; + } +} + // Identify issues function identifyIssues(result: DiagnosticResult): string[] { const issues: string[] = []; @@ -354,6 +378,13 @@ function identifyIssues(result: DiagnosticResult): string[] { } else if (result.wallet.isLow) { issues.push("Wallet balance is low (< $1.00)"); } + if (result.solanaBatch?.overclaimed) { + issues.push( + "Solana batch settlement: the gateway claimed more than this wallet signed. Unset CLAWROUTER_SOLANA_BATCH and check the channel on a Solana explorer", + ); + } else if (result.solanaBatch?.message) { + issues.push(`Solana batch settlement: ${result.solanaBatch.message}`); + } return finishIssues(result, issues); } @@ -447,6 +478,19 @@ function printDiagnostics(result: DiagnosticResult): void { console.log(` ${red("No wallet found")}`); } + if (result.solanaBatch) { + console.log("\nSolana batch settlement"); + const status = `Status: ${result.solanaBatch.status}`; + console.log(` ${result.solanaBatch.status === "on" ? green(status) : yellow(status)}`); + console.log(` ${green(`Channel store: ${result.solanaBatch.store}`)}`); + if (result.solanaBatch.channels.length === 0) { + console.log(` ${green("Channels: none opened yet")}`); + } + for (const line of result.solanaBatch.channels) { + console.log(` ${result.solanaBatch.overclaimed ? red(line) : green(line)}`); + } + } + printRestOfDiagnostics(result); } @@ -516,7 +560,9 @@ export function createDoctorX402Client(opts: { const account = privateKeyToAccount(opts.walletKey as `0x${string}`); const publicClient = createPublicClient({ chain: base, transport: http() }); const evmSigner = toClientEvmSigner(account, publicClient); - const x402 = new x402Client(); + // ClawRouter's spend policy governs; keep the SDK's own spendControls off, + // exactly as startProxy does. + const x402 = new x402Client().setSpendControls(false); registerSpendPolicyHook(x402, opts.spendControl ?? getSharedSpendControl()); registerExactEvmScheme(x402, { signer: evmSigner }); return x402; @@ -715,12 +761,13 @@ export async function runDoctor( // Collect all diagnostics // The key is resolved first: it decides whether a wallet is even relevant. const apiKey = await collectApiKeyInfo(); - const [system, wallet, network, logs, latestVersion] = await Promise.all([ + const [system, wallet, network, logs, latestVersion, solanaBatch] = await Promise.all([ collectSystemInfo(), collectWalletInfo(apiKey.configured), collectNetworkInfo(), collectLogInfo(), fetchLatestVersion(), + collectSolanaBatchInfo(apiKey.configured), ]); const result: DiagnosticResult = { @@ -732,6 +779,7 @@ export async function runDoctor( wallet, network, logs, + ...(solanaBatch ? { solanaBatch } : {}), issues: [], }; diff --git a/src/payment-preauth.ts b/src/payment-preauth.ts index 9d46e4c5..5065ed09 100644 --- a/src/payment-preauth.ts +++ b/src/payment-preauth.ts @@ -26,6 +26,7 @@ import { x402HTTPClient } from "@x402/fetch"; import { resolveMaxTokens } from "./max-tokens.js"; import { SpendPolicyError } from "./spend-control.js"; +import { BATCH_SCHEME, withoutBatchAccepts } from "./solana-batch.js"; type PaymentRequired = Parameters["createPaymentPayload"]>[0]; @@ -223,14 +224,86 @@ export function createPayFetchWithPreAuth( ); } + const sendPaid = (payload: PaymentPayload): Promise => { + const paidRequest = clonedRequest.clone(); + for (const [key, value] of Object.entries(httpClient.encodePaymentSignatureHeader(payload))) { + paidRequest.headers.set(key, value); + } + return baseFetch(paidRequest); + }; + + // Signing fails before anything is sent. When the offer included a + // batch-settlement accept, sign the same offer without it (`exact`). + const sign = async (): Promise => { + try { + return await client.createPaymentPayload(paymentRequired); + } catch (err) { + const exactOnly = + err instanceof SpendPolicyError ? undefined : withoutBatchAccepts(paymentRequired); + if (!exactOnly) throw err; + warnBatchFallback(err); + return client.createPaymentPayload(exactOnly); + } + }; + // Sign payment and retry - const payload = await client.createPaymentPayload(paymentRequired); - const paymentHeaders = httpClient.encodePaymentSignatureHeader(payload); - for (const [key, value] of Object.entries(paymentHeaders)) { - clonedRequest.headers.set(key, value); + let payload = await sign(); + let paidResponse = await sendPaid(payload); + + if (payload.accepted?.scheme === BATCH_SCHEME) { + // Unlike `exact`, the batch scheme keeps channel state that depends on the + // gateway's answer: it commits the confirmed cumulative, or resyncs from a + // corrective 402 and asks for one retry (what @x402/fetch does too). + if (await processBatchResult(httpClient, payload, paidResponse)) { + payload = await sign(); + paidResponse = await sendPaid(payload); + if (payload.accepted?.scheme === BATCH_SCHEME) { + await processBatchResult(httpClient, payload, paidResponse); + } + } + // A 402 is an answer: the gateway took nothing for this voucher, so paying + // the same request with `exact` cannot charge twice. Transport errors are + // NOT retried (see #317 above): the voucher may have been accepted. + const exactOnly = + paidResponse.status === 402 && payload.accepted?.scheme === BATCH_SCHEME + ? withoutBatchAccepts(paymentRequired) + : undefined; + if (exactOnly) { + warnBatchFallback(new Error("the gateway answered 402 to the batch payment")); + payload = await client.createPaymentPayload(exactOnly); + paidResponse = await sendPaid(payload); + } } - const paidResponse = await baseFetch(clonedRequest); notifyAcceptedPayment(paidResponse, payload); return paidResponse; }; } + +type PaymentPayload = Awaited["createPaymentPayload"]>>; + +/** Feed the gateway's answer to the batch scheme. Returns whether it asks for a retry. */ +async function processBatchResult( + httpClient: x402HTTPClient, + payload: PaymentPayload, + response: Response, +): Promise { + try { + const result = await httpClient.processPaymentResult( + payload, + (name) => response.headers.get(name), + response.status, + ); + return result.recovered; + } catch (err) { + console.warn( + `[ClawRouter] Solana batch: could not process the payment response: ${err instanceof Error ? err.message : String(err)}`, + ); + return false; + } +} + +function warnBatchFallback(err: unknown): void { + console.warn( + `[ClawRouter] Solana batch payment failed (${err instanceof Error ? err.message : String(err)}); paying this request with exact`, + ); +} diff --git a/src/polymarket/spend-policy.test.ts b/src/polymarket/spend-policy.test.ts index ae9e4e8d..512032bc 100644 --- a/src/polymarket/spend-policy.test.ts +++ b/src/polymarket/spend-policy.test.ts @@ -411,7 +411,8 @@ describe("the singleton is the one ledger every surface reads and writes", () => expect((await executeTrade(limitBuy)).isError).toBeFalsy(); // $5 notional, allowed let signerCalls = 0; - const client = new x402Client(); + // As startProxy builds it: ClawRouter's policy, not the SDK's spendControls. + const client = new x402Client().setSpendControls(false); registerSpendPolicyHook(client, getSharedSpendControl()); client.register(CAIP2_BASE, { scheme: "exact", diff --git a/src/proxy.ts b/src/proxy.ts index 0dcb3047..c23173c8 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -119,7 +119,8 @@ import { import { checkForUpdates } from "./updater.js"; import { loadExcludeList } from "./exclude-models.js"; import { demoteUnreliable, recordOutcome } from "./outcome-memory.js"; -import { PROXY_PORT } from "./config.js"; +import { PROXY_PORT, solanaBatchConfigFromEnv } from "./config.js"; +import type { SolanaBatchGuard } from "./solana-batch.js"; import { SessionJournal } from "./journal.js"; import { readLocalImageAsDataUri, ssrfSafeFetch, untrustedRequestReason } from "./local-guard.js"; import { applyUpstreamProxy } from "./upstream-proxy.js"; @@ -2797,7 +2798,10 @@ export async function startProxy(options: ProxyOptions): Promise { // pay", and we pay no one — the gateway bills the account server-side). // `account` stays undefined and every wallet-shaped field reads off it. const account = walletKey ? privateKeyToAccount(walletKey as `0x${string}`) : undefined; - const x402 = authMode === "wallet" ? new x402Client() : undefined; + // Spend limits are ClawRouter's own (`registerSpendPolicyHook` below), so the + // SDK's built-in spendControls (since @x402/core 2.28: default assets only, + // $1 per payment) are turned off to keep payments exactly as they were. + const x402 = authMode === "wallet" ? new x402Client().setSpendControls(false) : undefined; if (x402 && account) { const evmPublicClient = createPublicClient({ chain: base, transport: http() }); const evmSigner = toClientEvmSigner(account, evmPublicClient); @@ -2811,6 +2815,7 @@ export async function startProxy(options: ProxyOptions): Promise { // - solana:* wildcard (catches any CAIP-2 Solana network) // - V1 compat names: "solana", "solana-devnet", "solana-testnet" let solanaAddress: string | undefined; + let solanaBatchGuard: SolanaBatchGuard | undefined; if (x402 && solanaPrivateKeyBytes) { const { registerExactSvmScheme } = await import("@x402/svm/exact/client"); const { createKeyPairSignerFromPrivateKeyBytes } = await import("@solana/kit"); @@ -2842,6 +2847,28 @@ export async function startProxy(options: ProxyOptions): Promise { if (solanaRpcUrl) { console.log(`[ClawRouter] Solana RPC (payment signing + balance): ${solanaRpcUrl}`); } + + // Opt-in batch settlement (CLAWROUTER_SOLANA_BATCH). Off, misconfigured, or + // failing to load, every Solana payment stays on `exact`. + const batchConfig = solanaBatchConfigFromEnv(); + if (batchConfig.status === "on") { + try { + const { registerSolanaBatchScheme, formatMicrosAsUsd } = await import("./solana-batch.js"); + solanaBatchGuard = await registerSolanaBatchScheme(x402, solanaSigner, batchConfig, { + rpcUrl: solanaRpcUrl, + }); + solanaBatchGuard.start(); + console.log( + `[ClawRouter] Solana batch settlement: on (deposit $${formatMicrosAsUsd(batchConfig.depositMicros)}, trusted operator(s): ${batchConfig.allowedOperators.join(", ")})`, + ); + } catch (err) { + console.warn( + `[ClawRouter] Solana batch settlement could not start, paying with exact: ${err instanceof Error ? err.message : String(err)}`, + ); + } + } else if (batchConfig.message) { + console.warn(`[ClawRouter] Solana batch settlement: ${batchConfig.message}`); + } } // Stamp BlockRun's builder-code service code (`s`) onto every signed EVM @@ -4425,6 +4452,7 @@ export async function startProxy(options: ProxyOptions): Promise { }, 4000); sessionStore.close(); + solanaBatchGuard?.stop(); // Destroy all active connections before closing server for (const socket of connections) { socket.destroy(); diff --git a/src/solana-batch.payment.test.ts b/src/solana-batch.payment.test.ts new file mode 100644 index 00000000..1c731105 --- /dev/null +++ b/src/solana-batch.payment.test.ts @@ -0,0 +1,181 @@ +/** + * Solana batch settlement at the payment layer: what registerSolanaBatchScheme + * hands the SDK, and how createPayFetchWithPreAuth falls back to `exact`. + * + * Both schemes are fakes registered on a real x402Client, so the client's own + * selection and policies run while nothing signs or touches the network. + */ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { x402Client } from "@x402/fetch"; +import { decodePaymentSignatureHeader } from "@x402/core/http"; +import { createPayFetchWithPreAuth } from "./payment-preauth.js"; +import { SolanaBatchGuard, parseSolanaBatchConfig } from "./solana-batch.js"; + +const batchCtor = vi.fn(); +vi.mock("@x402/svm/batch-settlement/client", () => ({ + BatchSvmScheme: class { + readonly scheme = "batch-settlement"; + readonly paymentPolicy = (_v: number, accepts: unknown[]) => accepts; + constructor(...args: unknown[]) { + batchCtor(...args); + } + }, +})); + +const OPERATOR = "5YKPQUFjw5WQqhSUkEGKNNfYYVqnRRNbpYyL71qQ1vm3"; +const NETWORK = "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp"; +const accept = (scheme: string) => ({ + scheme, + network: NETWORK, + amount: "1000", + asset: "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", + payTo: "9xQeWvG816bUx9EPjHmaT23yvVM2ZWbrrpZb9PusVFin", + maxTimeoutSeconds: 300, + extra: scheme === "batch-settlement" ? { voucherSigner: "server", operator: OPERATOR } : {}, +}); +const CHALLENGE = { + x402Version: 2, + accepts: [accept("batch-settlement"), accept("exact")], + resource: { url: "https://sol.gw/api", description: "t", mimeType: "application/json" }, +}; + +function challenge402(): Response { + return new Response("{}", { + status: 402, + headers: { "payment-required": Buffer.from(JSON.stringify(CHALLENGE)).toString("base64") }, + }); +} + +/** A gateway that answers 402 without payment and records the scheme of each paid call. */ +function gateway(onPaid: (scheme: string) => Response | Promise) { + const paid: string[] = []; + const fn = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + const req = new Request(input, init); + const sig = req.headers.get("payment-signature"); + if (!sig) return challenge402(); + const scheme = decodePaymentSignatureHeader(sig).accepted.scheme; + paid.push(scheme); + return onPaid(scheme); + }); + return { fn: fn as unknown as typeof fetch, paid }; +} + +function fakeScheme(scheme: string, fail?: () => Error) { + return { + scheme, + createPaymentPayload: vi.fn(async (x402Version: number) => { + if (fail) throw fail(); + return { x402Version, payload: { scheme } }; + }), + }; +} + +function client(batchFails?: () => Error) { + const x402 = new x402Client().setSpendControls(false); + x402.register("solana:*", fakeScheme("exact")); + x402.register("solana:*", fakeScheme("batch-settlement", batchFails)); + return x402; +} + +const ok = () => new Response("{}", { status: 200 }); +const URL = "https://sol.gw/api/v1/chat/completions"; +const init = () => ({ method: "POST", body: JSON.stringify({ model: "m", messages: [] }) }); + +beforeEach(() => vi.spyOn(console, "warn").mockImplementation(() => {})); +afterEach(() => vi.restoreAllMocks()); + +describe("registerSolanaBatchScheme", () => { + it("trusts only the configured operators, capped at one deposit", async () => { + const { registerSolanaBatchScheme } = await import("./solana-batch.js"); + const x402 = new x402Client(); + const register = vi.spyOn(x402, "register"); + const registerPolicy = vi.spyOn(x402, "registerPolicy"); + const config = parseSolanaBatchConfig({ + CLAWROUTER_SOLANA_BATCH: "1", + CLAWROUTER_SOLANA_BATCH_DEPOSIT_USDC: "2.5", + CLAWROUTER_SOLANA_BATCH_OPERATORS: OPERATOR, + }); + const signer = {} as never; + const guard = await registerSolanaBatchScheme(x402, signer, config, { + storeFile: "/nonexistent/channels.json", + fetchAccount: async () => null, + }); + + expect(guard).toBeInstanceOf(SolanaBatchGuard); + const [passedSigner, options] = batchCtor.mock.calls.at(-1)!; + expect(passedSigner).toBe(signer); + expect(options).toMatchObject({ + depositAmount: "2500000", + serverSignedChannelsPolicy: { allowedOperators: [OPERATOR], maxDeposit: "$2.500000" }, + }); + expect(options.channelStorage.file).toBe("/nonexistent/channels.json"); + expect(register).toHaveBeenCalledWith( + "solana:*", + expect.objectContaining({ scheme: "batch-settlement" }), + ); + expect(registerPolicy).toHaveBeenCalledWith(guard.policy); + }); + + it("refuses a config that is not on", async () => { + const { registerSolanaBatchScheme } = await import("./solana-batch.js"); + const config = parseSolanaBatchConfig({ CLAWROUTER_SOLANA_BATCH: "1" }); + await expect(registerSolanaBatchScheme(new x402Client(), {} as never, config)).rejects.toThrow( + /no-operator/, + ); + }); +}); + +describe("payment fallback to exact", () => { + it("pays with batch when it works, and feeds the answer to the scheme", async () => { + const x402 = client(); + const onResponse = vi.fn(async () => undefined); + x402.onPaymentResponse(onResponse); + const gw = gateway(ok); + const res = await createPayFetchWithPreAuth(gw.fn, x402, undefined, { skipPreAuth: true })( + URL, + init(), + ); + expect(res.status).toBe(200); + expect(gw.paid).toEqual(["batch-settlement"]); + expect(onResponse).toHaveBeenCalledOnce(); + }); + + it("pays with exact when batch signing fails", async () => { + const gw = gateway(ok); + const pay = createPayFetchWithPreAuth( + gw.fn, + client(() => new Error("rpc down")), + undefined, + { skipPreAuth: true }, + ); + expect((await pay(URL, init())).status).toBe(200); + expect(gw.paid).toEqual(["exact"]); + }); + + it("pays with exact when the gateway answers 402 to the batch payment", async () => { + const gw = gateway((scheme) => (scheme === "exact" ? ok() : challenge402())); + const pay = createPayFetchWithPreAuth(gw.fn, client(), undefined, { skipPreAuth: true }); + expect((await pay(URL, init())).status).toBe(200); + expect(gw.paid).toEqual(["batch-settlement", "exact"]); + }); + + it("does not pay again when the batch send itself fails (the voucher may have landed)", async () => { + const gw = gateway(() => { + throw new TypeError("fetch failed: socket hang up"); + }); + const pay = createPayFetchWithPreAuth(gw.fn, client(), undefined, { skipPreAuth: true }); + await expect(pay(URL, init())).rejects.toThrow(/socket hang up/); + expect(gw.paid).toEqual(["batch-settlement"]); + }); + + it("pays with exact once the guard has disabled batch", async () => { + const x402 = client(); + const guard = new SolanaBatchGuard({ entries: async () => [] }, async () => null); + x402.registerPolicy(guard.policy); + vi.spyOn(console, "error").mockImplementation(() => {}); + guard.disable("test"); + const gw = gateway(ok); + await createPayFetchWithPreAuth(gw.fn, x402, undefined, { skipPreAuth: true })(URL, init()); + expect(gw.paid).toEqual(["exact"]); + }); +}); diff --git a/src/solana-batch.test.ts b/src/solana-batch.test.ts new file mode 100644 index 00000000..9681a7c0 --- /dev/null +++ b/src/solana-batch.test.ts @@ -0,0 +1,360 @@ +/** + * Solana batch settlement: config parsing, the durable channel store, the + * channel account decode and the claim comparison. No network: the account + * fetcher is injected everywhere. + */ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { mkdtempSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { + BATCH_CHANNEL_ACCOUNT_SIZE, + BATCH_CHANNEL_PROGRAM, + DEFAULT_BATCH_DEPOSIT_MICROS, + FileChannelStorage, + SolanaBatchGuard, + assessClaim, + checkChannelClaims, + decodeChannelAccount, + formatMicrosAsUsd, + inspectSolanaBatch, + parseSolanaBatchConfig, + parseUsdcToMicros, + signedCumulative, + withoutBatchAccepts, + type BatchClientChannelRecord, + type FetchedAccount, +} from "./solana-batch.js"; + +const OPERATOR = "5YKPQUFjw5WQqhSUkEGKNNfYYVqnRRNbpYyL71qQ1vm3"; +const CHANNEL = "9xQeWvG816bUx9EPjHmaT23yvVM2ZWbrrpZb9PusVFin"; + +function record(charged: string, pending?: string | string[]): BatchClientChannelRecord { + const rec = { + channelConfig: {}, + channelId: CHANNEL, + chargedCumulativeAmount: charged, + deposit: "1000000", + } as unknown as BatchClientChannelRecord; + if (pending !== undefined) { + const item = (amount: string, i: number) => ({ + amount: "1", + chargedCumulativeAmount: amount, + deposit: "1000000", + operationKey: `op${i}`, + payment: {}, + }); + (rec as { pending?: unknown }).pending = Array.isArray(pending) + ? pending.map(item) + : item(pending, 0); + } + return rec; +} + +/** A 256-byte channel account with the given deposit and settled watermark. */ +function channelAccount(deposit: bigint, settled: bigint): Uint8Array { + const data = new Uint8Array(BATCH_CHANNEL_ACCOUNT_SIZE); + const view = new DataView(data.buffer); + view.setUint8(0, 7); // discriminator + view.setUint8(1, 1); // version + view.setUint8(2, 254); // bump + view.setUint8(3, 1); // status + view.setBigUint64(4, 42n, true); // salt + view.setBigUint64(12, deposit, true); + view.setBigUint64(20, settled, true); + view.setBigUint64(28, 5n, true); // payout watermark + return data; +} + +const owned = (deposit: bigint, settled: bigint): FetchedAccount => ({ + owner: BATCH_CHANNEL_PROGRAM, + data: channelAccount(deposit, settled), +}); + +describe("parseSolanaBatchConfig", () => { + it("is off when nothing is set", () => { + expect(parseSolanaBatchConfig({})).toEqual({ + status: "off", + depositMicros: DEFAULT_BATCH_DEPOSIT_MICROS, + allowedOperators: [], + }); + }); + + it.each(["0", "false", "off", "", "maybe"])("stays off for CLAWROUTER_SOLANA_BATCH=%j", (v) => { + expect(parseSolanaBatchConfig({ CLAWROUTER_SOLANA_BATCH: v }).status).toBe("off"); + }); + + it("fails closed with no trusted operator", () => { + const config = parseSolanaBatchConfig({ CLAWROUTER_SOLANA_BATCH: "1" }); + expect(config.status).toBe("no-operator"); + expect(config.allowedOperators).toEqual([]); + expect(config.message).toMatch(/OPERATORS is empty/); + }); + + it.each(["1", "true", "ON", " yes "])("turns on for %j with an operator", (v) => { + const config = parseSolanaBatchConfig({ + CLAWROUTER_SOLANA_BATCH: v, + CLAWROUTER_SOLANA_BATCH_OPERATORS: OPERATOR, + }); + expect(config).toEqual({ + status: "on", + depositMicros: 1_000_000n, + allowedOperators: [OPERATOR], + }); + }); + + it("takes a custom deposit and a deduplicated operator list", () => { + const config = parseSolanaBatchConfig({ + CLAWROUTER_SOLANA_BATCH: "1", + CLAWROUTER_SOLANA_BATCH_DEPOSIT_USDC: "0.25", + CLAWROUTER_SOLANA_BATCH_OPERATORS: ` ${OPERATOR}, ${BATCH_CHANNEL_PROGRAM} ,${OPERATOR},`, + }); + expect(config.status).toBe("on"); + expect(config.depositMicros).toBe(250_000n); + expect(config.allowedOperators).toEqual([OPERATOR, BATCH_CHANNEL_PROGRAM]); + }); + + it.each(["0", "-1", "1.0000001", "abc", "1e3", "0x10"])( + "rejects deposit %j and stays on exact", + (deposit) => { + const config = parseSolanaBatchConfig({ + CLAWROUTER_SOLANA_BATCH: "1", + CLAWROUTER_SOLANA_BATCH_DEPOSIT_USDC: deposit, + CLAWROUTER_SOLANA_BATCH_OPERATORS: OPERATOR, + }); + expect(config.status).toBe("invalid"); + expect(config.allowedOperators).toEqual([]); + expect(config.message).toMatch(/DEPOSIT_USDC/); + }, + ); + + it("rejects an operator that is not a Solana address", () => { + const config = parseSolanaBatchConfig({ + CLAWROUTER_SOLANA_BATCH: "1", + CLAWROUTER_SOLANA_BATCH_OPERATORS: `${OPERATOR},0xdeadbeef`, + }); + expect(config.status).toBe("invalid"); + expect(config.allowedOperators).toEqual([]); + expect(config.message).toContain("0xdeadbeef"); + }); +}); + +describe("USDC amounts", () => { + it("parses decimal USDC to micros", () => { + expect(parseUsdcToMicros("1")).toBe(1_000_000n); + expect(parseUsdcToMicros("0.01")).toBe(10_000n); + expect(parseUsdcToMicros("2.000001")).toBe(2_000_001n); + expect(parseUsdcToMicros("0.000000")).toBeUndefined(); + }); + + it("formats micros as a dollar amount", () => { + expect(formatMicrosAsUsd(1_000_000n)).toBe("1.000000"); + expect(formatMicrosAsUsd(250_000n)).toBe("0.250000"); + }); +}); + +describe("FileChannelStorage", () => { + let dir: string; + let file: string; + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), "clawrouter-batch-")); + file = join(dir, "nested", "channels.json"); + }); + afterEach(() => rmSync(dir, { recursive: true, force: true })); + + it("reads nothing before the first write", async () => { + const storage = new FileChannelStorage(file); + expect(await storage.get("k")).toBeUndefined(); + expect(await storage.entries()).toEqual([]); + }); + + it("survives a restart: a new instance reads what the last one wrote", async () => { + await new FileChannelStorage(file).set("k", record("1500")); + const restarted = new FileChannelStorage(file); + expect((await restarted.get("k"))?.chargedCumulativeAmount).toBe("1500"); + }); + + it("writes atomically, privately, and leaves no temp file", async () => { + const storage = new FileChannelStorage(file); + await storage.set("k", record("1")); + expect(readdirSync(join(dir, "nested"))).toEqual(["channels.json"]); + if (process.platform !== "win32") { + expect(statSync(file).mode & 0o777).toBe(0o600); + } + }); + + it("deletes a record", async () => { + const storage = new FileChannelStorage(file); + await storage.set("a", record("1")); + await storage.set("b", record("2")); + await storage.delete("a"); + expect((await storage.entries()).map(([k]) => k)).toEqual(["b"]); + }); + + it("keeps both of two concurrent writes", async () => { + const storage = new FileChannelStorage(file); + await Promise.all([storage.set("a", record("1")), storage.set("b", record("2"))]); + expect(Object.keys(JSON.parse(readFileSync(file, "utf8"))).sort()).toEqual(["a", "b"]); + }); + + it("refuses a torn file instead of forgetting the signed cumulative", async () => { + const storage = new FileChannelStorage(file); + await storage.set("k", record("1")); + writeFileSync(file, '{"k":{"channelId":"'); + await expect(storage.get("k")).rejects.toThrow(/unreadable/); + await expect(storage.set("k", record("2"))).rejects.toThrow(/unreadable/); + expect(readFileSync(file, "utf8")).toBe('{"k":{"channelId":"'); // not overwritten + }); +}); + +describe("decodeChannelAccount", () => { + it("reads the u8 header and the u64 LE fields at their offsets", () => { + expect(decodeChannelAccount(channelAccount(1_000_000n, 3_000n))).toEqual({ + discriminator: 7, + version: 1, + bump: 254, + status: 1, + salt: 42n, + deposit: 1_000_000n, + settled: 3_000n, + payoutWatermark: 5n, + }); + }); + + it("decodes from a view into a larger buffer", () => { + const big = new Uint8Array(BATCH_CHANNEL_ACCOUNT_SIZE + 8); + big.set(channelAccount(9n, 4n), 8); + expect(decodeChannelAccount(big.subarray(8)).settled).toBe(4n); + }); + + it("rejects an account of the wrong size", () => { + expect(() => decodeChannelAccount(new Uint8Array(255))).toThrow(/255 bytes/); + }); +}); + +describe("claim comparison", () => { + it("counts the highest of confirmed and pending as signed", () => { + expect(signedCumulative(record("100"))).toBe(100n); + expect(signedCumulative(record("100", "250"))).toBe(250n); + expect(signedCumulative(record("300", ["120", "290"]))).toBe(300n); + }); + + it("is ok while claimed is at or below signed", () => { + expect(assessClaim(CHANNEL, owned(1_000_000n, 0n), 500n).status).toBe("ok"); + const equal = assessClaim(CHANNEL, owned(1_000_000n, 500n), 500n); + expect(equal).toMatchObject({ status: "ok", claimed: 500n, signed: 500n, deposit: 1_000_000n }); + }); + + it("flags a claim above what was signed", () => { + expect(assessClaim(CHANNEL, owned(1_000_000n, 501n), 500n).status).toBe("overclaimed"); + }); + + it("reports a missing account and a foreign owner without alarming", () => { + expect(assessClaim(CHANNEL, null, 1n).status).toBe("closed"); + const foreign = assessClaim(CHANNEL, { owner: OPERATOR, data: channelAccount(1n, 99n) }, 1n); + expect(foreign.status).toBe("unexpected-owner"); + }); + + it("turns a fetch failure into a finding and skips records without a channel id", async () => { + const findings = await checkChannelClaims( + [ + ["a", record("1")], + ["b", { ...record("1"), channelId: "" }], + ], + async () => { + throw new Error("rpc down"); + }, + ); + expect(findings).toEqual([{ channelId: CHANNEL, status: "error", detail: "rpc down" }]); + }); +}); + +describe("SolanaBatchGuard", () => { + const accepts = [ + { scheme: "batch-settlement", network: "solana:x" }, + { scheme: "exact", network: "solana:x" }, + ]; + const storageOf = (records: Array<[string, BatchClientChannelRecord]>) => ({ + entries: async () => records, + }); + + beforeEach(() => { + vi.spyOn(console, "error").mockImplementation(() => {}); + vi.spyOn(console, "warn").mockImplementation(() => {}); + }); + afterEach(() => vi.restoreAllMocks()); + + it("keeps batch while every claim is covered", async () => { + const guard = new SolanaBatchGuard(storageOf([["k", record("500")]]), async () => + owned(1_000_000n, 400n), + ); + await guard.check(); + expect(guard.active).toBe(true); + expect(guard.policy(2, accepts)).toEqual(accepts); + // batch first even when the gateway lists exact first + expect(guard.policy(2, [...accepts].reverse())).toEqual(accepts); + }); + + it("disables batch for the process on an overclaim, loudly", async () => { + const guard = new SolanaBatchGuard(storageOf([["k", record("500")]]), async () => + owned(1_000_000n, 1_000_000n), + ); + await guard.check(); + expect(guard.active).toBe(false); + expect(guard.disabledReason).toMatch(/claimed more than this wallet signed/); + expect(guard.policy(2, accepts)).toEqual([accepts[1]]); + expect(console.error).toHaveBeenCalledWith(expect.stringMatching(/DISABLED/)); + }); + + it("disables batch when the channel store cannot be read", async () => { + const guard = new SolanaBatchGuard( + { + entries: async () => { + throw new Error("store unreadable"); + }, + }, + async () => null, + ); + await guard.check(); + expect(guard.active).toBe(false); + }); +}); + +describe("withoutBatchAccepts", () => { + it("drops batch accepts and keeps the rest", () => { + const offer = { + x402Version: 2, + accepts: [{ scheme: "batch-settlement" }, { scheme: "exact" }], + }; + expect(withoutBatchAccepts(offer)).toEqual({ x402Version: 2, accepts: [{ scheme: "exact" }] }); + }); + + it("is undefined when there is no batch accept, or nothing else to pay with", () => { + expect(withoutBatchAccepts({ accepts: [{ scheme: "exact" }] })).toBeUndefined(); + expect(withoutBatchAccepts({ accepts: [{ scheme: "batch-settlement" }] })).toBeUndefined(); + }); +}); + +describe("inspectSolanaBatch (doctor)", () => { + it("reports each stored channel and an overclaim", async () => { + const dir = mkdtempSync(join(tmpdir(), "clawrouter-batch-")); + try { + const storeFile = join(dir, "channels.json"); + await new FileChannelStorage(storeFile).set("k", record("500")); + const config = parseSolanaBatchConfig({ + CLAWROUTER_SOLANA_BATCH: "1", + CLAWROUTER_SOLANA_BATCH_OPERATORS: OPERATOR, + }); + const report = await inspectSolanaBatch(config, { + storeFile, + fetchAccount: async () => owned(1_000_000n, 600n), + }); + expect(report.status).toBe("on"); + expect(report.overclaimed).toBe(true); + expect(report.channels[0]).toContain("ABOVE"); + expect(JSON.stringify(report)).toBeTruthy(); // doctor serializes it + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); +}); diff --git a/src/solana-batch.ts b/src/solana-batch.ts new file mode 100644 index 00000000..2f28754f --- /dev/null +++ b/src/solana-batch.ts @@ -0,0 +1,533 @@ +/** + * Solana x402 batch settlement (opt-in). + * + * sol.blockrun.ai offers two schemes on Solana mainnet: `exact` (one on-chain + * USDC transfer per call) and `batch-settlement` (one deposit opens a payment + * channel, then each call is paid with a signed voucher and the gateway claims + * vouchers in batches). This module wires the official SDK's `BatchSvmScheme` + * next to the `exact` scheme, off by default. + * + * Safety model. BlockRun's channels are server signed: the channel's on-chain + * voucher signer is the gateway's operator key, so the operator can claim up to + * the whole deposit without another signature from this wallet. The SDK refuses + * that mode unless the operator is trusted explicitly, so: + * + * - nothing happens unless CLAWROUTER_SOLANA_BATCH is on; + * - the trusted operator list is empty by default, and an empty list keeps + * every call on `exact` (fail closed); + * - the escrow cap (`maxDeposit`) equals the configured deposit, so a trusted + * operator can never hold more than one deposit; + * - every confirmed cumulative this wallet signed is persisted (atomic JSON + * writes), and a claim check compares the channel account's on-chain + * `settled` watermark with it. Claimed above signed turns batch off for + * the process and says so loudly; + * - any batch failure pays that request with `exact` instead (see + * payment-preauth.ts). + * + * No process.env access here (config.ts reads the environment) and no static + * @solana/kit or @x402/svm import, so loading this module on Base costs nothing. + */ + +import fs from "node:fs"; +import path from "node:path"; +import { homedir } from "node:os"; +import bs58 from "bs58"; +import type { x402Client } from "@x402/fetch"; +import type { BatchClientSigner, BatchSvmClientConfig } from "@x402/svm/batch-settlement/client"; + +// The SDK exports the config but not the storage types it names. +type BatchClientChannelStorage = NonNullable; +export type BatchClientChannelRecord = NonNullable< + Awaited> +>; + +export const BATCH_SCHEME = "batch-settlement"; + +/** Owner program of every batch-settlement channel account. */ +export const BATCH_CHANNEL_PROGRAM = "CHNLxYvVA28MJP9PrFuDXccuoGXAx7jBacfLEkahyGsX"; + +/** Size of a channel account, in bytes. */ +export const BATCH_CHANNEL_ACCOUNT_SIZE = 256; + +/** Default channel deposit: 1 USDC (6 decimals). */ +export const DEFAULT_BATCH_DEPOSIT_MICROS = 1_000_000n; + +/** How often a running proxy re-checks what the gateway has claimed. */ +export const CLAIM_CHECK_INTERVAL_MS = 10 * 60_000; + +export const DEFAULT_CHANNEL_STORE = path.join( + homedir(), + ".openclaw", + "blockrun", + "solana-batch-channels.json", +); + +const SOLANA_DEFAULT_RPC = "https://api.mainnet-beta.solana.com"; + +// ─── Config ──────────────────────────────────────────────────────────────── + +export type SolanaBatchConfig = { + /** + * - `off`: not requested (the default) + * - `on`: requested, valid, and at least one trusted operator + * - `no-operator`: requested with no trusted operator; stays on `exact` + * - `invalid`: requested with a value that does not parse; stays on `exact` + */ + status: "off" | "on" | "no-operator" | "invalid"; + depositMicros: bigint; + allowedOperators: string[]; + /** Why the status is not `on` or `off`, for the startup log and doctor. */ + message?: string; +}; + +export type SolanaBatchEnv = { + CLAWROUTER_SOLANA_BATCH?: string; + CLAWROUTER_SOLANA_BATCH_DEPOSIT_USDC?: string; + CLAWROUTER_SOLANA_BATCH_OPERATORS?: string; +}; + +const TRUTHY = new Set(["1", "true", "on", "yes"]); + +/** "1" → 1_000_000n, "0.25" → 250_000n. Undefined for anything else. */ +export function parseUsdcToMicros(value: string): bigint | undefined { + const match = /^(\d+)(?:\.(\d{1,6}))?$/.exec(value.trim()); + if (!match) return undefined; + const micros = BigInt(match[1]) * 1_000_000n + BigInt((match[2] ?? "").padEnd(6, "0")); + return micros > 0n ? micros : undefined; +} + +export function formatMicrosAsUsd(micros: bigint): string { + const whole = micros / 1_000_000n; + const frac = (micros % 1_000_000n).toString().padStart(6, "0"); + return `${whole}.${frac}`; +} + +/** A Solana address is base58 for exactly 32 bytes. */ +export function isSolanaAddress(value: string): boolean { + try { + return bs58.decode(value).length === 32; + } catch { + return false; + } +} + +export function parseSolanaBatchConfig(env: SolanaBatchEnv): SolanaBatchConfig { + const requested = TRUTHY.has((env.CLAWROUTER_SOLANA_BATCH ?? "").trim().toLowerCase()); + const off: SolanaBatchConfig = { + status: "off", + depositMicros: DEFAULT_BATCH_DEPOSIT_MICROS, + allowedOperators: [], + }; + if (!requested) return off; + + const rawDeposit = env.CLAWROUTER_SOLANA_BATCH_DEPOSIT_USDC?.trim(); + const depositMicros = rawDeposit ? parseUsdcToMicros(rawDeposit) : DEFAULT_BATCH_DEPOSIT_MICROS; + if (depositMicros === undefined) { + return { + ...off, + status: "invalid", + message: `CLAWROUTER_SOLANA_BATCH_DEPOSIT_USDC="${rawDeposit}" is not a positive USDC amount (up to 6 decimals)`, + }; + } + + const operators = [ + ...new Set( + (env.CLAWROUTER_SOLANA_BATCH_OPERATORS ?? "") + .split(",") + .map((s) => s.trim()) + .filter(Boolean), + ), + ]; + const bad = operators.filter((op) => !isSolanaAddress(op)); + if (bad.length > 0) { + return { + ...off, + depositMicros, + status: "invalid", + message: `CLAWROUTER_SOLANA_BATCH_OPERATORS has invalid Solana address(es): ${bad.join(", ")}`, + }; + } + if (operators.length === 0) { + return { + ...off, + depositMicros, + status: "no-operator", + message: + "CLAWROUTER_SOLANA_BATCH is on but CLAWROUTER_SOLANA_BATCH_OPERATORS is empty; paying with exact", + }; + } + return { status: "on", depositMicros, allowedOperators: operators }; +} + +// ─── Durable channel storage ─────────────────────────────────────────────── + +/** + * The SDK's channel storage, kept in one JSON file. Every write goes to a temp + * file first and is renamed into place, so a crash never leaves torn JSON, and + * writes are serialized so two concurrent payments cannot drop each other's + * update. A file that exists but does not parse is NOT treated as empty: that + * would forget the signed cumulative, so reads throw and the payment falls back + * to `exact` until an operator looks at the file. + */ +export class FileChannelStorage implements BatchClientChannelStorage { + private queue: Promise = Promise.resolve(); + + constructor(readonly file: string = DEFAULT_CHANNEL_STORE) {} + + private read(): Record { + let text: string; + try { + text = fs.readFileSync(this.file, "utf8"); + } catch (err) { + if ((err as NodeJS.ErrnoException).code === "ENOENT") return {}; + throw err; + } + let parsed: unknown; + try { + parsed = JSON.parse(text); + } catch (err) { + throw new Error(`Solana batch channel store ${this.file} is unreadable: ${String(err)}`, { + cause: err, + }); + } + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + throw new Error(`Solana batch channel store ${this.file} is not a JSON object`); + } + return parsed as Record; + } + + private write(data: Record): void { + fs.mkdirSync(path.dirname(this.file), { recursive: true, mode: 0o700 }); + const tmp = `${this.file}.${process.pid}.tmp`; + try { + fs.writeFileSync(tmp, JSON.stringify(data, null, 2), { mode: 0o600 }); + fs.renameSync(tmp, this.file); + } finally { + if (fs.existsSync(tmp)) fs.unlinkSync(tmp); + } + } + + private serialize(op: () => T): Promise { + const next = this.queue.then(op); + this.queue = next.catch(() => undefined); + return next; + } + + get(key: string): Promise { + return this.serialize(() => this.read()[key]); + } + + set(key: string, record: BatchClientChannelRecord): Promise { + return this.serialize(() => { + const data = this.read(); + data[key] = record; + this.write(data); + }); + } + + delete(key: string): Promise { + return this.serialize(() => { + const data = this.read(); + if (!(key in data)) return; + delete data[key]; + this.write(data); + }); + } + + entries(): Promise> { + return this.serialize(() => Object.entries(this.read())); + } +} + +// ─── Claim check ─────────────────────────────────────────────────────────── + +export type ChannelAccount = { + discriminator: number; + version: number; + bump: number; + status: number; + salt: bigint; + deposit: bigint; + /** What the operator has claimed so far (atomic USDC). */ + settled: bigint; + payoutWatermark: bigint; +}; + +/** Decode a channel account: u8 disc, version, bump, status, then u64 LE salt, deposit, settled, payout watermark. */ +export function decodeChannelAccount(data: Uint8Array): ChannelAccount { + if (data.length !== BATCH_CHANNEL_ACCOUNT_SIZE) { + throw new Error( + `channel account is ${data.length} bytes, expected ${BATCH_CHANNEL_ACCOUNT_SIZE}`, + ); + } + const view = new DataView(data.buffer, data.byteOffset, data.byteLength); + return { + discriminator: view.getUint8(0), + version: view.getUint8(1), + bump: view.getUint8(2), + status: view.getUint8(3), + salt: view.getBigUint64(4, true), + deposit: view.getBigUint64(12, true), + settled: view.getBigUint64(20, true), + payoutWatermark: view.getBigUint64(28, true), + }; +} + +/** + * The highest cumulative this wallet has signed for a channel: the confirmed + * amount, or a pending allocation not yet reconciled, whichever is higher. + * Counting pending keeps an in-flight voucher from reading as an overclaim. + */ +export function signedCumulative(record: BatchClientChannelRecord): bigint { + const pending = record.pending === undefined ? [] : [record.pending].flat(); + let max = BigInt(record.chargedCumulativeAmount); + for (const p of pending) { + const amount = BigInt(p.chargedCumulativeAmount); + if (amount > max) max = amount; + } + return max; +} + +export type ClaimFinding = { + channelId: string; + /** ok: claimed ≤ signed. overclaimed: claimed > signed. */ + status: "ok" | "overclaimed" | "closed" | "unexpected-owner" | "error"; + signed?: bigint; + claimed?: bigint; + deposit?: bigint; + detail?: string; +}; + +export type FetchedAccount = { owner: string; data: Uint8Array } | null; +export type AccountFetcher = (address: string) => Promise; + +/** Compare one channel's on-chain claim with what was signed locally. Pure. */ +export function assessClaim( + channelId: string, + account: FetchedAccount, + signed: bigint, +): ClaimFinding { + if (!account) return { channelId, status: "closed", signed }; + if (account.owner !== BATCH_CHANNEL_PROGRAM) { + return { channelId, status: "unexpected-owner", signed, detail: `owner ${account.owner}` }; + } + const decoded = decodeChannelAccount(account.data); + return { + channelId, + status: decoded.settled > signed ? "overclaimed" : "ok", + signed, + claimed: decoded.settled, + deposit: decoded.deposit, + }; +} + +/** Check every stored channel. Never throws: a failure is a finding. */ +export async function checkChannelClaims( + records: Array<[string, BatchClientChannelRecord]>, + fetchAccount: AccountFetcher, +): Promise { + const findings: ClaimFinding[] = []; + for (const [, record] of records) { + const channelId = String(record?.channelId ?? ""); + if (!channelId) continue; + try { + findings.push( + assessClaim(channelId, await fetchAccount(channelId), signedCumulative(record)), + ); + } catch (err) { + findings.push({ + channelId, + status: "error", + detail: err instanceof Error ? err.message : String(err), + }); + } + } + return findings; +} + +/** Account fetcher over Solana JSON-RPC (lazy @solana/kit import). */ +export function createRpcAccountFetcher(rpcUrl: string = SOLANA_DEFAULT_RPC): AccountFetcher { + return async (channelId) => { + const { address, createSolanaRpc } = await import("@solana/kit"); + const rpc = createSolanaRpc(rpcUrl); + const { value } = await rpc + .getAccountInfo(address(channelId), { encoding: "base64" }) + .send({ abortSignal: AbortSignal.timeout(10_000) }); + if (!value) return null; + return { owner: value.owner, data: Buffer.from(value.data[0], "base64") }; + }; +} + +export function describeFinding(f: ClaimFinding): string { + const usd = (v?: bigint) => (v === undefined ? "?" : `$${formatMicrosAsUsd(v)}`); + switch (f.status) { + case "ok": + return `${f.channelId}: claimed ${usd(f.claimed)} of ${usd(f.signed)} signed (deposit ${usd(f.deposit)})`; + case "overclaimed": + return `${f.channelId}: CLAIMED ${usd(f.claimed)} ABOVE ${usd(f.signed)} SIGNED (deposit ${usd(f.deposit)})`; + case "closed": + return `${f.channelId}: no account on chain (closed or not opened)`; + case "unexpected-owner": + return `${f.channelId}: not a batch channel account (${f.detail})`; + case "error": + return `${f.channelId}: check failed (${f.detail})`; + } +} + +// ─── Runtime guard ───────────────────────────────────────────────────────── + +/** + * Process-wide switch for batch settlement. While active, its `policy` moves + * batch-settlement accepts ahead of the others (the SDK's own policy only + * reorders among batch accepts, and the default selector takes the first, so + * an offer listing `exact` first would otherwise never use batch). Once a + * claim check has found an overclaim it drops every batch-settlement accept, + * so the rest of the process pays with `exact`. + */ +export class SolanaBatchGuard { + disabledReason: string | undefined; + lastFindings: ClaimFinding[] = []; + private timer: ReturnType | undefined; + + constructor( + private readonly storage: Pick, + private readonly fetchAccount: AccountFetcher, + ) {} + + get active(): boolean { + return this.disabledReason === undefined; + } + + readonly policy = (_version: number, accepts: T[]): T[] => { + const batch = accepts.filter((a) => a.scheme === BATCH_SCHEME); + const rest = accepts.filter((a) => a.scheme !== BATCH_SCHEME); + return this.active ? [...batch, ...rest] : rest; + }; + + disable(reason: string): void { + if (this.disabledReason !== undefined) return; + this.disabledReason = reason; + console.error( + `[ClawRouter] ⚠️ SOLANA BATCH SETTLEMENT DISABLED: ${reason}. ` + + `Every Solana payment now uses exact. Check the channel on a Solana explorer before re-enabling.`, + ); + } + + async check(): Promise { + let records: Array<[string, BatchClientChannelRecord]>; + try { + records = await this.storage.entries(); + } catch (err) { + // Without the signed cumulative there is nothing to compare a claim to. + this.disable(err instanceof Error ? err.message : String(err)); + return this.lastFindings; + } + this.lastFindings = await checkChannelClaims(records, this.fetchAccount); + const over = this.lastFindings.filter((f) => f.status === "overclaimed"); + if (over.length > 0) { + this.disable( + `operator claimed more than this wallet signed: ${over.map(describeFinding).join("; ")}`, + ); + } + for (const f of this.lastFindings) { + if (f.status === "error" || f.status === "unexpected-owner") { + console.warn(`[ClawRouter] Solana batch claim check: ${describeFinding(f)}`); + } + } + return this.lastFindings; + } + + start(intervalMs: number = CLAIM_CHECK_INTERVAL_MS): void { + void this.check(); + this.timer = setInterval(() => void this.check(), intervalMs); + this.timer.unref?.(); + } + + stop(): void { + if (this.timer) clearInterval(this.timer); + this.timer = undefined; + } +} + +/** + * Register `BatchSvmScheme` for `solana:*` next to the already-registered + * `exact` scheme. Call only when `config.status === "on"`. + */ +export async function registerSolanaBatchScheme( + x402: x402Client, + signer: BatchClientSigner, + config: SolanaBatchConfig, + opts: { rpcUrl?: string; storeFile?: string; fetchAccount?: AccountFetcher } = {}, +): Promise { + if (config.status !== "on") { + throw new Error(`Solana batch settlement is not enabled (status: ${config.status})`); + } + const { BatchSvmScheme } = await import("@x402/svm/batch-settlement/client"); + const storage = new FileChannelStorage(opts.storeFile); + const batch = new BatchSvmScheme(signer, { + depositAmount: config.depositMicros.toString(), + channelStorage: storage, + ...(opts.rpcUrl ? { rpcUrl: opts.rpcUrl } : {}), + serverSignedChannelsPolicy: { + allowedOperators: config.allowedOperators, + // The operator can claim the whole escrow, so the escrow is one deposit. + maxDeposit: `$${formatMicrosAsUsd(config.depositMicros)}`, + }, + }); + const guard = new SolanaBatchGuard( + storage, + opts.fetchAccount ?? createRpcAccountFetcher(opts.rpcUrl), + ); + x402.register("solana:*", batch); + // The SDK's policy drops server-signed accepts from untrusted operators; the + // guard's then prefers what batch remains, or drops it once disabled. + x402.registerPolicy(batch.paymentPolicy); + x402.registerPolicy(guard.policy); + return guard; +} + +/** The same 402 offer without its batch-settlement accepts, or undefined if nothing is left. */ +export function withoutBatchAccepts }>( + paymentRequired: T, +): T | undefined { + const accepts = paymentRequired.accepts.filter((a) => a.scheme !== BATCH_SCHEME); + if (accepts.length === 0 || accepts.length === paymentRequired.accepts.length) return undefined; + return { ...paymentRequired, accepts }; +} + +export type SolanaBatchReport = { + status: SolanaBatchConfig["status"]; + message?: string; + store: string; + channels: string[]; + overclaimed: boolean; +}; + +/** Read-only summary for `doctor`: config state plus a claim check of every stored channel. */ +export async function inspectSolanaBatch( + config: SolanaBatchConfig, + opts: { rpcUrl?: string; storeFile?: string; fetchAccount?: AccountFetcher } = {}, +): Promise { + const storage = new FileChannelStorage(opts.storeFile); + const report: SolanaBatchReport = { + status: config.status, + ...(config.message ? { message: config.message } : {}), + store: storage.file, + channels: [], + overclaimed: false, + }; + let records: Array<[string, BatchClientChannelRecord]>; + try { + records = await storage.entries(); + } catch (err) { + report.channels.push(err instanceof Error ? err.message : String(err)); + return report; + } + const findings = await checkChannelClaims( + records, + opts.fetchAccount ?? createRpcAccountFetcher(opts.rpcUrl), + ); + report.channels = findings.map(describeFinding); + report.overclaimed = findings.some((f) => f.status === "overclaimed"); + return report; +} diff --git a/src/spend-control.test.ts b/src/spend-control.test.ts index aaef4c1e..fd35381b 100644 --- a/src/spend-control.test.ts +++ b/src/spend-control.test.ts @@ -606,7 +606,9 @@ describe("x402 onBeforePaymentCreation spend policy", () => { const control = new SpendControl({ storage }); control.setPolicy("blockedPayees", [blocked]); - const client = new x402Client(); + // As startProxy builds it: ClawRouter's policy, not the SDK's spendControls. + + const client = new x402Client().setSpendControls(false); registerSpendPolicyHook(client, control); client.register(CAIP2_BASE, { scheme: "exact", @@ -624,7 +626,7 @@ describe("x402 onBeforePaymentCreation spend policy", () => { let signerCalls = 0; const control = new SpendControl({ storage: new InMemorySpendControlStorage() }); control.setLimit("hourly", 0.015); - const client = new x402Client(); + const client = new x402Client().setSpendControls(false); registerSpendPolicyHook(client, control); client.register(CAIP2_BASE, { scheme: "exact", @@ -646,7 +648,7 @@ describe("x402 onBeforePaymentCreation spend policy", () => { let signerCalls = 0; const control = new SpendControl({ storage: new InMemorySpendControlStorage() }); control.setLimit("hourly", 0.015); - const client = new x402Client(); + const client = new x402Client().setSpendControls(false); registerSpendPolicyHook(client, control); client.register(CAIP2_BASE, { scheme: "exact", @@ -679,7 +681,7 @@ describe("x402 onBeforePaymentCreation spend policy", () => { let signerCalls = 0; const control = new SpendControl({ storage: new InMemorySpendControlStorage() }); control.setLimit("perRequest", 0.01); - const client = new x402Client(); + const client = new x402Client().setSpendControls(false); registerSpendPolicyHook(client, control); client.register(CAIP2_BASE, { scheme: "exact", @@ -700,7 +702,7 @@ describe("x402 onBeforePaymentCreation spend policy", () => { let signerCalls = 0; const control = new SpendControl({ storage: new InMemorySpendControlStorage() }); control.setLimit("perRequest", 0.02); - const client = new x402Client(); + const client = new x402Client().setSpendControls(false); registerSpendPolicyHook(client, control); client.register(CAIP2_BASE, { scheme: "exact", @@ -747,7 +749,7 @@ describe("x402 onBeforePaymentCreation spend policy", () => { it("releases the reservation when the signer fails, instead of draining the window", async () => { const control = new SpendControl({ storage: new InMemorySpendControlStorage() }); control.setLimit("hourly", 0.015); - const client = new x402Client(); + const client = new x402Client().setSpendControls(false); registerSpendPolicyHook(client, control); client.register(CAIP2_BASE, { scheme: "exact", @@ -769,7 +771,7 @@ describe("x402 onBeforePaymentCreation spend policy", () => { it("throws a typed SpendPolicyError so callers can tell refusal from an upstream fault", async () => { const control = new SpendControl({ storage: new InMemorySpendControlStorage() }); control.setPolicy("blockedPayees", [blocked]); - const client = new x402Client(); + const client = new x402Client().setSpendControls(false); registerSpendPolicyHook(client, control); client.register(CAIP2_BASE, { scheme: "exact",