DEV Community

Cover image for A USDC deposit address with no private key — and the 9,999-block RPC cap that almost froze it
Edy Cu
Edy Cu

Posted on

A USDC deposit address with no private key — and the 9,999-block RPC cap that almost froze it

Every deposit address an exchange hands out is a private key someone has to generate, store, guard and eventually sign a sweep with. I built a deposit address that has no key at all — and then, the evening before submitting it, found out the live page would have frozen at "UNPAID" a few hours after deploy.

This is the mechanism, the bug, and what pinned it.

A deposit address with no private key

Pigeonhole runs on Arc, Circle's L1 where USDC is the native balance — it pays for gas, and a plain value transfer moves it. That one fact makes an old Ethereum trick work for a stablecoin.

The factory holds an immutable treasury. For any invoice, salt = keccak256(invoiceId), and the deposit address is the CREATE2 address of a 22-byte throwaway whose entire code is PUSH20 treasury; SELFDESTRUCT:

/// @notice The sweeper init-code, derived from the immutable treasury.
function initCode() public view returns (bytes memory) {
    return abi.encodePacked(hex"73", treasury, hex"ff");
}

/// @notice The deterministic deposit address for `salt` (offline-reproducible).
function predict(bytes32 salt) public view returns (address) {
    return address(
        uint160(uint256(keccak256(abi.encodePacked(hex"ff", address(this), salt, keccak256(initCode())))))
    );
}
Enter fullscreen mode Exit fullscreen mode

That address is handed out before anything is deployed. It has no code, no nonce and no key. Because USDC is Arc's native balance, sending to it just works — EXTCODESIZE == 0 is fine for a native send, and Arc's system emitter (0xffff…fffE) logs the movement as a Transfer.

When anyone calls sweep(salt), the factory CREATE2-deploys the throwaway. Its constructor is the SELFDESTRUCT, so the whole balance moves to the treasury inside the deploy transaction, and EIP-6780 deletes the account again because it was created in the same transaction:

/// @notice Sweep the pigeonhole for `salt` to the treasury. Permissionless; funds can only reach `treasury`.
function sweep(bytes32 salt) public returns (address deployed) {
    address expected = predict(salt);
    uint256 bal = expected.balance;
    bytes memory code = initCode();
    assembly {
        deployed := create2(0, add(code, 0x20), mload(code), salt)
    }
    if (deployed != expected) revert Create2Mismatch(expected, deployed);
    emit Swept(salt, expected, bal);
}
Enter fullscreen mode Exit fullscreen mode

The address is empty and reusable afterwards. A balance-moving sweep on the production factory is 64,162 gas at p50 over 25 bench rows, about $0.0013 at Arc's 20 Gwei floor.

On every other EVM chain, 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 everywhere else. On Arc it is both possible and observable.

No backend: PAID and SWEPT are one log filter

There is no database. PAID is "the system emitter has a Transfer with to == pigeonhole", SWEPT is "…and a Transfer with from == pigeonhole that took the balance to zero". The invoice is its URL.

Which means the entire product stands on eth_getLogs. That is where it nearly fell over.

The wall: 9,999 blocks

Day-2 code walked the full history in one call. It worked in every test and in every manual run, because the factory was hours old.

The pre-submission audit asked a boring question: what does the public RPC actually allow? The answer, measured on 2026-09-17: a span of 9,999 blocks is fine; 10,000 is rejected with -32012 requested range too large. Arc produces roughly two blocks a second. 9,999 blocks is about 85 minutes of chain.

So the page would have kept working for the rest of the afternoon, and then, for every invoice older than ~85 minutes, every refresh would have thrown, and the UI would have sat on "UNPAID" forever — for a paid invoice. The demo I had planned to record the next morning would have been the first thing to break.

The fix is unglamorous: chunk every scan, poll incrementally, and carry the invoice's creation block in its URL (?from=) so a scan never starts at the factory's genesis when it doesn't have to.

// Chunked, incremental eth_getLogs over the EIP-7708 system emitter.
// The public Arc RPC rejects any eth_getLogs span of 10,000+ blocks with -32012 "requested range too large"
// (measured 2026-09-17: 9,999 ok, 10,000 rejected). At ~2 blocks/s that is ~85 minutes of chain, so every
// scan must be chunked and every poll must be incremental — a fresh full-history scan per refresh is not viable.

/** Largest span the RPC accepts, with headroom (9,000 < 10,000). Exported so the test can pin it. */
export const MAX_LOG_SPAN = 9_000n;

/** Yields [from, to] inclusive spans, each ≤ MAX_LOG_SPAN blocks wide, covering from..to. */
export function* spans(from: bigint, to: bigint, max: bigint = MAX_LOG_SPAN): Generator<[bigint, bigint]> {
  for (let start = from; start <= to; start += max) {
    const end = start + max - 1n > to ? to : start + max - 1n;
    yield [start, end];
  }
}
Enter fullscreen mode Exit fullscreen mode

The walk itself is sequential and paced, because the same RPC also rate-limits bursts, and it reports every completed chunk so the caller can checkpoint:

export async function fetchMovements(client: LogClient, pigeonhole: Address, fromBlock: bigint, toBlock: bigint,
  onChunk?: (moves: Movement[], chunkEnd: bigint, progress: Progress) => void): Promise<Movement[]> {
  const out: any[] = [];
  const all = [...spans(fromBlock, toBlock)];
  for (let i = 0; i < all.length; i++) {
    const [a, b] = all[i];
    if (i > 0) await paced(client);
    const ins = await withRetry(() => client.getLogs({ address: ARC.systemEmitter, event: transferEvent, args: { to: pigeonhole }, fromBlock: a, toBlock: b }));
    await paced(client);
    const outs = await withRetry(() => client.getLogs({ address: ARC.systemEmitter, event: transferEvent, args: { from: pigeonhole }, fromBlock: a, toBlock: b }));
    out.push(...ins, ...outs);
    onChunk?.(toMovements([...ins, ...outs]), b, { done: i + 1, total: all.length, toBlock: b });
  }
  return toMovements(out);
}
Enter fullscreen mode Exit fullscreen mode

Checkpoints go to localStorage, so a returning visitor never re-walks what they already read. The two seeded demo invoices ship a committed, receipt-verified checkpoint in the repo so they open in seconds.

And it is pinned, because "fixed the same evening" is worth nothing without a test that fails if someone bumps the constant:

it("no span ever reaches 10,000 blocks, and the spans tile the range exactly", () => {
  const out = [...spans(21_337_182n, 21_337_182n + 250_000n)];
  expect(out.every(([a, b]) => b - a + 1n <= MAX_LOG_SPAN && b - a + 1n < 10_000n)).toBe(true);
  expect(out[0][0]).toBe(21_337_182n);
  expect(out.at(-1)![1]).toBe(21_337_182n + 250_000n);
  for (let i = 1; i < out.length; i++) expect(out[i][0]).toBe(out[i - 1][1] + 1n);
});
Enter fullscreen mode Exit fullscreen mode

Two more tests cover a 250k-block history fetching without -32012, and overlapping polls never double-counting a log (keyed by tx:logIndex — the RPC is load-balanced, and two backends can disagree about the head).

What I took from it

The mental model that was wrong: "a view call is free, so ask for everything." Log queries are not views. Every public RPC has a span cap and a rate, they are rarely in the docs, and they only bite once the chain has moved on without you — which is exactly the window between "demo recorded" and "reviewer opens the link".

The audit question that found it was not clever. It was "what are the limits of the one external thing this depends on, measured, not assumed." I now ask it on day one.

Honest limitations

  • The page needs an anonymous Arc RPC and reads history at the rate that RPC sustains (measured ≈ 0.5 eth_getLogs/s). An invoice URL without ?from= scans from the factory's deploy block — about 38 calls per day of chain — so the first read of an old invoice takes minutes. Progress is shown and never repeated, but it is minutes.
  • PAID latency is not benchmarked.
  • The treasury is immutable: a single point of failure, by design. There is no beneficiary rotation; the remedy is a new factory.
  • The factory's source is not verified on the explorer (its API sits behind a challenge page). The on-chain runtime code is byte-identical to forge build output — keccak 0x8806de8d… — and that check is in the repo.

Proof, if you want to check

Live page: https://pigeonhole.edycu.dev/ · repo: https://github.com/edycutjong/pigeonhole

If you run deposit infrastructure and this pattern would or wouldn't work for you, I'd genuinely like to hear why.

Top comments (0)