feat(cketh): drive the sweeper transaction pipeline from its own timer task - #11237
Conversation
There was a problem hiding this comment.
Pull request overview
Adds an independent timer-driven transaction pipeline for the ckETH sweeper address.
Changes:
- Adds the sweeper create/sign/send/resubmit/finalize driver.
- Generalizes withdrawal RPC helpers by sender and pipeline ID.
- Registers a dedicated timer guard and periodic task.
Reviewed changes
Copilot reviewed 7 out of 7 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
withdraw.rs |
Generalizes shared RPC helpers. |
sweep.rs |
Implements the sweeper pipeline driver. |
state/transactions/tests.rs |
Reuses the sweeper gas-limit constant. |
state.rs |
Adds sweeper address access and task type. |
main.rs |
Registers the sweeper timer. |
lib.rs |
Exports the sweep module. |
deposit_address/mod.rs |
Exposes the sweeper derivation path internally. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
a8b30dc to
02f02c0
Compare
02f02c0 to
73c453c
Compare
73c453c to
68f9246
Compare
…finity#11144) ## Why The minter's dedicated sweeper address needs to send Ethereum transactions, and it must not share the main address' nonce sequence: a sweep stuck behind a fee-starved transaction would head-of-line-block every user withdrawal. ## What A second instance of the transaction pipeline, for the sweeper address, on a nonce sequence of its own. A sweep burns no ckETH, so it is keyed by a plain counter rather than a ledger burn index, and is never reimbursed; it pays gas from the sweeper's prepaid balance, so it has no transaction fee it can fail to cover. Five audit events record the pipeline's transitions, with reconstruction, Candid mirrors and `.did`. The sweeper's start nonce can be set from both lifecycle arguments. It is optional on install where the main address' equivalent is required, because install arguments are replayed from the event log and a required field would be missing from every event already written. ## Scope The pipeline only. No timer drives it and nothing enqueues a sweep, so the new state stays empty: the sending task is [DEFI-2926](https://dfinity.atlassian.net/browse/DEFI-2926) in dfinity#11237, and EIP-7702 first-time delegation is dfinity#11250, stacked above. The sweep-queue source and prepaid-gas gating are still to come. Sweeper *funding* — burning ckETH from the minter's fee subaccount to prepay that gas — is a separate stack (DEFI-2933) that meets this one only in the withdrawal pipeline, where its request variant has already landed. Hence the audit events here are numbered from `n28`, above the funding event master now owns. ## Candid compatibility `CI_OVERRIDE_DIDC_CHECK` is set. The check flags the five cases this PR adds to the `Event` payload variant, since a variant returned to callers may not grow under Candid subtyping. It is the additive shape every new audit event has taken: existing cases keep their names and fields, and callers matching exhaustively on the old set see the new ones only for sweeper activity, which nothing enqueues yet. The new optional nonce field on the two argument types is compatible on its own. <sub>Stack created with <a href="https://github.com/github/gh-stack">GitHub Stacks CLI</a> • <a href="https://gh.io/stacks-feedback">Give Feedback 💬</a></sub> [DEFI-2917]: https://dfinity.atlassian.net/browse/DEFI-2917?atlOrigin=eyJpIjoiNWRkNTljNzYxNjVmNDY3MDlhMDU5Y2ZhYzA5YTRkZjUiLCJwIjoiZ2l0aHViLWNvbS1KU1cifQ [DEFI-2926]: https://dfinity.atlassian.net/browse/DEFI-2926?atlOrigin=eyJpIjoiNWRkNTljNzYxNjVmNDY3MDlhMDU5Y2ZhYzA5YTRkZjUiLCJwIjoiZ2l0aHViLWNvbS1KU1cifQ --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
68f9246 to
880dee7
Compare
|
✅ No security or compliance issues detected. Reviewed everything up to 3f8f452. Security Overview
Detected Code Changes
|
4aff994 to
5de5c17
Compare
5de5c17 to
b499dd5
Compare
There was a problem hiding this comment.
This pull request changes code owned by the Governance team. Therefore, make sure that
you have considered the following (for Governance-owned code):
-
Update
unreleased_changelog.md(if there are behavior changes, even if they are
non-breaking). -
Are there BREAKING changes?
-
Is a data migration needed?
-
Security review?
How to Satisfy This Automatic Review
-
Go to the bottom of the pull request page.
-
Look for where it says this bot is requesting changes.
-
Click the three dots to the right.
-
Select "Dismiss review".
-
In the text entry box, respond to each of the numbered items in the previous
section, declare one of the following:
-
Done.
-
$REASON_WHY_NO_NEED. E.g. for
unreleased_changelog.md, "No
canister behavior changes.", or for item 2, "Existing APIs
behave as before.".
Brief Guide to "Externally Visible" Changes
"Externally visible behavior change" is very often due to some NEW canister API.
Changes to EXISTING APIs are more likely to be "breaking".
If these changes are breaking, make sure that clients know how to migrate, how to
maintain their continuity of operations.
If your changes are behind a feature flag, then, do NOT add entrie(s) to
unreleased_changelog.md in this PR! But rather, add entrie(s) later, in the PR
that enables these changes in production.
Reference(s)
For a more comprehensive checklist, see here.
GOVERNANCE_CHECKLIST_REMINDER_DEDUP
|
✅ No security or compliance issues detected. Reviewed everything up to b499dd5. Security Overview
Detected Code ChangesThe diff is too large to display a summary of code changes. |
|
✅ No security or compliance issues detected. Reviewed everything up to b499dd5. Security Overview
Detected Code Changes
|
|
✅ No security or compliance issues detected. Reviewed everything up to b499dd5. Security Overview
Detected Code Changes
|
…ep (dfinity#11250) ## Why - A deposit address holds ERC-20 tokens but no code, so it cannot sweep itself. - The minter delegates it to the sweeper contract with an EIP-7702 authorization. - The cheapest carrier for that authorization is the sweep that needs it: the first sweep touching an address installs the delegation on the way. - The delegation persists, so every later sweep of that address is a plain EIP-1559 transaction. - `deposit_from_cex_demo` measures 94'932 gas for the first sweep of an address against 63'252 for the next. ## What - The sweeper lane sends either transaction type, and a sweep carries the signed authorizations it must install. - Two no-op refactorings come first: finalizing and fee-bumping stop being EIP-1559-only. - That is also what gives EIP-7702 fee-bumping for free, with no machinery of its own. - Authorizations are held in the request rather than derived while the transaction is built, so no replay and no fee bump ever re-signs one. - An authorization covers the chain, the delegate and the authority's nonce — nothing of the outer transaction. - A single place decides which type a sweep becomes, from whether anything is left to install. ## Scope - Nothing enqueues a sweep yet, so nothing builds an authorization in production. - Choosing which addresses still need delegating belongs to the sweep-queue source, along with reading each address' delegation from the chain. - Hence the request carries signed authorizations rather than a "needs delegation" flag. - The batch-dependent gas limit a delegating sweep needs is deferred with it: dfinity#11237's flat 100'000 does not cover one. ## Candid compatibility - Needs the `CI_OVERRIDE_DIDC_CHECK` label. - Two sweeper event cases change the type of their `transaction` field, and a third gains a required field. - Neither is a Candid subtype on a returned variant. - Those cases only reached the interface with dfinity#11144 and have never been released, so no deployed canister has emitted them and no client can be reading them. [DEFI-2917]: https://dfinity.atlassian.net/browse/DEFI-2917?atlOrigin=eyJpIjoiNWRkNTljNzYxNjVmNDY3MDlhMDU5Y2ZhYzA5YTRkZjUiLCJwIjoiZ2l0aHViLWNvbS1KU1cifQ --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
mbjorkqvist
left a comment
There was a problem hiding this comment.
Thanks @gregorydemay!
Four helpers in `withdraw` were implicitly about the main address: they called `minter_address()` themselves, or keyed receipts by `LedgerBurnIndex`. Reading a transaction count, broadcasting signed transactions and fetching finalized receipts are none of them specific to a sender or to what a request is keyed by. Take the sender as an argument and make the receipt fetch generic over the pipeline id, so a second pipeline sending from a different address can reuse them. `finalize_transactions_batch` keeps its behaviour: the receipt loop moves into `fetch_finalized_receipts`, which returns `None` where the loop used to return early. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Reschedules a sweep whose prepaid gas cannot pay the current fee, the way the withdrawal task does, and gives SweepId the Display that the task's log lines and the shared receipt fetcher need. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The task borrowed the withdrawal task's interval and retry interval, which said the two cadences must move together. They are equal for now and need not stay so. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Both send tasks refresh the shared gas fee estimate under one guard on an equal cadence, and the withdrawal task is registered first, so while withdrawals are pending the sweeper systematically read None and waited a whole interval to try again. It now retries, by which time the refresh it lost to has cached an estimate it can reuse. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Keeps the Display impl dropped in #11144 dropped: the id only ever reaches a log line, and the trait already requires Debug. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
b499dd5 to
7b30e89
Compare
The helpers serve both send lanes now, so their messages named neither the sender nor the lane. Their own logs already carry a task prefix; it is the shared ones that were ambiguous. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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 #11144 and #11237 have since landed the pipeline that does, with #11258 about to connect it to the sweep queue. 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 — the ETH it moves plus its fee ceiling, which caps every resubmission the pipeline makes for it — and gets back what it did not need when it finalizes: the fee it did not pay, plus the value it did not move if it failed. 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, and the one this stack described in review before sweeping landed. 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 Opus 5 (1M context) <noreply@anthropic.com>
Why
What
sweep::process_sweeper_transactionsruns the sweeper pipeline through the same create → sign → send → resubmit → finalize cycle as withdrawals.[3]and reads the sweeper address' own transaction count.TaskType::SweeperSendtimer guard, so a slow sweep round neither blocks the withdrawal task nor is blocked by it — the point of a separate nonce sequence.Prerequisite commit
withdrawwere implicitly about the main address, callingminter_address()themselves or keying receipts byLedgerBurnIndex.fetch_finalized_receipts, which returnsNonewhere the loop used to return early.Scope
SweepRequestyet, so the task early-returns on an empty pipeline and is inert in production.SweepRequestsource, and gating on prepaid sweep gas.Note for reviewers
5in both files.