Skip to content

test(cketh): adversarial end-to-end coverage of sweeper fee funding - #11097

Draft
mbjorkqvist wants to merge 6 commits into
mathias/DEFI-2933-observabilityfrom
mathias/DEFI-2933-hardening
Draft

test(cketh): adversarial end-to-end coverage of sweeper fee funding#11097
mbjorkqvist wants to merge 6 commits into
mathias/DEFI-2933-observabilityfrom
mathias/DEFI-2933-hardening

Conversation

@mbjorkqvist

@mbjorkqvist mbjorkqvist commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

Part of DEFI-2933 (sweeper fee funding), last of a seven-PR stack. Targets #11094.

Why

The stack's existing live test proves funding works. This one proves it fails safe. Each test drives a distinct way funding can go wrong through the real pipeline — real ckETH ledger, real EVM RPC canister, local anvil, threshold-ECDSA signature — and asserts the invariant the feature exists to protect: ETH never moves ahead of the ckETH burned to pay for it.

What

Three adversarial scenarios:

  • An empty fee account. The burn fails, so no ETH may move. Held for longer than a withdrawal-timer tick, since "the minter must not do this" can only be established as a bounded negative.
  • A sweeper already above the low-water mark. No funding is due, so nothing is burned. Also a bounded negative.
  • A funding transaction that fails on chain. The destination is given code, so a bare transfer runs out of gas and reverts. The ckETH stays burned and is not reimbursed — it becomes prepaid gas the next funding offsets against, which is exactly what keeps burned ahead of spent. The test waits for finalization and asserts the withdrawal reaches a terminal failed status rather than a reimbursement.

No live fee-spike test: that ceiling is pinned exactly by unit tests, and reproducing it here would mean driving anvil's base fee up over several ticks for little extra signal.

What it found

Waiting for finalization — rather than asserting mid-flight, as the earlier live test did — surfaced a production defect that would trap the withdrawal timer permanently. The fix ships in #11086, where it belongs; this PR is what made it visible.

Harness work this required

Two things had to change before these tests could run at all, both also in #11086:

  • A real deposit. Crediting the minter's address with anvil_setBalance moves ETH the minter's accounting has never seen. The harness now emits a genuine ReceivedEth log for the minter to scrape, so its ETH balance counter is backed the way it is in production.
  • A PocketIC server that outlives the suite. PocketIc::new hard-codes a 600-second hard TTL, after which the server exits unconditionally — mid-request, ignoring in-flight work, with the budget shared across the whole test binary. Any suite past ten minutes has its backend shot out from under it, and every client then panics with Connection reset by peer. The harness starts its own server with a TTL above the Bazel timeout.

Note on CI: the new target is tagged long_test, so ordinary CI skips it. It needs a run with CI_ALL_BAZEL_TARGETS before merge.

Stack

Merge in order; each PR targets the one above it.

# PR Status
1 #11060 — Read a native ETH balance via the EVM RPC canister ready for review
2 #11065 — Burn ckETH from the minter's own fee subaccount ready for review
3 #11072 — Add the SweeperFunding withdrawal-request variant ready for review
4 #11083 — Burn-first accounting for sweeper fee funding ready for review
5 #11086 — Sweeper fee-funding task, with an end-to-end test Copilot re-review pending, CI green incl. long tests
6 #11094 — Sweeper funding observability and the prepaid-gas gate open
7 Adversarial end-to-end coverage of sweeper fee funding this PR

Complements the happy-path e2e with the three ways funding can go wrong, each
driven through the real pipeline — real ckETH ledger, real EVM RPC canister,
tECDSA signature, local anvil:

- An empty fee account halts funding: nothing is burned, no ETH leaves the main
  address, and the minter reports why: if the fee account cannot cover a sweep,
  no sweep is submitted.
- A sweeper already above the low-water mark is left alone, so no ckETH is burned
  for gas that is already in place — the failure mode a wrongly-defaulted balance
  read would cause.
- A funding transaction that fails on chain is NOT reimbursed: the supply stays
  reduced and the burn is tracked as prepaid gas. This is the central promise,
  and it is now demonstrated end to end rather than only against a mocked receipt.

The last one has to be arranged, because it is otherwise unreachable: a bare
value transfer to a code-less EOA cannot fail, which is exactly why the sweeper
is an EOA. Placing `PUSH1 0, PUSH1 0, REVERT` at the sweeper address leaves the
transfer's 21'000 base gas with nothing to execute the code, so it fails.

Two of the three are bounded *negative* assertions — "the minter must not do X"
cannot be proven outright, so they watch for longer than a withdrawal-timer tick,
which is the practical substitute. That makes the target slow on purpose; if nine
minutes per CI run is not worth it, these are the natural candidates for a
nightly-only tag.

No live fee-spike test: the ceiling is pinned precisely by
`should_cap_resubmission_at_the_funded_amount` and
`should_fail_to_create_a_transaction_when_the_fee_exceeds_the_funded_amount`, and
reproducing it live would mean driving anvil's base fee up and waiting out several
timer ticks for little extra signal. Recorded in the test file so the omission is
deliberate rather than forgotten.

The harness now reads the sweeper address from the dashboard instead of scraping
the funding task's log line. That is what makes the "already topped up" scenario
possible — the address is needed before the task acts — and it exercises the
dashboard section as a side effect.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

CI_ALL_BAZEL_TARGETS Runs all bazel targets test

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant