feat(cketh): sweeper funding observability and the prepaid-gas gate - #11094
feat(cketh): sweeper funding observability and the prepaid-gas gate#11094mbjorkqvist wants to merge 28 commits into
Conversation
There was a problem hiding this comment.
Pull request overview
Adds observability and a fail-closed prepaid-gas gate for ckETH sweeper funding.
Changes:
- Caches sweeper balance observations and validates gas availability and freshness.
- Exposes funding accounting, balances, and age metrics.
- Adds dashboard reporting and tests for funding state and gate behavior.
Reviewed changes
Copilot reviewed 10 out of 10 changed files in this pull request and generated no comments.
Show a summary per file
| File | Description |
|---|---|
templates/dashboard.html |
Adds sweeper-funding dashboard section. |
src/sweeper.rs |
Caches observed sweeper balances. |
src/state/tests.rs |
Updates state fixture. |
src/state/sweeper_funding/tests.rs |
Tests prepaid-gas gate behavior. |
src/state/sweeper_funding.rs |
Defines observations and fail-closed gate. |
src/state.rs |
Stores the volatile balance observation. |
src/main.rs |
Exports six sweeper-funding metrics. |
src/lifecycle/init.rs |
Initializes the observation cache. |
src/dashboard/tests.rs |
Tests sweeper dashboard output. |
src/dashboard.rs |
Builds sweeper dashboard data. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| if observed.balance < required { | ||
| return Err(PrepaidGasUnavailable::Insufficient { | ||
| available: observed.balance, | ||
| required, | ||
| }); | ||
| } | ||
| Ok(observed.balance) |
There was a problem hiding this comment.
The observation about the function's semantics is correct — it is a per-request predicate against a snapshot, and two 6-wei sweeps would both pass against a 10-wei observation. I have made that explicit in the doc comment (cd36d1a), since the previous wording invited exactly this reading.
I don't think the conclusion follows, though, on two counts.
The backing invariant does not rest on this check. record_finalized_funding adds the whole transferred amount to cumulative_transferred as soon as the funding transaction finalizes, and cumulative_spent() = transferred + fees. Every wei at the sweeper address is therefore already covered by a burn that preceded the ETH moving, so drawing that balance down — even over-eagerly — cannot make cumulative spend outrun cumulative burn. What over-authorising costs is a sweep the sweeper cannot pay for: wasted signatures, nonces and outcalls, not under-backed ckETH.
There is no caller yet. Nothing in production calls check_prepaid_sweep_gas; this PR adds the observation cache and the predicate, while sweeping itself is [S2] (DEFI-2926), still in the backlog. The function takes Option<ObservedSweeperBalance> by value, so it cannot reserve anything by construction — reserving across several sweeps is the caller's job.
So rather than restructure a function whose caller does not exist, I have recorded the requirement on DEFI-2926: the sweeping task must subtract what it has already committed since the last observation, either by tracking outstanding reservations in state and including them in required or by reconciling each against its receipt. Flagging it here was useful — daily observations mean many sweeps can fall between two of them, so this would have been easy to get wrong.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 10 out of 10 changed files in this pull request and generated no new comments.
Suppressed comments (1)
rs/ethereum/cketh/minter/src/sweeper.rs:62
- The cache-write behavior is not asserted by the new tests: the gate and dashboard tests inject
ObservedSweeperBalancedirectly, whiletests/sweeper_funding.rsonly checks that funding lands. Removing this block—or accidentally placing it after theNotDuereturn—would therefore leave tests green but keep the gate atNeverObservedon the common no-funding path. Add task-level or integration coverage showing that every successful balance read refreshes the value and timestamp even when funding is not due, and that a failed read does not fabricate an observation.
// Cached before deciding anything: sweeping consults this far more often than it changes, and
// recording it even when no funding is due is what keeps the observation fresh.
let observed_at_nanos = ic_cdk::api::time();
mutate_state(|s| {
s.last_observed_sweeper_balance = Some(ObservedSweeperBalance {
balance: sweeper_balance,
observed_at_nanos,
});
});
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 12 out of 12 changed files in this pull request and generated no new comments.
Suppressed comments (2)
rs/ethereum/cketh/minter/src/state.rs:126
- The safety-critical upgrade behavior is not covered: existing tests exercise initial
Noneand successful/failed reads, but none starts withSome(...), performs replay/upgrade, and verifies the observation is absent afterward. Please add that test so this volatile cache cannot later become event-sourced or upgrade-persistent unnoticed; assert the post-upgrade metrics/gate remains fail-closed until a new balance callback completes.
/// The sweeper address' ETH balance as last read on chain, i.e. the prepaid sweep gas.
/// Volatile cache refreshed by the funding task, deliberately not event-sourced.
pub last_observed_sweeper_balance: Option<ObservedSweeperBalance>,
rs/ethereum/cketh/minter/src/main.rs:1135
- This gauge is not always the credit available to offset the next funding. Once a funding burn is accepted,
burned_not_yet_spent()includes that new burn, whilein_flight_funding().amountis explicitly earmarked andplan_fundingrefuses to reuse it. During that interval the metric can overstate usable credit by the entire funding amount. Either subtract the in-flight earmark when exporting the “outstanding credit” metric, or describe this as gross unspent burn and expose the offsettable credit separately.
w.encode_gauge(
"cketh_minter_sweeper_funding_burned_not_yet_spent",
s.sweeper_funding.burned_not_yet_spent().as_f64(),
"ckETH burned for sweeping but not yet spent, i.e. the credit that offsets \
the next funding.",
…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>
6320ea9 to
283069e
Compare
4d829c1 to
e5adda2
Compare
283069e to
17d0c30
Compare
b633db9 to
9d7cb6d
Compare
17d0c30 to
12c7666
Compare
9d7cb6d to
c062a60
Compare
The fixture no longer answers it, so a test that needs an observation to exist asks for one. Which is the point of making that opt-in: this test is about the observation being forgotten across an upgrade, and it now says out loud that the pre-upgrade read is answered while the post-upgrade one is not. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A funding transaction cannot fail on Ethereum unless an assumption broke: it is a bare transfer to an address derived from the minter's own key, with no code to revert in. The log line says as much, but a log cannot be alerted on, so the count is exposed as cketh_minter_sweeper_funding_failed_total and is expected to stay at zero. Counted where the receipt status is already matched, so it cannot drift from the transferred amount it sits beside, and saturating rather than checked: an alert that stops incrementing is a worse outcome than a wrapped count. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The gauge's help text promised credit a later funding could offset against. Nothing draws the surplus down any more, so it names its two parts instead -- the burn of a funding in flight, and fees provisioned but never paid. The earmark's acceptance time follows the request's own field in being optional, so the age gauge reports NaN when a funding is outstanding with no time recorded, rather than an age measured from zero. The dashboard renders it through the existing optional-timestamp macro, which also takes two unwraps out of the template.
The dashboard row and the funding-age gauge read the outstanding funding off the withdrawal pipeline now that the accounting no longer keeps one. Their age still comes from the request's own acceptance time, which stays optional, so the NaN reading for "outstanding but undated" is unchanged. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Follows the accessor moving onto `State` in the parent PR.
0bcd43c to
7a048b0
Compare
…observability The funding task no longer reads the sweeper address' balance over an HTTPS outcall, so the harness helpers that answered that read are gone and with them the two tests that only exercised recording an observation. The observation itself is removed next. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The prepaid-gas gate rested on a cached on-chain reading of the sweeper address' balance, which brought with it a freshness policy: an observation older than two days stopped sweeping, an upgrade dropped the cache, and a task that failed to read left the gate closed. All of that existed to make a value the minter did not own safe to spend against. It owns one already. The lower bound its own recorded fundings imply needs no refreshing, survives an upgrade because it is event-sourced, and cannot overstate what is there. The gate becomes a comparison against it, still fail-closed in the sense that matters: it authorises nothing the minter's records do not already account for, and refusing too often costs only delay. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`cketh_minter_sweeper_gas_balance` exported a cached on-chain reading, NaN until one had been taken, alongside an age gauge to say how much to trust it. Neither has anything to describe now: the gauge reports the lower bound the minter derives from its own recorded fundings, which is always defined and never stale, so the age gauge goes. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The prepaid-gas row reported a cached on-chain reading with the time it was taken, and "never observed" before the first one. It now shows the lower bound the minter tracks from its recorded fundings, which is defined from the start, so neither the timestamp nor the never-observed case has anything left to say. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 8 out of 8 changed files in this pull request and generated 4 comments.
Suppressed comments (1)
rs/ethereum/cketh/minter/src/state/sweeper_funding.rs:135
- The gate no longer matches the contract stated in the PR: it accepts an event-derived
Weibound and can only reportInsufficient, so it cannot distinguish never-observed or stale observations, and replayed funding state remains usable after an upgrade. Either implement the described non-event-sourced observation/timestamp checks (including the two-day limit) or update the PR’s requirements to this materially different design.
pub fn check_prepaid_sweep_gas(bound: Wei, required: Wei) -> Result<Wei, PrepaidGasUnavailable> {
if bound < required {
return Err(PrepaidGasUnavailable::Insufficient {
available: bound,
required,
});
| w.encode_gauge( | ||
| "cketh_minter_sweeper_gas_balance", | ||
| s.sweeper_funding.sweeper_balance_lower_bound().as_f64(), | ||
| "Prepaid sweep gas: a lower bound on the sweeper address' ETH balance, from the \ | ||
| fundings the minter has recorded as finalized -- not an observation of the \ | ||
| chain, and never above the true balance.", | ||
| )?; |
There was a problem hiding this comment.
Right on both counts, and the fix is the description: it still described the design this stack started with, where the funding task read the balance through the EVM RPC canister and cached it, and the gate refused on "never observed" or "stale". That was replaced by the tracked lower bound (the eth_getBalance thread on #11086), which removed the cache, the staleness limit and the age metric along with it. The description now says what the code does.
There is deliberately no balance-age metric in the new design: there is nothing to go stale, since the bound is derived from the minter's own events and cannot be older than the last funding it recorded. What can go wrong is a funding that never finalizes, which is what cketh_minter_sweeper_in_flight_funding_age_seconds is for.
| <tr id="sweeper-prepaid-gas"> | ||
| <th>Prepaid gas (lower bound)</th> | ||
| <td>{{ sweeper_funding.prepaid_gas_lower_bound }} Wei</td> |
There was a problem hiding this comment.
Same stale description, now corrected — there is no observation to show a timestamp for.
On the conflation: with a tracked bound, 0 Wei is not "never looked". It means no funding has been recorded as finalized yet, which is a fact about the minter's own history rather than a missing reading, and the row above it shows the low-water mark and target it is compared against. The distinction that mattered in the old design — "no gas" versus "never read the chain" — cannot arise when nothing reads the chain.
| DashboardAssert::assert_that(dashboard) | ||
| .has_sweeper_cketh_burned("1_000_000 Wei") | ||
| .has_sweeper_eth_spent("950_000 Wei") | ||
| // 1_000_000 burned - 950_000 spent: the credit that offsets the next funding. |
There was a problem hiding this comment.
Agreed, and fixed. That comment survived from a design where the surplus was discounted from the next burn; it now says the surplus stays as backing. The metric help text and the accounting docs already said so, which is what made the comment stand out.
| // What the caller does on a failure receipt: no ETH transferred, and the count bumped. | ||
| accounting.record_burn(Wei::new(BURN)); | ||
| accounting.record_failed_funding(); | ||
| accounting.record_finalized_funding(Wei::ZERO, Wei::new(FEE)); |
There was a problem hiding this comment.
A real gap, thank you — the counter was only ever incremented by a test calling record_failed_funding directly, so the wiring in record_finalized_transaction could have been deleted with the suite still green.
Covered now by should_record_a_funding_whose_transaction_failed_on_chain, which drives the events rather than the accounting: it accepts a funding, creates and signs the transaction, finalizes it with a failure receipt, and asserts the counter went 0 to 1, that the bound stays at zero because nothing was delivered, that the gas the failed transaction still paid is recorded as spent, and that the burn stays put. The success paths in the same module assert the counter stays at zero.
The failed-funding counter was only ever incremented by a test calling it directly, so the wiring in the state transition — the counter, the transfer that delivered nothing, and the gas a failed transaction still pays — could have been removed with the suite staying green. Copilot spotted it. Also drops a comment describing an accounting rule this design deliberately does not have: the unspent burn stays as backing, it does not discount a later funding. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
| )?; | ||
| w.encode_gauge( | ||
| "cketh_minter_sweeper_funding_burned_not_yet_spent", | ||
| s.sweeper_funding.burned_not_yet_spent().as_f64(), |
There was a problem hiding this comment.
A note on how this gauge sits next to the pre-existing cketh_minter_total_unspent_tx_fees, since this is the PR that puts the funding numbers beside it.
For a user withdrawal the ckETH burned is withdrawal_amount while the ETH leaving the main address is delivered + effective, so ETH at main address − ckETH supply == Σ (charged − effective) == total_unspent_tx_fees — the figure docs/cketh.adoc describes as fees charged but in retrospect not needed. A funding behaves the same way, since it burns its full amount and never discounts a later burn, so the identity still holds with funding live.
What does not hold is reading the two side by side. For a funding that succeeds, burned_not_yet_spent grows by exactly charged − effective — the same wei total_unspent_tx_fees just counted — so summing them double counts. For one that fails they diverge instead: the whole undelivered burn stays as backing, while the unspent-fee figure only sees the fee difference. Composing them means picking the user-withdrawal part of the older counter, which nothing exposes separately today.
I would rather not add a derived over-backing gauge here for the sake of it. Flagging it so it is on the record when the alerts on DEFI-2965 are written: alert on burned versus spent, not on a sum of these two.
| w.encode_gauge( | ||
| "cketh_minter_sweeper_gas_balance", | ||
| s.sweeper_funding.sweeper_balance_lower_bound().as_f64(), | ||
| "Prepaid sweep gas: a lower bound on the sweeper address' ETH balance, from the \ | ||
| fundings the minter has recorded as finalized -- not an observation of the \ | ||
| chain, and never above the true balance.", | ||
| )?; |
There was a problem hiding this comment.
Right on both counts, and the fix is the description: it still described the design this stack started with, where the funding task read the balance through the EVM RPC canister and cached it, and the gate refused on "never observed" or "stale". That was replaced by the tracked lower bound (the eth_getBalance thread on #11086), which removed the cache, the staleness limit and the age metric along with it. The description now says what the code does.
There is deliberately no balance-age metric in the new design: there is nothing to go stale, since the bound is derived from the minter's own events and cannot be older than the last funding it recorded. What can go wrong is a funding that never finalizes, which is what cketh_minter_sweeper_in_flight_funding_age_seconds is for.
| <tr id="sweeper-prepaid-gas"> | ||
| <th>Prepaid gas (lower bound)</th> | ||
| <td>{{ sweeper_funding.prepaid_gas_lower_bound }} Wei</td> |
There was a problem hiding this comment.
Same stale description, now corrected — there is no observation to show a timestamp for.
On the conflation: with a tracked bound, 0 Wei is not "never looked". It means no funding has been recorded as finalized yet, which is a fact about the minter's own history rather than a missing reading, and the row above it shows the low-water mark and target it is compared against. The distinction that mattered in the old design — "no gas" versus "never read the chain" — cannot arise when nothing reads the chain.
| DashboardAssert::assert_that(dashboard) | ||
| .has_sweeper_cketh_burned("1_000_000 Wei") | ||
| .has_sweeper_eth_spent("950_000 Wei") | ||
| // 1_000_000 burned - 950_000 spent: the credit that offsets the next funding. |
There was a problem hiding this comment.
Agreed, and fixed. That comment survived from a design where the surplus was discounted from the next burn; it now says the surplus stays as backing. The metric help text and the accounting docs already said so, which is what made the comment stand out.
| // What the caller does on a failure receipt: no ETH transferred, and the count bumped. | ||
| accounting.record_burn(Wei::new(BURN)); | ||
| accounting.record_failed_funding(); | ||
| accounting.record_finalized_funding(Wei::ZERO, Wei::new(FEE)); |
There was a problem hiding this comment.
A real gap, thank you — the counter was only ever incremented by a test calling record_failed_funding directly, so the wiring in record_finalized_transaction could have been deleted with the suite still green.
Covered now by should_record_a_funding_whose_transaction_failed_on_chain, which drives the events rather than the accounting: it accepts a funding, creates and signs the transaction, finalizes it with a failure receipt, and asserts the counter went 0 to 1, that the bound stays at zero because nothing was delivered, that the gas the failed transaction still paid is recorded as spent, and that the burn stays put. The success paths in the same module assert the counter stays at zero.
# Conflicts: # rs/ethereum/cketh/minter/src/sweeper/tests.rs
Part of DEFI-2933 (sweeper fee funding), second of the three remaining PRs. Targets #11086.
Why
Two things are missing once funding works: an operator cannot see whether ckETH is still fully backed, and sweeping has no way to ask whether it may spend gas at all. This adds both.
The gate
check_prepaid_sweep_gasanswers "does the prepaid gas cover this sweep?" against the lower bound the minter tracks from its own events — not a reading of the chain. That is what makes it fail closed: the bound errs low, so refusing can only withhold a sweep that would in fact have been paid for, which costs delay. Nothing is ever authorised that the minter's own records do not already account for.It is a precondition on a bound, not an allowance: it neither reserves nor deducts, so a caller issuing several sweeps must subtract what it has already committed — two 6-wei sweeps both pass against a 10-wei bound. The backing invariant does not rest on the gate at all: a funding counts its whole transfer as spent once it finalizes, so every wei at the sweeper address is already covered by a burn that preceded it.
The gate has no production caller yet; S2 (DEFI-2926) is what will consult it, exactly as the fee-subaccount burn waited for the funding task. Tests exercise it directly in the meantime.
Observability
Six metrics. Burned and spent are counters, so the invariant can be alerted on directly — burned must never fall below spent. Alongside them: how far burn currently runs ahead of spend, the prepaid-gas bound, a count of fundings that failed on chain, and the age of a funding awaiting finalization.
The burn-ahead gauge is the burn of a funding still in flight plus the fees earlier fundings provisioned but never paid. Nothing draws it down — the surplus stays as ckETH backing rather than discounting a later funding — so between fundings it only ratchets up.
The age to alert on is
sweeper_in_flight_funding_age_seconds: a funding that never finalizes blocks every later one, and nothing else here reveals that, since the gas-balance gauge reports what the minter has recorded rather than what is stuck. It reads 0 when no funding is outstanding, andNaNfor one whose acceptance time is unknown rather than an age measured from zero. The failed-funding counter is expected to stay at zero — funding is a bare transfer to an address derived from the minter's own key, with no code to revert in — so any non-zero value wants a look.Proposed alert conditions are recorded on DEFI-2965, which collects observability for this pipeline rather than alerting on each piece separately.
The dashboard gains a sweeper-funding section: the address, the prepaid-gas bound, the low-water mark and target it is compared against, burned and spent, the funding in flight if there is one, and the unspent burn. It renders an in-flight funding's acceptance time as a timestamp rather than an age because
DashboardTemplate::from_statemust stay callable outside a canister —ic_cdk::api::time()traps in unit tests — and ages belong in metrics anyway.Stack
Merge in order; each PR targets the one above it.