Keyless agents · Robinhood Chain 4663

Agents act on chain without owning the key.

You keep the key. Your agent gets a mandate instead — one asset, a spending cap, an expiry, and a list of who it may pay. It signs; the chain decides. It never holds the funds, never holds gas, and can never move value anywhere you did not approve. Revoke it in one transaction, and everything it already signed dies with it.

Four parties, and only one of them holds anything

The whole design is a separation of powers. The key that authorises is not the key that holds, and neither is the key that pays gas.

Owner you, with the keys Vault holds the tokens enforces the mandate Agent signs · holds nothing Relayer pays gas · decides nothing Approved payee on your allowlist funds + mandate reads signed intent submits tokens move here, once
The money takes one hop: vault to payee. The agent is never a stop on that route, not even for a single instruction — which is why a compromised agent key cannot drain anything, only spend what the mandate already allowed, to someone you already approved.

What it can do, and what it deliberately cannot

The second list is the product. Anything can send a token; the value here is in what stays impossible when your agent is wrong, compromised, or lied to.

Pay under a cap

Any ERC-20, metered in raw units. The cap is a hard ceiling, and a rejected payment consumes none of it.

Pay without gas

The agent signs an EIP-712 intent; anyone relays it. The agent never needs the native token.

Subscribe

Recurring charges with a period counter on chain. A crashed scheduler that retries cannot double-charge.

Pay a stranger

Only addresses on the allowlist you control. Everything else reverts, whatever the agent was told to do.

Outlive a revoke

Intents already signed die with the mandate. The generation is inside the signature.

Trade under the cap

Swap through the chain's router. Buying meters the cap; selling back into the mandate asset does not, so a soft-killed agent can still unwind.

Hold an allowance

The vault never calls approve. An allowance would survive revocation; this cannot.

Root the vault. Then the bot spends.

Three keys, three roles, three files. The split is the security model, not a convenience — and none of them is ever passed as an argument.

1  Build and check the chain

Every address is re-verified against the live network before anything runs.

$ git clone https://github.com/grokblock/rh-intents
$ npm install && npm run build && npm test
$ npm run probe            # re-verifies every address on chain

2  Separate the roles

The owner holds the money. The relayer holds gas. The agent holds nothing, ever — if it ever shows a balance, something is being done wrong.

$ export OWNER_KEY=./keys/owner.key      # issues mandates, withdraws
$ export AGENT_KEY=./keys/agent.key      # signs only
$ export RELAYER_KEY=./keys/relayer.key  # pays gas, authorises nothing

3  Write the mandate

One asset, a cap, an expiry, and the payees you approve. Nothing here is guessed: an unknown token's decimals will stop the command rather than be assumed.

$ node src/cli.mjs deploy
$ node src/cli.mjs merchant add --merchant 0xSHOP
$ node src/cli.mjs grant issue --agent 0xBOT --token 0xUSDG --cap 50 --days 30
$ node src/cli.mjs deposit --token 0xUSDG --amount 100

4  Let it spend

Every command that moves money takes --plan, which prints exactly what would happen and sends nothing.

$ node src/cli.mjs pay --merchant 0xSHOP --amount 5 --plan
$ node src/cli.mjs pay --merchant 0xSHOP --amount 5

Stopping it, three ways

All immediate. None requires the payee to cooperate.

$ node src/cli.mjs merchant remove --merchant 0xSHOP   # that payee only
$ node src/cli.mjs grant revise --agent 0xBOT --cap 0  # soft kill: alive, no headroom
$ node src/cli.mjs grant revoke --agent 0xBOT          # dead, and so are its signatures

Status

Read this before believing anything above. Every address was read off the chain, and npm run probe re-checks all of it.

ThingStateDetail
Contractwritten8,551 bytes · 34.8% of the EIP-170 limit
Tests74 passingagainst a real in-process EVM at chain 4663
Mainnet deploylive0x58B5B1800F52A494a7CEDB5b09ce97AAd41b496A
Real paymentdone1.5 USDG, agent signed, relayer paid gas
Real tradedoneUSDG↔WETH, gasless, agent balance unchanged
Trading / swapslive24 tests, incl. hostile routers and route substitution

Network

Verified by reading the chain, not copied from a doc page.

ItemValue
Mainnetchain 4663 · rpc.mainnet.chain.robinhood.com
Testnetchain 46630 · rpc.testnet.chain.robinhood.com
StackArbitrum Nitro, EVM
Payments unitUSDG · 0x5fc5360D0400a0Fd4f2af552ADD042D716F1d168 · 6 decimals
USDG on testnetdoes not exist — a rehearsal needs a mock token first

On trading

A swap intent exists now. It works the way the payment side does: the mandate bounds it, and buying meters the cap while selling back into the mandate asset does not — so an agent whose cap has been revised to zero can still unwind a position it already holds, where a revoke would strand it.

The contract does not build routes. This chain's router is a modified UniversalRouter whose v4 swap struct carries an extra minHopPriceX36 field, so calldata from the standard Uniswap SDK reverts against it, and the encoding can change again. A contract that encoded routes itself would be wrong the first time the router moved. So the caller supplies the calldata and the vault enforces the outcome instead: at most the authorised amount may leave, at least minOut must arrive, both measured from real balances after the call rather than believed from a return value. The router address is a hardcoded constant, so caller-supplied calldata reaches exactly one program.

One honest exception to a rule stated above: a swap cannot avoid an ERC-20 approval, because the router has to pull the input. The allowance is granted for exactly the input amount and set back to zero in the same transaction, so nothing survives the call and revocation stays final.

It has now run against the real router, and two things only the live chain could teach turned up. The first: an approval does not work here at all. UniversalRouter never spends an allowance made to itself, so the vault transfers the input and the route runs with payerIsUser = false — which is strictly better, because it means no allowance is created anywhere and the no-approve rule holds with no exception. The second: the fork is deeper than documented. V3_SWAP_EXACT_IN takes six parameters on this chain, not the standard five. Omit the trailing one and the router reads past the end of the calldata and reverts SliceOutOfBounds(). Both were caught by simulation, before any money moved.

Known limit

A token that reports a successful transfer while moving nothing is not detected. The vault meters what it authorised, not what the token actually did. Checking balances before and after would catch it, and would also break fee-on-transfer tokens, which move less on purpose. The owner chooses the token, so this is stated plainly and pinned in a test rather than defended against.

Robinhood Chain has first-class ERC-4337 support and all three EntryPoints deployed. This does not use them. Account abstraction is the right answer when an agent needs a general account acting under policy; here the agent needs exactly one capability, and a purpose-built vault says that in a fraction of the surface. Less surface is the whole security argument.