The idea was clean enough to be suspicious. A Safe multisig splits one act into two things that are not the same: signing is off-chain and free, and executing is an on-chain call that costs gas, can be made by anyone, and is assigned to nobody.
So transactions that already hold every signature they need just sit there. The money was approved and never moved.
Finding them looks trivial. The Safe Transaction Service exposes each Safe's queue, and every queued transaction carries isExecuted, confirmations and confirmationsRequired. One line:
!tx.isExecuted && tx.confirmations.length >= tx.confirmationsRequired
I was going to build a keeper that clears them. Before writing the pitch, I ran that detector, read-only, against 1,299 distinct Base mainnet Safes — sampled over 41 evenly-spaced windows covering all of Base history, blocks 2,000,000 to 50,776,046.
It found 366 transactions. 339 of them can never execute.
| The naive detector | Count |
|---|---|
| Transactions matching | 366 |
| ...permanently dead | 339 (92.6%) |
| ...live | 27 (7.4%) |
| ...live and at the Safe's current nonce | 6 |
...and simulating SUCCESS under eth_call
|
2 |
366 down to 2. The detector I was about to ship is ~93% false positives, and I only know that because I measured before I wrote the marketing.
Why they're dead
Every Safe transaction is bound to a nonce, and a Safe executes nonces strictly in order. When a transaction is proposed at nonce 7 and something else is later executed at nonce 7 — a replacement, a different payout, anything — the original stays in the queue forever. It still reads isExecuted: false. It still has all its signatures. It is also permanently unexecutable, because its slot is gone.
The queue does not mark this. Nothing in the object says "dead." You only learn it by comparing each queued transaction's nonce against the Safe's live on-chain nonce — an RPC call the naive detector never makes.
That is the whole gap between a demo and a tool. A keeper built on the one-liner would spend real gas retrying 339 transactions that are structurally incapable of landing.
What the measurement changed
I had the product backwards. I thought the hard part was cryptographic: reassembling collected 65-byte signatures into the blob checkSignatures expects. That part is about 40 lines.
The hard part is knowing which 7.4% are real, and then which of those would actually land. So the refusal logic stopped being defensive polish around the product and became the product. Every non-execution returns a named reason rather than a boolean:
export const REFUSALS = Object.freeze([
'not-next-nonce',
'below-threshold',
'eip1271-unsupported',
'refund-requested',
'delegatecall-refused',
'threshold-drift',
'owner-removed',
// assigned post-broadcast, never by the decision function:
'raced-gs026',
'inner-call-failed',
]);
My favourite is the one the survey forced me to write. The service permits two different proposals at the same nonce — that is exactly how "replace transaction" works in the Safe UI. The obvious code takes candidates[0]. But choosing between two competing proposals for someone else's treasury is a policy call, and it is not mine to make:
const candidates = transactions.filter(
(tx) => toCount(tx?.nonce) === nonceOnchain
);
if (candidates.length === 0) {
return refuse('not-next-nonce',
`No queued transaction at the live nonce ${nonceOnchain}.`);
}
if (candidates.length > 1) {
return refuse('not-next-nonce',
`${candidates.length} different proposals share nonce ${nonceOnchain}; ` +
`choosing between them is not gavel's call.`);
}
Two candidates refuses just as firmly as zero. Picking one would have looked like a working feature right up until it executed the wrong payout.
How it actually executes
Once a transaction survives every refusal, something still has to pay the gas — and that is where the design either works or collapses. gavel does not hold a key to your Safe. It is not an owner, it has no approval, no owner slot, no capital. isOwner(executor) returns false on chain for anyone who checks.
The broadcast goes through KeeperHub, which supplies a managed execution wallet: an address that is neither mine nor yours. That is the only reason the onboarding ask is one line — give me your Safe address — instead of the thing no treasury will ever agree to, which is handing a keeper a private key.
Safe Transaction Service -> queue + raw 65-byte confirmations
live on-chain reads -> nonce() / getThreshold() / getOwners()
pure decision function -> ten execTransaction args, or a named refusal
KeeperHub -> broadcasts from a wallet that owns nothing
The decision function in the middle has zero dependencies and zero I/O — no fetch, no clock, no randomness. That is what makes it testable offline against real captured bytes, and it is why the 80 tests need no network and no credentials.
The one that is hard to argue with
The survey turned up something I could not have staged. A real 2-of-3 Base Safe, 0xE06A1ad7346Dfda7Ce9BCFba751DABFd754BAfAD, nonce 2. Fully signed to threshold 35 minutes after it was proposed. Still sitting at its current on-chain nonce 522.6 days later.
Any address on Earth could have executed it for a year and five months. Nobody did, because executing was nobody's job.
Simulate it today and it reverts GS013 — the ETH balance is now zero. The authorisation was real, and the opportunity decayed while everyone assumed signing was the finish line.
Check the numbers yourself
Every figure above re-derives offline from committed data. No credentials, no network, about two tenths of a second:
git clone https://github.com/edycutjong/gavel
cd gavel
python3 survey/rederive.py
It asserts all 24 published figures against 1.3 MB of raw responses committed in survey/data/, and fails loudly if any one drifts. CI runs it on every push, so the prose in the README cannot quietly diverge from the measurement behind it. The test suite is separate: npm test, 80 tests in ~0.08 s, no network and no keys.
I would rather you check than believe me. That is the point of shipping the data with the claim.
What it doesn't do
- It is testnet. Execution runs on Ethereum Sepolia: 50 runs, 50 successful rows, zero failures. There is no mainnet execution. The measurement is mainnet; the execution is rehearsed.
- No EIP-1271 support. Contract-owner signatures are refused by name rather than handled — about 60 lines of offset arithmetic for a case I could not demo without deploying a signer contract.
- No per-Safe circuit breaker. A permanently-reverting transaction gets retried every sweep, at roughly four cents a day. First thing I would fix.
- The condition is rare, not epidemic. 5 of 1,298 readable Safes hold a live stall — 0.39%. I am not going to tell you there are thousands of stuck transactions waiting, because my own data says there aren't.
That last one cost me the bigger story, and it is the number I am most confident in.
Repo: https://github.com/edycutjong/gavel · Live: https://gavel.edycu.dev/
If you run multisig treasuries and want to know whether anything of yours is actually stuck, the survey code is read-only and takes a Safe address — no key, no approval, nothing to sign.
Built during the KeeperHub Agent Economy hackathon. The survey and the numbers above predate the writeup and stand on their own.
Top comments (0)