the layerthe consoleThe layer explains the bound. The console is where you write one.
THE CONSOLE
Make an agent. Say what it may do.
Then send it wherever it already trades.
UNTOLL sits between your agent and your wallet. You write the bound here, once. It holds inside the contract that moves the funds, so the agent trades at any destination you pick and it cannot spend past what you allowed. When it tries, the refusal carries a reason.
THE TEN THINGS A SCOPE CAN REFUSEone reason per refusal, named on chain
TARGET
A contract the scope never named.
SELECTOR
A call the scope never allowed.
PER_TX
More value in one transaction than you set.
PER_EPOCH
More value in a window than you set.
RECIPIENT
Output pointed somewhere you did not allow.
EXPIRED
After the date the scope runs out.
UNIT_PER_TX
More token units in one transaction than you set.
UNIT_PER_EPOCH
More token units in a window than you set.
GRANT_LIMIT
More standing approvals left behind than you set.
CALLDATA_SHORT
A call too short to carry the arguments the scope checks.
Every control in the console arms one of these. Nothing in the interface exists that the contract cannot enforce, and nothing the contract enforces is missing from it. The counter beside every scope screen reports that while you work.
where it runs · you pick, the bound travels
The agent trades where it already trades.
The bound lives at your wallet, not at the venue, so a destination is a choice you make rather than an agreement anyone has to sign. Four of them are here. The full list has six, and room to name your own.
What it may callA contract not on this list, or a call not on this list, does not execute.
Contracts it may touchTARGET
Paste an address. The scope stores the list once, at install, so nothing later can add to it.
Calls it may makeSELECTORCALLDATA_SHORT
A call arrives as four bytes and its arguments. An allowed call carrying too few bytes to hold the arguments the scope checks is refused as CALLDATA_SHORT, so a short call cannot slip past the checks that read those arguments.
How much it may moveTwo meters that do not substitute for each other: native value, and token units.
Value, per transactionPER_TX
ETH
Value, per windowPER_EPOCH
ETH
The windowresets on first use after it ends
Token units, per transactionUNIT_PER_TX
units
Token units, per windowUNIT_PER_EPOCH
units
A token call carries no native value, so the ETH caps bound nothing on a token market. The unit caps read the amount argument out of the call itself. One counter is shared across every metered contract, so two tokens of different decimals count against each other and the scope errs toward refusing.
Where the output may landThe one check a wallet allowlist cannot make, because it is an argument and not an address you called.
Recipient policyRECIPIENT
The same allowed call, to the same allowed contract, one address different a hundred bytes into the arguments. Under Me only the second one does not execute.
How long, and what it may leave behindAn approval outlives the scope, so the scope counts them.
Runs untilEXPIRED
Standing approvalsGRANT_LIMIT
64max, fixed in the contract
A constant rather than a setting, because the number of approvals is chosen by whoever can make grant calls, and during an incident that is the attacker. Fixing it makes revoking cost a known number of steps.
An approval is a row in the token's own storage and nothing here can reach it later, so the scope caps how many the agent may leave standing and lists them on the grant. Revoking the scope sweeps the ones it knows about.
These describe where UNTOLL's enforcement runs, not who we work with.
The bound lives at your wallet, so a destination costs nobody's signature. Each row states one thing: what this delegate can do at that execution model today, and how many of the ten reasons it can name there.
runs as-is
The delegate is EVM bytecode with a pinned runtime hash. Deploying it at this destination changes no code, and all ten reasons are available the moment it lands. It needs the chain to have activated EIP-7702, because the authorization tuple is what puts the scope in your own account.
not derived
A different execution model. The bound would have to be re-derived for it rather than ported, and until that work is done the console can hold the request but not the scope.
on chain here
A destination where UNTOLL contracts are already deployed and source-verified, so the deploy path at that chain is a thing we have done rather than a thing we plan.
The refusal set runs today on a local chain in Prague mode, with a real EIP-7702 authorization and the delegate deployed fresh each run. Every attempt in the record came out of that run. Separately, five UNTOLL contracts are deployed and source-verified on Robinhood Chain testnet, chain 46630, where the PoolManager runtime measures 24009 bytes.
Read it once, out loudThis is the sentence you are signing. It does not change after install.
Every clause above is a check inside the contract that moves the funds. Nothing here is advisory, and nothing here is a setting the agent can talk its way past, because the scope is stored once at install and there is no function that widens it.
What it cannot doTen reasons, each one a refusal with a name.
Change somethingWrites the scope in the shape the contract stores it, and hands it to you.
the record · what your agent asked for, and what it got
Every attempt gets an answer.
Two answers exist. A call inside the scope settles. A call outside it is held, and the reason is written down where you can point at it later. Both live in the same list, because both are the scope working.
scout
Your agent. Nothing attempted yet.
0
settled
0
held
The record fills the first time your agent acts. Until then the interesting reading is below: the same scope, six attempts, one of them allowed.
Six attempts, one scope
From the public demo run. One command, a local chain in Prague mode, the delegate deployed fresh and the keys generated per run.
1
settled
5
held
4
mined as events
0
wei to the attacker
#what the agent asked foranswer
01
An in-scope call to an allowed contract, output to the owner.
SETTLED
02
The identical call, output redirected to another address.
RECIPIENT
Same contract, same function, one address different a hundred bytes into the arguments. The scope reads that argument.
Held
reverted, named reason RecipientNotAllowed moved 0 wei
03
The same redirect again, this time through the monitored path.
RECIPIENT
Nothing forwards, and the refusal is mined as a ScopeViolation event, so the attempt is a published fact rather than a log you have to trust.
Held
mined ScopeViolation moved 0 wei
04
A call to drain() on an allowed contract.
SELECTOR
The contract was on the list. This call was not. Allowing a contract is not allowing everything it exposes.
Held
mined ScopeViolation moved 0 wei
05
An allowed call carrying 2 ETH.
PER_TX
Against a 1.0 ETH per-transaction cap. The window counter was never touched, because nothing settled.
Held
mined ScopeViolation moved 0 wei
06
An allowed call, to a contract the scope never named.
TARGET
The list is stored once at install. There is no function that adds to it later, which is why a compromised agent cannot widen its own reach.
Held
mined ScopeViolation moved 0 wei
Five held, one settled, and the balance that mattered never moved.
Run it yourself: git clone github.com/hookwright/untoll-agent-demo then npm install && npm run demo. The keys are generated per run and never funded, so nothing on your machine is at stake.
Attempts 02 and 03 are the same call twice, on the two entry points the delegate exposes. One reverts with a named reason and one mines an event. Neither forwards anything, and the difference is only where the receipt lives.