A Safe multisig splits one act into two that are not the same thing. Signing is free, off-chain, in the Safe Transaction Service. Executing is an on-chain call that costs gas, can be made by anyone, and is nobody's job. So fully-authorised transactions rot.
The obvious next move is to scan for them. We did β read-only, over 1,299 distinct Base mainnet Safes sampled across 41 evenly-spaced windows covering all of Base history (blocks 2,000,000 β 50,776,046). The result killed our own pitch and replaced it with a better one:
Naive detector: !isExecuted && confirmations >= confirmationsRequired |
Count |
|---|---|
| Transactions matching | 366 |
| β¦permanently dead β nonce already consumed by a different tx, can never execute | 339 (92.6%) |
| β¦live | 27 (7.4%) |
| β¦live and at the Safe's current nonce | 6 |
β¦and simulating SUCCESS under eth_call |
2 |
A detector built on the obvious definition is ~93% false positives.
And the condition itself is rare, not epidemic: 5 of 1,298 readable Safes (0.39%), or 3 of 297 (1.01%) among Safes that actually transact. Three of those five are threshold-1, so exactly one is a genuine multi-party coordination failure. We do not claim thousands of stuck transactions; the data we collected ourselves does not support it.
So the guards are not defensive polish around the product. The guards are the product. Anyone can concatenate signatures. Knowing which 7.4% are real β and then which of those would actually land β is the hard part, and it is measurable.
Every number in that table is reproducible from this repo. The scripts that made the calls, the
raw responses they returned, and a re-derivation that asserts all 24 published figures are committed
at survey/ β and CI runs it, so the prose cannot drift away from the measurement:
python3 survey/rederive.py # offline, no credentials, ~0.2sMethod in brief: Safe addresses derived from ProxyCreation and ExecutionSuccess logs on
mainnet.base.org; queues pulled from the Safe Transaction Service; liveness decided against
on-chain nonce() / getThreshold() / getOwners(), every confirmation ecrecover'd against
the current owner set, and every current-nonce candidate put through a read-only execTransaction
eth_call. No transaction was sent and no key was used. At n=1,298 the 95% CI on 0.39% is roughly
0.13%β0.90% β treat the claim as "about half a percent", and note this is a sample, not a census.
When a Safe transaction has collected enough owner signatures but nobody has paid the gas to execute it, gavel reassembles those signatures into the blob
checkSignaturesexpects and callsexecTransactionfrom a KeeperHub wallet that owns nothing on that Safe.
The onboarding ask is one line: give me your Safe address. No private key, no approval, no owner slot, no capital, no contract to deploy. Nothing gavel can do that the owners had not already signed for.
The decision surface is one pure file β src/assemble.mjs, 429 lines, zero
dependencies, zero I/O: no fetch, no clock, no randomness, no hashing. It takes one Safe's
off-chain queue plus three live on-chain reads (nonce(), getThreshold(), getOwners()) and
returns either the ten execTransaction arguments or a named refusal. fetch is available in
the KeeperHub sandbox; not using it is deliberate, because a pure function can be tested offline
against real captured bytes and an impure one cannot.
Four surfaces carry one transaction, and only one of them is ours. The queue and the
signatures come from the Safe Transaction Service; the liveness reads and the broadcast happen
inside KeeperHub; the chain and the receipt belong to Base and Etherscan. What gavel adds is
the middle box β a pure function that turns a queue plus three on-chain reads into either ten
execTransaction arguments or a refusal with a name.
Every non-execution has a machine-readable reason. Seven are decided pre-broadcast by the pure function; two are read post-broadcast from KeeperHub's own execution row.
| # | Outcome | Invariant | When |
|---|---|---|---|
| 1 | not-next-nonce |
I2 Β· I11 | No queued tx at the live on-chain nonce β or two different proposals share it, which is how "replace transaction" works, and choosing between them is not gavel's call |
| 2 | below-threshold |
I1 | Fewer distinct owner signatures than max(queue threshold, on-chain threshold) |
| 3 | eip1271-unsupported |
I7 | A CONTRACT_SIGNATURE / APPROVED_HASH confirmation, or one that is not 65 well-formed bytes |
| 4 | refund-requested |
I4 | gasPrice != 0, or non-zero gasToken / refundReceiver β see below, this is a drain vector aimed at the executor |
| 5 | delegatecall-refused |
I5 | operation == 1. We will not delegatecall someone's treasury on a schedule |
| 6 | threshold-drift |
I1 | The threshold was raised on-chain after signing; the queue still believes the old number is enough |
| 7 | owner-removed |
I6 | A confirmation from an address that is no longer an owner β a signature collected legitimately last week, from an owner removed yesterday |
| 8 | raced-gs026 |
post-broadcast | Someone else consumed the nonce first. An expected outcome, not a crash |
| 9 | inner-call-failed |
post-broadcast | execTransaction succeeded; the inner call reverted (GS013) |
Plus three guards that are deliberately not among the nine, because a guard whose correct behaviour is "never fires" must still not fall through to execution:
| Guard | Why it exists |
|---|---|
not-on-roster |
I3, re-checked inside the pure function and not only at the workflow's roster node, because a public Marketplace listing is a caller-open door |
incomplete-payload |
The Safe plugin's projection omits five of execTransaction's ten arguments. When they have not been hydrated we cannot evaluate the refund guard β so we refuse rather than assume zero |
malformed-payload |
Input that is not well-formed at all β a safeAddress that is not 20 bytes, a non-integer nonce, an empty owner array. Returned from eight call sites in src/assemble.mjs. Five of its eight call sites guard the queued transaction (tx.to, tx.gasPrice, tx.value, tx.safeTxGas, tx.baseGas β all Transaction Service fields); three guard the caller's own inputs. This row said "guards a caller, not a queued transaction" until 2026-09-09, and that wrong reading was the stated reason for its n/a above |
A refusal branch covered by a unit test is a claim about a pure function. A refusal branch with a
row in docs/outcomes-<chain>.jsonl is a claim about this software, running against a real Safe on
a real chain. Those are different evidence and this table does not let them look the same.
Every terminal state of a drain β refused, guarded, failed precondition, dry, executed β is written
by scripts/outcome-log.mjs. The table is generated, never hand-kept:
node scripts/outcomes.mjs --markdown.
| # | outcome | decided by | named in test/ |
observed live | rows |
|---|---|---|---|---|---|
| 1 | not-next-nonce |
assemble | β | β | 5 |
| 2 | below-threshold |
assemble | β | β | 3 |
| 3 | eip1271-unsupported |
assemble | β | β | 2 |
| 4 | refund-requested |
assemble | β | β | 2 |
| 5 | delegatecall-refused |
assemble | β | β | 2 |
| 6 | threshold-drift |
assemble | β | n/a | 0 |
| 7 | owner-removed |
assemble | β | n/a | 0 |
| 8 | raced-gs026 |
chain | β | β | 1 |
| 9 | inner-call-failed |
chain | β | β | 3 |
| β | not-on-roster |
assemble | β | β | 8 |
| β | incomplete-payload |
assemble | β | n/a | 0 |
| β | malformed-payload |
assemble | β | n/a | 0 |
All 8 reachable outcomes have been observed against a real Safe on a real chain. The last one,
eip1271-unsupported, needed no signer contract in the end: a Safe owner can pre-approve a hash
on-chain with approveHash(bytes32) instead of producing an ECDSA signature, and
checkSignatures then accepts a 65-byte word whose v is 1 β r is the owner address, s is
zero, and nothing is verified at all. That is the same shape the guard refuses, and it costs one
cheap transaction from an EOA rather than a deployment. seed.mjs govern --action approve-hash
does it.
Four are marked n/a, and that is a finding rather than a gap. They cannot be produced through
the production data source at all:
| outcome | why it cannot happen | measured |
|---|---|---|
threshold-drift |
the Safe Transaction Service reports confirmationsRequired as the live threshold, never a proposal-time snapshot, so "the queue still believes the old number is enough" is a state it will not serve |
BENCH_GOV, threshold raised 2β3 after signing; the service reported 3 immediately |
owner-removed |
the service prunes confirmations from addresses that are no longer owners β the stale signature never reaches a reader | BENCH_ROTATE, O2 signed, O2 removed; the service then returned the transaction with O2 absent |
incomplete-payload |
canvas-only: the Safe plugin projection omits five of the ten arguments. drain.mjs hydrates from the tx service and always has all ten |
β |
malformed-payload |
the Service validates and normalises the fields it serves, so it does not emit a malformed one. Not because it only guards a caller β five of its eight call sites guard tx.* fields that arrive from the Service. A different queue source could reach it |
β |
This corrects something this README used to claim. Outcome 7 was described as "a signature collected legitimately last week, from an owner removed yesterday β naive tooling broadcasts it." Naive tooling reading this service would never see that signature. The guards stay β the decision surface is pure and source-agnostic, and a queue that does not normalise (the plugin projection, a self-hosted service, a replayed fixture) can still present both shapes. They are defence in depth, and saying so is more useful than four green ticks.
KeeperHub is not a deployment target bolted on at the end β it is the runtime. gavel has no server, no scheduler and no wallet of its own; every read, every gate and the broadcast itself happen inside KeeperHub. Four API surfaces and the Safe plugin carry the whole flow:
Every surface below carries a liveness claim with a date. "Live" means it ran against a real Safe on a real chain and the executionId is on record. The canvas rows are dated 2026-09-11, because that is the day the Pro trial made them reachable β earlier than that this table said "authored, not in the production path" for all of them, and it was right to.
| Surface | Liveness | Where | What it does |
|---|---|---|---|
POST /api/execute/contract-call |
β live β all 150 executions | scripts/drain.mjs:210 |
Direct Execution. Always simulate: true as a preflight, then the real call. There is no idempotency key β this README claimed one until 2026-09-09 and the API exposes none. Two identical calls both broadcast, which is exactly what the deliberate race did; one came back Error(GS026). Only the Safe nonce prevents a double-spend |
GET /api/analytics/runs |
β live β 150 rows | scripts/audit.mjs:54 |
KeeperHub's own execution rows are the audit ledger: id, source, status, transactionHashes, startedAt, completedAt, durationMs, gasUsedWei, error. Those we render and never compute. verified / receiptStatus / blockNumber / gasUsed are ours, added only under --verify from getTransactionReceipt against the RPC β this README credited them to KeeperHub until 2026-09-09 |
| MCP server | β live, design-time | spikes | get_plugin, get_spending_limits, list_projects, list_integrations, GET /api/mcp/schemas. Every platform claim here was checked against a live MCP call |
POST /api/workflows/create Β· PATCH /api/workflows/{id} |
β live β 2026-09-11 | scripts/sync.mjs --push |
Emits and creates the gavel-drain graph β 11 nodes, 10 edges, committed at workflows/. Rejected with upgrade_required on the free plan until 09-11 (issue #2279, still a real DX gap); created as 7v0qwhcp5gcex58gjugyg the day the trial unlocked it. create stores workflowType: read regardless of nodes and only PATCH derives it β so the first save is what makes a write workflow write |
Canvas execution β POST /api/workflows/{id}/execute |
β live β 7 runs, 2026-09-11 | docs/outcomes-11155111.jsonl rows with stage: canvas |
The graph decided inside KeeperHub 7 times: 2 executions (hq4av5dbacb1i5z9bxfs0 β 0x188addfbβ¦, 819zav3gl3fkd89n7cb42 β 0x1377a121β¦), 2 named refusals (not-next-nonce, refund-requested), 1 deliberate GS026 race loss (j9g0a58oxem50jct5qpt7), 2 generator-bug runs kept in the fix log. Same assemble.mjs, same decisions drain.mjs makes |
Marketplace listing β POST /api/mcp/workflows/gavel-drain/call |
β live β listed 2026-09-11 12:36 UTC | workflows/gavel-drain-11155111-listed.json |
Slug gavel-drain, $0.05 USDC/call, x402 v2 on Base. The row is write, so a paid call returns execTransaction calldata the caller signs from their own wallet β a judge can drain a roster Safe themselves. Zero external paid calls so far; say so before anyone asks |
Safe plugin β safe/get-pending-transactions |
β live on the canvas β 2026-09-11 | workflows/*.json node queue-1 |
The queue read runs through the Safe plugin on every canvas run. -threshold, -owners, -nonce are in the Base graph only: the plugin's on-chain reads reject Sepolia (DX-6), so the Sepolia graph takes them with web3/read-contract against the Safe's own view functions. drain.mjs still reads api.safe.global and the RPC directly |
web3/read-contract Γ3 Β· web3/write-contract |
β live on the canvas β 2026-09-11 | workflows/*.json nodes threshold-1, owners-1, nonce-1, exec-1 |
exec-1 is why the graph exists β all 7 Safe plugin actions are reads, so there is no Safe-plugin write action to execute with. It sent both canvas executions and swallowed the race loss: with failOnError: false a reverted write comes back {"error":"Contract call failed: Error(GS026)","success":true} and the execution finalises as success with no hash |
So be precise about what "through KeeperHub" means here. Value moved through KeeperHub 150
times via the Direct Execution API (09-06/07) and the ledger proving it is KeeperHub's own β that
part was always real. As of 2026-09-11 the decision also runs inside KeeperHub: the canvas
workflow read the queue through the Safe plugin, read the chain, ran assemble.mjs in a Code
node and sent execTransaction from a write node β 2 executions, 2 refusals, 1 race loss. The
canvas track record is one day old and drain.mjs has the longer one. Both import the same
assemble.mjs, so the decision is identical either way; we are not going to blur which path
produced which number.
The graph is generated, never hand-drawn: sync.mjs injects src/assemble.mjs
verbatim into the Code node, so the function the tests run offline and the function the canvas
runs on-chain cannot drift apart. That is the whole reason the decision surface is pure.
Eleven reproducible findings came out of building against it, dated as they were hit and filed
upstream β see DX-REPORT.md; three of them (DX-9/10/11) came out of the first
day on the canvas. The sharpest is still DX-8: analytics/runs ages history out with no signal, so the endpoint this project treats as the audit trail returned 150
runs on 09-07 and 2 on 09-09. audit.mjs now refuses to shrink docs/receipts-*.json without
--prune, because a regenerate-in-place would have destroyed a ledger the API can no longer
reproduce. The transaction hashes on disk remain verifiable on the explorer regardless.
One of those filings is now KeeperHub code. PR #2277
β a regenerable trace-method availability probe with 38 tests, closing issue #2247 β was merged
2026-09-10 and shipped in KeeperHub v3.5.0
on 2026-09-12, credited in the release notes. Issues #2278 and #2279 are closed (DX-1 was fixed
upstream by another contributor in #2351); #2407 and #2408 are open. The filing ledger with dates and
review rounds is at the top of DX-REPORT.md.
safe/get-pending-transactions documents its output as "safeTxHash, to, value, data, operation,
nonce, confirmations, confirmationsRequired, dataDecoded, submissionDate".
execTransaction takes ten arguments. The projection supplies four of the payload fields and
omits safeTxGas, baseGas, gasPrice, gasToken, refundReceiver β verified live against
the running plugin, not read off a docs page.
Two of those five are not cosmetic. execTransaction pays a refund from the Safe to
refundReceiver and calls gasToken. A hostile pair is a drain vector aimed precisely at
whoever executes β which is us. The natural workaround is to default the missing fields to zero, and
that silently disarms the single most dangerous guard in the design: every payload then looks
refund-free, including the ones that are not.
gavel refuses with incomplete-payload instead, and reads the raw Transaction Service alongside the
plugin to hydrate the five fields. Filed upstream as DX-1 in
DX-REPORT.md β the strongest of eleven findings, with a fix that is a passthrough
rather than a redesign.
Every Safe integration guide tells you to normalise the signature v byte. gavel does not, and
src/assemble.mjs says why in a comment written before we had the data: the
Transaction Service stores each signature exactly as the signing client produced it, and Safe's own
clients already write v = 27/28 (EIP-712) or v = 31/32 (eth_sign) β precisely what
checkSignatures expects. Pass-through is correct; adding normalisation would have been the bug.
That was a prediction. Then we staged a real threshold-met payout and read the bytes back:
confirmations[0] 0x019db835β¦0c4a ETH_SIGN 65 bytes v = 0x1f (31)
confirmations[1] 0x20A5EdcDβ¦D754 ETH_SIGN 65 bytes v = 0x20 (32)
Predicted, then measured. The cryptographic surface stays at six lines.
150 end-to-end executions through KeeperHub, on Ethereum Sepolia (11155111) β the
rehearsal chain. docs/receipts-11155111.json holds all 150,
150 of 150 success, every one via the Direct Execution API, in the window
2026-09-06T16:01Z β 2026-09-07T10:11Z. The execution detailed below is an earlier one and is
deliberately not among those 150 β it is the first that ran end to end, and it is the one whose
every field was checked by hand:
| tx | 0xf0b3b611β¦a4cc68 |
| executionId | qy3vfai4lux3yokk7ilea Β· receiptStatus: success Β· block 11,618,878 |
| gasUsed | 144,637 |
| value moved | 10.00 USDC out of a Safe, to the payee |
isOwner(executor) on that Safe |
false β checked on chain |
| Safe nonce | 0 β 1 |
A threshold-met, deliberately unexecuted payout was drained by an address that owns nothing on that
Safe. Three gates ran first and all passed: assemble.mjs β a local read-only eth_call β
KeeperHub's own simulate: true preflight.
Also standing up:
- 12 Safes deployed on Ethereum Sepolia from one manifest, CREATE2-deterministic, eight of them funded β
thresholds 1-of-2 through 3-of-5, nine of them on the opt-in roster. That was five until
2026-09-09: the four
BENCH_*Safes each exist to demonstrate one named refusal, and none of them could, becausenot-on-rosteris the FIRST check inassemble.mjsand aroster:falseSafe refuses on the roster gate before reaching its own scenario.HERO_Bstays off the roster deliberately β it carriesassertNotOnRosterand is the witness that the guard fires. - The volume loop recycles, and you should know that when reading any execution count. Every
Safe swept is one we deployed, and the payouts they queue are ours β this is mechanism volume,
never summed with third-party volume. Value used to flow O1 -> Safe -> PAYEE exactly once, and
since the Sepolia faucet grants 20 USDC the cast simply ran dry.
scripts/seed.mjs recyclenow returns drained USDC from PAYEE to O1 soscripts/sweep.mjscan runfund -> stage -> drainagain. The same USDC therefore moves many times. Execution counts are counts of executions, not of distinct dollars, and the refusal invariant is what the volume is there to test. - 87 tests, ~0.08 s, including
test/live-fixture.test.mjs, which runs the untouchedassemble.mjsover a committed real Safe Transaction Service response. Unit fixtures prove we are self-consistent; that file proves we match reality, and they are deliberately two different files. - A test that asserts the executor is never an owner of any Safe in the cast, because that non-membership is the product claim.
- A test that asserts no
receiptsEligible: falsechain can contribute a row toEVIDENCE.mdβ the testnet/evidence separation enforced in code, not in a habit.
- No mainnet execution exists yet.
EVIDENCE.mdis absent by design rather than empty-by-accident:scripts/audit.mjsrefuses to write a row from a chain markedreceiptsEligible: false, and the only executions so far are on the rehearsal chain. The rehearsal log lives separately atdocs/rehearsal-11155111.mdand is labelled NOT EVIDENCE at the top. - The canvas workflow only became possible late.
POST /api/workflows/createrejects the graph on the free plan withupgrade_required: bothcode/run-code(which holds the entire decision surface) andHTTP Request(needed only because of the DX-1 gap above) require pro, and nothing β schemas,get_plugin, or 106 crawled doc pages β discloses that before create time. That is DX-7, filed as issue #2279 and maintainer-confirmed 09-04; it still stands as a developer-experience problem even though a 14-day trial activated 2026-09-11 no longer blocks us. The workflow has therefore run on the canvas only since 2026-09-11, across 7 executions, whereasscripts/drain.mjshas the 150-run track record on the Direct Execution API. Both import the sameassemble.mjs, so the decision is identical. - Two more generator bugs were only findable by running. The first canvas run died at the
address gate because
matchesRegexβ listed in the docs β cannot pass the Condition validator in any form; the second died in the Code node because templates are substituted as JSON values andsync.mjshad quoted two of them. Both fixed insync.mjsand both filed inDX-REPORT.md. - The Marketplace listing has zero external paid calls. It went live 2026-09-11 12:36 UTC at $0.05; nobody outside this team has paid to call it. Not published to the KeeperHub Hub; no demo of the listing in the video.
Open that transaction and the top-level From is 0x809D8252β¦0444 β a relayer we do not recognise β
and To is a contract we do not recognise. Our execTransaction is an internal call. This will
be the first thing a judge notices, so here it is before you wonder.
KeeperHub writes on supported networks are gas-sponsored by default, and the docs are explicit
that "a sponsored transaction was not sent by your wallet, so it does not appear there." But
eth_getCode on our executor returns:
0xef0100955d84139e7621bc571b117d8eb5d28a4a222c6f
^^^^^^ EIP-7702 delegation designator
ββ delegate target 0x955d8413β¦2c6f
The executor is a delegated EOA, not a bypassed one. Under EIP-7702 the delegate code runs in
the EOA's own context, so msg.sender at the Safe is still the executor's address β which is
exactly why the executor's nonce incremented (0 β 1) while its ETH balance did not change.
So the claim β executed by an address that owns none of them β is true, and isOwner(executor)
returns false on chain for anyone who wants to check it themselves. What is not true is that you
can verify it by opening the executor's address page and counting. Verify by transaction hash.
There is currently no supported way to opt out of sponsorship, which matters for any keeper whose entire evidence is "watch this one address". Filed as DX-4.
The survey turned up material we could not have staged.
0xE06A1ad7346Dfda7Ce9BCFba751DABFd754BAfAD, nonce 2 β 522.6 days. A real 2-of-3 Safe on
Base. Proposed 2025-03-28T20:24Z, fully signed to threshold 35 minutes later by two addresses
that ecrecover to its current owners, and sitting at the Safe's current on-chain nonce ever since.
The execution slot has been open for a year and five months, and any address on Earth could have
called execTransaction on it the entire time.
And the honest part: simulated today it reverts with GS013. The signatures pass; the inner
batch fails, because the Safe's ETH balance is now 0. The authorisation is real and the opportunity
decayed β which is the sharpest possible argument for gavel, not against it.
That is also why scripts/drain.mjs will not broadcast without a passing
read-only simulation: another Safe in the sample, 0xF262c998β¦6DA8, has nine fully-signed
transactions that have never executed. Two of them sat at the live nonce and were therefore
simulated; both reverted. The revert carried no reason string β survey/data/verified.json
records REVERT:execution reverted, and the other seven were never simulated at all β so "a guard
rejects these" is the likeliest reading and not a measured one. Until 2026-09-09 this paragraph
named InvalidSignatures(), which appears nowhere in the committed data. A tool that promises to clear those is advertising drains that can never land.
One command checks everything. Zero credentials, no network, under a second:
git clone https://github.com/edycutjong/gavel && cd gavel
npm install
npm run verifyIt runs every gate CI runs, in the same order, and prints PASS/FAIL per gate:
| Gate | What it proves |
|---|---|
| Decision surface is pure | src/assemble.mjs contains no import, eval, fetch, Date.now, Math.random or process.env β invariant I10 |
| JS test suite | 87 tests, node --test |
| Published figures re-derive | all 24 README figures, re-derived from 1.25 MB of committed raw responses |
| Solidity tests | forge test β skipped loudly if foundry is absent, never passed quietly |
An optional gate that cannot run reports SKIP, not PASS. A gate you can't run must never look green.
If you would rather run the pieces yourself:
npm test # 87 tests, ~0.08 s, no network, no keysThat suite includes test/live-fixture.test.mjs, which runs src/assemble.mjs over a committed real
Transaction Service response β so "the tests pass" is not a claim about fixtures we invented.
The rehearsal token has its own suite, and it also needs nothing installed β no forge-std, no
lib/, no submodules, because the four cheatcodes it uses are declared inline:
forge test # 22 tests, one of them a fuzz run
forge coverage # 100% lines, statements, branches and funcsCI enforces that 100%, on all four metrics, rather than reporting it.
The remaining commands touch real chains and need real credentials, kept in ~/.config/ and never in
this tree (~/.config/gavel/seed.txt for the cast mnemonic, ~/.config/gavel/safe-api-key,
~/.config/keeperhub/env):
# read-only: balances, deploy state, roster. Spends nothing.
node scripts/seed.mjs status --chain 11155111
# compute the 12 CREATE2 Safe addresses without deploying anything.
node scripts/seed.mjs predict --chain 11155111
# deploy / fund the cast, then propose+sign a payout to threshold and STOP.
node scripts/seed.mjs deploy --chain 11155111
node scripts/seed.mjs fund --chain 11155111
node scripts/seed.mjs stage --chain 11155111 --safe HERO_A
# run the pure function against a LIVE queue + live on-chain state.
node scripts/verify-assemble.mjs --chain 11155111 --safe HERO_A
# three gates, then stop. Dry by default β --execute is the only way to broadcast.
node scripts/drain.mjs --chain 11155111 --address 0xB8709787228046C4612D969A153942e1693e6169
# regenerate the workflow JSON with assemble.mjs injected verbatim into the Code node.
node scripts/sync.mjs --chain 8453 --safe-integration <id>
# render KeeperHub's own execution rows into a log; refuses ineligible chains.
node scripts/audit.mjs --chain 11155111drain.mjs without --execute stops after the three gates, always. A named refusal exits 3 (assemble-decided) or 1 (raced-gs026 and inner-call-failed, caught after the pure function); exit 0 means the gates passed β
refusing correctly is a success, not an error.
| Path | What it is |
|---|---|
src/assemble.mjs |
The entire decision surface. Pure, zero deps, zero I/O. Injected verbatim into the workflow's Code node by sync.mjs, so canvas and repo cannot drift |
src/manifest.json |
The 12-Safe cast + per-chain constants. receiptsEligible is load-bearing |
src/roster.json |
The opt-in list. Derived from the manifest by seed.mjs roster, never hand-kept |
scripts/seed.mjs |
status Β· predict Β· deploy Β· fund Β· stage Β· recycle Β· roster Β· govern. Stages the conditions. stage --hostile <variant> builds a queue shaped to force ONE named refusal; govern is the only subcommand that spends gas on something other than a payout β approve-hash produces the unverified v=1 word that outcome 3 refuses, and the two config actions are kept for the record even though the service normalises both outcomes away |
scripts/drain.mjs |
assemble β local eth_call β KeeperHub simulate β Direct Execution |
scripts/verify-assemble.mjs |
The pure function against a live queue; --write-fixture turns today's response into a regression test |
scripts/sync.mjs |
Emits the workflow JSON per chain (three of the four Safe plugin reads swap to web3/read-contract on testnets; queue-1 stays safe/get-pending-transactions on both, because queue monitoring does support Sepolia β see DX-6) |
scripts/audit.mjs |
Renders KeeperHub execution rows. Renders; never computes |
scripts/outcome-log.mjs |
One durable row per terminal state of a drain. KeeperHub's ledger can only ever show successes β outcomes 1-7 are decided before anything is sent β so refusals needed a record of their own |
scripts/outcomes.mjs |
Generates the coverage table above from that log plus test/. --markdown for the pasteable form |
scripts/bench.py |
Reduces those rows to p50/p95 duration and gas cost. Python 3 stdlib, no network β a reducer, never a harness: with no receipts there is no output |
contracts/ |
MockUSDC.sol, the testnet stand-in, and contracts/test/ β 22 tests at 100% coverage on every metric, dependency-free |
test/ |
87 tests: unit fixtures, the live-response regression file, manifest/roster invariants, and the coverage-gap suite (COVERAGE.md) |
survey/ |
The 1,299-Safe measurement: collectors, 1.25 MB of raw responses, and rederive.py, which asserts all 24 published figures offline |
DX-REPORT.md |
Eleven reproducible KeeperHub findings, dated as they were hit |
workflows/ |
Generated gavel-drain graph, 11 nodes (1 trigger + 10 actions), 10 edges |
docs/rehearsal-11155111.md |
Rehearsal log. Labelled NOT EVIDENCE |
docs/outcomes-11155111.jsonl |
Every decision gavel made on that chain, refusals included. Append-only JSON Lines |
| Not built | Reason |
|---|---|
| Any risk score or AI opinion on the payload | The owners already decided. An opinion turns gavel into a gatekeeper, which is the opposite of the product |
| EIP-1271 contract signatures | ~60 lines of offset arithmetic for a minority case. Refused by name: eip1271-unsupported β and the refusal is demonstrated, without a signer contract: an owner pre-approving a hash on-chain produces the same unverified v = 1 word, which seed.mjs govern --action approve-hash does in one transaction |
| A dashboard | The demo runs on three surfaces we did not build: app.safe.global, the KeeperHub canvas, the block explorer |
| A database, or a copy of KeeperHub's execution rows | Its rows carry status, durationMs and gasUsedWei, and --verify adds the chain's own receiptStatus, blockNumber and gasUsed. A copy of that is a worse copy. docs/outcomes-*.jsonl is not that copy β it records what gavel decided, which is a strictly larger set: a refusal never reaches the API, and a failed run carries no transaction hash, so neither can appear in execution rows |
| A custom contract, staking, or a bond | gavel's claim is that the executor needs no trust. A bond invents the trust relationship the design exists to delete |
| Private-mempool routing | It does exist on KeeperHub write actions. We turn it down: private-mempool writes are not gas-sponsored, and execution outputs carry no public/private route field, so the route could never be evidenced on a receipt |
| A Safe-plugin write action | It does not exist. All 7 Safe plugin actions are reads. Saying so is more useful than implying otherwise |
Corrections we made to our own published claims, dated, each one checkable against the commit
named. They are here rather than only in git log because a retraction nobody can read is not a
retraction.
| Date | We said | It was actually | Fixed in |
|---|---|---|---|
| 2026-09-11 | This README, the landing page, the deck and the frozen BUIDL answers all said the workflow does not exist on the canvas | True until 2026-09-11 08:06 UTC, false after. A Pro trial unlocked workflows/create; the graph ran 7 times on the canvas the same day and was listed on the Marketplace. The frozen DoraHacks Q2 answer still carries the old claim and cannot be edited β read it as dated 09-09 |
fb6ea60, f086cfc, a5414a5 |
| 2026-09-09 | Outcome 7 described "a signature collected legitimately last week, from an owner removed yesterday β naive tooling broadcasts it" | Naive tooling would never see that signature. The Safe Transaction Service prunes confirmations from addresses that are no longer owners, and reports confirmationsRequired as the live threshold β so owner-removed and threshold-drift cannot be produced through it at all. Measured on BENCH_ROTATE and BENCH_GOV, not inferred. Both guards stay as defence in depth; the coverage table now says n/a |
c0fef30 |
| 2026-09-09 | audit.mjs said "Do not hand-edit β regenerate" |
Regenerating destroyed the ledger. KeeperHub's analytics/runs aged 150 runs down to 2 between 09-07 and 09-09 with no retention signal, so a regenerate-in-place replaced a 150-row audit trail with a 1-row one and exited 0. It came back only because the file was committed. A shrinking rewrite is now a refusal. Filed as DX-8 |
8d55bcb |
| 2026-09-09 | The landing page repeated two of the three invented claims above | site/index.html carried the same non-existent Idempotency-Key, and credited KeeperHub with verified / receiptStatus / blockNumber / gasUsed in the same words the README did. Fixing the README did not fix the page a judge actually opens first, and the second sweep is the only reason it was caught |
site/index.html |
| 2026-09-09 | Stale counts in five separate places, found five separate times | The README said 50 executions (data: 150) and 80 tests (suite: 87); rostering four Safes took the opt-in list from five to nine while the README still said five; and the landing page and deck were worse β site/index.html claimed 24 executions, median 14.3 s against a real 150 and 11.6 s, plus 80 tests in three more places; survey/README.md still asserted the 22 figures this very table retracts; and DEMO.md said 64 tests, untouched since 09-02 and sitting on the reproduce path the README sends judges down. One cause throughout: the data is regenerated and the prose is not |
0c3ccfc, 88bb3d2, site |
| 2026-09-07 | The README said rederive.py asserts 22 published figures |
It asserts 24. A stale count, carried across the README, the project description and a published write-up | 408ff1b β and the number is no longer hardcoded in npm run verify, so it cannot drift again |
| 2026-09-06 | Our OG image led with a Safe "stalled 9d 04h" | An invented duration on an invented Safe. The assets were generated on Aug 26 against a pitch that no longer held, and their hero figures were composites. Every number in them is now measured | 154694d |
| 2026-09-06 | Eleven invariants, stated as holding | Three of eleven did not hold as written, and in the generated workflow none of them were load-bearing. An adversarial review also found a critical injection: sync.mjs spliced a webhook-supplied address raw into a JS source string, so a caller could close the literal and hoist their own assemble() over the real one, bypassing all eleven guards at once. Every case was a working exploit before it was a test |
ac621ab |
| 2026-09-06 | The test badge said 50 tests | The suite had 64. Corrected β then found stale a second time and corrected again | 414176c, 3e795e9 |
| 2026-09-06 | The demo video's closing card listed three honest gaps while its own narration said "two honest gaps, both on screen" | The count disagreed with the voiceover, and the third bullet denied the existence of the video a judge was watching at that moment. It was also submission status, not an engineering limitation | be6383f |
The largest correction is the product's premise. We set out to build a keeper that clears stuck
Safe transactions, on the assumption that the hard part was cryptographic β reassembling collected
signatures into the blob checkSignatures expects. That part is about 40 lines.
Then we ran the obvious detector against 1,299 Base mainnet Safes, read-only, before writing the pitch. It matched 366 transactions and 339 of them can never execute. The detector we were about to ship is ~93% false positives, and the condition is rare β 5 of 1,298 readable Safes, 0.39% β not the epidemic a launch post would have implied.
So the refusal logic is not defensive polish around the product. It is the product. We would rather say that here than have you find it.
MIT β see LICENSE.
