Skip to content
Merged
1 change: 1 addition & 0 deletions rs/ethereum/cketh/minter/src/lifecycle/init.rs
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,7 @@ impl TryFrom<InitArg> for State {
log_scrapings,
automatic_deposits: AutomaticDeposits::default(),
sweeper_contract_address,
sweeper_funding: Default::default(),
};
state.validate_config()?;
Ok(state)
Expand Down
32 changes: 32 additions & 0 deletions rs/ethereum/cketh/minter/src/state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ use crate::numeric::{
};
use crate::state::automatic_deposits::{AutomaticDeposits, ScanProgress};
use crate::state::eth_logs_scraping::{LogScrapingId, LogScrapings};
use crate::state::sweeper_funding::{SweeperFundingAccounting, SweeperFundingConfig};
use crate::state::transactions::{Erc20WithdrawalRequest, TransactionCallData, WithdrawalRequest};
use crate::timed_sized_map::{Entry, Timestamp};
use crate::tx::GasFeeEstimate;
Expand All @@ -32,6 +33,7 @@ pub mod audit;
pub mod automatic_deposits;
pub mod eth_logs_scraping;
pub mod event;
pub mod sweeper_funding;
pub mod transactions;

#[cfg(test)]
Expand Down Expand Up @@ -120,6 +122,9 @@ pub struct State {
/// Address of the sweeper smart contract on Ethereum, which the minter
/// delegates to when sweeping funded deposit addresses.
pub sweeper_contract_address: Option<Address>,

/// Burn-first accounting for sweeper fee funding, folded from the audit events.
Comment thread
gregorydemay marked this conversation as resolved.
Outdated
pub sweeper_funding: SweeperFundingAccounting,
}

#[derive(Eq, PartialEq, Debug)]
Expand Down Expand Up @@ -181,6 +186,16 @@ impl State {
EthereumNetwork::Mainnet => Wei::new(2_000_000_000_000),
EthereumNetwork::Sepolia => Wei::new(10_000_000_000),
};
if SweeperFundingConfig::for_minimum_withdrawal_amount(self.cketh_minimum_withdrawal_amount)
.is_none()
{
return Err(InvalidStateError::InvalidMinimumWithdrawalAmount(format!(
"minimum_withdrawal_amount {} is too large: the sweeper funding target is {} \
times it, which does not fit",
self.cketh_minimum_withdrawal_amount,
crate::state::sweeper_funding::SWEEPER_FUNDING_TARGET_IN_MINIMUM_WITHDRAWAL_AMOUNTS,
)));
}
if self.cketh_minimum_withdrawal_amount < cketh_ledger_transfer_fee {
return Err(InvalidStateError::InvalidMinimumWithdrawalAmount(
"minimum_withdrawal_amount must cover ledger transaction fee, \
Expand Down Expand Up @@ -429,6 +444,15 @@ impl State {
self.eth_balance.total_effective_tx_fees_add(tx_fee);
self.eth_balance.total_unspent_tx_fees_add(unspent_tx_fee);

if matches!(withdrawal_request, WithdrawalRequest::SweeperFunding(_)) {
let transferred = match receipt.status {
TransactionStatus::Success => tx.transaction().amount,
TransactionStatus::Failure => Wei::ZERO,
};
self.sweeper_funding
.record_finalized_funding(transferred, tx_fee);
}

if receipt.status == TransactionStatus::Success && !tx.transaction_data().is_empty() {
let TransactionCallData::Erc20Transfer { to: _, value } = TransactionCallData::decode(
tx.transaction_data(),
Expand Down Expand Up @@ -595,6 +619,13 @@ impl State {
self.validate_config()
}

/// When to top the sweeper address up, and to what: derived from the minimum withdrawal
/// amount, which [`Self::validate_config`] keeps small enough for the derivation to fit.
pub fn sweeper_funding_config(&self) -> SweeperFundingConfig {
SweeperFundingConfig::for_minimum_withdrawal_amount(self.cketh_minimum_withdrawal_amount)
.expect("BUG: validate_config rejects a minimum withdrawal amount this large")
}

/// Checks whether two states are equivalent.
pub fn is_equivalent_to(&self, other: &Self) -> Result<(), String> {
// We define the equivalence using the upgrade procedure.
Expand Down Expand Up @@ -632,6 +663,7 @@ impl State {
self.sweeper_contract_address,
other.sweeper_contract_address
);
ensure_eq!(self.sweeper_funding, other.sweeper_funding);

self.withdrawal_transactions
.is_equivalent_to(&other.withdrawal_transactions)
Expand Down
1 change: 1 addition & 0 deletions rs/ethereum/cketh/minter/src/state/audit.rs
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ pub fn apply_state_transition(state: &mut State, payload: &EventType) {
.record_withdrawal_request(request.clone());
}
EventType::AcceptedSweeperFundingRequest(request) => {
state.sweeper_funding.record_burn(request.withdrawal_amount);
// Named explicitly: the payload converts to `CkEth` on its own, which would make the
// funding reimbursable.
state
Expand Down
127 changes: 127 additions & 0 deletions rs/ethereum/cketh/minter/src/state/sweeper_funding.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
//! Burn-first accounting for sweeper fee funding, per
//! `rs/ethereum/cketh/docs/deposit_from_cex.md`, "Fund the transaction fees without touching the
Comment thread
mbjorkqvist marked this conversation as resolved.
Outdated
//! ckETH backing": ckETH is burned from the minter's fee subaccount *before* the ETH moves, so that
//! at every instant
//!
//! ```text
//! cumulative ckETH burned for sweeping >= cumulative ETH debited from the main address for sweeping
//! ```
//!
//! The surplus is never re-minted and never discounted from a later burn. It sits at the *main*
//! address, not the sweeper's, so it is tracked here rather than read back on chain.

#[cfg(test)]
mod tests;

use crate::numeric::Wei;

/// How much ckETH has been burned for sweeping and how much of it has actually been spent.
///
/// Not CBOR-serializable: rebuilt from the audit events on every upgrade, never persisted.
Comment thread
gregorydemay marked this conversation as resolved.
Outdated
#[derive(Clone, Eq, PartialEq, Debug)]
pub struct SweeperFundingAccounting {
cumulative_burned: Wei,
cumulative_transferred: Wei,
Comment thread
gregorydemay marked this conversation as resolved.
/// Fees of finalized funding transactions, successful or not.
cumulative_transaction_fees: Wei,
}

impl Default for SweeperFundingAccounting {
fn default() -> Self {
Self {
cumulative_burned: Wei::ZERO,
cumulative_transferred: Wei::ZERO,
cumulative_transaction_fees: Wei::ZERO,
}
}
}

impl SweeperFundingAccounting {
/// Records a burn from the fee subaccount, before any ETH moves.
pub fn record_burn(&mut self, amount: Wei) {
self.cumulative_burned = self
.cumulative_burned
.checked_add(amount)
.expect("BUG: overflow in cumulative burned for sweeping");
}

/// Records a finalized funding transaction: `transferred` reached the sweeper address (zero if
/// the transaction failed) and `transaction_fee` was spent on gas either way.
pub fn record_finalized_funding(&mut self, transferred: Wei, transaction_fee: Wei) {
self.cumulative_transferred = self
.cumulative_transferred
.checked_add(transferred)
.expect("BUG: overflow in cumulative transferred to the sweeper");
self.cumulative_transaction_fees = self
.cumulative_transaction_fees
.checked_add(transaction_fee)
.expect("BUG: overflow in cumulative sweeper funding fees");
// Checked eagerly so a violation surfaces at the transition that caused it.
let _ = self.burned_not_yet_spent();
}

/// Total ETH debited from the main address on account of sweeping.
pub fn cumulative_spent(&self) -> Wei {
self.cumulative_transferred
.checked_add(self.cumulative_transaction_fees)
.expect("BUG: overflow in cumulative spent on sweeping")
}

pub fn cumulative_burned(&self) -> Wei {
self.cumulative_burned
}

/// ckETH burned for sweeping that has not been spent yet: the burn of a funding in flight, plus
/// the fees earlier fundings provisioned but did not pay. Panics rather than saturating if spend
/// ever exceeds burn, which would mean ckETH is under-backed.
pub fn burned_not_yet_spent(&self) -> Wei {
self.cumulative_burned
.checked_sub(self.cumulative_spent())
.expect(
"BUG: more ETH spent on sweeping than ckETH burned for it, \
meaning ckETH is under-backed",
)
}
}

/// When to top the sweeper address up, and to what. Derived from the minimum withdrawal amount, so
/// that the gap between the two — the smallest amount a funding moves — clears the ledger minimum by
/// construction.
#[derive(Clone, Copy, Eq, PartialEq, Debug)]
pub struct SweeperFundingConfig {
/// Fund the sweeper address once its ETH balance falls below this.
pub low_water_mark: Wei,
Comment thread
gregorydemay marked this conversation as resolved.
/// Top the sweeper address up to this balance.
pub target: Wei,
}

/// The target in minimum withdrawal amounts: 0.3 ETH against mainnet's 0.03. Provisional, to be
/// calibrated during the Sepolia rollout.
pub const SWEEPER_FUNDING_TARGET_IN_MINIMUM_WITHDRAWAL_AMOUNTS: u8 = 10;

impl SweeperFundingConfig {
/// Refilling starts at half the target, so a funding moves at least five minimum withdrawal
/// amounts. `None` if the target would overflow, which [`State::validate_config`] rejects.
///
/// [`State::validate_config`]: crate::state::State::validate_config
pub fn for_minimum_withdrawal_amount(minimum_withdrawal_amount: Wei) -> Option<Self> {
let target = minimum_withdrawal_amount
.checked_mul(SWEEPER_FUNDING_TARGET_IN_MINIMUM_WITHDRAWAL_AMOUNTS)?;
Some(Self {
low_water_mark: target
.checked_div_floor(2_u8)
.expect("BUG: dividing by a non-zero constant"),
target,
})
}

/// How much ETH to move to bring `sweeper_balance` up to the target, or `None` when the
/// balance is still above the low-water mark and no funding is due.
pub fn amount_due(&self, sweeper_balance: Wei) -> Option<Wei> {
if sweeper_balance >= self.low_water_mark {
return None;
}
// Non-zero: the balance is below the low-water mark, which is half the target.
self.target.checked_sub(sweeper_balance)
Comment thread
mbjorkqvist marked this conversation as resolved.
Outdated
}
}
174 changes: 174 additions & 0 deletions rs/ethereum/cketh/minter/src/state/sweeper_funding/tests.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
use crate::numeric::Wei;
use crate::state::sweeper_funding::{SweeperFundingAccounting, SweeperFundingConfig};

const BURN: u128 = 100_000_000_000_000_000; // 0.1 ETH
const FEE: u128 = 1_000_000_000_000_000; // 0.001 ETH

mod accounting {
use super::*;

#[test]
fn should_start_empty() {
let accounting = SweeperFundingAccounting::default();

assert_eq!(accounting.cumulative_burned(), Wei::ZERO);
assert_eq!(accounting.cumulative_spent(), Wei::ZERO);
assert_eq!(accounting.burned_not_yet_spent(), Wei::ZERO);
}

#[test]
fn should_leave_no_surplus_after_a_successful_funding() {
let mut accounting = SweeperFundingAccounting::default();
accounting.record_burn(Wei::new(BURN));
accounting.record_finalized_funding(Wei::new(BURN - FEE), Wei::new(FEE));

assert_eq!(accounting.cumulative_spent(), Wei::new(BURN));
assert_eq!(
accounting.burned_not_yet_spent(),
Wei::ZERO,
"burn and spend must balance exactly on success"
);
}

#[test]
fn should_keep_the_unspent_fee_as_surplus_after_a_successful_funding() {
let mut accounting = SweeperFundingAccounting::default();
accounting.record_burn(Wei::new(BURN));
accounting.record_finalized_funding(Wei::new(BURN - FEE), Wei::new(FEE / 2));

assert_eq!(
accounting.burned_not_yet_spent(),
Wei::new(FEE - FEE / 2),
Comment thread
mbjorkqvist marked this conversation as resolved.
Outdated
"the fee provisioned but never paid stays as backing"
);
}

#[test]
fn should_keep_the_burn_as_surplus_after_a_failed_funding() {
let mut accounting = SweeperFundingAccounting::default();
accounting.record_burn(Wei::new(BURN));
accounting.record_finalized_funding(Wei::ZERO, Wei::new(FEE));

assert_eq!(accounting.cumulative_spent(), Wei::new(FEE));
assert_eq!(
accounting.burned_not_yet_spent(),
Wei::new(BURN - FEE),
"everything except the gas actually paid stays as backing"
);
assert!(
accounting.cumulative_burned() > accounting.cumulative_spent(),
"burned must exceed spent, i.e. ckETH is over-backed rather than under-backed"
);
}

#[test]
fn should_accumulate_across_fundings() {
let mut accounting = SweeperFundingAccounting::default();
for _ in 0..3 {
accounting.record_burn(Wei::new(BURN));
accounting.record_finalized_funding(Wei::new(BURN - FEE), Wei::new(FEE));
}

assert_eq!(accounting.cumulative_burned(), Wei::new(3 * BURN));
assert_eq!(accounting.cumulative_spent(), Wei::new(3 * BURN));
assert_eq!(accounting.burned_not_yet_spent(), Wei::ZERO);
}

#[test]
#[should_panic(expected = "more ETH spent on sweeping than ckETH burned")]
fn should_panic_when_spending_more_than_was_burned() {
let mut accounting = SweeperFundingAccounting::default();
accounting.record_burn(Wei::new(FEE));

accounting.record_finalized_funding(Wei::new(BURN), Wei::new(FEE));
}
}

mod config {
use super::*;
use crate::state::sweeper_funding::SWEEPER_FUNDING_TARGET_IN_MINIMUM_WITHDRAWAL_AMOUNTS;

const MINIMUM_BURN: u128 = 30_000_000_000_000_000; // ckETH's mainnet minimum withdrawal amount

fn config_for(minimum_withdrawal_amount: u128) -> SweeperFundingConfig {
SweeperFundingConfig::for_minimum_withdrawal_amount(Wei::new(minimum_withdrawal_amount))
.expect("test setup: the bounds must fit")
}

#[test]
fn should_derive_the_bounds_from_the_minimum_withdrawal_amount() {
let config = config_for(MINIMUM_BURN);

assert_eq!(
config.target,
Wei::new(MINIMUM_BURN * SWEEPER_FUNDING_TARGET_IN_MINIMUM_WITHDRAWAL_AMOUNTS as u128)
);
assert_eq!(
config.low_water_mark,
config.target.checked_div_floor(2_u8).unwrap()
);
}
Comment thread
gregorydemay marked this conversation as resolved.
Outdated

#[test]
fn should_leave_headroom_above_the_minimum_withdrawal_amount() {
for minimum in [
1,
1_000,
10_000_000_000, // Sepolia's ledger transfer fee
MINIMUM_BURN,
1_000 * MINIMUM_BURN,
] {
let config = config_for(minimum);
let headroom = config
.target
.checked_sub(config.low_water_mark)
.expect("the target must exceed the low-water mark");

assert!(
headroom >= Wei::new(minimum),
"a funding of a minter with minimum {minimum} moves at least {headroom}, \
which must cover the minimum itself"
);
}
}

#[test]
fn should_report_no_bounds_when_the_target_would_not_fit() {
let too_large = Wei::MAX
.checked_div_floor(SWEEPER_FUNDING_TARGET_IN_MINIMUM_WITHDRAWAL_AMOUNTS)
.unwrap()
.checked_add(Wei::ONE)
.unwrap();

assert_eq!(
SweeperFundingConfig::for_minimum_withdrawal_amount(too_large),
None,
"the caller must find out rather than the derivation trapping"
);
}

#[test]
fn should_not_fund_above_the_low_water_mark() {
let config = config_for(MINIMUM_BURN);

assert_eq!(config.amount_due(config.target), None);
assert_eq!(
config.amount_due(config.low_water_mark),
None,
"at the mark, not below"
);
}

#[test]
fn should_fund_up_to_the_target() {
let config = config_for(MINIMUM_BURN);
let just_below = config.low_water_mark.checked_sub(Wei::ONE).unwrap();

assert_eq!(
config.amount_due(just_below),
Some(config.target.checked_sub(just_below).unwrap()),
"top up the shortfall to the target, not a fixed amount"
);
Comment thread
gregorydemay marked this conversation as resolved.
Outdated
assert_eq!(config.amount_due(Wei::ZERO), Some(config.target));
}
}
Loading
Loading