Skip to content

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

Draft
gregorydemay wants to merge 5 commits into
greg/sweeper-send-lanefrom
greg/sweeper-eip7702
Draft

feat(cketh): install a deposit address' delegation with its first sweep#11250
gregorydemay wants to merge 5 commits into
greg/sweeper-send-lanefrom
greg/sweeper-eip7702

Conversation

@gregorydemay

@gregorydemay gregorydemay commented Aug 21, 2026

Copy link
Copy Markdown
Contributor

Part of DEFI-2917 (deposit-from-CEX), stacked on #11144, which gives the sweeper address the pipeline this teaches a second transaction type.

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, and the cheapest place to carry that authorization is the sweep that needs it: the first sweep touching an address installs the delegation on the way, and the delegation then 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 one address against 63'252 for the next.

What

Two refactorings first, each a no-op: finalization and fee-bumping stop being EIP-1559-only, and a request names the transaction it turns into rather than the pipeline naming one type for both lanes. Both reduce to a single new primitive — a transaction that can restate itself at a different price — which is also what gives EIP-7702 fee-bumping for free, with no machinery of its own.

Then the sweeper lane carries either transaction type, and a sweep carries the signed authorizations it must install. Holding them in the request keeps building the transaction a pure function of it and puts them in the accepted event, so neither a replay nor a fee bump ever re-signs one: an authorization covers the chain, the delegate and the authority's nonce, and nothing of the outer transaction. One place decides which type a sweep becomes, from whether there is anything left to install, which is what keeps the two representations in step.

Scope

Nothing enqueues a sweep yet, so nothing builds an authorization in production. Which addresses still need delegating is deliberately left to the sweep-queue source, along with reading each address' delegation from the chain — the request carries signed authorizations rather than a "needs delegation" flag precisely so that decision lives there. The batch-dependent gas limit a delegating sweep needs is deferred with it; #11237's flat 100'000 does not cover one.

Tests

A two-variant transaction type goes wrong by signing the wrong preimage, so both variants are checked to hash and to encode byte-identically to the transaction they wrap, with golden CBOR for each since the event log stores them. On the pipeline side: which variant a request produces, delegations surviving a fee bump, and finalizing a sweep that installed them.

Candid compatibility

Two new type definitions, used only by the sweeper events #11144 adds and that therefore exist nowhere on mainnet. The UnsignedTransaction carried by the mainnet withdrawal events is left alone rather than widened with an optional authorization list, and the call data and signature components are blobs like their neighbours.

Stack created with GitHub Stacks CLIGive Feedback 💬

@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 on lines +191 to +194
fn map_signed_sweep_transaction(raw_transaction: &str) -> SignedSweepTransaction {
let (transaction, signature) = decode_signed_transaction(raw_transaction);
SignedSweepTransaction::from((SweepTransaction::Eip1559(transaction), signature))
}
Comment on lines +248 to +251
fn map_signed_sweep_transaction(raw_transaction: &str) -> SignedSweepTransaction {
let (transaction, signature) = decode_signed_transaction(raw_transaction);
SignedSweepTransaction::from((SweepTransaction::Eip1559(transaction), signature))
}

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 and others added 3 commits August 21, 2026 11:53
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>
gregorydemay and others added 2 commits August 21, 2026 11:07
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>
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.

2 participants