Skip to content

Build1 publisher3 min readPublished

A 22-byte self-destructing stub sweeps USDC from a deposit address that has no key

Pigeonhole hands out CREATE2 deposit addresses on Arc and collects them with a stub that self-destructs inside the deploy transaction. Because paid state comes only from eth_getLogs, the public RPC's 9,999-block cap gave every invoice a shelf life.

The Engineer · Build desk

Illustration accompanying A 22-byte self-destructing stub sweeps USDC from a deposit address that has no key

What happened

  • Pigeonhole derives each deposit address as the CREATE2 address of a 22-byte throwaway whose entire code is PUSH20 treasury followed by SELFDESTRUCT, with the salt set to keccak256 of the invoice ID.
  • A pre-submission audit measured the public Arc RPC on 2026-09-17 and found that an eth_getLogs span of 9,999 blocks is accepted while 10,000 returns error -32012, requested range too large.
  • The version that passed every test scanned the whole chain history in a single call, which only worked because the factory was a few hours old when the tests ran.

Compiled by The EngineerSomething wrong?How this is made

Why it matters

  • capability An operator can issue deposit addresses it holds no key for, so there is nothing to generate, rotate or guard per customer, and the material an attacker would want does not exist until the sweep transaction.
  • cost Collection is priced in the same asset as the invoice, so a merchant modelling per-deposit cost works with a tenth of a cent of gas and no exchange-rate assumption in the spreadsheet.
  • constraint Copying the pattern to a chain where USDC is an ERC-20 loses both halves of it: the stub cannot move the token balance, and the incoming payment would not show up in logs to begin with.
  • decision Anyone deriving product state from logs on Arc now has to decide whether a provider-side query limit is an acceptable dependency or whether the scan runs against a node they operate.

Two properties of Arc have to hold at once for this to work. USDC is the native balance there, so a value transfer to an address with no code settles the way ether does on Ethereum [1]. Arc's system emitter at 0xffff...fffE then writes that movement as a Transfer event, and an empty EXTCODESIZE does not block the send [6]. Everywhere else USDC is an ERC-20. SELFDESTRUCT cannot move an ERC-20 balance, and a native send to a codeless address leaves no log, so the pattern is either impossible or blind on other EVM chains, according to the dev.to write-up [12].

The collection path is short. sweep(salt) recomputes the expected address, CREATE2-deploys the init code, and reverts with Create2Mismatch if the deployed address is not the predicted one [8]. That init code is hex 73, the 20-byte treasury address, hex ff [3]: one byte of PUSH20, twenty bytes of address, one byte of SELFDESTRUCT, 22 bytes total [4]. Because the constructor is the SELFDESTRUCT, the balance reaches the treasury inside the deploy transaction, and EIP-6780 deletes the account again since it was created in that same transaction [7]. The address comes out empty and reusable [9].

The write-up benchmarks a balance-moving sweep at 64,162 gas, p50 over 25 bench rows against the production factory, about $0.0013 at Arc's 20 Gwei floor [10]. The multiplication is straightforward: 64,162 gas at 20 Gwei is 0.00128 USDC, and no conversion step is involved because the gas asset is the stablecoin [11]. For the dollar reading to transfer, the 20 Gwei floor has to hold and USDC has to sit at par; the sample is 25 runs against one factory, so it is the median of one configuration.

There is no database. PAID means the system emitter has a Transfer with to == pigeonhole, SWEPT means there is also a Transfer from it that took the balance to zero, and the invoice is its URL [13]. Every page load is therefore an eth_getLogs query, and the query has a ceiling.

The first version asked for the whole history in one call and passed every test, because the factory was hours old at the time [14]. Nothing in a test suite ages. A pre-submission check measured the public RPC on 2026-09-17: a span of 9,999 blocks was accepted, 10,000 was rejected with -32012 "requested range too large" [15]. Arc produces roughly two blocks a second, which puts 9,999 blocks at about 5,000 seconds of chain [17], near the 85 minutes the write-up cites [16]. Past that point a refresh for an older invoice throws, and the page keeps showing UNPAID for an invoice that was paid [18].

The repair is three changes: chunk every scan, poll incrementally, and carry the invoice's creation block in its URL as ?from= [19]. With ?from= set, the span of each query depends on how old the invoice is, not on how old the factory is. The code is MIT-licensed, and the write-up links a live page and a 2:30 recording of a full cycle on Arc mainnet [20].

What to watch

  • Whether 9,999 blocks is documented Arc RPC policy or one provider's default: the figure comes from a single measurement on 2026-09-17.
  • Whether Arc's 20 Gwei gas floor holds, since the per-sweep cost is quoted directly in USDC rather than converted.
  • Whether the same eth_getLogs cap appears on a private node or a second provider, which would tell you where the limit is set.
Loading claim ledger
Loading source directory links
Loading share composer
Loading topic controls
Loading related stories