For builders
ALLOW tickets
Toll's pre-sign check can sign a single-use ticket for one exact call. A contract that requires the ticket only runs calls Toll checked a moment before.
On this page
What a ticket is
When an agent asks the pre-sign check about a transaction, it can also ask for a ticket. If the answer qualifies, Toll signs a compact EIP-712 message bound to that call. A router, a Uniswap v4 hook, a smart-account module or a delegation caveat can then refuse the call without one.
- Single use. Each ticket has a random nonce. A consumer marks it used, so it never works twice.
- 60 seconds. It expires 60 s after it is signed, checked against block.timestamp (validAfter is set 30 s back for clock drift).
- One exact call. It binds the account that sends it (from), the call (target, value and the hash of the calldata, or a v4 swap’s pool, direction, amount and price limit) and, if you name one, the one contract allowed to consume it.
- Signed by Toll with its own key, separate from the key that signs pre-sign reports. The signer is below.
- BLOCK is never signed. A WARN is signed only when no warning is high or critical, and then as verdict 1, so a contract can accept ALLOW only. No ticket without from, and none for a transaction with an EIP-7702 authorization list.
Get one
Send the transaction to POST https://t0ll.xyz/api/tx/check as usual, with "ticket": true, or {consumer, scope}: consumer is the contract that will check it (left out, any contract can), and scope is "call" (default) or "v4swap". The same field works on the paid route POST /api/x402/tx and on the MCP tool check_tx.
curl -s https://t0ll.xyz/api/tx/check \
-H 'content-type: application/json' \
-d '{
"chainId": 4663,
"from": "0xYourAgentWallet",
"to": "0xTarget",
"data": "0x…",
"value": "0",
"ticket": { "consumer": "0xYourRouter", "scope": "call" }
}'For a router, check the inner call (the target and calldata the router will call), with your router as the consumer.
What comes back
The usual ALLOW / WARN / BLOCK answer, plus x-toll.ticket: the ticket, its signature and the signer, or why none was issued.
"x-toll": {
"ticket": {
"ticket": {
"scope": 1,
"verdict": 0,
"account": "0xyouragentwallet",
"consumer": "0xyourrouter",
"subject": "0x…",
"validAfter": 1793000000,
"validUntil": 1793000090,
"nonce": "0x…",
"reportHash": "0x…"
},
"signature": "0x…",
"signer": "0x4a2c93066d94f1e69f3ead5438c4efddf80a46ba"
}
}
// or, when no ticket is issued:
"x-toll": { "ticket": { "why": "No ticket: the answer is BLOCK, and a BLOCK is never signed." } }EIP-712 domain: name Toll Allow, version 1, chainId 4663, no verifyingContract (the consumer field binds the contract instead). Type:
Ticket(uint8 scope,uint8 verdict,address account,address consumer,bytes32 subject,uint64 validAfter,uint64 validUntil,bytes32 nonce,bytes32 reportHash)
Scope 1 (call): subject = keccak256(abi.encode(target, value, keccak256(data))). Scope 2 (v4 swap): subject = keccak256(abi.encode(poolId, zeroForOne, amountSpecified, sqrtPriceLimitX96)), read from a HookrRouter swap. reportHash links the ticket to the signed pre-sign report in the same answer.
Check it in a contract
The Solidity side is open source: github.com/t0ll-xyz/toll-allow (MIT, Foundry). Inherit TollAllowConsumer and call _consume. It checks, in order: scope, verdict at most your maximum, account, consumer (this contract or any), subject, the time window, not used, and the signature (an EOA or an ERC-1271 signer set). Then it marks the nonce used and emits TicketConsumed. Any failed check reverts.
A router
A router that cannot take an extra argument reads the ticket from the end of the calldata:
// forge install t0ll-xyz/toll-allow (remapping: toll-allow/=lib/toll-allow/)
import {Ticket, TollAllow} from "toll-allow/src/TollAllow.sol";
import {TollAllowConsumer} from "toll-allow/src/TollAllowConsumer.sol";
contract MyRouter is TollAllowConsumer {
constructor() TollAllowConsumer(0x4A2C93066d94F1E69f3EAd5438c4efDdf80a46ba) {}
function exec(address target, bytes calldata data) external payable {
(bytes calldata body, Ticket memory t, bytes memory sig, bool found) = TollAllow.splitSuffix(data);
require(found, "ticket required");
// account = who calls, subject = this exact call, maxVerdict 0 = ALLOW only (1 also takes a signed WARN)
_consume(t, sig, msg.sender, TollAllow.callSubject(target, msg.value, body), 0);
(bool ok,) = target.call{value: msg.value}(body);
require(ok);
}
}body ‖ abi.encode(ticket, signature) ‖ uint32 len ‖ 0x900bc3e1
// 0x900bc3e1 = bytes4(keccak256("TollAllow.v1")); the subject is over body only
// in JavaScript, with @tollxyz/guard 0.4.1:
import { enforce, appendTicketSuffix } from "@tollxyz/guard";
const r = await enforce("transaction", () => ({ chainId: 4663, from, to: target, data, value }), { ticket: { consumer: ROUTER } });
const withTicket = appendTicketSuffix(data, r.ticket.ticket, r.ticket.signature);A Uniswap v4 hook
Pass abi.encode(ticket, signature) as hookData. In a hook, sender is the router, not the trader: decide what account means for your hook and ask for the ticket with that account.
// beforeSwap: hookData = abi.encode(ticket, signature), asked with "scope": "v4swap"
(Ticket memory t, bytes memory sig) = abi.decode(hookData, (Ticket, bytes));
bytes32 subject = TollAllow.swapSubject(
PoolId.unwrap(key.toId()), params.zeroForOne, params.amountSpecified, params.sqrtPriceLimitX96);
_consume(t, sig, account, subject, 0);A delegation caveat
A caveat enforcer under the MetaMask DelegationManager (deployed on Robinhood Chain) can take abi.encode(ticket, signature) as the redeemer’s args and, in beforeHook, compute callSubject(target, value, callData) from the execution, with the delegator as account. Keep one enforcer per signer so its used map is shared across every delegation.
Ready to use: TollAllowEnforcer is deployed on Robinhood Chain, source verified on Sourcify (exact match), no owner and no admin. Add it as a caveat with terms = abi.encode(tollSigner, maxVerdict, valueThreshold); the README has the encoding.
0x6290dfc474f566b3bf612ad78e9dd3890473fdb5
The signer to pin
Toll’s ALLOW signer on Robinhood Chain (4663):
0x4A2C93066d94F1E69f3EAd5438c4efDdf80a46ba
Pin it as tollSigner, or pin a TollSignerSet (in the same repo) that you control and that includes it, so keys can be added or removed without redeploying your contract. It only signs tickets. On the explorer.
Gas
Measured with forge test in toll-allow, including the call into a minimal consumer: _consume with the plain signer is about ~69,100 gas (~70,700 through a TollSignerSet). Most of it is the new used slot and the signature check.
Trust model
- A ticket is necessary, never sufficient. It says Toll’s checks found no reason to stop this exact call when it signed, nothing more. Keep your own checks: slippage, limits, access control.
- Keep an owner bypass behind a timelock. If Toll is unreachable or its key is lost, users must still be able to get out. A delayed bypass keeps that path open without making the ticket optional day to day.
- Bind it to your contract. Ask with your contract as consumer, so a ticket meant for another contract does not verify at yours.
- The domain uses chain 4663, so a ticket never verifies on another chain.
Protect RPC
No contract to change: point your agent’s wallet at https://t0ll.xyz/api/rpc/protect instead of the Robinhood Chain RPC. Blocked transactions are refused before they reach the chain; reads pass through.
import { createWalletClient, http } from "viem";
const wallet = createWalletClient({
account, // your agent's key
chain: robinhoodChain, // chain id 4663
transport: http("https://t0ll.xyz/api/rpc/protect"), // or ?mode=strict
});- Reads go to the chain’s RPC through the same list of allowed read methods as the site’s own proxy. Any other method is a JSON-RPC error.
- A signed transaction (eth_sendRawTransaction, eth_sendRawTransactionConditional) is decoded, its signer recovered, and checked. BLOCK is refused with error -32003 and never sent on. ALLOW and WARN are sent to the chain and its answer comes back.
- The fast path answers within 1.5 s and checks fewer things than the pre-sign check: the decode, EIP-7702 delegates, approvals and transfers, calldata it cannot read that sends ETH, and coin checks Toll already holds. No simulation, no signer history. If the same transaction went through /api/tx/check or check_tx in the last 30 s, that full answer is used instead. The full check then runs in the background.
- Strict mode (?mode=strict) waits up to 8 s for the full check and refuses WARN too, and a check that did not finish.
- The signed transaction is never stored or logged (a refused one stays valid): only its hash, the verdict and the warning codes.
{
"jsonrpc": "2.0",
"id": 7,
"error": {
"code": -32003,
"message": "Toll blocked this transaction: <the top reason>",
"data": { "action": "BLOCK", "hash": "0x…", "warnings": [{ "code": "…", "severity": "critical", "message": "…" }], "link": "https://t0ll.xyz/…" }
}
}Notes
- Robinhood Chain only. The ticket costs nothing extra: it rides on the free check (within the free coins a day) or the paid one.
- The check itself: pre-sign check. From an AI app: Use Toll in your AI. In code: Plugins & SDKs (@tollxyz/guard 0.4.1 asks for tickets and builds the suffix).
- Information only. Facts read from the chain, not advice.