Approval-Bound Payments
Tie each payment to the exact terms a person approved, spend that approval once, and keep money from standing in for authority.
On this page
An agent that pays for things raises three questions that are easy to run together: may the agent invoke this tool, did a person agree to these terms, and did the money move. Each has a different witness. A capability grant answers the first, a signed approval the second, a payment proof the third. A system that lets one answer stand in for another can be paid into acting, or approved into paying someone it never named.
Chio keeps the three checks apart. A payment lane spends money only under an approval verified against the exact intent the approver signed, the approval pays once, and no payment proof ever produces the authorization to call a tool. The running example is a refund on order-77: USD 58.00 in USDC on Base, above the USD 50 line where your pricing lead must sign.
Authorization, approval, and payment are separate checks
Each check reads its own input and produces its own result. None of them reads another’s result as a substitute for its own input.
Three checks, three separate answers
The grant admits the refund call. The signed approval verifies into a VerifiedApproval bound to this request and its intent hash, and the EIP-3009 lane checks chain, payee, and amount against it before it issues the transfer authorization. The replay slot for this request and intent is now spent.
- Call
- dispatched under the grant
- Payment
- artifact issued, replay slot consumed
VerifiedApproval; budgets, holds, and guards are left out.The refund dispatches only when the grant admits it, and the transfer is issued only when the lane accepts the approval. A refused payment leaves an admitted call with nothing paid; a paid call with no grant is refused before dispatch. Settlement Rails covers how rail outcomes are observed and recorded on the receipt.
A verified approval binds one request, one intent, and its terms
Declare the rule and the settlement terms on the service. The terms go inside the governed intent, so the approver’s signature covers the chain, the payee, the amount, and the token along with the request:
import { Computer, USD } from "@chio-protocol/sdk";
const project = await Computer.open("quickstart");
const pricing = await project.service("pricing", { tools: { refund: "src/pricing.py:refund" } });
const grant = await pricing.grant({ to: "did:chio:z6MkbY4…e1R", tools: ["refund"], budget: USD(500), expiresIn: "24h" });
const rule = await pricing.require({ approval: "you", when: "refund.amount > 50" });
const terms = await pricing.settle("refund", {
rail: "eip3009",
chain: "eip155:8453",
token: "USDC",
payee: "refund.wallet",
});
for (const step of [grant, rule, terms]) console.log(step.describe());grant cap-01c3…5a72 to did:chio:z6MkbY4…e1R, allows pricing.refund rule refund.amount > 50 waits for your approval settles refund over eip3009 on eip155:8453 in USDC, payee from refund.wallet binding chain, payee, amount, token contract, dispatch, capability, expiry
from chio import Computer, USD
project = Computer.open("quickstart")
pricing = project.service("pricing", tools={"refund": "src/pricing.py:refund"})
grant = pricing.grant(to="did:chio:z6MkbY4…e1R", tools=["refund"], budget=USD(500), expires_in="24h")
rule = pricing.require(approval="you", when="refund.amount > 50")
terms = pricing.settle(
"refund",
rail="eip3009",
chain="eip155:8453",
token="USDC",
payee="refund.wallet",
)
for step in (grant, rule, terms):
print(step.describe())grant cap-01c3…5a72 to did:chio:z6MkbY4…e1R, allows pricing.refund rule refund.amount > 50 waits for your approval settles refund over eip3009 on eip155:8453 in USDC, payee from refund.wallet binding chain, payee, amount, token contract, dispatch, capability, expiry
use chio::{Approval, Computer, ServiceGrant, Settle, USD};
let project = Computer::open("quickstart").await?;
let pricing = project.service("pricing").tool("refund", "src/pricing.py:refund").create().await?;
let grant = ServiceGrant::to("did:chio:z6MkbY4…e1R").tool("refund").budget(USD(500));
let grant = pricing.grant(grant.expires_in("24h"))?;
let rule = pricing.require(Approval::by_you().when("refund.amount > 50")).await?;
let terms = pricing.settle(
Settle::tool("refund").rail("eip3009").chain("eip155:8453").token("USDC").payee("refund.wallet"),
).await?;
println!("{}
{}
{}", grant.describe(), rule.describe(), terms.describe());grant cap-01c3…5a72 to did:chio:z6MkbY4…e1R, allows pricing.refund rule refund.amount > 50 waits for your approval settles refund over eip3009 on eip155:8453 in USDC, payee from refund.wallet binding chain, payee, amount, token contract, dispatch, capability, expiry
A payment lane accepts only a VerifiedApproval, and only verify_governed_approval constructs one. It checks the token’s Ed25519 signature, an Approved decision, the validity window, the expected approver, subject, and request id, and that the intent’s hash equals the hash the approver signed. The intent must carry its settlement terms, the approval may not outlive the token, and no approval lives longer than 3,600 seconds.
The result binds the request id, the intent hash, the approval id, and the terms: chain, payee, amount in minor units, token symbol and contract, settlement currency, dispatch id, capability id, and expiry. Its fields are private and it cannot be cloned, so a copy cannot be paired with a fresh nonce. The raw lane builders that take bare terms are private to the settlement crate.
Each lane checks the terms the approver signed
Your pricing lead approves from the terminal. The pending request shows the terms the signature will cover:
chio approvals pending
chio approve apr-01c7…9b4epending apr-01c7…9b4e z6MkbY4…e1R refund order-77 USD 58.00 waiting 40s
pays 58.00 USDC on eip155:8453 to 0x7a3f…91be, approval expires in 1h
approved apr-01c7…9b4e, signed by did:chio:z6Mkf3…Qp9, binds request 2c8d…e017
intent 6f41…a0b3, which carries the settlement termsEach lane consumes the approval and compares the payment it is about to authorize with the signed terms. A mismatch returns InvalidBinding naming the lane and the field, and the lane issues nothing.
| Lane | Checked against the approval | Single use |
|---|---|---|
| x402 | Chain, payee, amount, dispatch, capability; token by symbol | Approval slot |
| EIP-3009 | Chain, payee, value, token contract, window inside the expiry | Approval slot and nonce |
| Circle | Chain, payee, amount, token symbol, dispatch | Approval slot |
| ERC-4337 paymaster | None; gas sponsorship only | Not a spend authorization |
x402 names tokens by symbol, so an approval that pins a token contract is refused on that lane, and the accepted tokens are filtered to the approved symbol. EIP-3009 requires the contract, and its authorization’s validBefore may not pass the approval’s expiry. The paymaster check takes no approval at all, and an executor never reads allowed: true as permission to spend.
One approval pays once
Watch the service while the buyer’s agent retries the refund and redirects it, and a stranger pays for a quote it holds no grant for:
for await (const call of pricing.events()) console.log(call.summary);00:00 z6MkbY4 refund order-77 USD 58.00 pending approval required 00:41 z6MkbY4 refund order-77 USD 58.00 allow apr-01c7…9b4e, paid 0x7a3f…91be 00:52 z6MkbY4 refund order-77 USD 58.00 refused approval already consumed 01:30 z6MkbY4 refund order-77 USD 58.00 refused EIP-3009 payee mismatch 01:44 z6MkQx8 quote sku-301 at 80% refused paid over x402, holds no grant
for call in pricing.events():
print(call.summary)00:00 z6MkbY4 refund order-77 USD 58.00 pending approval required 00:41 z6MkbY4 refund order-77 USD 58.00 allow apr-01c7…9b4e, paid 0x7a3f…91be 00:52 z6MkbY4 refund order-77 USD 58.00 refused approval already consumed 01:30 z6MkbY4 refund order-77 USD 58.00 refused EIP-3009 payee mismatch 01:44 z6MkQx8 quote sku-301 at 80% refused paid over x402, holds no grant
let mut calls = pricing.events();
while let Some(call) = calls.next().await {
println!("{}", call?.summary());
}00:00 z6MkbY4 refund order-77 USD 58.00 pending approval required 00:41 z6MkbY4 refund order-77 USD 58.00 allow apr-01c7…9b4e, paid 0x7a3f…91be 00:52 z6MkbY4 refund order-77 USD 58.00 refused approval already consumed 01:30 z6MkbY4 refund order-77 USD 58.00 refused EIP-3009 payee mismatch 01:44 z6MkQx8 quote sku-301 at 80% refused paid over x402, holds no grant
The settlement replay store keys each approval on its request id and governed intent hash, and the lane consumes that slot only when it issues a payment artifact; the second refund gets governed approval has already been consumed by an issued settlement artifact (replay rejected). EIP-3009 adds its own store, keyed on the payer, chain, token contract, and nonce, and kept until the authorization’s validBefore. Both stores have durable SQLite forms.
The kernel keeps a third store for dispatch, keyed on subject, request, and intent. With no store configured, a governed call is denied rather than admitted unchecked.
A payment proof never authorizes a tool call
A verified x402 payment yields claims under claim.public_settlement.* and nothing else. ToolCallAuthorization is granted only by from_capability_grant, when a grant names the server and tool, includes invoke, and carries no unmet constraint; a settlement verifier report always returns it denied, without reading a field. The conformance suite holds this with x402_payment_does_not_authorize_tool_call, including forged verdicts and claims.
The x402 adapter also fails closed on the money itself. A facilitator response that omits settled reads as unsettled, and a prepaid-final rail refuses with x402 authorization did not complete final prepayment. Attestations get the same treatment: an EAS or Verax attestation is a display record and never parses as an inclusion proof, as Anchoring shows.