Autonomous on-chain security agent · built on KeeperHub
The agent that takes the approval away before it can be used.
Token approvals are the most common wallet-drain vector, and the tools that
revoke them automatically all start the same way: a human connects a wallet.
A keeper wallet never can. Revoker is a self-hosted policy engine
for wallets with no human at all — the moment a threat rule fires it executes
approve(spender, 0) through KeeperHub.
Every number on this page links to a transaction you can open.
(p95 25.17s)
succeeded
sponsored 25/25
Proof of execution
A real drain, really stopped.
The full cycle, executed on Ethereum Sepolia. Three transactions, in order. Open each one.
-
The trap
Keeper wallet grants unlimited approval
The everyday act that arms every drain:
approve(spender, MAX_UINT256)to a spender contract. Allowance jumps to 1.157e77 — effectively infinite. -
The response
Revoker fires — no human involved
Two rules trip —
unlimited-to-unverifiedanddenylisted. Revoker executesapprove(spender, 0)through KeeperHub's atomiccheck-and-execute, with no human in the loop. -
The verdict
The drainer fires anyway
The drain transaction succeeded. It did not revert. It was not blocked. It ran exactly as its author intended — and it found nothing to take, because the approval was already gone.
Step 3 is the whole story. Revoker doesn't intercept the attack — it makes the attack find an empty room.
took 0
watched wallet's balance unchanged: 10,000 mUSDC across the whole sequence
Reading the explorer links: KeeperHub
executes through a sponsored relay, so the from address is KeeperHub's relayer —
not the signer. The flow is relayer 0x809d…0444 → forwarder 0x5af5…f07d →
Turnkey account 0x5E2e…bab7. The signer appears in the calldata; this is expected,
not an anomaly.
Don't take our word for it
Check the allowance yourself, right now.
One cast call against a public RPC. No credentials, no account,
no trust in this page required.
cast call 0x4facb5FD1682c4449cAD42b7590861f7eD5c88Cb \
"allowance(address,address)(uint256)" \
0x5E2e5Fd3aD7fDC9B94482930db8b5F45E439bab7 \
0x8eBf8540EdE8e40CD94825C418758d4029D8892e \
--rpc-url https://ethereum-sepolia-rpc.publicnode.com
Expected output: 0 — the allowance Revoker zeroed is still zero.
The technical differentiator
No stale revoke, ever.
Most agents read state, decide, then write. That gap between deciding and acting is exactly what a mempool-watching drainer needs.
read-then-write a typical agent
transferFrom in here — before the revoke lands.
check-and-execute Revoker, via KeeperHub
The condition is evaluated against the live chain, not a value this process passed in. On the reference run KeeperHub reported, at execution time:
condition re-read server-side at execution time, then executed in the same operation= 2256 − 1 — the MAX_UINT256 allowance, re-read server-side.
No key to steal. Signing happens inside a Turnkey enclave via KeeperHub — the agent process never holds a private key. An autonomous agent with a hot key is a liability; one that can only ask a policy-bound signer to act is not.
The second ledger
The approval an ERC-20 watcher cannot see.
Permit2 is the shared approval contract most of the ERC-20 ecosystem now
routes through, and it keeps its own allowance ledger. A grant written there
never touches the token contract — no Approval event, no change to the
token's own allowance mapping.
watch ERC-20 Approval logs every approval tool — including this one, until recently
Approval event and its own allowance mapping
never changes. This isn't a missed alert — there is no event to subscribe to.
read Permit2's ledger Revoker, second watch path
lockdown() takes an array and clears them all at once.| # | Step | Block | Transaction |
|---|---|---|---|
| 1 | Upstream approve(PERMIT2, MAX) — the enabling grant |
11445297 | 0xa52cb025…5855a2 |
| 2 | Permit2.approve — arm the threat in Permit2's ledger |
11445298 | 0xe978f12f…c73297 |
| 3 | lockdown() — the revoke | 11445392 | 0x20d70cf1…6ba124 |
Reading step 3. Open the Logs tab — exactly one
entry, a Lockdown event emitted by canonical Permit2 itself
(0x000000000022D473030F116dDEE9F6B43aC78BA3), and the slot's
amount went from MAX_UINT160 to 0. The block column is
there for the same reason as the one above: 11445297 → 11445298 →
11445392 is one sequence you can check rather than take on trust.
The detour that made this land. The guard first read Permit2's own
allowance(), which returns three values. The check-and-execute condition is
{operator, value} with no tuple selector, so it had no scalar to compare:
it scored gt 0 as false, skipped the write, and logged a tidy success while
the grant sat there fully armed. Only a real transaction could have caught that.
The fix is a 723-byte ownerless, storageless view
(deployed at block 11445389,
after the threat was armed — which is why it sits outside the sequence above) that
flattens the tuple to a single uint160. The action still calls canonical
Permit2, and check and action still sit inside the same operation, so the
no-front-run property holds here too. If the helper is missing the agent refuses to
submit rather than send an unguarded lockdown.
Detection
Four rules, two holds. No model.
Concrete, auditable threat conditions. Every firing carries its evidence into the audit trail, so a revoke can be justified after the fact.
unlimited-to-unverified
A MAX_UINT256 allowance granted to a contract whose source is unreadable —
infinite trust in code nobody can inspect.
signal: KeeperHub ABI resolution, live
young-spender
The spender contract was deployed less than 7 days ago. Fresh contracts holding spend rights are a classic drain setup.
signal: eth_getCode + binary search
denylisted
The spender is on the known-bad list. No further debate — a confirmed bad actor with an open allowance gets revoked.
signal: data/denylist.json
permit2-long-lived
A Permit2 allowance still valid more than 30 days out, held by a contract whose source is unreadable. An expired one is left alone — the transfer would revert anyway.
signal: Permit2 ledger + block timestamp
Any one rule firing is sufficient. These are independent signals of different kinds, not weighted terms in a score — requiring consensus would mean ignoring a confirmed deny-list hit because the contract happened to be verified. And deliberately not an ML risk model: an agent that moves funds on an opaque score is not auditable, and “the model said so” is not a defence when it is wrong.
Benchmark
Measured, not claimed.
25 full detect→revoke cycles against live Sepolia, each one confirmed by reading the chain — not by trusting the execution report.
| Metric | p50 | p95 | min | max |
|---|---|---|---|---|
| response — detection → revoke confirmed | 13.47s | 25.17s | 9.31s | 26.55s |
| exposure — threat live → revoke confirmed | 13.71s | 25.55s | 9.72s | 26.71s |
What these numbers do not include. The benchmark triggers detection immediately
rather than waiting for the poll timer, so a deployment polling every
pollIntervalMs adds an average of pollIntervalMs/2 on top.
The p95 is nearly double the p50 because three of the twenty-five cycles hit a slow block-inclusion window — that variance is the network's, not the agent's, which is exactly why this is reported as a distribution instead of a headline number. Full per-cycle table: BENCHMARK.md in the repo.
Integration
KeeperHub is the engine, not decoration.
Remove KeeperHub and Revoker needs seven separate systems: a transaction
relayer, a congestion-aware gas oracle, an MEV-protected submission route, a confirmation
poller, an action-discovery layer, an ABI resolution service, and an audit-log pipeline —
plus a custody solution. The sharpest dependency isn't convenience; it's that the
atomic check-and-execute and the enclave-held key are things a standalone
script cannot honestly replicate.
| Surface | Used for |
|---|---|
| POST /api/execute/check-and-execute | the atomic revoke |
| POST /api/execute/contract-call | contract writes, arming the demo approval |
| POST /api/execute/transfer | native transfers |
| GET /api/execute/{id}/status | confirmation, gas, sponsorship, audit record |
| GET /api/chains | network + explorer resolution |
| GET /api/chains/{id}/abi | source-verification signal for rule 1 |
| GET /api/user/wallet | signer identity assertion |
| GET /api/user/wallet/balances | token discovery (curated registry) |
| GET /api/workflows | workflow listing — tooling, not the agent |
| POST /api/workflows/create | publishes the workflow definition |
| PATCH /api/workflows/{id} | updates it in place on re-run |
| simulate: true | pre-flight dry runs before spending gas |
| Idempotency-Key | safe retries without double-execution |
| gasLimitMultiplier | the fee ladder’s two rungs |
| X-Poll-Interval-Hint | server-paced status polling |
Scope
Known limits, stated plainly.
A security tool that hides its blind spots is part of the problem. These are Revoker's, on the record.
-
Sepolia testnet only
The mainnet path is documented, not executed. No real funds were risked producing any number on this page.
-
Token discovery is watchlist-scoped
No public RPC serves an address-less
eth_getLogsover a useful block range. Revoker protects the tokens it is told to watch, rather than implying coverage it does not have. Production would resolve the set from an indexer. -
young-spenderneeds an archive RPCWithout one it returns
INDETERMINATEand names the remedy — loudly — instead of reporting “safe”. A threat rule that silently degrades into a rubber stamp is worse than one that admits it cannot see. -
The threat model is narrow on purpose
A spender that is verified, aged, and absent from the deny-list trips nothing. That case is out of scope, not silently mishandled.
-
The /verify dashboard runs locally
A continuous watcher cannot be a serverless function, so the live dashboard is served from the same long-lived process that does the watching.
Demo
Watch the full cycle.
2:34 · the exact cycle proven above — seed the threat, watch Revoker detect and revoke it, then let the drainer fire anyway. Watch on YouTube ↗