Skip to content

feat(cketh): install a deposit address' delegation with its first sweep - #11250

Merged
gregorydemay merged 14 commits into
masterfrom
greg/sweeper-eip7702
Aug 25, 2026
Merged

feat(cketh): install a deposit address' delegation with its first sweep#11250
gregorydemay merged 14 commits into
masterfrom
greg/sweeper-eip7702

Conversation

@gregorydemay

@gregorydemay gregorydemay commented Aug 21, 2026

Copy link
Copy Markdown
Contributor

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: feat(cketh): drive the sweeper transaction pipeline from its own timer task #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 feat(cketh): give the sweeper address its own transaction pipeline #11144 and have never been released, so no deployed canister has emitted them and no client can be reading them.

@gregorydemay gregorydemay changed the title greg/sweeper eip7702 feat(cketh): install a deposit address' delegation with its first sweep Aug 21, 2026
@github-actions github-actions Bot added the feat label Aug 21, 2026
@gregorydemay
gregorydemay requested a balanced review from Copilot August 21, 2026 07:39

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 EIP-7702 delegation installation to first-time ckETH deposit-address sweeps while generalizing transaction pipeline mechanics.

Changes:

  • Introduces EIP-1559/EIP-7702 sweep transactions.
  • Generalizes finalization and fee resubmission.
  • Extends audit events and Candid types with signed authorizations.

Reviewed changes

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

Show a summary per file
File Description
tests/dump_stable_memory.rs Maps new sweep events.
src/tx/tests.rs Tests sweep encoding and signing.
src/tx/sweep.rs Defines variant sweep transactions.
src/tx/signed.rs Expands the signable transaction interface.
src/tx/mod.rs Generalizes fee resubmission.
src/tx/finalized.rs Adds generic finalized transactions.
src/tx/eip_7702.rs Enables EIP-7702 fee bumps.
src/tx/eip_1559.rs Adopts generic transaction machinery.
src/state/transactions/tests.rs Tests delegating sweep pipelines.
src/state/transactions/request.rs Creates the appropriate sweep variant.
src/state/transactions/mod.rs Generalizes transaction pipelines.
src/state/event.rs Stores sweep transaction variants.
src/state/audit/tests.rs Updates event replay mapping.
src/main.rs Exposes authorization data in events.
src/endpoints.rs Adds Candid-facing sweep types.
cketh_minter.did Updates the public Candid interface.

💡 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/tests/dump_stable_memory.rs
Comment thread rs/ethereum/cketh/minter/src/state/audit/tests.rs

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

Suppressed comments (2)

rs/ethereum/cketh/minter/tests/dump_stable_memory.rs:193

  • Delegating sweeps are signed type-0x04 transactions, but this path always calls an EIP-1559-only decoder and then constructs SweepTransaction::Eip1559. Once a signed delegating sweep appears in get_events, decode_signed_transaction rejects it at its TypedTransaction::Eip1559 match, so stable-memory dumping cannot process the new event. Decode type 0x04 (including its authorization list) and construct the matching sweep variant.
fn map_signed_sweep_transaction(raw_transaction: &str) -> SignedSweepTransaction {
    let (transaction, signature) = decode_signed_transaction(raw_transaction);
    SignedSweepTransaction::from((SweepTransaction::Eip1559(transaction), signature))

rs/ethereum/cketh/minter/src/state/audit/tests.rs:250

  • This replay mapper still accepts only TypedTransaction::Eip1559 and unconditionally rebuilds the signed sweep as that variant. A signed first sweep is type 0x04, so refreshing/replaying events after delegating sweeps begin will panic instead of reconstructing state. Decode EIP-7702 raw transactions and preserve their authorization list here.
        fn map_signed_sweep_transaction(raw_transaction: &str) -> SignedSweepTransaction {
            let (transaction, signature) = decode_signed_transaction(raw_transaction);
            SignedSweepTransaction::from((SweepTransaction::Eip1559(transaction), signature))

@gregorydemay
gregorydemay force-pushed the greg/sweeper-eip7702 branch 3 times, most recently from 86f084b to 62aa850 Compare August 21, 2026 08:54
Base automatically changed from greg/sweeper-send-lane to master August 24, 2026 12:47
gregorydemay and others added 5 commits August 24, 2026 12:48
Finalization and fee-bumping were written against EIP-1559 alone:
`FinalizedEip1559Transaction` was a concrete struct, `try_finalize` an inherent
method on `SignedEip1559TransactionRequest`, and `resubmit` an inherent method
on `Resubmittable<SignedEip1559TransactionRequest>`. The sweeper pipeline will
carry type-`0x04` transactions to install a deposit address' EIP-7702
delegation, and would have needed all three mirrored for it — the very TODO
`SignedEip7702TransactionRequest` carried.

All three reduce to one primitive. Give `SignableTransaction` its remaining
field accessors and `with_price_and_amount`, and everything else follows
generically: `Finalized<T>` replaces the concrete struct with
`FinalizedEip1559Transaction` as its alias, `try_finalize` moves to
`Signed<T>`, `resubmit` to `Resubmittable<Signed<T>>`, and
`equal_ignoring_fee_and_amount` takes any two transactions of one type.
EIP-7702 gets fee-bumping for free: an authorization is signed over
`(chain_id, delegate, nonce)` only, so bumping the outer fee leaves the
authorization list valid, and struct update syntax carries it along.

`Finalized<T>` bounds `T: SignableTransaction` on the struct, as
`TransactionPipeline<R: PipelineRequest>` does: a finalized transaction can only
come from a signed one, and the derived `Decode` needs the bound to see through
`Signed<T>`.

No behaviour change. `equal_ignoring_fee_and_amount` still compares
`gas_limit`, taking it from the right-hand transaction while overriding only
the two fees and the amount.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The pipeline was generic over its request but not over what that request
becomes: `created_tx`, `sent_tx` and `finalized_tx` were pinned to
`Eip1559TransactionRequest`, as were `create_transaction`,
`assert_created_transaction`, `TransactionStage` and every accessor between
them. The sweeper pipeline needs to carry type-`0x04` transactions to install a
deposit address' EIP-7702 delegation, and cannot while the pipeline names the
transaction type itself.

Add `PipelineRequest::Transaction` and let the pipeline speak it throughout: a
request already knows the transaction it turns into, so it is the request that
should say so. The associated type requires `SignableTransaction` and nothing
more, since that is all a request needs to build one. `Clone + Eq + Debug` are
required by the pipeline's impl block instead, which is what clones
transactions into signing batches and compares them in its assertions.

Both pipelines still set `Transaction = Eip1559TransactionRequest`, so this
changes no behaviour: every stored type, event and call site resolves to exactly
what it did before. The sweeper's own transaction type comes next.

`CreatedTransaction<R>` and `SentTransaction<R>` name the two `Resubmittable`
nestings the pipeline stores, taking over from the EIP-1559-pinned
`TransactionRequest`/`SignedTransactionRequest` aliases, which stay in `tx` for
the withdrawal-lane tests.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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,
and the cheapest place to carry that authorization is the sweep that needs it:
the first sweep touching an address is a type-`0x04` transaction that installs
the delegation on the way. The delegation then persists, so every later sweep of
that address is a plain type-`0x02` transaction — which is what
`deposit_from_cex_demo` measures, at 94'932 gas for the first sweep of one
address against 63'252 for the next.

`SweepTransaction` is what the sweeper pipeline now carries: an EIP-1559
transaction, or an EIP-7702 one whose authorization list installs the
delegations. `SweepRequest::authorizations` holds them, signed, so
`create_transaction` stays a pure function of the request and the accepted event
carries them — a replay never re-signs, and neither does a fee bump, since an
authorization covers `(chain_id, delegate, nonce)` only and nothing of the outer
transaction.

`SweepTransaction::new` is the single place that picks the variant, and it picks
it from whether there is anything to install. That keeps the two representations
in step: the `Eip7702` variant always carries a non-empty authorization list,
which is what its RLP encoding already asserts, and the Candid mirror can carry
the list next to the transaction without a tag of its own.

The three sweeper transaction events change shape accordingly, with
`UnsignedSweeperTransaction` as their Candid mirror rather than a widened
`UnsignedTransaction`: the withdrawal events on mainnet keep the type they have
today, untouched. `AcceptedSweepRequest` mirrors the delegations too.

Which addresses still need delegating is not decided here — nothing enqueues a
sweep yet. Because the request carries signed authorizations rather than a flag,
that decision belongs entirely to the sweep-queue source, together with reading
each address' delegation from the chain.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The `SignedSweeperTransaction` audit event records only the raw hex of the
broadcast transaction, and the reverse mapping used to replay event dumps
decoded it with `ethers_core`, which predates EIP-7702 and only understands
type `0x02`. A genuine type-`0x04` sweep would therefore be replayed as an
EIP-1559 transaction and compare unequal to the recorded one.

Add the inverse of the type-`0x04` RLP encoder next to the encoder itself, and
let both reverse mappings dispatch on the transaction type byte so a sweep is
rebuilt as the variant it was actually sent as.

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

`map_signed_sweep_transaction` reached for `hex::decode` and stripped the `0x`
prefix by hand, while the very same file already turns a raw transaction into
bytes with `ethers_core::types::Bytes::from_str`, prefix included. Reuse that
and the `hex` dependency the sweeper binary just gained is no longer needed.

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

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

Comment thread rs/ethereum/cketh/minter/src/tx/eip_7702.rs
The authorization list is independent of the sweep's call data: a batch
sweeping N deposit addresses carries a tuple only for those not yet
delegated to the sweeper contract, so the list can be shorter than the
set of addresses swept.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
gregorydemay and others added 3 commits August 24, 2026 13:48
Fold the two variant-selection tests into one table-driven test and add
the mixed case, where a batch sweeping several deposit addresses carries
an authorization only for those not yet delegated. Every case asserts
that the sweep itself - destination, amount, call data and nonce - comes
from the request regardless of the variant chosen.

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

The decoder has two properties a reader is entitled to question: it reimplements
what a library could do, and it sits in productive code although only tests call
it. Neither is a preference, so note the constraint behind each and the ticket
that lifts both.

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

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

Suppressed comments (1)

rs/ethereum/cketh/minter/src/tx/eip_7702.rs:287

  • decode can panic on a malformed type-0x04 payload with an empty authorization list. Self::from immediately computes the hash, which calls Eip7702TransactionRequest::rlp_inner and triggers its non-empty-list assertion. Since this decoder returns Result and documents malformed payloads as errors, reject the empty list before constructing Signed.
            authorization_list: decode_list(&rlp, 9)?
                .iter()
                .map(|item| {
                    Ok(SignedAuthorization {
                        chain_id: decode_val(item, 0)?,
                        delegate: decode_address(item, 1)?,
                        nonce: decode_amount(item, 2)?,
                        y_parity: decode_val(item, 3)?,
                        r: decode_u256(item, 4)?,
                        s: decode_u256(item, 5)?,
                    })
                })
                .collect::<Result<_, String>>()?,

@gregorydemay
gregorydemay marked this pull request as ready for review August 24, 2026 14:23
@gregorydemay
gregorydemay requested a review from a team as a code owner August 24, 2026 14:23
@github-actions github-actions Bot added the @defi label Aug 24, 2026
@zeropath-ai

zeropath-ai Bot commented Aug 24, 2026

Copy link
Copy Markdown

No security or compliance issues detected. Reviewed everything up to 78bb2dc.

Security Overview
Detected Code Changes
Change Type Relevant files
Enhancement ► rs/ethereum/cketh/minter/cketh_minter.did
    + Add SignedAuthorization and UnsignedSweeperTransaction types and update UnsignedTransaction references
► rs/ethereum/cketh/minter/src/endpoints.rs
    + Introduce SignedAuthorization and UnsignedSweeperTransaction structs and update related event data models to use SweeperTransaction and authorizations
► rs/ethereum/cketh/minter/src/main.rs
    + Update event mapping to handle new Sweeper-related types and authorizations
► rs/ethereum/cketh/minter/src/state/audit/tests.rs
    + Adjust tests to account for SignedAuthorization and UnsignedSweeperTransaction; update mappings and types
► rs/ethereum/cketh/minter/src/state/event.rs
    + Replace Eip1559TransactionRequest with SweeperTransaction in event types; add SignedSweepTransaction and related types
► rs/ethereum/cketh/minter/src/state/transactions/mod.rs
    + Generalize transaction handling to support SweeperTransaction and SignedSweepTransaction; introduce CreatedTransaction, SentTransaction, and Finalized generic types; adjust TransactionPipeline and related types
► rs/ethereum/cketh/minter/src/state/transactions/request.rs
    + Extend PipelineRequest trait with Transaction associated type; SweepRequest now uses SweepTransaction; add support for SweeperTransaction creation and delegation authorizations
► rs/ethereum/cketh/minter/src/state/transactions/tests.rs
    + Update sweep lane tests to reflect new SweeperTransaction types, authorization handling, and nonce behavior

The decoder built the transaction before anything checked the list, and
hashing it on the way into Signed hit rlp_inner's non-empty assertion,
so the malformed input with the clearest error to report was the one
that panicked instead of reporting it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@gregorydemay gregorydemay added the CI_OVERRIDE_DIDC_CHECK Skips the backwards compatibility didc check (explain in PR description why) label Aug 24, 2026
pull Bot pushed a commit to bit-cook/ic that referenced this pull request Aug 24, 2026
…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>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

@mbjorkqvist mbjorkqvist 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.

Thanks @gregorydemay!

Comment thread rs/ethereum/cketh/minter/src/tx/sweep.rs Outdated
Comment thread rs/ethereum/cketh/minter/src/tx/sweep.rs
gregorydemay and others added 2 commits August 25, 2026 09:01
…esentable

The Eip7702 variant's field was public, so the invariant its only
constructor upheld could be bypassed, and the check that caught a
bypass was an assertion reached while hashing. Decoding took the same
path, so an event carrying an empty authorization list would have
trapped in post_upgrade replay rather than failing an operation.

The transaction now sits behind a newtype with a private field and a
fallible constructor that decoding goes through as well. The encoding
is transparent, so the CBOR is unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
EIP-7702 skips an authorization whose chain id is neither zero nor the
current chain instead of failing the transaction, so a mismatch would
pay the per-authorization gas, install no delegation, and then call an
address with no code — recorded by the pipeline as a finalized success.
The minter signs these itself, so a mismatch is a bug, not a condition
to handle.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@gregorydemay
gregorydemay added this pull request to the merge queue Aug 25, 2026
Merged via the queue into master with commit 82b2a66 Aug 25, 2026
40 checks passed
pull Bot pushed a commit to bit-cook/ic that referenced this pull request Sep 3, 2026
…ecoder (dfinity#11302)

Resolves the `TODO(DEFI-2970)` left by dfinity#11250.

## Why

- `SignedEip7702TransactionRequest::decode` sat in productive code
although only tests called it, because no test-only home was reachable
from both this crate's `cfg(test)` unit tests and the
`dump_stable_memory` binary.
- It was hand-written because the only EIP-7702-aware library was not a
dependency: ethers was archived before EIP-7702 and stops at transaction
type `0x02`.
- `alloy` decodes EIP-7702, so both problems go away together.

## What

- The decoder and its RLP helpers are gone; the replay paths decode both
sweep transaction types through `TxEnvelope::decode_2718`, which also
retires the transaction-type sniffing they did by hand.
- In exchange the minter's EIP-7702 encoding gains the independent
oracle it never had, comparing the payload to sign, the broadcast
payload and the transaction hash — the way the EIP-1559 one does.
- Until now only pinned alloy-rs, trust-wallet and viem vectors covered
that encoding; those stay.

## Scope

- The decode-time rejection of an empty authorization list goes with the
decoder; the invariant itself stays asserted where the list is encoded.

[DEFI-2970]:
https://dfinity.atlassian.net/browse/DEFI-2970?atlOrigin=eyJpIjoiNWRkNTljNzYxNjVmNDY3MDlhMDU5Y2ZhYzA5YTRkZjUiLCJwIjoiZ2l0aHViLWNvbS1KU1cifQ
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

CI_OVERRIDE_DIDC_CHECK Skips the backwards compatibility didc check (explain in PR description why) @defi feat

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants