Protocol
tab is an x402 payment scheme called tab-vault. It keeps the shape of x402: a seller answers 402 with what it accepts, the buyer retries with a signed payment in a header, and a facilitator verifies and settles. The difference is where the money sits. It sits in a vault the owner controls, and the agent signs with a key whose policy the vault enforces.
Parties
- Owner. A wallet that deposits into the vault and mints keys. Only the owner can withdraw.
- Agent. A process holding a key. It signs payments. It cannot move funds anywhere a signed payment does not name, and not beyond the policy.
- Seller. An HTTP endpoint that wants to be paid per request. It never touches keys or balances.
- Facilitator. A service that checks a payment before the seller does the work and submits it to the chain afterwards. Anyone can run one. Anyone can also call the vault directly, so a facilitator that goes down does not strand money.
The 402 response
A seller that wants payment returns status 402 with a JSON body. The accepts list follows x402. The tab-specific fields are in extra.
{
"x402Version": 1,
"error": "Payment required",
"accepts": [{
"scheme": "tab-vault",
"network": "eip155:4663",
"maxAmountRequired": "10000",
"resource": "https://example.com/research",
"description": "",
"mimeType": "application/json",
"payTo": "0xSeller",
"maxTimeoutSeconds": 60,
"asset": "0xUSDG",
"extra": {
"vault": "0xVault",
"name": "TabVault",
"version": "1",
"facilitator": "https://tab.example/x402"
}
}]
}
Amounts are strings in the token's base units. 10000 is 0.01 USDG.
The payment header
The buyer retries the same request with an X-PAYMENT header: base64 of this JSON.
{
"x402Version": 1,
"scheme": "tab-vault",
"network": "eip155:4663",
"payload": {
"owner": "0xOwner",
"agent": "0xAgentKey",
"token": "0xUSDG",
"to": "0xSeller",
"amount": "10000",
"nonce": "0x…32 bytes…",
"deadline": 1757200000,
"signature": "0x…65 bytes…"
}
}
The nonce is random and scoped to the owner. The deadline is a unix time after which the vault refuses the payment. The seller answers a successful paid request with X-PAYMENT-RESPONSE, base64 of the settlement result below.
Facilitator API
Three routes, the same names x402 facilitators use.
GET /x402/supported
{ "kinds": [{ "scheme": "tab-vault", "network": "eip155:4663" }] }
POST /x402/verify
Body: { paymentPayload, paymentRequirements }, the decoded header and the accepts entry the seller sent. The facilitator checks, in order:
- the scheme, network and vault match what it serves
- the payee, token and amount match the requirements
- the deadline has not passed
- the signature recovers to the agent address
- on chain: the policy is active, the token matches, the amount is within the per-payment limit and today's remaining cap, the balance covers the amount plus fee, and the nonce is unused
- the amount fits after subtracting payments it has verified but not yet settled for the same key
{ "isValid": true, "payer": "0xOwner" }
{ "isValid": false, "invalidReason": "OverDailyCap" }
POST /x402/settle
Same body. The facilitator runs verify again, then submits pay() to the vault and waits for the receipt.
{ "success": true, "txHash": "0x…", "networkId": "eip155:4663", "payer": "0xOwner" }
{ "success": false, "errorReason": "NonceUsed" }
Verify is a promise the facilitator makes with its own reputation. Settle is the chain's answer. A seller that serves before settling takes a small credit risk on the facilitator for the time in between, which is why the middleware settles first by default.
Signing
Payments are EIP-712 typed data. The domain is the vault.
domain = { name: "TabVault", version: "1", chainId: 4663, verifyingContract: vault }
Pay(
address owner,
address agent,
address token,
address to,
uint256 amount,
bytes32 nonce,
uint256 deadline
)
The signer must be the agent address in the message. A signature over a different amount, payee or owner fails inside the contract, not only at the facilitator.
Contract
One contract, TabVault, holds every owner's balances and policies.
| Function | Who | What |
|---|---|---|
deposit(token, amount) | owner | Pulls tokens with transferFrom and credits the owner's balance. |
withdraw(token, amount) | owner | Sends tokens back to the owner. |
setAgent(agent, token, maxPerPayment, dailyCap, expiry, restrictPayees) | owner | Creates or replaces a key's policy. The daily window survives a replacement. |
setPayees(agent, list, allowed) | owner | Adds or removes addresses the key may pay when restrictPayees is set. |
revokeAgent(agent) | owner | Deactivates the key. |
pay(owner, agent, token, to, amount, nonce, deadline, signature) | anyone | Settles a signed payment. Charges the fee on top from the owner's balance. Sends amount to the payee. |
available(owner, agent) | view | The most the key can pay right now: the smaller of the per-payment limit, what is left of today's cap, and the balance after fee. |
Reverts are named so a facilitator can pass them through: Expired, NotActive, WrongToken, OverMaxPerPayment, OverDailyCap, PayeeNotAllowed, NonceUsed, BadSignature, InsufficientBalance.
The daily window opens at the first payment and lasts 24 hours. The cap counts amounts, not fees. The admin can set a fee up to 500 basis points and collects it as an internal balance, withdrawn like any owner.
SDK
One module, sdk/index.js, imported as tab. It depends on viem for signing and address checks and on nothing else.
| Export | What it does |
|---|---|
createPayFetch({ agentKey, owner, maxAmount?, chainId?, fetch? }) | Returns a fetch that answers a 402 by signing and retrying once. Refuses offers above maxAmount or on another chain. The response carries paid and payment. |
createPaywall({ facilitator, payTo, asset, vault, chainId, price, settle?, description?, maxTimeoutSeconds? }) | Returns check, middleware and requirements. price is a base-unit string or a function of the request. settle is before by default. |
paywall.check({ header, resource, req }) | The framework-free core. Returns a 402 description, or { ok, payment, settle() } once the facilitator has verified. |
paywall.middleware(handler) | Express-style wrapper around check. Sets req.payment and the X-PAYMENT-RESPONSE header. |
signPayment({ agentKey, owner, chainId, vault, token, to, amount, deadline, nonce? }) | Builds and signs one payment. Generates a random nonce when none is given. |
encodePayment(obj), decodePayment(header) | Base64 JSON in both directions. decodePayment returns null on anything malformed. |
validPayload(payment) | Structural check of a decoded header. Returns a reason string or null. |
VAULT_ABI, ERC20_ABI, PAY_TYPES, vaultDomain(chainId, vault) | The pieces a custom client or facilitator needs to talk to the contract directly. |
Running a facilitator
A facilitator is a small HTTP service with an RPC connection, the vault address, and a key that holds enough ETH for gas. It does not hold anyone's tokens. What it must get right:
- Verify is a reservation. Two requests can arrive for the same key inside one block. The facilitator keeps a short-lived table of payments it has said yes to and subtracts them before saying yes again, so the daily cap is not overcommitted while settlements are in flight.
- Settle is serialized per sender. Transactions from the facilitator key go out in order so nonces do not collide. A failed settlement releases its reservation and returns the contract's error name.
- Nothing is trusted from the seller. The requirements the seller passes in are compared against the signed payload, not the other way round. A seller cannot get paid more than the agent signed for.
- A floor on price. The facilitator pays gas for each settlement, so it refuses payments under a minimum it sets, with the reason
BelowMinimumPrice. The minimum is reported by its status route. - Replaceable. Because
pay()is callable by anyone, a seller that stops trusting a facilitator can settle its own payments, and a second facilitator can serve the same vault with no migration.
The reference facilitator is server/facilitator.js. It runs inside the site server when VAULT_ADDRESS and FACILITATOR_PRIVATE_KEY are set, keeps its ledger in SQLite, and exposes the three routes above plus /api/ledger?owner= and /api/facilitator. A public instance runs at https://tab-sb5f.onrender.com/x402.
Trust model
- Funds move only against a signature from an active key, to the address in that signature, within the policy. The facilitator cannot change any of that.
- A leaked key can spend at most the daily cap per day until the owner revokes it.
- A facilitator can refuse to settle. The owner or the seller can then call
pay()themselves with the same signature. - The facilitator pays gas and may charge the vault fee for it. The fee is visible on chain.
- The vault is live on Robinhood Chain at 0x5e97c3557f8d4094A7a0f7F2815414974becf171, deployed at block 56350964 with the fee at 0. The compiler input for source verification is in the repository under contracts/verify.