Plan: signed payments and smart-wallet deployment¶
A smart wallet has its address before it has code, so it can hold USDC before it's ever been deployed. Deploying it takes a transaction, and that takes gas: the problem Bruno solves. This plan lets a wallet be deployed by the same transaction that takes its payment and makes its call, over x402, REST and JSON-RPC alike.
Two things are added, each optional, each run by the runner before the call. The transaction stays a single one, sent as a standalone bundle, and atomic: if any step fails, nothing lands.
- A payment transfer: a signed EIP-3009 transfer from the payer to the payee. x402 payments already work this way; this brings it to REST and JSON-RPC, so the call no longer has to pay from within.
- A deployment: a factory call that deploys an account if it has no code yet. It comes either from an ERC-6492 signature or from the request itself.
This branch checks off three items in priorities:
- A signed EIP-3009 transfer as a submission's payment, over REST and JSON-RPC.
- ERC-6492: a smart wallet that isn't deployed yet pays, and its first payment deploys it.
- AgentKit's
send_usdc_gaslessfrom a smart wallet.
The runner¶
The deployment step is new. The transfer step exists today for x402, and now serves submissions too.
- Deploy: if there's a deployment and its account has no code (or the relay has asked to
prepare it; see below), call
factorywithfactoryData, with the gas left over after the rest is reserved (see Pricing). If the call fails, the runner reverts with the factory's revert data. - Read the payee's balance.
- Transfer: if the payment has a transfer, call the token's
transferWithAuthorization. The signature goes through the(v, r, s)overload if it's 65 bytes, or thebytesoverload otherwise; a wrapped signature's inner signature is used. - Make the call.
- Read the payee's balance again, and revert unless it rose by at least the amount.
- Self-destruct.
The account's code is checked when the transaction runs, so a wallet that someone else deploys in the meantime is skipped, not deployed twice.
Any factory. Bruno keeps no allowlist. The factory call runs inside the runner, which is new for each transaction and holds nothing, and it's atomic with the payment. A factory can do whatever it likes, the same as the client's own call.
Interface changes¶
Quantities are hex, as everywhere else in the interface. All new fields are optional, so every request that works today still works.
Submissions: REST and JSON-RPC¶
REST takes the submission as the body of POST /{chain}/v1/submissions. JSON-RPC takes the same
object as postpaid_sendTransaction's one parameter.
Today:
{
"call": {"to": "0x…", "data": "0x…"},
"authorizationList": [],
"gas": "0x186a0",
"payment": {
"token": "0x…USDC",
"payee": "0x…",
"amount": "0x2a2c50d"
},
"maxBlockNumber": "0xb712a0"
}
Proposed, with both new fields:
{
"call": {"to": "0x…", "data": "0x…"},
"gas": "0x186a0",
"payment": {
"token": "0x…USDC",
"payee": "0x…",
"amount": "0x2a2c50d",
"transfer": {
"from": "0x…the payer",
"validAfter": "0x0",
"validBefore": "0x6a1f3c80",
"nonce": "0x…32 bytes",
"signature": "0x…"
}
},
"deployment": {
"account": "0x…the account to deploy",
"factory": "0x…",
"factoryData": "0x…"
},
"maxBlockNumber": "0xb712a0"
}
Over JSON-RPC:
{
"jsonrpc": "2.0",
"id": 1,
"method": "postpaid_sendTransaction",
"params": [
{
"call": {"to": "0x…", "data": "0x…"},
"gas": "0x186a0",
"payment": {
"token": "0x…USDC",
"payee": "0x…",
"amount": "0x2a2c50d",
"transfer": {
"from": "0x…",
"validAfter": "0x0",
"validBefore": "0x6a1f3c80",
"nonce": "0x…",
"signature": "0x…"
}
},
"deployment": {
"account": "0x…",
"factory": "0x…",
"factoryData": "0x…"
},
"maxBlockNumber": "0xb712a0"
}
]
}
payment.transferis EIP-3009'stransferWithAuthorization, fromfromtopayeeforamount. Itstoandvalueare the payment's ownpayeeandamount, so they aren't repeated. The token must support EIP-3009 (USDC does). The signature may be a key's, a contract's (ERC-1271), or ERC-6492-wrapped.deploymentuses ERC-4337's names (factory,factoryData) for the same thing. It's for an account the call needs that isn't deployed yet, such as the call's own target.- A wrapped signature brings its own deployment, for
from. A request can also givedeploymentfor another account, but only one account is deployed per submission: two that differ are refused as invalid params.
Paying over x402¶
The request body (POST /{chain}/v1/x402) gets the same optional payer and deployment as a
quote (below), so the 402 can price them:
{
"call": {"to": "0x…", "data": "0x…"},
"gas": "0x186a0",
"payer": "0x…",
"deployment": {
"account": "0x…",
"factory": "0x…",
"factoryData": "0x…"
}
}
The payment itself doesn't change: x402's PAYMENT-SIGNATURE already carries an EIP-3009
authorization and its signature, which may now be ERC-6492-wrapped.
Pricing: one path for all three¶
A quote, a submission's price check and x402's 402 are the same calculation, from the same inputs: the call's gas and length, any EIP-7702 authority, and, for a payment transfer, who pays and what their signature check costs. Each interface only differs in how it carries them.
New optional inputs, on all three:
payer: the account signing the payment transfer.deployment:{account, factory, factoryData}, as on a submission.-
verifyGas: the gas the payer's signature check takes, if the client knows it. -
REST,
GET /{chain}/v1/quote: query parameterspayer,verifyGas, andaccount,factoryandfactoryDatafor a deployment:
$ curl -s https://bruno.mailam.app/sepolia/v1/quote -G \
-d gas=0x186a0 \
-d callBytes=0x200 \
-d tokens=0x…USDC \
-d payer=0x…wallet \
-d account=0x…wallet \
-d factory=0x…factory \
-d factoryData=0x… | jq
- JSON-RPC,
postpaid_quote: the same, withdeploymentas an object:
{
"gas": "0x186a0",
"callBytes": "0x200",
"tokens": ["0x…USDC"],
"payer": "0x…wallet",
"deployment": {
"account": "0x…wallet",
"factory": "0x…factory",
"factoryData": "0x…"
}
}
- x402: the same fields in the request body (above).
Before the payment is signed (a quote, or the first 402), the price adds:
- The deployment: the factory call, estimated with
eth_estimateGasagainst the real factory and data, when adeploymentis declared. - The signature check, when the payer is a contract (a
deploymentis declared, orpayerhas code). Its gas is the first of: verifyGasfrom the request;- the wallet gas schedule's figure for the deployment's factory (below);
- the schedule's largest figure for the chain's fork, for a wallet Bruno doesn't know.
- Nothing for a key, which is priced from the gas schedule and the token's measurement, as today. Requests that don't declare a smart wallet pay nothing extra.
Once the payment is signed, the real transaction is estimated with one eth_estimateGas:
the runner exactly as it will be sent, from the relay's account, with no gas price. That
estimate is the gas limit and decides the price. If the payment falls short, a submission is
refused as underpaid with the amount it needs, and x402 answers 402 at the full price. Our
client signs again automatically.
To make the estimate possible, the deployment and transfer steps don't have their gas built
into the runner. They get whatever the transaction has, less what the rest needs. Before the
call, the runner checks that the call will get at least its declared gas, and reverts
(GasExhausted()) otherwise; today it only checks that what follows the call is covered. With
the new check, the estimate's binary search can't go below the declared gas, so the estimate
covers it even when the call uses less.
Checked on anvil (2026-10-27), Prague and Amsterdam, with today's runner (a Calibur batch
paying USDC, with an EIP-7702 authorization): eth_estimateGas handles the self-destruct, the
estimate is the exact minimum (1 gas less fails), and an underpaying runner's estimate fails
with Underpaid()'s revert data. Without the new check, the estimate follows the gas the call
actually uses: 178,218 against 1,139,327 computed for 1,000,000 declared on Prague.
A wrapped signature runs the deployment by itself; deployment in the request is what gets
the first price right. Under exact, the payer signs exactly the 402's amount, and x402's own
client pays only once, so a client with an undeployed wallet should declare it. Ours does, for
its users and for AgentKit. deployment is also how a submission names an account other than
the payer, such as the call's own target.
The wallet gas schedule¶
Next to the forks' gas schedules (forks.py, forks.ts): for each known factory, the wallet it
deploys and the gas its signature check takes, per fork. Its notes say how each figure was
measured. It has both versions of Coinbase Smart Wallet in use, each with a key as its owner:
| Wallet | Factory | Who creates it | Prague | Amsterdam |
|---|---|---|---|---|
| v1 | 0x0BA5ED0c…428a |
viem's toCoinbaseSmartAccount by default |
14,693 | 15,093 |
| v1.1 (July 2025) | 0xBA5ED110…5842 |
CDP smart accounts (AgentKit's CdpSmartWalletProvider), the Base Account SDK |
14,368 | 14,768 |
Both sign alike (the same signature, replay-safe hash and factory data), but v1.1 is built anew
(via_ir, Prague), so its check costs differently, and the same owners make a different
account in each. viem's types require naming the version; at run time it defaults to v1.
The figures are verified by a test in make check, on both forks: it deploys each wallet
through its vendored factory on anvil, measures a transfer's check, and asserts it equals the
schedule. A fork or contract change that moves a figure fails the test with the
measured value.
Passkey (WebAuthn) owners are left out for now: P-256 verification is far more expensive, and
which wallets support it, through which factories, needs research first (plan).
Their clients can pass verifyGas, or get the margin.
ERC-6492, as written¶
A wrapped signature is abi.encode((address, bytes, bytes), (factory, factoryCalldata,
signature)) ++ magicBytes, where magicBytes is 0x6492 repeated 16 times. A verifier checks
the magic bytes first, then ERC-1271, then ecrecover. If the wallet has no code, it makes the
factory call and then asks isValidSignature with the inner signature.
It also covers a wallet that's deployed but not ready ("prepare"): if the wallet has code and ERC-1271 fails, the verifier makes the call and tries again. The relay does this in simulation: if the transfer fails with the wallet as it is, it simulates again with the call, and if that succeeds, the plan asks the runner to make it.
The standard doesn't say what happens if the factory call fails. Its reference implementation reverts, and so does the runner.
Detecting an undeployed wallet¶
ERC-6492 is itself the standard signal: a wallet that isn't deployed wraps its signatures, and
the wrapper carries the factory and its data. So a client only needs to sign and look at the
result, with no wallet-specific code. viem's parseErc6492Signature unwraps it, giving the
factory, its data and the inner signature; the deployment's account is the signer's address.
What wraps, as of 2026-10-27:
| Signer | Wraps while undeployed |
|---|---|
viem's smart accounts (toSmartAccount, such as toCoinbaseSmartAccount) |
Yes: signTypedData checks the account's code and wraps if there's none (viem 2.38.3, account-abstraction/accounts/toSmartAccount.js) |
| ZeroDev's Kernel accounts | Yes: built on viem's toSmartAccount |
| CDP smart accounts (CDP SDK) | Yes: CDP's own example signTypedData.eip6492.ts shows it |
| Keys and EIP-7702 accounts | No wrapper: nothing to deploy (viem skips its 0x7702 placeholder factory) |
Reading the code alone (eth_getCode, which every AgentKit wallet provider can do through
getPublicClient()) isn't enough: an empty address may be a key or an undeployed wallet, and
the code doesn't say how to deploy it.
Clients and AgentKit¶
The TypeScript client handles it for everyone who uses it:
- For a smart account (viem's
type: "smart"), it sendspayerwith the price request, anddeploymentfromgetFactoryArgs()while the account is undeployed.verifyGasis left to the relay's wallet gas schedule. - If a signed payment is still refused as short, it asks again at the price it's given and signs that.
AgentKit signs through each wallet provider's signTypedData, which toSigner() passes to
our client unchanged, so all three smart-wallet providers above (CDP smart wallet, ZeroDev, and
viem accounts) are covered by the client. What's left is send_usdc_gasless's transfer to the
recipient, which the provider builds itself and which assumes 65 bytes (v, r, s):
- Use the
bytesoverload for any other length. - Unwrap an ERC-6492 signature to its inner signature, and take the deployment from it. USDC doesn't understand 6492, and the runner deploys the wallet before the call runs.
This needs a new @mailam/bruno-agentkit release, 0.5.0, built and tested against AgentKit
0.10.4, its latest (2026-10-28).
Steps¶
- SPEC: the runner's deployment step, and its transfer step for submissions; the new request fields; ERC-6492 in x402 payments.
- Runner (Python): both steps, given the gas left over; the check that the call gets its declared gas.
- The wallet gas schedule (
wallets.py,wallets.ts): Coinbase Smart Wallet with a key owner, per fork, and the test that measures it on both forks. - The forks' measurement scripts moved from
scratchpad/intotools/, with amaketarget. - Relay (Python): the new fields; unwrapping; deploy or prepare; pricing; refusals.
- Worker: the same, module for module; golden vectors from
make vectors. - TypeScript client: the new fields;
payAndSubmit, paying a submission by a signed transfer; a smart account's payer and deployment declared (getFactoryArgs, as viem's smart accounts give it), a wrapped signature's priced again if not; a payment short of its own price paid again, once;transferCalldataandunwrappedexported. Still no dependencies. - AgentKit: the transfer to the recipient through the overload its signature's length takes, unwrapped; an undeployed wallet's call measured by a deployless eth_call; its deployment declared. No viem dependency of its own.
- Tests on anvil, against both relays, with the vendored Coinbase Smart Wallet factory:
- the same price from REST's quote, JSON-RPC's quote and x402's 402 for the same call, payer and deployment, and a payment signed at that price lands;
- a submission paid by a key's signed transfer, over REST and over JSON-RPC;
- an undeployed wallet paying over x402 (signed by the test), then paying again once deployed;
- the same through x402's own Python client, unmodified, its signer an undeployed Coinbase Smart Wallet's: the first 402 is its price, paid once;
- an undeployed wallet paying a submission with a wrapped signature;
- a
deploymentfor the call's target, with a key paying; - refused, with nothing landing: a factory call that fails, a wrong inner signature, and two different deployments in one request;
- a wallet deployed by someone else before inclusion is skipped, not deployed twice;
- AgentKit's
send_usdc_gaslessfrom an undeployed smart wallet (viem'stoCoinbaseSmartAccount, in Docker,make npm-e2e); - the same from a deployed smart wallet;
- the prepare case, with a minimal contract (hand-assembled, 37 bytes) that only accepts signatures after a setup call: prepared by its wrapper, it lands; unwrapped, refused.
- DESIGN, the REST, JSON-RPC and x402 pages, the support page and priorities; ERC-6492 in the standards list.
- The supported smart wallets on the support page, with a GitHub issue template for requesting another, linked from there.
- The client's and AgentKit's READMEs; 0.5.0 (the relay,
@mailam/brunoand@mailam/bruno-agentkitat 0.5.0-dev.1).
Decisions¶
- Pricing: before signing, the factory call estimated and the signature check from
verifyGas, the wallet gas schedule, or its largest figure; once signed, oneeth_estimateGasof the real transaction decides. Noeth_simulateV1. - The prepare case: implemented as the standard says. If the wallet has code, the relay simulates the transfer without the call first, and only on failure with it. Tested with a minimal contract that only accepts signatures after a setup call.
- One deployment per submission, for now.
- Version: 0.5.0.
- Passkey owners: not in the wallet gas schedule yet; see the research item in priorities.