feat(cketh): sweeper fee-funding task, with an end-to-end test - #11086
feat(cketh): sweeper fee-funding task, with an end-to-end test#11086mbjorkqvist wants to merge 43 commits into
Conversation
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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_livehas 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 thesupply_before/fee_account_beforesnapshots, 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
left a comment
There was a problem hiding this comment.
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:
- 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;
- read the sweeper address, set its balance above the low-water mark, then fund the fee account;
- upgrade the minter to re-arm the timers, so a check runs again inside the test;
- 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.
There was a problem hiding this comment.
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()callsupgrade_minter(), whose zero-delay timer startsfund_sweeper_addressbefore 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 awaitingeth_getBalance. If either task burns before these queries,burnedis 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());
There was a problem hiding this comment.
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_livereturns. The test then recordssupply_beforeandfee_account_beforeafter that burn, so the eventual transfer makesburned == 0and 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();
…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>
7b14621 to
f2bddc0
Compare
0cd713d to
6320ea9
Compare
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.
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.
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>
|
✅ No security or compliance issues detected. Reviewed everything up to 9e941fe. Security Overview
Detected Code Changes
|
There was a problem hiding this comment.
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, butnew_liveitself 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",
| // 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. |
There was a problem hiding this comment.
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.
| 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. |
There was a problem hiding this comment.
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
left a comment
There was a problem hiding this comment.
LGTM! The main question is about the test infrastructure setup
| // 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)) { |
There was a problem hiding this comment.
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(); |
There was a problem hiding this comment.
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.
| ) | ||
|
|
||
| rust_test( | ||
| name = "sweeper_funding", |
There was a problem hiding this comment.
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.
| 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); | ||
| } |
There was a problem hiding this comment.
I still very much want to reuse the existing infrastructure that we have and then do the ckETH deposit flow, which we already have.
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
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 taggedlong_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_INTERVALis 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.ymlrecords it.Stack
Merge in order; each PR targets the one above it.