Skip to content

feat(cketh): sweeper fee-funding task, with an end-to-end test - #11086

Open
mbjorkqvist wants to merge 43 commits into
masterfrom
mathias/DEFI-2933-funding-task
Open

feat(cketh): sweeper fee-funding task, with an end-to-end test#11086
mbjorkqvist wants to merge 43 commits into
masterfrom
mathias/DEFI-2933-funding-task

Conversation

@mbjorkqvist

@mbjorkqvist mbjorkqvist commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

Part of DEFI-2933 (sweeper fee funding), first of the three remaining PRs. Targets master, now that #11083 has merged.

Also carries the fee-subaccount burn helper, folded in from #11065 — that PR was closed rather than merged, since on its own it was a constant and a function nothing called. It lands here instead, next to its only caller and the end-to-end test that drives it through the minter. The ICRC-2 rule it depends on — that a spender may spend from an account only when it names that account's own subaccount — is covered for every ledger in #11139.

What

The periodic task that keeps the dedicated sweeper address funded: derive the address, decide whether a top-up is due, burn ckETH from the minter's fee subaccount, and only then queue the transfer. Everything after the burn is the existing withdrawal pipeline. A failed burn halts funding — and eventually sweeping — rather than moving ETH nothing has paid for.

How it knows the sweeper is low

Not by reading the chain. The minter already records what it sent: the ETH that finalized fundings delivered is a lower bound on the sweeper's balance, and nothing else debits that address until sweeping lands (S2), at which point the bound subtracts the gas submitted sweeps provisioned. ETH sent there by anyone else only pushes the true balance above the bound.

A bound is the right input for the decision, because it errs in the safe direction: at or above the low-water mark it proves no funding is needed; below it, we may top up when we need not, which costs a burn we are backing anyway and self-corrects, since our own transfer raises the bound. It also means the funding task makes no HTTPS outcall at all — no provider agreement to choose, no block height to pick, no unreadable-balance path to handle, and nothing to go stale.

What a funding burns

The amount it moves, and nothing else: the fee is carved out of that same amount, exactly as for a user withdrawal, so a top-up of 0.3 ETH — the target ten minimum withdrawals imply on mainnet — burns 0.3 ckETH and the sweeper receives 0.3 minus the fee. Whatever part of the provisioned fee goes unpaid stays as backing rather than being credited against the next funding — the simplification agreed for this iteration, which leaves funding accounted for the same way withdrawals already are.

The amount always clears the ledger's own minimum without a floor of its own, since a funding moves at least the configured headroom and validation keeps that at or above the minimum withdrawal amount. A real burn is what gives the funding the ledger index the whole withdrawal pipeline is keyed by.

Only one funding is allowed in flight at a time, which the planner reads off the withdrawal pipeline — a funding request that is pending, or has a created or sent transaction, is outstanding. Deriving it rather than mirroring it in the accounting means the two cannot disagree, so there is no inconsistent state to detect. The rule itself is prudence rather than a correctness requirement — two fundings would each be covered by their own burn — but it keeps a single funding on the withdrawal nonce lane and the accounting easy to follow.

A production guard the adversarial test forced

Finalizing a funding debits the minter's ETH balance counter, which is credited only by deposits. In steady state every debit is covered by its own burn, so the counter cannot go negative — but on a fresh deployment it starts at zero while the main address may already hold ETH, and nothing previously stopped a funding from reaching for it. The debit at finalization would then underflow and trap, in the withdrawal timer, which would be stuck permanently and head-of-line block every user withdrawal behind it. It does not need anything to go wrong: a perfectly successful funding triggers it.

Planning now refuses to fund beyond the deposit-backed balance and logs why. Waiting is the safe direction — the sweeper simply stays unfunded until deposits cover it.

This was invisible until the adversarial test in the last PR of the stack waited for finalization; both live tests previously finished while the transfer was still in flight, which is also why the assertions could not see it.

Live tests that no longer wait

The funding path sits behind the 6-minute withdrawal timer, so the end-to-end test used to spend that time on the wall clock. It now buys the ticks with advance_time, which works on a live instance: auto-progress sets the time once and thereafter advances by elapsed deltas, so an explicit jump sticks. 372s to 18.6s, and the target is no longer tagged long_test, so it runs in the ordinary pipeline. The harness also sets the certified time before going live, the same race #11299 just fixed for the balance-scan harness.

Two rules the harness documents at the constants that enforce them: a jump fires a due interval timer once however far it jumps, so N ticks need N jumps; and nothing may be in flight when time moves, because CANISTER_HTTP_TIMEOUT_INTERVAL is 60 seconds and PocketIC uses the real payload builder — so the harness lets outcalls drain after each jump.

Cost

Replaying the recorded mainnet event log through the funding accounting costs 2.13% more instructions in bench_post_upgrade (1.155B to 1.180B); canbench/results.yml records it.

Stack

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

# PR Status
#11060 — Read a native ETH balance via the EVM RPC canister merged
#11065 — Burn ckETH from the minter's own fee subaccount closed; folded into #11072 and #11086
#11072 — Add the SweeperFunding withdrawal-request variant merged
#11083 — Burn-first accounting for sweeper fee funding merged
1 Sweeper fee-funding task, with an end-to-end test this PR
2 #11094 — Sweeper funding observability and the prepaid-gas gate draft
3 #11097 — Adversarial end-to-end coverage of sweeper fee funding draft

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds periodic ckETH sweeper fee funding through the existing withdrawal pipeline.

Changes:

  • Adds balance-based funding, burn-first accounting, and in-flight protection.
  • Separates transferred ETH from ckETH burned in events and requests.
  • Adds mock support, unit tests, and a live end-to-end Anvil test.

Reviewed changes

Copilot reviewed 22 out of 22 changed files in this pull request and generated 1 comment.

Show a summary per file
File Description
rs/ethereum/cketh/test_utils/src/sweeper_funding.rs Adds the live funding test harness.
rs/ethereum/cketh/test_utils/src/mock.rs Supports and filters balance RPC calls.
rs/ethereum/cketh/test_utils/src/lib.rs Settles install-time funding checks.
rs/ethereum/cketh/test_utils/src/anvil.rs Adds mainnet-like Anvil and balance helpers.
rs/ethereum/cketh/minter/tests/sweeper_funding.rs Tests funding end to end.
rs/ethereum/cketh/minter/tests/dump_stable_memory.rs Maps the new burn field.
rs/ethereum/cketh/minter/tests/cketh.rs Filters scraping outcall assertions.
rs/ethereum/cketh/minter/src/sweeper/tests.rs Tests funding plans and concurrency guards.
rs/ethereum/cketh/minter/src/sweeper.rs Implements the funding task.
rs/ethereum/cketh/minter/src/state/transactions/tests.rs Updates funding fixtures.
rs/ethereum/cketh/minter/src/state/transactions/mod.rs Separates burn and transfer amounts.
rs/ethereum/cketh/minter/src/state/tests.rs Updates state generators and fixtures.
rs/ethereum/cketh/minter/src/state/sweeper_funding/tests.rs Tests in-flight accounting.
rs/ethereum/cketh/minter/src/state/sweeper_funding.rs Tracks earmarked funding.
rs/ethereum/cketh/minter/src/state/audit/tests.rs Updates event mapping tests.
rs/ethereum/cketh/minter/src/state/audit.rs Replays burn and in-flight state.
rs/ethereum/cketh/minter/src/state.rs Finalizes funding accounting.
rs/ethereum/cketh/minter/src/main.rs Registers funding timers and exposes events.
rs/ethereum/cketh/minter/src/lib.rs Exports sweeper logic and intervals.
rs/ethereum/cketh/minter/src/endpoints.rs Exposes the burn amount in events.
rs/ethereum/cketh/minter/cketh_minter.did Updates the Candid event schema.
rs/ethereum/cketh/minter/BUILD.bazel Adds the long-running test target.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread rs/ethereum/cketh/minter/src/main.rs Outdated

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 22 out of 22 changed files in this pull request and generated no new comments.

Suppressed comments (1)

rs/ethereum/cketh/minter/tests/sweeper_funding.rs:17

  • This seeds the fee account only after new_live has installed the minter, but installation immediately starts the funding callback (test_utils/src/sweeper_funding.rs:92-95). The callback can therefore attempt the burn before this transfer, or burn between this transfer and the supply_before/fee_account_before snapshots, making this long test nondeterministically fail or observe zero burned. Seed the fee account before installing the minter (for example, pass the initial balance into the setup constructor) so the task cannot race the test arrangement.
    setup.mint_cketh(setup.fee_account(), FEE_ACCOUNT_BALANCE);

@mbjorkqvist mbjorkqvist left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

On the suppressed comment (tests/sweeper_funding.rs:17, seeding the fee account after install)

Confirmed and fixed in 3a2e797. Both failure modes you describe are real, and there is a connection worth recording: the ordering fix in the previous round made this race more likely, not less. The old 60-second delay had been accidentally giving the test time to seed the fee account before the task looked; sequencing the check straight after the key fetch removed that grace period.

The fee account is now seeded through the ledger's initial_balances, so the balance exists before the minter is installed at all and the task cannot precede it — your suggestion, and the right shape, since the ledger is installed first. new_live_with_empty_fee_account covers the one test that wants it empty.

Verified by running both live suites rather than reasoning about it, and that turned up a second instance of the same class which the fix had exposed:

should_not_fund_a_sweeper_above_the_low_water_mark (in PR 7 of this stack) failed — supply 1.0 → 0.9 ETH, a burn where the test asserts none. With the fee account funded from install and the check firing immediately, the install-time check funded the sweeper before the test could top it up. And there is no second chance inside the test window: the next scheduled check is 24 hours away.

That test now arranges itself in the only order that is deterministic:

  1. start with an empty fee account, so the install-time check decides a funding is due, fails to burn, and changes nothing — the address is undiscoverable until the minter caches its key, so this is the only window;
  2. read the sweeper address, set its balance above the low-water mark, then fund the fee account;
  3. upgrade the minter to re-arm the timers, so a check runs again inside the test;
  4. assert no burn — which now means it declined with a funded fee account and a real opportunity to act.

Both suites pass: sweeper_funding 370 s, sweeper_funding_hardening 492 s. The other two hardening tests needed no change — one deliberately uses the empty fee account, and the revert test's set_code has the full 6-minute withdrawal-timer window before the transfer is sent, so its burn landing early is expected rather than racy.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 24 out of 25 changed files in this pull request and generated 1 comment.

Suppressed comments (1)

rs/ethereum/cketh/minter/tests/sweeper_funding.rs:19

  • These “before” snapshots race the operation under test. new_live() calls upgrade_minter(), whose zero-delay timer starts fund_sweeper_address before the constructor returns; live PocketIC keeps processing that timer concurrently. The install-time task can also observe the deposit because it reads state only after awaiting eth_getBalance. If either task burns before these queries, burned is observed as zero (or the subtraction uses a post-burn baseline), making the long test flaky. Capture/retain the seeded supply, fee-account, and ETH baselines before re-arming funding, then expose them to this assertion.
    let supply_before = setup.cketh_total_supply();
    let fee_account_before = setup.cketh_balance_of(setup.fee_account());
    let minter_eth_before = setup.anvil_eth_balance(&setup.minter_address());

Comment thread rs/ethereum/cketh/minter/BUILD.bazel Outdated

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 25 out of 26 changed files in this pull request and generated no new comments.

Suppressed comments (1)

rs/ethereum/cketh/test_utils/src/sweeper_funding.rs:144

  • This setup relies on a nondeterministic timer race. The install-time funding callback waits only for the ECDSA key, while the install-time scraper runs concurrently; if scraping credits the pre-emitted deposit first, this initial check can burn and enqueue funding before new_live returns. The test then records supply_before and fee_account_before after that burn, so the eventual transfer makes burned == 0 and fails. Arrange the deposit and fee balance so funding cannot succeed until the post-upgrade run, or preserve a pre-funding baseline rather than assuming the scraper loses the race.
        // The install-time funding check races the scrape, so it will have seen a zero balance, and
        // the next scheduled one is a whole interval away. Re-arm the timers once the deposit has
        // landed so tests start from a minter that can actually fund.
        setup.await_deposit_credited(Duration::from_secs(300));
        setup.upgrade_minter();

Comment thread rs/ethereum/cketh/test_utils/src/sweeper_funding.rs Outdated
Comment thread rs/ethereum/cketh/minter/BUILD.bazel Outdated

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 25 out of 26 changed files in this pull request and generated no new comments.

Rachit2323 pushed a commit to Rachit2323/ic that referenced this pull request Aug 12, 2026
…ity#11060)

Part of [DEFI-2933](https://dfinity.atlassian.net/browse/DEFI-2933)
(sweeper fee funding), first of a seven-PR stack.

## Why

Funding the sweeper address with gas requires knowing how much gas it
already holds. The EVM RPC canister exposes no endpoint for a native ETH
balance, and its Rust client offers no getter for one, so the minter
currently has no way to ask.

## What

Reads the balance through the EVM RPC canister's generic JSON-RPC
passthrough, which forwards a payload to every provider and agrees on
one answer under the configured consensus strategy. That strategy is a
threshold of the providers — 3 of 4 on mainnet, 2 of 4 on Sepolia — and
it is the only agreement accepted: there is no client-side reduction, so
a result the canister reports as inconsistent stays an error rather than
being resolved by picking a winner.

Because the canister deserializes each response's `result` field, what
the minter receives is the quantity itself rather than any surrounding
JSON. It is therefore decoded exactly: quotes, padding, leading zeros
and sign characters are the provider's own malformation and are rejected
rather than repaired.

A failed read is an error, never a zero. This is the decision the rest
of the stack depends on: confusing "could not read the balance" with "no
gas left" would burn ckETH to top up an address that is already funded,
which is pure loss. The request builder and the result decoder are pure
functions so both sides of that guarantee are pinned directly, including
a test asserting that no error input can decode to a zero balance.

The route was also proven end to end against a live EVM RPC canister and
a local anvil node, reaching 3-of-4 consensus at both `latest` and
`finalized`.

## Stack

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

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

[DEFI-2933]:
https://dfinity.atlassian.net/browse/DEFI-2933?atlOrigin=eyJpIjoiNWRkNTljNzYxNjVmNDY3MDlhMDU5Y2ZhYzA5YTRkZjUiLCJwIjoiZ2l0aHViLWNvbS1KU1cifQ

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
@mbjorkqvist
mbjorkqvist force-pushed the mathias/DEFI-2933-burn-first-accounting branch 2 times, most recently from 7b14621 to f2bddc0 Compare August 14, 2026 09:10
@mbjorkqvist
mbjorkqvist force-pushed the mathias/DEFI-2933-funding-task branch from 0cd713d to 6320ea9 Compare August 14, 2026 13:28
Added for the balance read; with the read gone nothing names it, and #11243 can
put it back in one line when its own tests need it.
mbjorkqvist added a commit that referenced this pull request Aug 25, 2026
Master generified the transaction pipeline over its request type and added a
second lane for sweeps, so `outstanding_sweeper_funding` moves from the generic
pipeline — where it could not match on `WithdrawalRequest` variants — onto
`WithdrawalTransactions`, which knows the concrete type. Master also grew its own
`State::sweeper_address`, so ours goes and its explanation moves to master's, and
the pipeline's `record_withdrawal_request` and free `create_transaction` are now
`record_request` and a `PipelineRequest` method.
mbjorkqvist added a commit that referenced this pull request Aug 25, 2026
mbjorkqvist and others added 9 commits August 25, 2026 06:42
Added for the long-lived HTTP server that kept a live instance alive past
600 seconds; `advance_time` replaced that, and the dependency outlived it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The run at install is the only other chance to fund, and on a fresh
deployment it fires before any deposit is credited, so it refuses. A daily
interval then leaves the sweeper unfunded for a day; a check costs a heap
read unless a funding is actually due, so checking hourly bounds that gap
for nothing. How often the minter actually funds is set by the low-water
mark, not by the interval.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`plan_funding` reported an in-flight funding before asking whether anything
was due, so an hourly check with a topped-up sweeper reported the funding it
happened to find in the pipeline rather than "nothing to do" — noise now, a
misleading metric once one exists. Asking `amount_due` first makes the quiet
case quiet.

Also walks the pipeline from the furthest stage backwards, so the funding
reported is the one accepted first — the one that would be stuck — rather
than whichever stage the scan reached first, and pins that with an
assertion. Narrows the deposit-backed balance check's comment to what it
actually buys: solvency rests on every debit being covered by its own burn,
not on this comparison.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`make_live` returns as soon as its auto-progress task is spawned, before
that task's own time-set applies a five-year jump. An ingress message
submitted in between is stamped from the genesis clock and then
retroactively expired, and never answered — the flake #11299 fixed for the
balance-scan harness. Jumping the clock synchronously first leaves
auto-progress a millisecond-sized step. Nothing to drain beforehand here:
no canister installed by then issues outcalls of its own.

Also brings the comments back in line with a funding decision that reads no
balance off the chain, and drops the empty-fee-account constructor, which
belongs with the hardening tests that use it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Plus three test-side tidies: a subtraction that would report an underflow
as a panic rather than as the failed assertion it is, a helper whose `pub`
outgrew its `pub(crate)` module, and a loop range this branch reworded for
no reason.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Replaying the recorded mainnet log through the funding accounting costs
2.13% more instructions. Expected for state and an event type the replay did
not have before, and well inside the post-upgrade budget.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Reverts the hourly interval: with the minter upgraded regularly and each
upgrade running the check immediately, the day-long gap only bites a fresh
deployment, where nothing consumes gas yet. Worth revisiting with real gas
figures once sweeping lands, and as a ramp rather than a shorter constant if
so — `DEPOSIT_ADDRESS_SCAN_WINDOW` already has that shape.

Trims the comments this round added, down to what the code does not say:
the ordering rationale in `plan_funding` belongs in its commit message, the
balance check needs only the case it exists for, and the harness comment can
point at #11299 rather than restate it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Nothing calls it, and its doc still pointed at a `set_eth_balance` the
balance-bound work removed. `settle` mines through anvil directly.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@mbjorkqvist
mbjorkqvist marked this pull request as ready for review August 25, 2026 07:51
@mbjorkqvist
mbjorkqvist requested a review from a team as a code owner August 25, 2026 07:51
@github-actions github-actions Bot added the @defi label Aug 25, 2026
@mbjorkqvist
mbjorkqvist requested review from gregorydemay and a balanced review from Copilot August 25, 2026 07:51
@zeropath-ai

zeropath-ai Bot commented Aug 25, 2026

Copy link
Copy Markdown

No security or compliance issues detected. Reviewed everything up to 9e941fe.

Security Overview
Detected Code Changes
Change Type Relevant files
Enhancement ► rs/ethereum/cketh/minter/src/sweeper.rs
    Add sweeper funding functionality and funding logic
► rs/ethereum/cketh/minter/src/main.rs
    Wire in sweeper funding task and interval
► rs/ethereum/cketh/minter/src/state.rs
    Expose sweeper address and adjust tests visibility
► rs/ethereum/cketh/minter/src/state/sweeper_funding.rs
    Add sweeper funding accounting and balance bound logic
► rs/ethereum/cketh/minter/src/state/tests.rs
    Update tests to use new EthBalance type and related tests
► rs/ethereum/cketh/minter/src/state/transactions/mod.rs
    Introduce outstanding_sweeper_funding accessor
► rs/ethereum/cketh/minter/src/state/transactions/tests.rs
    Adjust tests to reflect new outstanding funding behavior
► rs/ethereum/cketh/minter/tests/deposit_from_cex.rs
    Adjust live balance scan and funding tests to new live setup
Enhancement ► rs/ethereum/cketh/minter/canbench/results.yml
    Update benchmarks due to changes in sweeper funding flow
Enhancement ► rs/ethereum/cketh/minter/BUILD.bazel
    Add sweeper funding test and related test targets
Enhancement ► rs/ethereum/cketh/docs/deposit_from_cex.md
    Clarify daily funding flow and sweeper funding description
Enhancement ► rs/ethereum/cketh/minter/src/ledger_client.rs
    Add burn_from_own_subaccount and burn_from_with_spender variants to support sweeper funding
Enhancement ► rs/ethereum/cketh/minter/src/main.rs
    Import sweeper module and funding interval constant
► rs/ethereum/cketh/minter/src/main.rs
    Register sweeper funding timer and interval
Enhancement ► rs/ethereum/cketh/minter/src/state.rs
    Make tests module public in some places and expose sweeper-related state accessors
► rs/ethereum/cketh/minter/src/state.rs
    Add SweeperFunding-related state and TaskType variants
Enhancement ► rs/ethereum/cketh/minter/src/state/sweeper_funding.rs
    Add sweeper balance lower bound accessor and related logic
Enhancement ► rs/ethereum/cketh/minter/src/state/sweeper_funding/tests.rs
    Add tests for sweeper funding balance lower bound and funding scenarios
Enhancement ► rs/ethereum/cketh/minter/src/state/tests.rs
    Add EthBalance helper and test utilities for sweeper funding tests
Enhancement ► rs/ethereum/cketh/minter/src/state/transactions/mod.rs
    Add outstanding_sweeper_funding retrieval logic
Enhancement ► rs/ethereum/cketh/minter/src/state/transactions/tests.rs
    Update withdrawal and funding tests to reflect new outstanding funding behavior
Enhancement ► rs/ethereum/cketh/minter/tests/deposit_from_cex.rs
    Update live balance scan and funding tests to use new live setup

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 18 out of 18 changed files in this pull request and generated 2 comments.

Suppressed comments (1)

rs/ethereum/cketh/minter/BUILD.bazel:341

  • size = "medium" gives this target a 300-second timeout, but new_live itself permits 300 seconds for deposit crediting, after which this test permits another 120 seconds for the funding decision and multiple settle windows. On a slow run Bazel can kill the test before the harness deadlines and their diagnostics fire. Restore an explicit longer timeout for this live test.
    size = "medium",

Comment thread rs/ethereum/cketh/minter/src/sweeper.rs Outdated
Comment on lines +40 to +42
// What the minter's own records say reached the sweeper address, rather than a chain read: the
// bound errs low, so it can only delay a funding, never authorise one against gas that is not
// there.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You are right, and the docstring on plan_funding below already had it the other way round from this comment — which is how the slip survived. Fixed in 704b743: erring low can only make a funding look due sooner than it is, never hide one that is.

The "never authorise gas that is not there" half belongs to the other reader of the same bound, the sweeping side, where erring low withholds gas rather than over-committing it. That distinction now lives where both readers meet, in the design doc.

Comment on lines +354 to +357
delivered, less the gas submitted sweeps provisioned — and may reconcile that
bound against the chain whenever it chooses. The bound errs low: ETH anyone else
sends to the address only pushes the true balance above it, so the bound can only
delay a funding or a sweep, never authorise one against gas that is not there.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed — the two effects were collapsed into one sentence. Fixed in 704b743; the paragraph now separates them: a funding may be triggered earlier than strictly needed, never skipped, and a sweep may be held back, never authorised against gas that is not there.

Copilot is right that the call site had it backwards: understating the
sweeper's balance can only bring a funding forward, never delay one. The
design doc conflated the two readers of the bound, which err in opposite
directions — a funding comes early, a sweep is held back.

Also gives every wait in the harness one deadline, sized to fail inside the
Bazel budget of the targets that run it. The waits summed to more than
`size = "medium"` grants, so a genuinely hung minter would have been killed
with nothing to show rather than failing with its logs.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

@gregorydemay gregorydemay left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM! The main question is about the test infrastructure setup

Comment thread rs/ethereum/cketh/minter/src/sweeper.rs Outdated
// Erring low can only make a funding look due sooner than it is, never hide one that is.
let sweeper_balance = read_state(|s| s.sweeper_funding.sweeper_balance_lower_bound());

let amount = match read_state(|s| plan_funding(s, sweeper_balance)) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: since plan_funding already needs a &State there is no need for the second parameter sweeper_balance, since it can be read from state.


#[test]
fn should_fund_the_sweeper_address_by_burning_cketh_from_the_fee_account() {
let setup = SweeperFundingSetup::new_live();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would like to rebuild it on that rather than relocate the tests: it removes the duplication you are pointing at, and it inherits a property live_scan.rs documents and this harness does not — build the fixture on a non-live instance and only then go live, since building against an auto-progressing instance "reproducibly failed under CPU contention".

I don't follow the reasoning, at the end we want to test the feature end-to-end (as much as possible) and so we will need one test file to do that, and the sweeper funding task is part of that feature. If there is some problems with the current test harness, then let's fix those.

Comment thread rs/ethereum/cketh/minter/BUILD.bazel Outdated
)

rust_test(
name = "sweeper_funding",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Less of an issue now that ticks are cheap, but the fixture mismatch stands.

I don't agree with this part: there is not fixture mismatch, we want to test the full feature. As part of that feature, the ckETH ledger is also needed because the ledger fees are what is used to pay for the sweeper transaction fees.

Comment on lines +219 to +255
pub(crate) fn emit_received_eth(
&self,
helper: &Address,
from: &Address,
value: u128,
principal_topic: &[u8; 32],
) {
// Keccak256("ReceivedEth(address,uint256,bytes32)")
let received_eth_topic = keccak256(b"ReceivedEth(address,uint256,bytes32)");
let mut from_topic = [0_u8; 32];
from_topic[12..].copy_from_slice(from.as_ref());

let mut code = Vec::new();
// mstore(0, value) — the log's data word.
code.push(0x7f);
code.extend_from_slice(&u256_be(value));
code.extend_from_slice(&[0x60, 0x00, 0x52]);
// LOG3 pops offset, size, topic1, topic2, topic3, so push them in reverse.
code.push(0x7f);
code.extend_from_slice(principal_topic);
code.push(0x7f);
code.extend_from_slice(&from_topic);
code.push(0x7f);
code.extend_from_slice(&received_eth_topic);
// PUSH1 32 (size), PUSH1 0 (offset), LOG3, STOP. LOG3 is 0xa3 — 0xa2 is LOG2, which would
// drop the principal topic and the minter would reject the event as having invalid topics.
code.extend_from_slice(&[0x60, 0x20, 0x60, 0x00, 0xa3, 0x00]);

self.set_code(helper, &code);
let hash = self.send_transaction(from, Some(helper), &[]);
assert!(
status_ok(&self.await_receipt(&hash)),
"emitting the deposit log reverted"
);
// The minter reads at `finalized`, which trails `latest`.
self.mine(3);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I still very much want to reuse the existing infrastructure that we have and then do the ckETH deposit flow, which we already have.

mbjorkqvist and others added 7 commits August 25, 2026 12:27
Nothing in the harness credited the minter honestly: a hand-assembled
`ReceivedEth` log stood in for a deposit, `anvil_setBalance` put ETH at the
minter's address that no deposit had paid in, and the fee account was minted
its ckETH directly. All three are gone.

The harness now deploys `DepositHelperWithSubaccount.sol` — the production
contract, compiled at test time by the vendored solc, as the demo test
already does — against the address the minter derives, adds it by upgrade the
way mainnet gained it, and deposits through it. The ETH arrives at the
minter's address because a depositor sent it, and the event the minter
scrapes is the one the contract emits.

The fee account is credited the same way, by a deposit naming its subaccount.
It earns its ckETH from ledger fees in production, but at 2e12 wei a transfer
that would take 150'000 transfers to reach the funding target.

Deposits now land after the scrape that runs at install, so waiting for them
buys the tick that scrapes them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
# Conflicts:
#	rs/ethereum/cketh/minter/canbench/results.yml
Both sides of the merge had moved the figure. Regenerated with the `_update`
target rather than picking a side: measured on the merged tree it comes out
at 1.177B, below master's new 1.187B baseline.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`plan_funding` took a `&State` and, separately, a bound derived from that
same state — so a caller could decide against a balance the state
contradicts, which is exactly what the tests did. It now reads the bound
itself, and the task takes one snapshot for the decision and the log lines.

That makes the tests say what they mean. Two of them funded the whole target
and then asserted a funding was due again, which only held because they
passed a zero balance in; they now fund partially, so "the guard lifted" is
distinguishable from "the sweeper needs nothing" — and a new test asserts the
second case, where the state alone makes it true. The repeated-funding test
arranges its fundings rather than planning them, since nothing draws the
bound down until sweeping spends the gas.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The harness built its own PocketIC instance and installed its own ledger,
EVM RPC canister and minter — a second copy of what `CkEthSetup` already
does, differing only in ways nothing needed. It now wraps that fixture
against an owned anvil node, exactly as the balance-scan harness does, and
the test moves into `deposit_from_cex` so the feature's live tests share one
target.

What was duplication is gone: the instance builder, the three installs, the
controller, the ledger balance query, the address polling. What is left is
what the shared fixture has no reason to know about — the anvil-side
arranging and the buying of minter time.

The go-live sequence itself was duplicated too; both harnesses now call
`switch_to_live`. Its anvil is mainnet-like for both, which the fixture's
minter has always assumed: it signs for `EthereumNetwork::Mainnet`, so a
chain id of 31337 would have had anvil reject anything it sent.

Two things do not survive the move, both because the instance is live: the
minter's address has to be awaited without ticking, and the upgrade goes
through the client rather than `CkEthSetup`'s tick-driven stop. Both are
commented where they sit.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`live_scan.rs` and `sweeper_funding.rs` were the same wrapper twice — a
fixture, an owned anvil node, and a switch to live outcalls — differing only
in what they built and what they knew how to arrange. They become
`LiveSetup<S>`, generic over the fixture, in one module.

Everything a live test needs regardless of feature now lives in one place:
buying minter time, depositing through the helper contract, reading the
dashboard and the canister log, and the anvil-side accessors. The balance
scan keeps its token seeding and scan waiter as a layer on
`LiveSetup<CkErc20Setup>`; funding keeps its fee-account seeding on
`LiveSetup<CkEthSetup>`. 797 lines across two modules become 700 in one,
having gained the generic structure.

The funding fixture also stops holding the tests' ledger baselines. It leaves
the minter's timers un-armed instead, so a test reads its own numbers and
then says when the minter may act — which is what the baselines were working
around.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
# Conflicts:
#	rs/ethereum/cketh/minter/src/lib.rs
#	rs/ethereum/cketh/minter/src/main.rs
#	rs/ethereum/cketh/minter/src/state.rs
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants