Skip to content

v402 — Pay for Tool APIs with Spending Controls#171

Open
valeo-cash wants to merge 7 commits intoBankrBot:mainfrom
valeo-cash:feat/v402-payment-protocol
Open

v402 — Pay for Tool APIs with Spending Controls#171
valeo-cash wants to merge 7 commits intoBankrBot:mainfrom
valeo-cash:feat/v402-payment-protocol

Conversation

@valeo-cash
Copy link

@valeo-cash valeo-cash commented Feb 19, 2026

v402 is an HTTP-native payment protocol for AI agents on Solana. It uses the standard HTTP 402 Payment Required status code to enable per-call micropayments between agents and tool services.

The flow: Agent requests a tool API → server returns 402 with a V402-Intent header (amount, merchant, tool_id) → agent pays in USDC on Solana → agent retries with V402-Payment header containing the tx signature → server gateway verifies payment directly on-chain (no facilitator, no custodian) → returns 200 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:

  1. Parses the V402-Intent header (amount, merchant, tool_id)
  2. Checks your spending policy (daily cap, per-call cap, tool allowlist)
  3. Submits a Solana USDC transfer if policy passes
  4. Retries with V402-Payment header (tx_signature as proof)
  5. Gateway verifies payment on-chain — no facilitator
  6. Returns results + Ed25519 signed receipt

If policy rejects → payment is never submitted. The human stays in control.

Spending controls

  • Daily cap (e.g. $5/day)
  • Per-call cap (e.g. $1 max per tool call)
  • Tool allowlist (only web_search, get_token_price, etc.)
  • Merchant allowlist
  • All enforced client-side before any Solana transaction

Key properties

  • Non-custodial: USDC goes directly from agent wallet to merchant wallet on Solana
  • No facilitator: Gateway reads the blockchain to verify payment — no third party
  • Portable receipts: Ed25519 signed, verifiable by anyone without contacting the merchant
  • Tool-aware: Intents include tool_id so policies can restrict what the agent pays for

Slash commands

  • /v402 budget — remaining budget
  • /v402 history — payment audit trail
  • /v402 verify <receipt> — verify receipt on-chain
  • /v402 wallet — wallet address and balances

Links


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 v402 skill.

Overview
Adds a new v402 OpenClaw skill that can automatically handle HTTP 402 paywalls: parse V402-Intent, enforce local spending policy (daily/per-call caps + tool/merchant allowlists), submit a Solana USDC transfer, retry with V402-Payment, and capture the returned V402-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 .gitignore entries to avoid committing local script state and node_modules.

Written by Cursor Bugbot for commit 659bff5. This will update automatically on new commits. Configure here.

Copy link

@cursor cursor bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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";
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unused import of recordPayment in HTTP flow

Low Severity

The recordPayment function is imported from v402-policy.mjs but never called in v402-http.mjs. Payment recording happens inside submitPayment (in v402-pay.mjs line 83), making this import unnecessary.

Fix in Cursor Fix in Web

// Step 5 — submit payment
const payment = await submitPayment({
amount,
merchant: intent.merchant,
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Fix in Cursor Fix in Web

merchant: intent.merchant,
intentId: intent.id,
toolId: intent.tool_id,
});
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Fix in Cursor Fix in Web

body: args.body ? JSON.parse(args.body) : undefined,
headers: args.headers ? JSON.parse(args.headers) : {},
});
console.log(JSON.stringify(result, null, 2));
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Fix in Cursor Fix in Web

if (state.dayStart !== todayUTC()) {
state.dailySpent = 0;
state.dayStart = todayUTC();
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Fix in Cursor Fix in Web

merchant,
txSignature: sig,
intentId: intentId || "",
});
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Fix in Cursor Fix in Web

);

const tx = new Transaction().add(ix);
const sig = await sendAndConfirmTransaction(connection, tx, [wallet]);
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Fix in Cursor Fix in Web

merchantAta.address,
wallet.publicKey,
lamports,
);
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Fix in Cursor Fix in Web

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));
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Fix in Cursor Fix in Web

const txSuccess = txInfo.meta?.err === null;

const accountKeys = txInfo.transaction.message.staticAccountKeys?.map(k => k.toBase58?.()) ??
txInfo.transaction.message.accountKeys?.map(k => k.toBase58?.()) ?? [];
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Fix in Cursor Fix in Web

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant