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",