The agent that lands the revoke before the drainer moves.
We let the drain contract fire after the revoke.
It succeeded, and it took zero.
See the transaction.
Token approvals are the most common wallet-drain vector. You grant
approve(spender, MAX_UINT256) once to try a new dApp, then forget it exists.
Months later the spender is compromised, upgraded maliciously, or was a scam all
along — and once the drain starts it is instant and irreversible.
The industry's answer to this is read-only: scanners and trust scores that
tell you an approval is risky. KeeperHub's own marketplace has
token-approval-risk-scanner-* and wallet-trust-score-*. None of them act.
"Isn't this already solved?" is the fair question, and the honest answer is that prior art exists but takes a different shape.
The commercial attempt at automated wallet rescue — Harpie was the best-known,
and it shut down in March 2025 — worked by racing the drainer: watch the
mempool, and when a malicious transferFrom appears, front-run it and sweep the
assets somewhere safe. That approach has two costs. It is a gas auction you can
lose, and it requires the user to grant the rescue service its own token
approval. The anti-drain tool needed the exact primitive that causes the problem.
General automation platforms (OpenZeppelin's Defender lineage, now its open-source Monitor and Relayer) can fire condition-triggered transactions, but they are plumbing you assemble into a product — not an approval-threat agent.
Revoker takes the smaller, more reliable target: remove the approval instead of racing the transfer. There is no auction to lose and no mempool to win, because once the allowance is zero the attack has nothing to execute against. It is open-source and non-custodial — signing happens inside a Turnkey enclave and this process never holds a key, so you are not handing rescue rights to anyone — and every decision carries its evidence into an auditable trail.
Revoker acts. It watches a wallet's live approval set and, the instant a concrete
threat condition fires, autonomously executes approve(spender, 0) through
KeeperHub — landing a real, linkable, state-changing transaction.
flowchart TD
A["Approval / ApprovalForAll logs"] --> B[watcher]
B --> C{3 threat rules}
C -->|none fire| D["threat.cleared — keep watching"]
C -->|any fires| E["KeeperHub<br/>POST /api/execute/check-and-execute"]
E --> F["re-read allowance<br/>+ approve spender, 0<br/><b>ONE atomic operation</b>"]
F --> G["eth_call: confirm allowance == 0"]
G -->|zero| H["revoke.confirmed"]
G -->|non-zero| I["revoke.failed — retried next scan"]
H --> J[("audit trail")]
I --> J
D --> J
classDef threat fill:#2a1416,stroke:#ff5c5c,color:#ffb3b3
classDef act fill:#1a1f2e,stroke:#4ea1ff,color:#cfe4ff
classDef ok fill:#12241a,stroke:#35d07f,color:#a9e9c6
class C threat
class E,F act
class H,J ok
The revoke goes through check-and-execute rather than a read followed by a
write. That matters: the allowance is re-read and the revoke fired inside the
same server-side operation, so a drainer cannot slip a transferFrom between our
check and our act. A check-then-act implementation has a race window; this
does not.
| Layer | Technology | Why |
|---|---|---|
| Execution + custody | KeeperHub | Signs via a Turnkey enclave — this process never holds a private key |
| Chain reads | viem + public RPC | The watcher polls continuously; an execution API round trip would add latency to the number that matters |
| Contracts | Solidity 0.8.28, Foundry | Dependency-free fixtures, so the demo reproduces with no package installs |
| Runtime | TypeScript strict, Node 22 | noUncheckedIndexedAccess, verbatimModuleSyntax |
| Dashboard | Node http + SSE, zero-dependency HTML |
No CDN, no build step |
| Tests | Vitest + Foundry | 44 unit + 42 Solidity (100% contract coverage), weighted toward the negatives |
| Rule | Fires when | Signal source |
|---|---|---|
unlimited-to-unverified |
MAX_UINT256 allowance to a contract whose source is unreadable |
KeeperHub ABI resolution |
young-spender |
spender contract deployed < 7 days ago | eth_getCode, binary search |
denylisted |
spender is on the known-bad list | data/denylist.json |
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.
Every firing carries the evidence that produced it into the audit trail, so a revoke can be justified after the fact. Deliberately not an ML "maliciousness score": the model said so is not a defence when it is wrong.
Remove KeeperHub and Revoker needs seven separate systems: a transaction relayer, a congestion-aware gas oracle with backoff, an MEV-protected submission route, a status/confirmation poller, an action-discovery layer, an ABI resolution service, and an audit-log pipeline — plus a custody solution. KeeperHub signs through a Turnkey enclave, so this process never holds a private key.
10 distinct surfaces across 12 application call sites:
| Surface | Used for | Where |
|---|---|---|
POST /api/execute/check-and-execute |
the atomic revoke | src/revoke.ts |
POST /api/execute/contract-call |
arming the demo approval, contract writes | scripts/seed.ts, scripts/bench.ts |
POST /api/execute/transfer |
native transfers | scripts/spike.ts |
GET /api/execute/{id}/status |
confirmation, gas, sponsorship, audit record | src/revoke.ts |
GET /api/chains |
network + explorer resolution | scripts/spike.ts |
GET /api/chains/{id}/abi |
source-verification signal for threat rule 1 | src/rules.ts |
GET /api/user/wallet |
signer identity assertion | scripts/spike.ts |
GET /api/user/wallet/balances |
token discovery | src/watcher.ts |
simulate: true |
dry-run validation in the integration spike | scripts/spike.ts |
Idempotency-Key |
safe retries without double-execution | src/keeperhub.ts |
The ABI endpoint is worth calling out: it does not merely fetch ABIs here, it powers a threat rule. Unverified source is not proof of malice, but it means nobody can read what the code does — exactly the position a victim is in when they grant an unlimited approval.
Every claim below links to a transaction anyone can verify.
The full cycle, executed on Sepolia. Every step is a transaction you can open.
| # | Step | Transaction | Result |
|---|---|---|---|
| 1 | Victim grants approve(spender, MAX_UINT256) |
0xeb4243d1…fe1113 |
allowance = 1.157e77 |
| 2 | Revoker fires approve(spender, 0) via check-and-execute |
0x15f0f816…541a82 |
allowance = 0 |
| 3 | Drainer fires anyway | 0x5579da99…6e1e4d |
takes 0. Funds intact. |
Step 3 is the one that matters. The drain transaction succeeded — it did not revert, it was not blocked, it ran exactly as its author intended. It simply had nothing left to take, because the approval was already gone. The victim's balance is unchanged at 10,000 mUSDC across the whole sequence.
Verify it yourself, no credentials needed:
cast call 0x4facb5FD1682c4449cAD42b7590861f7eD5c88Cb \
"allowance(address,address)(uint256)" \
0x5E2e5Fd3aD7fDC9B94482930db8b5F45E439bab7 \
0x8eBf8540EdE8e40CD94825C418758d4029D8892e \
--rpc-url https://ethereum-sepolia-rpc.publicnode.com
# 0The condition was evaluated against the live chain, not a cached value —
KeeperHub reported observedValue: 115792089237316195423570985008687907853269984665640564039457584007913129639935
at execution time.
| What | Transaction |
|---|---|
| First execution via KeeperHub | 0xacc7979a…c11409 |
pnpm spike — full integration proof |
0x1f95fdd3…bf3d9d |
| Funding the deploy key, via KeeperHub | 0x00b4c5fb…e5c739 |
All verified independently of KeeperHub's own reporting, via public RPC
eth_getTransactionReceipt. pnpm spike reproduces the integration proof and
fails loudly if any step cannot be verified on-chain.
Contract addresses: deployments.json.
KeeperHub executes through a sponsored relay, so the from address on the
explorer is KeeperHub's relayer — not the signer. The flow is:
relayer 0x809d…0444 → forwarder 0x5af5…f07d → our Turnkey account 0x5E2e…bab7
Our account address appears in the transaction calldata, and the value moves
internally. This is expected: KeeperHub sponsors gas (the signer's balance is
untouched — verified) and submits through its own routing. Do not expect the
signer address in the from field.
| 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 |
25/25 cycles succeeded. Gas per revoke was 46,482 at both p50 and p95 (range 46,458–46,482 across the run), sponsored in every cycle. Two figures rather than one because conflating the agent's own speed with the user's real exposure window would flatter the result.
Neither figure includes polling delay — the benchmark triggers detection
immediately rather than waiting for the timer, so a deployment polling every
pollIntervalMs adds an average of pollIntervalMs/2 on top. The p95 is nearly
double the p50 (1.92x) because four consecutive 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 transaction links: BENCHMARK.md.
| Layer | Count | What it pins |
|---|---|---|
| Threat rules | 15 | True-positives, and that a verified/aged/non-deny-listed spender raises no threat |
| KeeperHub client | 14 | 4xx is not retried; isSourceVerified fails closed |
| Revoke path | 7 | Reports failure when the API claims success but the allowance survives |
| Audit trail | 8 | bigint serialisation; a broken subscriber cannot stop the loop |
| Solidity | 42 | 100% coverage — lines, statements, branches, functions. The drain succeeds and takes zero post-revoke; 5 fuzz suites |
| Total | 86 |
CI runs three jobs behind a gate — quality (lint, types, coverage), security
(pnpm audit, gitleaks over full history, a credential grep that fails the
build), and contracts (forge build --sizes, forge test).
The on-chain proof is deliberately not in CI: it needs a funded wallet and an org API key, and running it per-PR would spend real gas and put credentials in CI. It stays manual and reproducible — see DEMO.md.
Token discovery requires an explicit watchlist (data/watchlist.json). No
public RPC will serve an address-less eth_getLogs over a useful block range —
publicnode requires an address filter, 1rpc caps the range at 50 blocks — and
KeeperHub's balances endpoint only covers a curated token registry. Production
would resolve this set from an indexer. Revoker protects the tokens it is told
to watch, rather than implying coverage it does not have.
young-spender needs an archive node. eth_getCode at a block from last
week is unanswerable on a pruning RPC. The rule returns INDETERMINATE and
names the remedy instead of reporting "safe" — a threat rule that silently
degrades into a rubber stamp is worse than one that admits it cannot see. Point
SEPOLIA_RPC_URL at an archive node to enable it.
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.
Sepolia only. Mainnet is a documented path, not executed — no real user funds are put at risk for a demo.
Node 22+, pnpm 10+, and Foundry.
You also need a KeeperHub organisation API key and its Turnkey wallet address to run anything that touches a chain.
pnpm install
cd contracts && forge install foundry-rs/forge-std --no-git && forge build && cd ..Credentials resolve from process.env, then ~/.config/keeperhub/env, then a
local .env. Copy .env.example to start. Nothing secret is
ever committed.
KH_API_KEY=kh_... # app.keeperhub.com -> Settings -> API Keys
KH_WALLET_ADDRESS=0x... # app.keeperhub.com -> Wallet tab
KH_NETWORK=sepolia
KH_CHAIN_ID=11155111
SEPOLIA_RPC_URL=https://ethereum-sepolia-rpc.publicnode.com
Note on the signer.
approve(spender, 0)clears the allowance ofmsg.senderand nobody else's. KeeperHub signs exclusively for your org's Turnkey account, so that account is necessarily both the watched wallet and the revoke sender. The spike asserts your configured address matches the one KeeperHub actually controls, and fails loudly if it does not.
pnpm spike # prove the KeeperHub integration end-to-end
pnpm seed # stage the threat (idempotent — safe to re-run)
pnpm watch -- --once # watch Revoker detect it and take it away
pnpm verify # same, with the live dashboard at localhost:3000/verify
pnpm bench # p50/p95 over N=25 cyclespnpm watch -- --dry-run detects and reports without executing anything.
pnpm verify runs the watcher and streams its audit trail to the browser over
Server-Sent Events — pushed as decisions happen, not polled. Open
http://localhost:3000/verify, then run pnpm seed in another terminal and
watch the timeline animate: threat.detected → revoke.submit →
revoke.confirmed, with the Etherscan link rendered the moment it lands.
It is a long-lived process by necessity, not by preference: an agent that
watches approvals continuously cannot be a serverless function, so /verify is
served from the same process that does the watching.
pnpm check # everything CI runs
pnpm test # 44 unit tests
pnpm contracts:test # 42 Solidity tests, 100% coverage
pnpm contracts:coverage # prove it
pnpm lint # eslint
pnpm typecheck # tsc --noEmitmake help lists every target.
pnpm ciis a reserved pnpm command and silently shadows a script of that name — the script here ispnpm check.
src/
keeperhub.ts KeeperHub client — retry/backoff, rate pacing, idempotency
watcher.ts the autonomous loop: scan → assess → revoke
rules.ts the three threat rules
revoke.ts the atomic check-and-execute revoke
chain.ts read-side chain access (viem)
audit.ts structured audit trail + SSE subscriber hook
server.ts the /verify dashboard
scripts/
spike.ts 7-step integration proof
seed.ts idempotent threat staging
bench.ts N=25 p50/p95 benchmark
contracts/
src/ MockUSDC, RoachMotelSpender
test/ Solidity tests + fuzz
- Real transaction executed through KeeperHub
- Autonomous watch → detect → revoke loop
- Three auditable threat rules
- Reproducible seed + p50/p95 benchmark
- Live SSE dashboard
- CI, security scanning, 86 tests (100% contract coverage)
- Indexer-backed token discovery, removing the watchlist limit
- Mainnet with a policy layer — spending caps, daily revoke ceiling, allow-list escape hatch
| Document | What's in it |
|---|---|
| DEMO.md | Reproduce everything from a clean checkout, with expected output |
| ARCHITECTURE.md | The loop, the TOCTOU decision, failure modes, why KeeperHub |
| BENCHMARK.md | p50/p95 latency over N=25, per-cycle transaction links |
| deployments.json | Contract addresses and deploy transactions |
| .github/SECURITY.md | Threat model, and what does not count as a vulnerability |
| feedback.md | Zero-to-first-transaction teardown of KeeperHub — 6 findings plus one self-correction |
| starter/ | keeperhub-quickstart — that teardown as a runnable, dependency-free template |
MIT — see LICENSE.
Built for Agents Onchain by KeeperHub — the execution and reliability layer this agent runs on.