Skip to content

Commit 0ff3907

Browse files
gregorydemayclaude
andauthored
feat(cketh): task skeleton to create sweeper requests (#11347)
## Why - The sweep queue that the balance scan fills has nothing turning its deposits into sweeps, and the signing that will do so needs a canister — which is what makes it untestable today. ## What - An async `CanisterRuntime` trait extending `TimeProvider` with tECDSA signing as its first capability, plus the implementation backed by the system API and a mock for tests. - A skeleton `create_pending_sweeper_requests`, its task type, and the timer that runs it. - The skeleton does nothing yet, and a unit test pins that down: a deposit sits in the sweep queue, the runtime carries no expectation, and the whole state comes back unchanged. Each later commit then shows up as a diff against a passing test. [DEFI-2926]: https://dfinity.atlassian.net/browse/DEFI-2926 --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 7ccab90 commit 0ff3907

17 files changed

Lines changed: 336 additions & 71 deletions

File tree

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

rs/ethereum/cketh/minter/BUILD.bazel

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ filegroup(
3030
crate_name = "ic_cketh_minter",
3131
proc_macro_deps = [
3232
# Keep sorted.
33+
"@crate_index//:async-trait",
3334
"@crate_index//:strum_macros",
3435
],
3536
version = "0.1.0",

rs/ethereum/cketh/minter/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ path = "bin/principal_to_hex.rs"
1616

1717
[dependencies]
1818
askama = { workspace = true }
19+
async-trait = { workspace = true }
1920
canbench-rs = { workspace = true, optional = true }
2021
candid = { workspace = true }
2122
ethnum = { workspace = true }

rs/ethereum/cketh/minter/src/attestation/mod.rs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ mod tests;
1313
use crate::deposit_address::{DepositAddressSchema, deposit_derivation_path};
1414
use crate::eth_logs::encode_principal;
1515
use crate::eth_rpc::Hash;
16+
use crate::runtime::CanisterRuntime;
1617
use crate::tx::{TransactionSignature, sign_digest};
1718
use ic_ethereum_types::Address;
1819
use icrc_ledger_types::icrc1::account::Account;
@@ -96,12 +97,14 @@ impl AttestationRequest {
9697
///
9798
/// # Errors
9899
/// * a description of why the threshold-ECDSA signature could not be produced.
99-
pub async fn sign_attestation(
100+
pub async fn sign_attestation<R: CanisterRuntime>(
100101
request: &AttestationRequest,
102+
runtime: &R,
101103
) -> Result<TransactionSignature, String> {
102104
sign_digest(
103105
&request.digest(),
104106
&deposit_derivation_path(DepositAddressSchema::CkErc20, &request.account),
107+
runtime,
105108
)
106109
.await
107110
}

rs/ethereum/cketh/minter/src/lib.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ pub mod management;
1919
pub mod map;
2020
pub mod memo;
2121
pub mod numeric;
22+
pub mod runtime;
2223
pub mod state;
2324
pub mod storage;
2425
pub mod sweep;
@@ -44,6 +45,10 @@ pub const REFRESH_LATEST_BLOCK_HEIGHT_INTERVAL: Duration = Duration::from_secs(3
4445
pub const BALANCE_SCAN_INTERVAL: Duration = Duration::from_secs(30);
4546
pub const SWEEPER_FUNDING_INTERVAL: Duration = Duration::from_secs(24 * 60 * 60);
4647
pub const PROCESS_ETH_RETRIEVE_TRANSACTIONS_INTERVAL: Duration = Duration::from_secs(6 * 60);
48+
/// How often the minter turns detected deposits into sweeper requests. Far shorter than the
49+
/// intervals that send transactions: the mint follows the sweep, so this interval is part of a
50+
/// user's crediting latency, and creating a request is cheap — it signs, but sends nothing.
51+
pub const SWEEP_ENQUEUE_INTERVAL: Duration = Duration::from_secs(60);
4752
pub const PROCESS_REIMBURSEMENT: Duration = Duration::from_secs(3 * 60);
4853
pub const PROCESS_ETH_RETRIEVE_TRANSACTIONS_RETRY_INTERVAL: Duration = Duration::from_secs(3 * 60);
4954
pub const PROCESS_SWEEPER_TRANSACTIONS_INTERVAL: Duration = Duration::from_secs(6 * 60);

rs/ethereum/cketh/minter/src/main.rs

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ use ic_cketh_minter::lifecycle::MinterArg;
2929
use ic_cketh_minter::logs::INFO;
3030
use ic_cketh_minter::memo::{self, BurnMemo};
3131
use ic_cketh_minter::numeric::{Erc20Value, LedgerBurnIndex, Wei};
32+
use ic_cketh_minter::runtime::IC_CANISTER_RUNTIME;
3233
use ic_cketh_minter::state::audit::{Event, EventType, process_event};
3334
use ic_cketh_minter::state::automatic_deposits::DepositRequest;
3435
use ic_cketh_minter::state::eth_logs_scraping::{LogScrapingId, LogScrapingInfo};
@@ -39,7 +40,7 @@ use ic_cketh_minter::state::transactions::{
3940
use ic_cketh_minter::state::{
4041
STATE, State, lazy_call_ecdsa_public_key, mutate_state, read_state, transactions,
4142
};
42-
use ic_cketh_minter::sweep::process_sweeper_transactions;
43+
use ic_cketh_minter::sweep::{create_pending_sweeper_requests, process_sweeper_transactions};
4344
use ic_cketh_minter::sweeper::fund_sweeper_address;
4445
use ic_cketh_minter::time::IC_TIME_PROVIDER;
4546
use ic_cketh_minter::timed_sized_map::Timestamp;
@@ -51,7 +52,7 @@ use ic_cketh_minter::withdraw::{
5152
use ic_cketh_minter::{
5253
BALANCE_SCAN_INTERVAL, PROCESS_ETH_RETRIEVE_TRANSACTIONS_INTERVAL, PROCESS_REIMBURSEMENT,
5354
PROCESS_SWEEPER_TRANSACTIONS_INTERVAL, REFRESH_LATEST_BLOCK_HEIGHT_INTERVAL,
54-
SCRAPING_ETH_LOGS_INTERVAL, SWEEPER_FUNDING_INTERVAL, state, storage,
55+
SCRAPING_ETH_LOGS_INTERVAL, SWEEP_ENQUEUE_INTERVAL, SWEEPER_FUNDING_INTERVAL, state, storage,
5556
};
5657
use ic_cketh_minter::{endpoints, erc20};
5758
use ic_ethereum_types::Address;
@@ -84,7 +85,7 @@ fn validate_ckerc20_active() {
8485
fn setup_timers() {
8586
ic_cdk_timers::set_timer(Duration::from_secs(0), async {
8687
// Initialize the minter's public key to make the address known.
87-
let _ = lazy_call_ecdsa_public_key().await;
88+
let _ = lazy_call_ecdsa_public_key(&IC_CANISTER_RUNTIME).await;
8889
// Sequenced after the key rather than scheduled on a delay: the sweeper address cannot be
8990
// derived without it, and a delay only guesses at when it will be cached. Running here also
9091
// keeps the two off separate tasks, since two concurrent `ecdsa_public_key` calls trap.
@@ -106,10 +107,13 @@ fn setup_timers() {
106107
refresh_latest_block_height().await;
107108
});
108109
ic_cdk_timers::set_timer_interval(PROCESS_ETH_RETRIEVE_TRANSACTIONS_INTERVAL, async || {
109-
process_retrieve_eth_requests(IC_TIME_PROVIDER).await;
110+
process_retrieve_eth_requests(IC_CANISTER_RUNTIME).await;
111+
});
112+
ic_cdk_timers::set_timer_interval(SWEEP_ENQUEUE_INTERVAL, async || {
113+
create_pending_sweeper_requests(&IC_CANISTER_RUNTIME).await;
110114
});
111115
ic_cdk_timers::set_timer_interval(PROCESS_SWEEPER_TRANSACTIONS_INTERVAL, async || {
112-
process_sweeper_transactions(IC_TIME_PROVIDER).await;
116+
process_sweeper_transactions(IC_CANISTER_RUNTIME).await;
113117
});
114118
ic_cdk_timers::set_timer_interval(PROCESS_REIMBURSEMENT, async || {
115119
process_reimbursement(&IC_TIME_PROVIDER).await;
@@ -193,7 +197,9 @@ fn post_upgrade(minter_arg: Option<MinterArg>) {
193197

194198
#[update]
195199
async fn minter_address() -> String {
196-
state::minter_address().await.to_string()
200+
state::minter_address(&IC_CANISTER_RUNTIME)
201+
.await
202+
.to_string()
197203
}
198204

199205
#[update]
@@ -240,7 +246,7 @@ async fn deposit_erc20(arg: DepositErc20Arg) -> Result<DepositErc20Response, Dep
240246

241247
// Not armed yet: register. Ensure the minter's ECDSA public key has been fetched and cached in
242248
// the state so that the (synchronous) registration below can derive the address.
243-
state::lazy_call_ecdsa_public_key_with_chain_code().await;
249+
state::lazy_call_ecdsa_public_key_with_chain_code(&IC_CANISTER_RUNTIME).await;
244250
let now = Timestamp::from_nanos(ic_cdk::api::time());
245251
// Re-check the status after the await: a concurrent balance scan may have detected a deposit and
246252
// moved this pair into the sweep queue while we waited for the ECDSA key (only possible right

rs/ethereum/cketh/minter/src/management/mod.rs

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
use ic_cdk::call::{CallFailed, RejectCode};
1+
use ic_cdk::call::{CallFailed, Error as CdkCallError, RejectCode};
22
use ic_cdk_management_canister::SignCallError;
33
use ic_management_canister_types_private::DerivationPath;
44
use std::fmt;
@@ -79,6 +79,17 @@ impl Reason {
7979
}
8080
}
8181

82+
fn from_cdk_call_error(error: CdkCallError) -> Self {
83+
match error {
84+
CdkCallError::CandidDecodeFailed(e) => {
85+
Self::InternalError(format!("candid decode failed: {e}"))
86+
}
87+
CdkCallError::CallRejected(rejected) => Self::from_call_failed(rejected.into()),
88+
CdkCallError::InsufficientLiquidCycleBalance(e) => Self::from_call_failed(e.into()),
89+
CdkCallError::CallPerformFailed(e) => Self::from_call_failed(e.into()),
90+
}
91+
}
92+
8293
fn from_call_failed(failed: CallFailed) -> Self {
8394
match failed {
8495
CallFailed::CallRejected(rejected) => {
@@ -104,6 +115,28 @@ impl Reason {
104115
}
105116
}
106117

118+
/// Fetches the canister's tECDSA public key and chain code from the management canister.
119+
pub async fn ecdsa_public_key(
120+
key_name: String,
121+
derivation_path: DerivationPath,
122+
) -> Result<ic_cdk_management_canister::EcdsaPublicKeyResult, CallError> {
123+
use ic_cdk_management_canister::{EcdsaCurve, EcdsaKeyId, EcdsaPublicKeyArgs};
124+
125+
ic_cdk_management_canister::ecdsa_public_key(&EcdsaPublicKeyArgs {
126+
canister_id: None,
127+
derivation_path: derivation_path.into_inner(),
128+
key_id: EcdsaKeyId {
129+
curve: EcdsaCurve::Secp256k1,
130+
name: key_name,
131+
},
132+
})
133+
.await
134+
.map_err(|error| CallError {
135+
method: "ecdsa_public_key".to_string(),
136+
reason: Reason::from_cdk_call_error(error),
137+
})
138+
}
139+
107140
/// Signs a message hash using the tECDSA API.
108141
pub async fn sign_with_ecdsa(
109142
key_name: String,
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
use crate::management::CallError;
2+
use crate::time::{IC_TIME_PROVIDER, TimeProvider};
3+
use async_trait::async_trait;
4+
use ic_cdk_management_canister::EcdsaPublicKeyResult;
5+
use ic_management_canister_types_private::DerivationPath;
6+
use serde_bytes::ByteBuf;
7+
8+
/// The canister capabilities the minter needs from its environment.
9+
///
10+
/// Abstracting them away lets the logic that drives them be exercised without a canister.
11+
#[async_trait]
12+
pub trait CanisterRuntime: TimeProvider {
13+
/// Signs a message hash with the tECDSA key `key_name` derived along `derivation_path`.
14+
async fn sign_with_ecdsa(
15+
&self,
16+
key_name: String,
17+
derivation_path: Vec<Vec<u8>>,
18+
message_hash: [u8; 32],
19+
) -> Result<[u8; 64], CallError>;
20+
21+
/// The tECDSA public key `key_name` derived along `derivation_path`, with its chain code.
22+
async fn ecdsa_public_key(
23+
&self,
24+
key_name: String,
25+
derivation_path: Vec<Vec<u8>>,
26+
) -> Result<EcdsaPublicKeyResult, CallError>;
27+
}
28+
29+
/// The [`CanisterRuntime`] used in production, backed by the Internet Computer system API.
30+
pub const IC_CANISTER_RUNTIME: IcCanisterRuntime = IcCanisterRuntime;
31+
32+
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
33+
pub struct IcCanisterRuntime;
34+
35+
impl TimeProvider for IcCanisterRuntime {
36+
fn time(&self) -> u64 {
37+
IC_TIME_PROVIDER.time()
38+
}
39+
}
40+
41+
#[async_trait]
42+
impl CanisterRuntime for IcCanisterRuntime {
43+
async fn sign_with_ecdsa(
44+
&self,
45+
key_name: String,
46+
derivation_path: Vec<Vec<u8>>,
47+
message_hash: [u8; 32],
48+
) -> Result<[u8; 64], CallError> {
49+
crate::management::sign_with_ecdsa(
50+
key_name,
51+
DerivationPath::new(derivation_path.into_iter().map(ByteBuf::from).collect()),
52+
message_hash,
53+
)
54+
.await
55+
}
56+
57+
async fn ecdsa_public_key(
58+
&self,
59+
key_name: String,
60+
derivation_path: Vec<Vec<u8>>,
61+
) -> Result<EcdsaPublicKeyResult, CallError> {
62+
crate::management::ecdsa_public_key(
63+
key_name,
64+
DerivationPath::new(derivation_path.into_iter().map(ByteBuf::from).collect()),
65+
)
66+
.await
67+
}
68+
}

rs/ethereum/cketh/minter/src/state.rs

Lines changed: 19 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ use crate::map::DedupMultiKeyMap;
1212
use crate::numeric::{
1313
BlockNumber, Erc20Value, LedgerBurnIndex, LedgerMintIndex, TransactionNonce, Wei,
1414
};
15+
use crate::runtime::CanisterRuntime;
1516
use crate::state::automatic_deposits::{AutomaticDeposits, ScanProgress};
1617
use crate::state::eth_logs_scraping::{LogScrapingId, LogScrapings};
1718
use crate::state::sweeper_funding::{SweeperFundingAccounting, SweeperFundingConfig};
@@ -799,11 +800,9 @@ where
799800
})
800801
}
801802

802-
pub async fn lazy_call_ecdsa_public_key_with_chain_code() -> (PublicKey, [u8; 32]) {
803-
use ic_cdk_management_canister::{
804-
EcdsaCurve, EcdsaKeyId, EcdsaPublicKeyArgs, ecdsa_public_key,
805-
};
806-
803+
pub async fn lazy_call_ecdsa_public_key_with_chain_code<R: CanisterRuntime>(
804+
runtime: &R,
805+
) -> (PublicKey, [u8; 32]) {
807806
fn to_public_key_and_chain_code(response: &EcdsaPublicKeyResult) -> (PublicKey, [u8; 32]) {
808807
let public_key = PublicKey::deserialize_sec1(&response.public_key).unwrap_or_else(|e| {
809808
ic_cdk::trap(format!("failed to decode minter's public key: {e:?}"))
@@ -823,29 +822,26 @@ pub async fn lazy_call_ecdsa_public_key_with_chain_code() -> (PublicKey, [u8; 32
823822
}
824823
let key_name = read_state(|s| s.ecdsa_key_name.clone());
825824
log!(DEBUG, "Fetching the ECDSA public key {key_name}");
826-
let response = ecdsa_public_key(&EcdsaPublicKeyArgs {
827-
canister_id: None,
828-
derivation_path: crate::MAIN_DERIVATION_PATH
829-
.into_iter()
830-
.map(|x| x.to_vec())
831-
.collect(),
832-
key_id: EcdsaKeyId {
833-
curve: EcdsaCurve::Secp256k1,
834-
name: key_name,
835-
},
836-
})
837-
.await
838-
.unwrap_or_else(|err| ic_cdk::trap(format!("failed to get minter's public key: {err}")));
825+
let response = runtime
826+
.ecdsa_public_key(
827+
key_name,
828+
crate::MAIN_DERIVATION_PATH
829+
.into_iter()
830+
.map(|x| x.to_vec())
831+
.collect(),
832+
)
833+
.await
834+
.unwrap_or_else(|err| ic_cdk::trap(format!("failed to get minter's public key: {err}")));
839835
mutate_state(|s| s.ecdsa_public_key = Some(response.clone()));
840836
to_public_key_and_chain_code(&response)
841837
}
842838

843-
pub async fn lazy_call_ecdsa_public_key() -> PublicKey {
844-
lazy_call_ecdsa_public_key_with_chain_code().await.0
839+
pub async fn lazy_call_ecdsa_public_key<R: CanisterRuntime>(runtime: &R) -> PublicKey {
840+
lazy_call_ecdsa_public_key_with_chain_code(runtime).await.0
845841
}
846842

847-
pub async fn minter_address() -> Address {
848-
ecdsa_public_key_to_address(&lazy_call_ecdsa_public_key().await)
843+
pub async fn minter_address<R: CanisterRuntime>(runtime: &R) -> Address {
844+
ecdsa_public_key_to_address(&lazy_call_ecdsa_public_key(runtime).await)
849845
}
850846

851847
#[derive(Clone, Eq, PartialEq, Debug)]
@@ -986,4 +982,5 @@ pub enum TaskType {
986982
BalanceScan,
987983
SweeperFunding,
988984
SweeperSend,
985+
SweeperEnqueue,
989986
}

0 commit comments

Comments
 (0)