v402 — Pay for Tool APIs with Spending Controls#171
v402 — Pay for Tool APIs with Spending Controls#171valeo-cash wants to merge 7 commits intoBankrBot:mainfrom
Conversation
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 14 potential issues.
Bugbot Autofix is OFF. To automatically fix reported issues with Cloud Agents, enable Autofix in the Cursor dashboard.
| #!/usr/bin/env node | ||
|
|
||
| import { fileURLToPath } from "url"; | ||
| import { checkPolicy, recordPayment, getBudget } from "./v402-policy.mjs"; |
| // Step 5 — submit payment | ||
| const payment = await submitPayment({ | ||
| amount, | ||
| merchant: intent.merchant, |
There was a problem hiding this comment.
Missing recipient field fallback breaks protocol spec
Medium Severity
The code assumes intents use the merchant field for the wallet address, but the protocol spec defines recipient as the canonical required field with merchant as an alias. If a server returns an intent with only recipient (no merchant), the code will pass undefined to checkPolicy and submitPayment, causing payments to fail or bypass merchant allowlist checks.
| merchant: intent.merchant, | ||
| intentId: intent.id, | ||
| toolId: intent.tool_id, | ||
| }); |
There was a problem hiding this comment.
Missing required field validation causes crash on payment
High Severity
No validation is performed on required intent fields (id, amount, merchant) before passing them to submitPayment. If a malicious or malformed server omits these fields, the code crashes: missing merchant causes new PublicKey(undefined) to throw at v402-pay.mjs line 51, and missing id causes undefined values in payment records and retry headers.
| body: args.body ? JSON.parse(args.body) : undefined, | ||
| headers: args.headers ? JSON.parse(args.headers) : {}, | ||
| }); | ||
| console.log(JSON.stringify(result, null, 2)); |
There was a problem hiding this comment.
Missing CLI argument validation causes silent failures
Medium Severity
The CLI accepts undefined url argument and passes it to fetch, which throws a cryptic TypeError. The user sees {"error":"Failed to parse URL from undefined"} instead of a clear "missing --url argument" message. Similarly, v402-pay.mjs and v402-verify.mjs accept undefined required arguments without validation.
| if (state.dayStart !== todayUTC()) { | ||
| state.dailySpent = 0; | ||
| state.dayStart = todayUTC(); | ||
| } |
There was a problem hiding this comment.
Daily reset loses payment history permanently
Medium Severity
When a new UTC day starts, loadState resets dailySpent to zero but doesn't persist this change to disk. The payments array from previous days remains in the returned state object but is never saved. If the state file isn't written to again (no new payments that day), the stale data persists. More critically, if a payment is recorded later that day, the old payments array is loaded again, then a new payment is appended and saved—but this creates confusion about which payments count toward the current day's spending.
| merchant, | ||
| txSignature: sig, | ||
| intentId: intentId || "", | ||
| }); |
There was a problem hiding this comment.
Payment recorded before server validates the transaction
High Severity
recordPayment is called immediately after the on-chain transaction confirms (line 79-83), before the server validates the payment and returns success. If the retry request fails (server returns non-200, payment intent expired, merchant rejects, etc.), the payment is already recorded in local state, counting against the daily budget. The user loses both the USDC and the budget allowance without receiving the service.
| ); | ||
|
|
||
| const tx = new Transaction().add(ix); | ||
| const sig = await sendAndConfirmTransaction(connection, tx, [wallet]); |
There was a problem hiding this comment.
Missing transaction memo breaks idempotency guarantee
High Severity
The transaction is built with only a transfer instruction and no memo. According to the protocol spec, idempotency is enforced via a unique reference field in the transaction memo to prevent double-spending. Without the memo, the same intent can be paid multiple times, or the gateway may reject valid payments that lack the required reference.
| merchantAta.address, | ||
| wallet.publicKey, | ||
| lamports, | ||
| ); |
There was a problem hiding this comment.
Merchant ATA creation cost not accounted in spending
Medium Severity
When paying a new merchant for the first time, getOrCreateAssociatedTokenAccount may create their token account, costing the payer approximately 0.00203928 SOL in rent. This SOL cost is not checked, warned about, or counted against any spending cap. The transaction will fail if the payer has insufficient SOL, but the error message only mentions USDC balance.
| const key = process.env.V402_WALLET_PRIVATE_KEY; | ||
| if (!key) throw new Error("V402_WALLET_PRIVATE_KEY is not set"); | ||
| return Keypair.fromSecretKey(bs58.decode(key)); | ||
| } |
There was a problem hiding this comment.
Invalid wallet private key crashes without helpful error
Medium Severity
loadWallet checks if the private key env var exists but doesn't validate its format. If the key is invalid base58 or wrong length, bs58.decode or Keypair.fromSecretKey throws a cryptic error. Users see generic exceptions instead of guidance that their V402_WALLET_PRIVATE_KEY is malformed.
| const txSuccess = txInfo.meta?.err === null; | ||
|
|
||
| const accountKeys = txInfo.transaction.message.staticAccountKeys?.map(k => k.toBase58?.()) ?? | ||
| txInfo.transaction.message.accountKeys?.map(k => k.toBase58?.()) ?? []; |
There was a problem hiding this comment.
Optional chaining on PublicKey method creates null values
Medium Severity
The code uses optional chaining on toBase58() method calls (k.toBase58?.()), which can result in null values in the accountKeys array if the method doesn't exist. These nulls will fail string comparison with accountKeys.includes(receipt.merchant), causing false negatives where valid merchant matches are reported as invalid.


v402 is an HTTP-native payment protocol for AI agents on Solana. It uses the standard
HTTP 402 Payment Requiredstatus code to enable per-call micropayments between agents and tool services.The flow: Agent requests a tool API → server returns
402with aV402-Intentheader (amount, merchant, tool_id) → agent pays in USDC on Solana → agent retries withV402-Paymentheader containing the tx signature → server gateway verifies payment directly on-chain (no facilitator, no custodian) → returns200 OK+ Ed25519 signed receipt.This skill teaches OpenClaw agents to handle that flow automatically, with built-in spending controls so agents can never overspend.
What the skill does
When your agent hits a paid API that returns
402 Payment Required, this skill:V402-Intentheader (amount, merchant, tool_id)V402-Paymentheader (tx_signature as proof)If policy rejects → payment is never submitted. The human stays in control.
Spending controls
web_search,get_token_price, etc.)Key properties
Slash commands
/v402 budget— remaining budget/v402 history— payment audit trail/v402 verify <receipt>— verify receipt on-chain/v402 wallet— wallet address and balancesLinks
Note
Medium Risk
Adds new code that can initiate on-chain USDC transfers based on HTTP responses and local policy, so mistakes could lead to unintended spending or failed payments; changes are mostly additive and isolated to the new
v402skill.Overview
Adds a new
v402OpenClaw skill that can automatically handleHTTP 402paywalls: parseV402-Intent, enforce local spending policy (daily/per-call caps + tool/merchant allowlists), submit a Solana USDC transfer, retry withV402-Payment, and capture the returnedV402-Receipt.Introduces Node-based helper CLIs/scripts for policy state tracking (
.v402-state.json), on-chain payment submission (Solana SPL USDC), and receipt/transaction verification via Solana RPC, plus installation/docs and.gitignoreentries to avoid committing local script state andnode_modules.Written by Cursor Bugbot for commit 659bff5. This will update automatically on new commits. Configure here.