Skip to content

fix(cketh): provision sweep gas out of the sweeper balance bound - #11342

Draft
gregorydemay wants to merge 3 commits into
gdemay/DEFI-2926-deposit-from-cex-e2efrom
greg/DEFI-2933-provision-sweep-gas
Draft

fix(cketh): provision sweep gas out of the sweeper balance bound#11342
gregorydemay wants to merge 3 commits into
gdemay/DEFI-2926-deposit-from-cex-e2efrom
greg/DEFI-2933-provision-sweep-gas

Conversation

@gregorydemay

@gregorydemay gregorydemay commented Aug 27, 2026

Copy link
Copy Markdown
Contributor

Why

  • The funding task decides whether to top the sweeper address up from a lower bound on its ETH balance, tracked from the minter's own events (feat(cketh): sweeper fee-funding task, with an end-to-end test #11086). Nothing debited that bound, which was true while nothing spent from the sweeper address — and the layers below connect the sweep queue to the pipeline that does.
  • Without a debit the bound only ever grows: fundings top the sweeper up once, sweeps spend the gas, and every counter still reports a full sweeper — amount_due declines fundings the sweeper actually needs, and sweeping stalls while looking healthy.

What

  • An accepted sweep provisions the most it can cost the sweeper address: the ETH it moves plus its fee ceiling, the same ceiling every resubmission is checked against. When it finalizes it hands back what it did not need — the fee it did not pay, plus the value it did not move if it failed.
  • Provisioning at acceptance rather than debiting at spend keeps the bound erring low while sweeps are in flight: a sweep whose finalization is never observed delays a funding rather than letting the minter believe in gas that is gone. Acceptance also happens exactly once per sweep, where a signature happens once per resubmission.
  • The counters stay out of cumulative_spent — that ETH was counted there when the funding delivered it, and counting it twice would trip the burn-first invariant (R14) — and the bound floors at zero rather than trapping, since an upgrade legitimately restarts the counters with sweeps still in flight.

This is #11329 (thanks @mbjorkqvist), integrated on top of the sweep stack per the hand-off on DEFI-2933: rebased onto the sweep-queue lifecycle, whose deposit release now lives in the same finalization transition the refund settles in.

@gregorydemay gregorydemay changed the title greg/DEFI 2933 provision sweep gas fix(cketh): provision sweep gas out of the sweeper balance bound Aug 27, 2026
@gregorydemay
gregorydemay requested a balanced review from Copilot August 27, 2026 07:00
@github-actions github-actions Bot added the fix label Aug 27, 2026

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

Updates ckETH sweeper funding accounting so in-flight and finalized sweep costs correctly reduce the tracked balance lower bound.

Changes:

  • Provision accepted sweeps against the sweeper balance bound.
  • Refund unused fees and failed transfer values upon finalization.
  • Add lifecycle tests, shared fixtures, and updated documentation.

Reviewed changes

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

Show a summary per file
File Description
rs/ethereum/cketh/minter/src/test_fixtures.rs Adds a reusable sweep request fixture.
rs/ethereum/cketh/minter/src/state/transactions/tests.rs Reuses the shared sweep fixture.
rs/ethereum/cketh/minter/src/state/sweeper_funding/tests.rs Tests provisioning, refunds, accounting invariants, and flooring.
rs/ethereum/cketh/minter/src/state/sweeper_funding.rs Tracks provisioned and refunded sweep costs.
rs/ethereum/cketh/minter/src/state/audit.rs Wires accounting into sweep lifecycle events.
rs/ethereum/cketh/minter/src/state.rs Settles sweeper balance accounting at acceptance and finalization.
rs/ethereum/cketh/docs/deposit_from_cex.md Documents the revised lower-bound calculation.

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

@gregorydemay
gregorydemay force-pushed the greg/DEFI-2933-provision-sweep-gas branch from 63a865b to 928e0c0 Compare August 27, 2026 07:56
@gregorydemay
gregorydemay requested a balanced review from Copilot August 27, 2026 08:04

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 7 out of 7 changed files in this pull request and generated no new comments.

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 7 out of 7 changed files in this pull request and generated 1 comment.

Comment on lines +114 to +117
/// Provisioning at acceptance rather than at spend is what keeps this a bound while sweeps are in
/// flight: gas a committed sweep will pay stops counting as available immediately, and a sweep
/// whose finalization is never observed leaves the bound too *low* — which delays a funding —
/// rather than too high, which would let the minter believe in gas that is gone. ETH sent to the

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.

🤖 Confirmed against the code — the doc had the direction backwards. SweeperFundingConfig::amount_due returns None only while sweeper_balance >= low_water_mark (state/sweeper_funding.rs:178-187), so a bound that reads too low crosses the mark sooner, and the amount it then asks for is target - sweeper_balance, i.e. larger too. plan_funding's own doc already said so ("Erring low can only make a funding look due sooner than it is, never hide one that is due", sweeper.rs:149-150), so the two were contradicting each other.

Fixed in ee264fb: the bound now reads as bringing a funding forward and making it larger, with the pointer to amount_due. The safety argument is unchanged — erring low is still the safe direction, just for the reason the planner gives.

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 7 out of 7 changed files in this pull request and generated no new comments.


/// Records the part of a finalized sweep's provision it did not need, putting it back into the
/// balance bound.
pub fn record_finalized_sweep(&mut self, refunded: Wei) {

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.

cumulative_sweep_provisioned is only ever offset here, so a sweep that never finalizes keeps its provision permanently.

That's reachable. A sweep's ceiling is priced once at acceptance (sweep/mod.rs:158-161) and never re-priced. Once base + priority exceeds floor(ceiling / gas_limit) — roughly a doubling of the base fee — create_transaction fails and the request is rescheduled indefinitely (sweep/mod.rs:394-402); reschedule_request only moves it within pending_requests, and nothing else takes it out. Not a spike problem, since those mean-revert — the realistic case is a sweep accepted in a quiet trough at ~0.5 gwei, whose ~1.5 gwei ceiling can then sit unaffordable for weeks.

Small per event (~0.005–0.02 ETH at normal gas, against a 0.3 ETH target), but permanent, cumulative, and currently invisible — nothing in sweeper_funding is exported as a metric.

The signal worth alerting on is the age of the oldest unfinalized sweep, not the amount: age is independent of load and gas price, where any wei-denominated gauge moves with the base fee and can't separate "busy" from "leaking". The withdrawal pipeline already has exactly this — oldest_incomplete_request_timestamp() (transactions/mod.rs:1409-1414), exported as cketh_oldest_incomplete_eth_withdrawal_request_age_seconds (main.rs:1313-1321) — and the pieces for the sweep equivalent are already in place: SweepRequest.created_at is public, and the "incomplete" enumeration is the one outstanding_sweeper_funding already uses (transactions/mod.rs:1223-1229).

pub fn oldest_incomplete_sweep_timestamp(&self) -> Option<u64> {
    self.incomplete_requests_iter().map(|r| r.created_at).min()
}

A wei gauge is still worth having to size the impact on the bound, but under a steady stream it never returns to zero, so it wants a floor comparison (min_over_time(...[24h]) ratcheting upward) rather than a threshold on the level:

/// Provision still held by sweeps that have not finalized.
pub fn outstanding_sweep_provision(&self) -> Wei {
    self.cumulative_sweep_provisioned
        .checked_sub(self.cumulative_sweep_refunded)
        .unwrap_or(Wei::ZERO)
}

/// Drives the already-accepted `request` through the sweeper pipeline to a receipt of `status`,
/// priced below its ceiling so that part of the provision comes back, and returns the fee that
/// receipt charged.
fn finalize(state: &mut State, request: &SweepRequest, status: TransactionStatus) -> Wei {

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.

Would it make sense to add a test that, after SignedSweeperTransaction, apply ReplacedSweeperTransaction at a higher price still under the ceiling, finalize on the replacement's receipt, and assert the bound moved by that receipt's fee rather than by two ceilings?

Two smaller gaps:

  • No test with two accepted sweeps. cumulative_sweep_provisioned is a running total, and every test uses one sweep, so nothing would catch an overwrite instead of an add.
  • should_floor_the_bound_at_zero_rather_than_trap never calls deliver(), so cumulative_transferred == 0 and it only proves 0 - x saturates. The case the doc describes - an upgrade that restarts the counters, or a sweeper funded before it was tracked - is a nonzero delivery smaller than the provision. Calling deliver() with an amount below request.max_transaction_fee exercises that path.

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 29 out of 29 changed files in this pull request and generated 3 comments.

Suppressed comments (2)

Previously missed (2) — in code that hasn't changed since the last review.

rs/ethereum/cketh/minter/src/state/automatic_deposits/mod.rs:220

  • This literal emits large runs of spaces in the operational log message, making failure diagnostics harder to read. Use Rust line continuations so source wrapping produces single spaces.
                    "[record_finalized_sweep_transaction]: DROPPING {request:?} from the sweep                      queue: {id:?} failed and the minter does not retry. Its {:?} stays at {}, and                      reaching it again needs the pair armed afresh.",

rs/ethereum/cketh/test_utils/src/anvil.rs:316

  • Blocks are searched newest-first, but transactions within each block are still searched oldest-first. With interval mining, multiple transactions from this sender can share the head block, so this can return an earlier transaction rather than the documented most recent one. Reverse the per-block iteration too.
            for transaction in transactions {

Comment on lines +307 to +312
/// The nonce is always zero, whatever the address actually holds. A deposit address is at
/// nonce zero exactly while it has never been delegated — applying an authorization spends it
/// — so the tuple either installs the delegation or is skipped, and both are correct in any
/// order the sweeps carrying them land. That is what lets a sweep authorize every address it
/// touches without tracking which ones are already delegated, at the price of the intrinsic
/// gas a skipped tuple still costs.
Comment on lines 804 to +808
deposit_helper : text;
owner : principal;
subaccount : opt blob;
y_parity : bool;
// 32-byte signature components.
r : blob;
s : blob;
// The attestation signed by the deposit address itself.
attestation : TransactionSignature;
Comment on lines +687 to +690
// The single ERC-20 contract this sweep moves.
token : text;
// The deposits the sweep moves, one per account.
items : vec AuthorizedSweepItem;
gregorydemay and others added 3 commits August 29, 2026 10:52
The funding decision reads a lower bound on the sweeper address' balance,
tracked from the minter's own events. Nothing debited it, which was true while
nothing spent from that address — and the layers below have since landed the
pipeline that does. With those in place the bound only ever grows: fundings top
the sweeper up once, sweeps spend the gas, and the bound still reports a full
sweeper, so `amount_due` declines every later funding. The sweeper drains,
sweeping stalls, and every counter says it is funded.

An accepted sweep now provisions the most it can cost that address — its fee
ceiling, which caps every resubmission the pipeline makes for it — and gets back
the fee it did not pay when it finalizes. An ERC-20 sweep moves its tokens
through call data and carries no ETH value of its own, so the fee is the whole
of what it can cost, and a reverted sweep is charged for its gas like any other.

Provisioning at acceptance rather than debiting at spend is what keeps this a
bound while sweeps are in flight. Gas a committed sweep will pay stops counting
as available immediately, and a sweep whose finalization is never observed
leaves the bound too low — which delays a funding — rather than too high, which
would let the minter believe in gas that is gone. It is the discipline the
withdrawal pipeline already applies to its own fees.

The counters are kept out of `cumulative_spent`: this ETH was counted there once
already, when the funding that delivered it finalized, and counting it twice
would make spend overtake burn and trip the burn-first invariant. The bound
floors at zero rather than trapping, since an upgrade that starts the counters
from zero — or a sweeper funded before it was tracked — can legitimately leave
provisioning above deliveries, and trapping in a state transition would take the
replay of every later event with it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YK3ggw1xtE3HZTGGBu66oD
…prepaid gas

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YK3ggw1xtE3HZTGGBu66oD
@gregorydemay
gregorydemay force-pushed the greg/DEFI-2933-provision-sweep-gas branch from 542ceb8 to a124541 Compare August 29, 2026 10:52
@gregorydemay
gregorydemay requested a balanced review from Copilot August 29, 2026 10:53

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 7 out of 7 changed files in this pull request and generated no new comments.

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