Skip to content
Closed
Show file tree
Hide file tree
Changes from 8 commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
137 changes: 137 additions & 0 deletions rs/ethereum/cketh/minter/src/eth_rpc_client/get_balance.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
//! Reading a native ETH balance through the EVM RPC canister.
//!
//! The EVM RPC canister exposes no `eth_getBalance` endpoint and `evm_rpc_client` offers no
//! balance getter, so the balance is read with the canister's generic `multi_request`: it forwards
//! an arbitrary JSON-RPC payload to every provider, deserializes each response's `result` field
//! into a string, and agrees on one under the configured consensus strategy — a threshold of the
//! providers (3 of 4 on mainnet, 2 of 4 on Sepolia), which is the only agreement this module
//! accepts.
//!
//! Because the canister deserializes `result`, what arrives here is the quantity itself rather than
//! any surrounding JSON, so it is decoded exactly: quotes or padding would be the provider's own
//! and are rejected rather than trimmed away.
//!
//! The first consumer is the sweeper address' ETH balance, which *is* the prepaid-sweep-gas
//! counter (`rs/ethereum/cketh/docs/deposit_from_cex.md`, "Fund the transaction fees without
//! touching the ckETH backing"): sweeping may only spend ETH that has already been covered by a
//! ckETH burn, and the sweeper's on-chain balance is what makes that reconcilable.

use crate::eth_rpc_client::{
MIN_ATTACHED_CYCLES, MultiCallError, NoReduction, ToReducedWithStrategy, rpc_client,
};
use crate::numeric::Wei;
use crate::state::read_state;
use evm_rpc_types::BlockTag;
use ic_ethereum_types::Address;
use serde_json::json;

#[cfg(test)]
mod tests;

/// Why an `eth_getBalance` result could not be read as an amount of wei.
#[derive(Clone, Eq, PartialEq, Debug)]
pub enum DecodeBalanceError {
/// The result is not a `0x`-prefixed hex quantity.
NotAQuantity(String),
/// The result is a hex quantity but does not fit into 32 bytes.
TooLarge(String),
}

/// Why reading a balance failed. Not `Clone`, matching [`MultiCallError`].
#[derive(Eq, PartialEq, Debug)]
pub enum GetBalanceError {
/// The call itself failed, or the providers did not agree.
Rpc(MultiCallError<String>),
/// The providers agreed on a result that is not a valid quantity, which means either a
/// broken provider or a bug on our side — never a balance of zero.
Decode(DecodeBalanceError),
}

/// The native ETH balance of `address` at `block`.
///
/// Fails rather than defaulting to zero on any error: a zero balance and "we could not read the
/// balance" must not be confused, since the caller uses this to decide whether spending is
/// already covered by a burn.
pub async fn eth_get_balance(address: &Address, block: BlockTag) -> Result<Wei, GetBalanceError> {
let payload = eth_get_balance_request(address, &block);
let result = read_state(rpc_client)
.multi_request(payload)
.with_cycles(MIN_ATTACHED_CYCLES)
.try_send()
.await
// No client-side reduction: the answer is whatever the EVM RPC canister's own consensus
// strategy agreed on, and an inconsistent result stays an error.
//
// That threshold is [`rpc_client`]'s and is network-dependent — 3 of 4 providers on
// mainnet, 2 of 4 on Sepolia — so this is "the configured threshold agreed", not "a
// majority agreed". Deliberately the same threshold as every other read the minter makes:
// holding this one call to a stricter bar would need its own client, and would buy nothing
// on the network where the difference applies, since ckSepoliaETH is not worth anything.
//
// Deliberately *not* `StrictMajorityByKey`, despite the name. That strategy returns the
// largest ballot whenever it beats the runner-up, so a 2/1/1 split across four providers
// wins on two votes — and it only ever runs on results the canister has already declared
// inconsistent, i.e. precisely when the configured threshold was not met. Picking a winner
// there would quietly settle for fewer providers than the threshold demands, on the number
// that decides whether ckETH gets burned.
.reduce_with_strategy(NoReduction)
.map_err(GetBalanceError::Rpc)?;
decode_balance(&result).map_err(GetBalanceError::Decode)
}

/// The JSON-RPC payload asking for `address`' balance at `block`.
///
/// The address is rendered in lowercase rather than EIP-55 form: the checksum carries no meaning
/// over JSON-RPC (addresses are compared case-insensitively) and a single canonical rendering
/// keeps the request byte-identical across providers.
pub fn eth_get_balance_request(address: &Address, block: &BlockTag) -> serde_json::Value {
json!({
"jsonrpc": "2.0",
"id": 1,
"method": "eth_getBalance",
"params": [format!("0x{}", hex::encode(address.as_ref())), block_tag_param(block)],
})
}

/// Reads an `eth_getBalance` result — a hex quantity such as `0x1bc16d674ec80000` — as wei.
pub fn decode_balance(result: &str) -> Result<Wei, DecodeBalanceError> {
// Decoded exactly as received. `multi_request` yields the JSON string's *contents* — the EVM
// RPC canister deserializes the `result` field into a `String` — so a provider speaking the
// protocol gives a bare quantity: no envelope, no quotes, no surrounding whitespace. Repairing
// anything else would defeat the point of the check, since the value most easily manufactured
// that way is zero, and zero reads as "the sweeper is empty" and buys a burn that was never
// needed.
let Some(digits) = result.strip_prefix("0x") else {
return Err(DecodeBalanceError::NotAQuantity(result.to_string()));
};
// A JSON-RPC quantity is minimal-length hex: at least one digit, no leading zero unless the
// value *is* zero. Checking the digits here rather than inferring them from a parse failure
// also keeps `Wei::from_str_hex`'s tolerance for a leading `+` out of reach.
if digits.is_empty()
|| !digits.chars().all(|c| c.is_ascii_hexdigit())
|| (digits.len() > 1 && digits.starts_with('0'))
{
return Err(DecodeBalanceError::NotAQuantity(result.to_string()));
}
// The digits are known good, so the only rejection left is a value too wide for 32 bytes.
Wei::from_str_hex(result).map_err(|_| DecodeBalanceError::TooLarge(result.to_string()))
}

/// Renders a block tag the way the JSON-RPC `eth_getBalance` parameter expects it: a named tag,
/// or a minimal-length hex quantity for an explicit block number.
fn block_tag_param(block: &BlockTag) -> String {
match block {
BlockTag::Latest => "latest".to_string(),
BlockTag::Finalized => "finalized".to_string(),
BlockTag::Safe => "safe".to_string(),
BlockTag::Earliest => "earliest".to_string(),
BlockTag::Pending => "pending".to_string(),
BlockTag::Number(number) => {
// A JSON-RPC quantity is minimal-length hex, so strip leading zeros — but never all
// of them: block zero is `0x0`, not `0x`.
let digits = hex::encode(number.clone().into_be_bytes());
let trimmed = digits.trim_start_matches('0');
format!("0x{}", if trimmed.is_empty() { "0" } else { trimmed })
}
}
}
189 changes: 189 additions & 0 deletions rs/ethereum/cketh/minter/src/eth_rpc_client/get_balance/tests.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
use crate::eth_rpc_client::get_balance::{
DecodeBalanceError, decode_balance, eth_get_balance_request,
};
use crate::numeric::Wei;
use evm_rpc_types::{BlockTag, Nat256};
use ic_ethereum_types::Address;
use serde_json::json;

fn address() -> Address {
Address::new([0xa7; 20])
}

mod request {
use super::*;

#[test]
fn should_build_eth_get_balance_payload() {
assert_eq!(
eth_get_balance_request(&address(), &BlockTag::Finalized),
json!({
"jsonrpc": "2.0",
"id": 1,
"method": "eth_getBalance",
"params": ["0xa7a7a7a7a7a7a7a7a7a7a7a7a7a7a7a7a7a7a7a7", "finalized"],
})
);
}

#[test]
fn should_render_the_address_in_lowercase() {
// 0xdA…, whose EIP-55 form is mixed case: the payload must not depend on the checksum.
let usdt: Address = "0xdAC17F958D2ee523a2206206994597C13D831ec7"
.parse()
.unwrap();
let payload = eth_get_balance_request(&usdt, &BlockTag::Latest);

assert_eq!(
payload["params"][0],
json!("0xdac17f958d2ee523a2206206994597c13d831ec7")
);
}

#[test]
fn should_render_every_named_block_tag() {
for (tag, expected) in [
(BlockTag::Latest, "latest"),
(BlockTag::Finalized, "finalized"),
(BlockTag::Safe, "safe"),
(BlockTag::Earliest, "earliest"),
(BlockTag::Pending, "pending"),
] {
assert_eq!(
eth_get_balance_request(&address(), &tag)["params"][1],
json!(expected),
"unexpected rendering of {tag:?}"
);
}
}

#[test]
fn should_render_a_block_number_as_a_minimal_length_quantity() {
for (number, expected) in [
(0_u64, "0x0"),
(1, "0x1"),
(15, "0xf"),
(16, "0x10"),
(255, "0xff"),
(256, "0x100"),
(4_272_876, "0x4132ec"),
(u64::MAX, "0xffffffffffffffff"),
] {
assert_eq!(
eth_get_balance_request(&address(), &BlockTag::Number(Nat256::from(number)))["params"]
[1],
json!(expected),
"unexpected rendering of block {number}"
);
}
}
}

mod decode {
use super::*;

#[test]
fn should_decode_quantities() {
for (result, expected) in [
("0x0", Wei::ZERO),
("0x1", Wei::new(1)),
("0xf", Wei::new(15)),
// 3.5 ETH, the value the live anvil test reads back.
("0x30927f74c9de0000", Wei::new(3_500_000_000_000_000_000)),
("0xde0b6b3a7640000", Wei::new(1_000_000_000_000_000_000)),
] {
assert_eq!(decode_balance(result), Ok(expected), "failed on {result}");
}
}

#[test]
fn should_decode_the_largest_representable_balance() {
let max = format!("0x{}", "f".repeat(64));
assert_eq!(decode_balance(&max), Ok(Wei::MAX));
}

/// Case is the one rendering difference that is not a protocol violation, so it stays valid.
#[test]
fn should_decode_an_uppercase_quantity() {
assert_eq!(
decode_balance("0xDE0B6B3A7640000"),
Ok(Wei::new(1_000_000_000_000_000_000))
);
}

#[test]
fn should_reject_non_quantities() {
for result in [
"", // empty
"0x", // prefix only
"1", // no prefix
"1234", // no prefix
"latest", // a block tag, not a balance
"0xnothex", // non-hex digits
"0x 1", // embedded space
"null", // a JSON null passed through verbatim
"-0x1", // negative
"+0x1", // signed; the integer parser would otherwise accept the sign
"0x+1", // sign after the prefix
"\"0x1\"", // quoted: the canister hands back contents, so quotes are the provider's
" 0x1 ", // padded, for the same reason
] {
assert_eq!(
decode_balance(result),
Err(DecodeBalanceError::NotAQuantity(result.to_string())),
"should have rejected {result:?}"
);
}
}

#[test]
fn should_reject_a_quantity_with_a_leading_zero() {
assert_eq!(
decode_balance("0x0de0b6b3a7640000"),
Err(DecodeBalanceError::NotAQuantity(
"0x0de0b6b3a7640000".to_string()
)),
"a non-minimal quantity is a provider not speaking the protocol"
);
assert_eq!(
decode_balance("0x0"),
Ok(Wei::ZERO),
"but zero itself is 0x0"
);
}

#[test]
fn should_reject_a_balance_wider_than_32_bytes() {
// 33 bytes of 0xff: a valid hex quantity that cannot be a balance.
let too_large = format!("0x{}", "f".repeat(66));
assert_eq!(
decode_balance(&too_large),
Err(DecodeBalanceError::TooLarge(too_large.clone()))
);
}

#[test]
fn should_never_read_an_error_as_a_zero_balance() {
// Guards the property the funding task depends on: "could not read the balance" must
// never be mistaken for "the sweeper has no gas left", which would trigger a burn.
for result in [
"",
"0x",
"null",
"latest",
// One-sided and repeated quotes: stripping quote characters would turn these into a
// zero, which is exactly the confusion this guards.
"\"0x0",
"0x0\"",
"\"\"0x0\"\"",
// Non-minimal quantities are not the protocol's, so they are provider errors too.
"0x00",
"0x0000000000000000",
] {
assert!(
decode_balance(result).is_err(),
"{result:?} must not decode to a balance"
);
}
}
}
1 change: 1 addition & 0 deletions rs/ethereum/cketh/minter/src/eth_rpc_client/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ use std::{
fmt::Debug,
};

pub mod get_balance;
pub mod responses;

#[cfg(test)]
Expand Down
26 changes: 25 additions & 1 deletion rs/ethereum/cketh/minter/src/ledger_client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -71,12 +71,36 @@ impl LedgerClient {
from: Account,
amount: A,
memo: BurnMemo,
) -> Result<LedgerBurnIndex, LedgerBurnError> {
self.burn_from_with_spender(from, None, amount, memo).await
}

pub async fn burn_from_own_subaccount<A: Into<Nat>>(
&self,
subaccount: [u8; 32],
amount: A,
memo: BurnMemo,
) -> Result<LedgerBurnIndex, LedgerBurnError> {
let from = Account {
owner: ic_cdk::api::canister_self(),
subaccount: Some(subaccount),
};
self.burn_from_with_spender(from, Some(subaccount), amount, memo)
.await
}

async fn burn_from_with_spender<A: Into<Nat>>(
&self,
from: Account,
spender_subaccount: Option<[u8; 32]>,
amount: A,
memo: BurnMemo,
) -> Result<LedgerBurnIndex, LedgerBurnError> {
let amount = amount.into();
match self
.client
.transfer_from(TransferFromArgs {
spender_subaccount: None,
spender_subaccount,
from,
to: ic_cdk::api::canister_self().into(),
amount: amount.clone(),
Expand Down
7 changes: 7 additions & 0 deletions rs/ethereum/cketh/minter/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -45,3 +45,10 @@ pub const EVM_RPC_ID_PRODUCTION: Principal =
Principal::from_slice(&[0, 0, 0, 0, 2, 48, 0, 204, 1, 1]);
pub const EVM_RPC_ID_STAGING: Principal = Principal::from_slice(&[0, 0, 0, 0, 2, 48, 0, 161, 1, 1]);
pub const CKETH_LEDGER_MEMO_SIZE: u16 = 80;

pub const CKETH_FEE_SUBACCOUNT: [u8; 32] = {
let mut subaccount = [0_u8; 32];
subaccount[30] = 0x0f;
subaccount[31] = 0xee;
subaccount
};
Loading
Loading