Skip to content

feat(cketh): record the deposits a sweep moves and size its gas to the delegate's work - #11330

Closed
gregorydemay wants to merge 4 commits into
masterfrom
greg/DEFI-2926-sweep-request-deposits
Closed

feat(cketh): record the deposits a sweep moves and size its gas to the delegate's work#11330
gregorydemay wants to merge 4 commits into
masterfrom
greg/DEFI-2926-sweep-request-deposits

Conversation

@gregorydemay

Copy link
Copy Markdown
Contributor

Why

  • A sweep request said what call data it carried but not which deposits it took, so the sweep queue could only be reconstructed by decoding the call data.
  • Its gas limit was a flat base plus a per-deposit cost, which cannot bound what the delegate actually executes.

What

  • The request records the (account, token) deposits it moves, so the sweep queue and the set of delegated addresses are both reconstructible from the event log.
  • The gas limit follows the delegate's work: sweepErc20Batch hands the whole token array to every address it sweeps, so balance checks and transfers both grow as distinct addresses times distinct tokens.
  • It budgets a transfer for every such pair, not only the pairs the request names: sweepErc20 moves any balance it finds, and a deposit address accumulates residue.
  • Only the addresses a sweep delegates pay an authorization cost on top.

Tests

  • A table over batch shapes pins each term of the limit.
  • One test asserts a batch of more pairs from fewer deposits costs more, so a limit that followed the deposit count would fail it.

…e delegate's work

A sweep request now carries the (account, token) deposits it takes, so the
sweep queue and the set of delegated addresses are both reconstructible from
the log without decoding call data.

Its gas limit follows what the delegate executes rather than the number of
deposits: sweepErc20Batch hands the whole token array to every address it
sweeps, so both the balance checks and the transfers grow as distinct
addresses times distinct tokens, and it moves any pair it finds a balance at
— not only the pairs the request names. Only the addresses a sweep delegates
pay an authorization on top.

The limit sits well above what a sweep consumes: a four-deep call nest gives
each level 63/64 of the gas left (EIP-150), so the limit a batch needs is
strictly more than the gas it uses. Overshooting only asks the sweeper
address to prepay more, the remainder being refunded, whereas an
underestimate burns the whole transaction.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@github-actions github-actions Bot added the feat label Aug 26, 2026
A sweep no longer distinguishes the addresses it delegates from the ones
already delegated: it carries an authorization tuple for every deposit
address it touches, always signed for nonce 0.

Applying an EIP-7702 authorization increments the authority's nonce, so
nonce 0 means the address has never been delegated. A tuple signed for it
therefore installs the delegation if there is none, and is skipped
otherwise — the transaction stays valid, the designator already in place is
untouched, and the delegated call works. Both outcomes are correct in every
ordering, without stored state and without reading the chain, which is what
makes tracking who is delegated unnecessary.

`SweptDeposit::delegating` goes with that tracking, and the gas limit
charges one authorization per distinct address rather than per tuple the
request happens to carry. A skipped tuple still pays its base cost, so the
budget is unchanged in the worst case.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Comment thread rs/ethereum/cketh/minter/src/state/transactions/mod.rs
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Comment thread rs/ethereum/cketh/minter/src/state/transactions/tests.rs Outdated
Comment thread rs/ethereum/cketh/minter/src/state/transactions/tests.rs Outdated
…okens

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@gregorydemay
gregorydemay marked this pull request as ready for review August 26, 2026 14:00
@gregorydemay
gregorydemay requested a review from a team as a code owner August 26, 2026 14:00
@github-actions github-actions Bot added the @defi label Aug 26, 2026
@zeropath-ai

zeropath-ai Bot commented Aug 26, 2026

Copy link
Copy Markdown

No security or compliance issues detected. Reviewed everything up to 098ae8c.

Security Overview
Detected Code Changes
Change Type Relevant files
Enhancement ► rs/ethereum/cketh/minter/src/endpoints.rs
    Add SweptDeposit struct and integrate into events payloads
► rs/ethereum/cketh/minter/src/main.rs
    Wire SweptDeposit into event mapping for AcceptedSweepRequest
► rs/ethereum/cketh/minter/src/state/transactions/mod.rs
    Introduce SweptDeposit type usage in transaction state
► rs/ethereum/cketh/minter/src/state/transactions/tests.rs
    Update tests to include SweptDeposit handling and gas calculation references
► rs/ethereum/cketh/minter/src/state/transactions/request.rs
    Document transaction creation behavior for sweeps with delegations vs none
► rs/ethereum/cketh/minter/src/tx/sweep.rs
    Update SweepTransaction documentation to reflect SweptDeposit flow

@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!

The PR description still describes the first commit. Since ab70606 dropped SweptDeposit::delegating and gave every touched address a tuple, two claims under "What" no longer hold:

  • "the sweep queue and the set of delegated addresses are both reconstructible from the event log" — only the sweep queue is; a skipped tuple leaves no trace of whether the address was already delegated.
  • "Only the addresses a sweep delegates pay an authorization cost on top" - every address does now.

///
/// The enqueuing side sends one token per sweep, so the product is the addresses in practice;
/// the general form is what keeps the limit safe for any request that reaches here.
pub fn gas_limit(&self) -> GasAmount {

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.

This gas_limit() is now used in tests, but in production, the SWEEP_TRANSACTION_GAS_LIMIT is still used, is that on purpose?

[
(SWEEP_GAS_PER_BALANCE_CHECK, pairs),
(SWEEP_GAS_PER_TRANSFER, pairs),
(SWEEP_GAS_PER_AUTHORIZATION, addresses),

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.

Should this be:

Suggested change
(SWEEP_GAS_PER_AUTHORIZATION, addresses),
(SWEEP_GAS_PER_AUTHORIZATION, as_u64(self.authorizations.len())),

?

Otherwise, e.g., delegating_sweep_request() has 2 tuples but deposits: vec![], so gas_limit() == 60_000, while the intrinsic cost alone is 21_000 + 2 × 25_000 = 71_000.

Alternatively, if they should always be the same length (although the deposit_from_cex.md design doc says:

mixed batches are fine (tuples only for not-yet-delegated addresses, already-delegated ones ride along without tuples)

), add enforcement of this.

Comment on lines +245 to +246
/// Gas one EIP-7702 authorization costs: 12'500 (`PER_AUTH_BASE_COST`) plus the 25'000
/// (`PER_EMPTY_ACCOUNT_COST`) a deposit EOA pays, its account holding no ETH and no code yet.

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.

This doesn't seem to match what the design doc says:

Upfront gas, charged to M: 21'000 base + calldata + 25'000 per tuple (PER_EMPTY_ACCOUNT_COST; the 12'500 refund does not apply since D, holding only token balances, is not in the state trie).

The value of the constant itself seems fine.

Comment on lines +222 to +223
/// with the transfers. Each is deliberately generous: unspent gas is refunded, whereas an
/// underestimate wastes the whole transaction.

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.

create_transaction derives max_fee_per_gas = max_transaction_fee / gas_limit, so overshooting here doesn't reserve more - it bids proportionally less, and creation is refused once base_fee + priority exceeds the quotient (resubmission can't recover it either: the whole allowance is already committed). Is the plan for the enqueuing side to size max_transaction_fee as gas_limit() x ceiling? If so, worth a line here - as written, "unspent gas is refunded" reads as though the slack were free, when it's what sets the base-fee headroom.

#[n(6)]
pub authorizations: Vec<SignedAuthorization>,
/// The deposits this sweep moves, one per `(account, token)` pair, in the order the sweep queue
/// offered them. Not the shape of `data`, which names one item per deposit address: an account

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.

gas_limit() now derives the limit from deposits, but nothing ties deposits to data: assert_created_transaction checks destination, amount, data and authorizations, but not that the deposits describe the calldata. A request whose deposits under-describe its batch would silently get an undersized limit.

Is the plan to enforce the correspondence at the (still to come) construction site, or should there be an assertion here?

/// A transaction sent from the minter's dedicated sweeper address: an EIP-7702 transaction
/// (`0x04`) whose authorization list carries one tuple per deposit address it sweeps, or a plain
/// EIP-1559 one (`0x02`) for a sweep that delegates nothing — the native-ETH deposit addresses are
/// deliberately never delegated, so their sweeps take that shape.

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.

Is this the right justification for the EIP-1559 variant? Per the design doc, a native-ETH sweep is paid by the deposit address itself:

ETH sweeps need no EIP-7702 at all (deposit pays its own gas, 21'000 gas, cheapest possible)

so it is sent from that address rather than from the sweeper address on its own nonce sequence, which is what a SweepTransaction is. And SweptDeposit requires an erc20_contract_address, so a native-ETH sweep cannot be represented in deposits either.

Now that every touched address carries a tuple, is SweepTransaction::Eip1559 reachable for any real sweep?

@gregorydemay

Copy link
Copy Markdown
Contributor Author

🤖 Closing: superseded by the restructured stack #11353 (PRs #11359#11360#11362#11363#11342).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants