diff --git a/Cargo.lock b/Cargo.lock index b67935b1..4d136ff3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -300,6 +300,83 @@ dependencies = [ "fs_extra", ] +[[package]] +name = "axum" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b52af3cb4058c895d37317bb27508dccc8e5f2d39454016b297bf4a400597b8" +dependencies = [ + "axum-core", + "base64 0.22.1", + "bytes", + "form_urlencoded", + "futures-util", + "http 1.4.0", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "serde_core", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sha1", + "sync_wrapper", + "tokio", + "tokio-tungstenite", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" +dependencies = [ + "bytes", + "futures-core", + "http 1.4.0", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "sync_wrapper", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-reverse-proxy" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2fd12e7914373315282245414431ebaddfabe3a142b9bc5210f83c559e5e882" +dependencies = [ + "axum", + "bytes", + "futures-util", + "http 1.4.0", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "rand 0.9.2", + "tokio", + "tokio-tungstenite", + "tower", + "tracing", + "url", +] + [[package]] name = "backoff" version = "0.4.0" @@ -729,7 +806,10 @@ dependencies = [ "assert2", "assert_matches", "async-trait", + "axum", + "axum-reverse-proxy", "candid", + "canhttp", "canlog", "cksol-types", "cksol-types-internal", @@ -1818,6 +1898,16 @@ version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +[[package]] +name = "hdrhistogram" +version = "7.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "765c9198f173dd59ce26ff9f95ef0aafd0a0fe01fb9d72841bc5066a4c06511d" +dependencies = [ + "byteorder", + "num-traits", +] + [[package]] name = "heck" version = "0.5.0" @@ -1919,6 +2009,12 @@ version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + [[package]] name = "hyper" version = "1.8.1" @@ -1933,6 +2029,7 @@ dependencies = [ "http 1.4.0", "http-body", "httparse", + "httpdate", "itoa", "pin-project-lite", "pin-utils", @@ -2671,6 +2768,12 @@ dependencies = [ "regex-automata", ] +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + [[package]] name = "memchr" version = "2.8.0" @@ -4032,6 +4135,17 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + [[package]] name = "serde_repr" version = "0.1.20" @@ -6478,11 +6592,16 @@ checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" dependencies = [ "futures-core", "futures-util", + "hdrhistogram", + "indexmap", "pin-project-lite", + "slab", "sync_wrapper", "tokio", + "tokio-util", "tower-layer", "tower-service", + "tracing", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index d0632db9..92daf4a5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,11 +14,14 @@ readme = "README.md" assert2 = "0.4.0" assert_matches = "1.5.0" async-trait = "0.1.89" +axum = "0.8" +axum-reverse-proxy = { version = "1.1", default-features = false } base64 = "0.22.1" bincode = "1.3.3" bs58 = "0.5.1" candid = "0.10.20" candid_parser = "0.3.0" +canhttp = "0.5.2" canlog = "0.2.0" derive_more = { version = "2.1.1", features = ["from"] } futures = "0.3.31" diff --git a/integration_tests/Cargo.toml b/integration_tests/Cargo.toml index a18c3dd3..a1e22be4 100644 --- a/integration_tests/Cargo.toml +++ b/integration_tests/Cargo.toml @@ -9,7 +9,10 @@ license.workspace = true [dependencies] async-trait = { workspace = true } +axum = { workspace = true } +axum-reverse-proxy = { workspace = true } candid = { workspace = true } +canhttp = { workspace = true } canlog = { workspace = true } cksol-types = { path = "../libs/types" } cksol-types-internal = { path = "../libs/types-internal", features = ["log", "event"] } diff --git a/integration_tests/src/json_rpc_reverse_proxy.rs b/integration_tests/src/json_rpc_reverse_proxy.rs new file mode 100644 index 00000000..91a96031 --- /dev/null +++ b/integration_tests/src/json_rpc_reverse_proxy.rs @@ -0,0 +1,83 @@ +//! A JSON-RPC reverse proxy with a configurable request blocklist. + +use axum::{ + Extension, Router, body::to_bytes, extract::Request, middleware, response::IntoResponse, +}; +use axum_reverse_proxy::ReverseProxy; +use canhttp::http::json::JsonRpcRequest; +use serde_json::Value; +use std::sync::Arc; +use tokio::sync::RwLock; + +#[derive(Clone, Debug)] +pub struct JsonRpcRequestMatcher { + method: String, +} + +impl JsonRpcRequestMatcher { + pub fn with_method(method: impl Into) -> Self { + Self { + method: method.into(), + } + } + + fn matches(&self, body: &[u8]) -> bool { + serde_json::from_slice::>(body) + .is_ok_and(|req| req.method() == self.method) + } +} + +type Blocklist = Arc>>; + +pub struct JsonRpcReverseProxy { + blocklist: Blocklist, + port: u16, +} + +impl JsonRpcReverseProxy { + pub async fn start(target_url: &str, port: u16) -> Self { + let blocklist: Blocklist = Default::default(); + let proxy: Router<()> = ReverseProxy::new("/", target_url).into(); + let app = proxy + .layer(middleware::from_fn(block_middleware)) + .layer(Extension(blocklist.clone())); + let listener = tokio::net::TcpListener::bind(("127.0.0.1", port)) + .await + .unwrap(); + tokio::spawn(async move { axum::serve(listener, app).await.ok() }); + Self { blocklist, port } + } + + pub fn url(&self) -> String { + format!("http://127.0.0.1:{}", self.port) + } + + pub async fn block(&self, filter: JsonRpcRequestMatcher) { + self.blocklist.write().await.push(filter); + } + + pub async fn clear_blocklist(&self) { + self.blocklist.write().await.clear(); + } +} + +async fn block_middleware( + Extension(blocklist): Extension, + req: Request, + next: middleware::Next, +) -> axum::response::Response { + let blocklist = blocklist.read().await; + if blocklist.is_empty() { + return next.run(req).await; + } + + let (parts, body) = req.into_parts(); + let bytes = to_bytes(body, 64 * 1024).await.unwrap(); + + if blocklist.iter().any(|f| f.matches(&bytes)) { + return axum::http::StatusCode::SERVICE_UNAVAILABLE.into_response(); + } + + next.run(Request::from_parts(parts, axum::body::Body::from(bytes))) + .await +} diff --git a/integration_tests/src/lib.rs b/integration_tests/src/lib.rs index e7533575..f306dccb 100644 --- a/integration_tests/src/lib.rs +++ b/integration_tests/src/lib.rs @@ -28,6 +28,7 @@ use std::{default::Default, env::var, fs, ops::Deref, path::PathBuf, time::Durat pub mod events; pub mod fixtures; +pub mod json_rpc_reverse_proxy; pub mod ledger_init_args; #[derive(Default)] @@ -43,6 +44,7 @@ pub struct SetupBuilder { sol_rpc_install_args: Option, initial_ledger_balances: Option>, proxy_canister: bool, + json_rpc_proxy: Option<(String, u16)>, } impl SetupBuilder { @@ -73,14 +75,46 @@ impl SetupBuilder { self } + /// Route all SOL RPC traffic through a JSON-RPC proxy that can block + /// requests by method name. Use [`Setup::proxy`] to toggle the blocklist. + pub fn with_json_rpc_proxy(mut self, target_url: &str, port: u16) -> Self { + self.json_rpc_proxy = Some((target_url.to_string(), port)); + self + } + pub async fn build(self) -> Setup { - Setup::new( + use sol_rpc_types::{OverrideProvider, RegexSubstitution}; + + let proxy = match &self.json_rpc_proxy { + Some((target_url, port)) => { + Some(json_rpc_reverse_proxy::JsonRpcReverseProxy::start(target_url, *port).await) + } + None => None, + }; + + let sol_rpc_install_args = match &proxy { + Some(p) => { + let mut args = self.sol_rpc_install_args.unwrap_or_default(); + args.override_provider = Some(OverrideProvider { + override_url: Some(RegexSubstitution { + pattern: ".*".into(), + replacement: p.url(), + }), + }); + args + } + None => self.sol_rpc_install_args.unwrap_or_default(), + }; + + let mut setup = Setup::new( self.make_live.unwrap_or_default(), - self.sol_rpc_install_args.unwrap_or_default(), + sol_rpc_install_args, self.initial_ledger_balances, self.proxy_canister, ) - .await + .await; + setup.json_rpc_proxy = proxy; + setup } } @@ -90,6 +124,7 @@ pub struct Setup { ledger_canister_id: CanisterId, sol_rpc_canister_id: CanisterId, proxy_canister_id: Option, + json_rpc_proxy: Option, } impl Setup { @@ -103,6 +138,12 @@ impl Setup { pub const DEFAULT_MINIMUM_DEPOSIT_AMOUNT: Lamport = 10_000_000; // 0.01 SOL pub const DEFAULT_UPDATE_BALANCE_REQUIRED_CYCLES: u128 = 1_000_000_000_000; + pub fn json_rpc_proxy(&self) -> &json_rpc_reverse_proxy::JsonRpcReverseProxy { + self.json_rpc_proxy + .as_ref() + .expect("Setup was not built with a JSON-RPC proxy") + } + pub async fn new( make_live: PocketIcMode, sol_rpc_install_args: sol_rpc_types::InstallArgs, @@ -212,6 +253,7 @@ impl Setup { ledger_canister_id, sol_rpc_canister_id, proxy_canister_id, + json_rpc_proxy: None, } } diff --git a/integration_tests/tests/solana_test_validator.rs b/integration_tests/tests/solana_test_validator.rs index 1d7a34f8..eede9183 100644 --- a/integration_tests/tests/solana_test_validator.rs +++ b/integration_tests/tests/solana_test_validator.rs @@ -2,8 +2,9 @@ use assert_matches::assert_matches; use candid::Principal; use cksol_int_tests::{Setup, SetupBuilder, fixtures::MINTER_ADDRESS}; use cksol_types::{DepositStatus, UpdateBalanceArgs}; +use cksol_types_internal::event::EventType; use icrc_ledger_types::icrc1::account::Account; -use sol_rpc_types::{InstallArgs, Lamport, OverrideProvider, RegexSubstitution}; +use sol_rpc_types::Lamport; use solana_address::Address; use solana_client::{nonblocking::rpc_client::RpcClient, rpc_config::CommitmentConfig}; use solana_keypair::{Keypair, Signer}; @@ -20,29 +21,24 @@ const DEPOSITOR_PRINCIPAL: Principal = Principal::from_slice(&[0x9d, 0xf7, 0x99] // Solana fee per transaction signature const FEE_PER_SIGNATURE: Lamport = 5_000; +const PROXY_PORT: u16 = 18899; #[tokio::test(flavor = "multi_thread")] -async fn should_deposit_and_consolidate_funds() { +async fn should_deposit_consolidate_and_resubmit() { + use cksol_int_tests::json_rpc_reverse_proxy::JsonRpcRequestMatcher; + const NUM_DEPOSITS: u8 = 15; let setup = SetupBuilder::new() .with_proxy_canister() .with_pocket_ic_live_mode() - .with_sol_rpc_install_args(InstallArgs { - override_provider: Some(OverrideProvider { - override_url: Some(RegexSubstitution { - pattern: ".*".into(), - replacement: SOLANA_VALIDATOR_URL.to_string(), - }), - }), - ..InstallArgs::default() - }) + .with_json_rpc_proxy(SOLANA_VALIDATOR_URL, PROXY_PORT) .build() .await; - // Bootstrap minter account airdrop_and_confirm(MINTER_ADDRESS, LAMPORTS_PER_SOL).await; + // Create deposits let (deposit_addresses, deposit_amounts): (Vec<_>, Vec<_>) = futures::future::join_all((1_u8..=NUM_DEPOSITS).map(async |i| { let account = Account { @@ -57,38 +53,92 @@ async fn should_deposit_and_consolidate_funds() { .into_iter() .unzip(); - // Check deposit consolidation - let deposit_account_balances_before_consolidation = get_balances(&deposit_addresses).await; - let minter_balance_before_consolidation = get_solana_balance(&MINTER_ADDRESS).await; + // Trigger consolidation and verify funds are moved + let balances_before = get_balances(&deposit_addresses).await; + let minter_balance_before = get_solana_balance(&MINTER_ADDRESS).await; setup.advance_time(Duration::from_mins(10)).await; tokio::time::sleep(Duration::from_secs(5)).await; - // Ensure the deposited funds were consolidated, note that we do not assert the balance after - // consolidation to be zero due to potential funds leftover from previous tests - for (deposit_address, (&balance_before, &deposit_amount)) in deposit_addresses.iter().zip( - deposit_account_balances_before_consolidation - .iter() - .zip(&deposit_amounts), - ) { - let balance_after = get_solana_balance(deposit_address).await; - assert_eq!(balance_after, balance_before - deposit_amount); + for (addr, (&before, &amount)) in deposit_addresses + .iter() + .zip(balances_before.iter().zip(&deposit_amounts)) + { + assert_eq!(get_solana_balance(addr).await, before - amount); } - let minter_balance_after_consolidation = get_solana_balance(&MINTER_ADDRESS).await; assert_eq!( - minter_balance_after_consolidation, - minter_balance_before_consolidation + deposit_amounts.iter().sum::() + get_solana_balance(&MINTER_ADDRESS).await, + minter_balance_before + deposit_amounts.iter().sum::() - consolidation_transaction_fees(NUM_DEPOSITS as u64) ); + // Block sendTransaction and create new deposits for the resubmission test + setup + .json_rpc_proxy() + .block(JsonRpcRequestMatcher::with_method("sendTransaction")) + .await; + + let (resubmit_addresses, resubmit_amounts): (Vec<_>, Vec<_>) = + futures::future::join_all((1_u8..=3).map(async |i| { + let account = Account { + owner: DEPOSITOR_PRINCIPAL, + subaccount: Some([i + 100; 32]), + }; + let deposit_amount = (i as u64 * LAMPORTS_PER_SOL) / 10; + let deposit_address = deposit_to_account(&setup, account, deposit_amount).await; + (deposit_address, deposit_amount) + })) + .await + .into_iter() + .unzip(); + + let resubmit_balances_before = get_balances(&resubmit_addresses).await; + let minter_balance_before_resubmit = get_solana_balance(&MINTER_ADDRESS).await; + + // Trigger consolidation — transaction is "submitted" but dropped by the proxy + setup.advance_time(Duration::from_mins(10)).await; + tokio::time::sleep(Duration::from_secs(3)).await; + + setup.json_rpc_proxy().clear_blocklist().await; + + // Funds should NOT be consolidated yet + assert_eq!( + resubmit_balances_before, + get_balances(&resubmit_addresses).await, + ); + + // Wait for the transaction to expire (150 slots ≈ 60s) and the resubmission timer to fire + tokio::time::sleep(Duration::from_secs(130)).await; + + let events = setup.minter().get_all_events().await; + assert!( + events + .iter() + .any(|e| matches!(e.payload, EventType::ResubmittedTransaction { .. })) + ); + + // Wait for the resubmitted transaction to confirm + tokio::time::sleep(Duration::from_secs(15)).await; + + // Verify funds are now consolidated + for (addr, (&before, &amount)) in resubmit_addresses + .iter() + .zip(resubmit_balances_before.iter().zip(&resubmit_amounts)) + { + assert_eq!(get_solana_balance(addr).await, before - amount); + } + assert_eq!( + get_solana_balance(&MINTER_ADDRESS).await, + minter_balance_before_resubmit + resubmit_amounts.iter().sum::() + - consolidation_transaction_fees(3) + ); + setup.drop().await; } fn consolidation_transaction_fees(num_deposits: u64) -> Lamport { - // Maximum number of transfer instructions per consolidation transaction const MAX_ACCOUNTS_PER_CONSOLIDATION_TRANSACTION: u64 = 9; let num_transactions = num_deposits.div_ceil(MAX_ACCOUNTS_PER_CONSOLIDATION_TRANSACTION); - // Total signatures = num_transactions (fee payers) + num_deposits (sources) (num_transactions + num_deposits) * FEE_PER_SIGNATURE } @@ -96,8 +146,6 @@ async fn deposit_to_account(setup: &Setup, account: Account, amount: Lamport) -> let expected_mint_amount = amount - Setup::DEFAULT_DEPOSIT_FEE; let deposit_address = setup.minter().get_deposit_address(account).await.into(); - println!("Depositing {amount} Lamport to address {deposit_address}"); - let balance_before = setup.ledger().balance_of(account).await; assert_eq!(balance_before, 0); @@ -125,11 +173,8 @@ async fn deposit_to_account(setup: &Setup, account: Account, amount: Lamport) -> async fn send_deposit_to_address(deposit_address: Address, deposit_amount: Lamport) -> Signature { let sender = Keypair::new(); - - // Fund sender with an airdrop airdrop_and_confirm(sender.pubkey(), 2 * deposit_amount).await; - // Build and submit deposit transaction let rpc = rpc_client(); let recent_blockhash = rpc.get_latest_blockhash().await.unwrap(); let transaction = solana_system_transaction::transfer( @@ -145,26 +190,24 @@ async fn send_deposit_to_address(deposit_address: Address, deposit_amount: Lampo async fn airdrop_and_confirm(address: Address, airdrop_amount: Lamport) { let rpc = rpc_client(); - let balance_before = rpc.get_balance(&address).await.unwrap(); - let blockhash = rpc.get_latest_blockhash().await.unwrap(); let airdrop_signature = rpc .request_airdrop_with_blockhash(&address, airdrop_amount, &blockhash) .await .unwrap(); confirm_transaction(&rpc, &airdrop_signature, CommitmentConfig::confirmed()).await; - - let balance_after = rpc.get_balance(&address).await.unwrap(); - assert_eq!(balance_after, balance_before + airdrop_amount); + assert_eq!( + rpc.get_balance(&address).await.unwrap(), + balance_before + airdrop_amount + ); } async fn confirm_transaction(rpc: &RpcClient, signature: &Signature, commitment: CommitmentConfig) { for _ in 0..60 { - let response = rpc + if let Ok(result) = rpc .confirm_transaction_with_commitment(signature, commitment) - .await; - if let Ok(result) = response + .await && result.value { return; diff --git a/minter/src/consolidate/mod.rs b/minter/src/consolidate/mod.rs index 3aeaffd2..1afa6769 100644 --- a/minter/src/consolidate/mod.rs +++ b/minter/src/consolidate/mod.rs @@ -14,8 +14,12 @@ use solana_signature::Signature; use std::time::Duration; use thiserror::Error; +#[cfg(test)] +mod tests; + pub const DEPOSIT_CONSOLIDATION_DELAY: Duration = Duration::from_mins(10); const MAX_CONCURRENT_TRANSACTIONS: usize = 10; +const MAX_TRANSFERS_PER_CONSOLIDATION: usize = MAX_SIGNATURES as usize - 1; pub async fn consolidate_deposits(runtime: R) { let _guard = match TimerGuard::new(TaskType::DepositConsolidation) { @@ -23,21 +27,19 @@ pub async fn consolidate_deposits(runtime: R) { Err(_) => return, }; - if read_state(|state| state.funds_to_consolidate().is_empty()) { - return; - } - let funds_to_consolidate: Vec<_> = read_state(|state| { state .funds_to_consolidate() .clone() .into_iter() .collect::>() - // Need to account for fee payer signature - .chunks(MAX_SIGNATURES as usize - 1) + .chunks(MAX_TRANSFERS_PER_CONSOLIDATION) .map(|c| c.to_vec()) .collect() }); + if funds_to_consolidate.is_empty() { + return; + } for round in funds_to_consolidate.chunks(MAX_CONCURRENT_TRANSACTIONS) { let recent_blockhash = match get_recent_blockhash(&runtime).await { @@ -95,7 +97,7 @@ async fn submit_consolidation_transaction( recent_blockhash: Hash, ) -> Result { let minter_account = Account { - owner: ic_cdk::api::canister_self(), + owner: runtime.canister_self(), subaccount: None, }; let (transaction, signers) = create_signed_transfer_transaction( @@ -107,9 +109,11 @@ async fn submit_consolidation_transaction( ) .await?; + let signature = transaction.signatures[0]; let message = transaction.message.clone(); - let signature = submit_transaction(runtime, transaction).await?; + // Record events before trying to submit the transaction to ensure we don't + // resubmit the same transaction twice in case submission fails. mutate_state(|state| { process_event( state, @@ -132,5 +136,7 @@ async fn submit_consolidation_transaction( ) }); + submit_transaction(runtime, transaction).await?; + Ok(signature) } diff --git a/minter/src/consolidate/tests.rs b/minter/src/consolidate/tests.rs new file mode 100644 index 00000000..7f74d946 --- /dev/null +++ b/minter/src/consolidate/tests.rs @@ -0,0 +1,284 @@ +use super::{MAX_TRANSFERS_PER_CONSOLIDATION, consolidate_deposits}; +use crate::{ + state::{ + TaskType, + audit::process_event, + event::{DepositId, EventType}, + mutate_state, + }, + test_fixtures::{ + DEPOSIT_FEE, EventsAssert, init_schnorr_master_key, init_state, + runtime::TestCanisterRuntime, + }, +}; +use assert_matches::assert_matches; +use candid::Principal; +use icrc_ledger_types::icrc1::account::Account; +use sol_rpc_types::{ConfirmedBlock, MultiRpcResult, RpcError, Slot}; +use solana_signature::Signature; + +type SlotResult = MultiRpcResult; +type BlockResult = MultiRpcResult; +type SendTransactionResult = MultiRpcResult; + +#[tokio::test] +async fn should_return_early_if_no_funds_to_consolidate() { + setup(); + + consolidate_deposits(TestCanisterRuntime::new()).await; + + EventsAssert::assert_no_events_recorded(); +} + +#[tokio::test] +async fn should_return_early_if_task_already_active() { + setup(); + + add_funds_to_consolidate(vec![(account(0), 1_000_000_000)]); + mutate_state(|s| { + s.active_tasks_mut().insert(TaskType::DepositConsolidation); + }); + + consolidate_deposits(TestCanisterRuntime::new()).await; + + // Only AcceptedDeposit event from setup, no consolidation events + EventsAssert::from_recorded() + .expect_event(|e| assert_matches!(e, EventType::AcceptedDeposit { .. })) + .assert_no_more_events(); +} + +#[tokio::test] +async fn should_return_early_if_fetching_blockhash_fails() { + setup(); + + add_funds_to_consolidate(vec![(account(0), 1_000_000_000)]); + + let error = SlotResult::Consistent(Err(RpcError::ValidationError("Error".to_string()))); + let runtime = TestCanisterRuntime::new() + .add_stub_response(error.clone()) + .add_stub_response(error.clone()) + .add_stub_response(error); + + consolidate_deposits(runtime).await; + + // Only AcceptedDeposit event from setup, no consolidation events + EventsAssert::from_recorded() + .expect_event(|e| assert_matches!(e, EventType::AcceptedDeposit { .. })) + .assert_no_more_events(); +} + +#[tokio::test] +async fn should_submit_single_consolidation_request() { + setup(); + + let deposit_account = account(0); + let deposit_amount = 1_000_000_000_u64; + add_funds_to_consolidate(vec![(deposit_account, deposit_amount)]); + + // Fee payer signature is first in the transaction and becomes the transaction ID + let fee_payer_signature = Signature::from([0x11; 64]); + let slot = 100; + let runtime = TestCanisterRuntime::new() + .with_increasing_time() + // get_recent_blockhash calls + .add_stub_response(SlotResult::Consistent(Ok(slot))) + .add_stub_response(BlockResult::Consistent(Ok(block()))) + // get_slot call + .add_stub_response(SlotResult::Consistent(Ok(slot))) + .add_stub_response(SendTransactionResult::Consistent(Ok( + fee_payer_signature.into() + ))) + // Two signatures needed: fee payer (minter) + source (deposit account) + .add_signature(fee_payer_signature.into()) + .add_signature([0x22; 64]); + + consolidate_deposits(runtime).await; + + EventsAssert::from_recorded() + .expect_event(|e| { + assert_matches!( + e, + EventType::AcceptedDeposit { + deposit_id, + deposit_amount: amount, + .. + } if deposit_id.account == deposit_account && amount == deposit_amount + ) + }) + .expect_event(|e| { + assert_matches!(e, EventType::ConsolidatedDeposits { deposits } + if deposits == vec![(deposit_account, deposit_amount)] + ) + }) + .expect_event(|e| { + assert_matches!(e, EventType::SubmittedTransaction { signature, slot: event_slot, .. } + if signature == fee_payer_signature && event_slot == slot + ) + }) + .assert_no_more_events(); +} + +#[tokio::test] +async fn should_record_events_even_if_transaction_submission_fails() { + setup(); + + let deposit_account = account(0); + let deposit_amount = 1_000_000_000_u64; + add_funds_to_consolidate(vec![(deposit_account, deposit_amount)]); + + let fee_payer_signature = Signature::from([0x11; 64]); + let slot = 100; + let runtime = TestCanisterRuntime::new() + .with_increasing_time() + // get_recent_blockhash calls + .add_stub_response(SlotResult::Consistent(Ok(slot))) + .add_stub_response(BlockResult::Consistent(Ok(block()))) + // get_slot call + .add_stub_response(SlotResult::Consistent(Ok(slot))) + // Transaction submission call fails (e.g. due to inconsistent results) + .add_stub_response(SendTransactionResult::Inconsistent(vec![])) + .add_signature(fee_payer_signature.into()) + .add_signature([0x22; 64]); + + consolidate_deposits(runtime).await; + + EventsAssert::from_recorded() + .expect_event(|e| { + assert_matches!(e, EventType::AcceptedDeposit { deposit_id, deposit_amount: amount, ..} + if deposit_id.account == deposit_account && amount == deposit_amount + ) + }) + .expect_event(|e| { + assert_matches!(e, EventType::ConsolidatedDeposits { deposits } + if deposits == vec![(deposit_account, deposit_amount)] + ) + }) + .expect_event(|e| { + assert_matches!(e, EventType::SubmittedTransaction { signature, slot: event_slot, .. } + if signature == fee_payer_signature && event_slot == slot + ) + }) + .assert_no_more_events(); +} + +#[tokio::test] +async fn should_submit_multiple_consolidation_batches() { + const NUM_DEPOSITS: usize = 11; + setup(); + + let funds: Vec<_> = (0..NUM_DEPOSITS) + .map(|i| (account(i as u8), (i as u64 + 1) * 1_000_000_000)) + .collect(); + add_funds_to_consolidate(funds.clone()); + + // Calculate expected batch sizes, i.e. the number of transfers per transaction submitted + let batch_1_size = MAX_TRANSFERS_PER_CONSOLIDATION; // 9 accounts + let batch_2_size = NUM_DEPOSITS - batch_1_size; // 2 accounts + + // Fee payer signatures (first signature in each batch) become transaction IDs + let fee_payer_signature_1 = Signature::from([0x00; 64]); // index 0 + let fee_payer_signature_2 = Signature::from([0x0A; 64]); // index 10 + let slot = 100; + + let mut runtime = TestCanisterRuntime::new() + .with_increasing_time() + // get_recent_blockhash calls + .add_stub_response(SlotResult::Consistent(Ok(slot))) + .add_stub_response(BlockResult::Consistent(Ok(block()))) + // get_slot call + .add_stub_response(SlotResult::Consistent(Ok(slot))) + .add_stub_response(SendTransactionResult::Consistent(Ok( + fee_payer_signature_1.into() + ))) + .add_stub_response(SendTransactionResult::Consistent(Ok( + fee_payer_signature_2.into() + ))); + + // Signatures needed: fee payer + each source account per batch + for i in 0..13 { + runtime = runtime.add_signature([i as u8; 64]); + } + + consolidate_deposits(runtime).await; + + let mut events_assert = EventsAssert::from_recorded(); + // AcceptedDeposit events from setup + for (account, amount) in funds.iter().cloned() { + events_assert = events_assert.expect_event(move |e| { + assert_matches!(e, EventType::AcceptedDeposit { deposit_id, deposit_amount, .. } + if deposit_id.account == account && deposit_amount == amount + ) + }); + } + // Batch 1: 9 deposits consolidated together + events_assert = events_assert + .expect_event(|e| { + assert_matches!(e, EventType::ConsolidatedDeposits { deposits } + if deposits.len() == batch_1_size + ) + }) + .expect_event(|e| { + assert_matches!(e, EventType::SubmittedTransaction { signature, slot: event_slot, .. } + if signature == fee_payer_signature_1 && event_slot == slot + ) + }); + // Batch 2: 2 deposits consolidated together + events_assert = events_assert + .expect_event(|e| { + assert_matches!(e, EventType::ConsolidatedDeposits { deposits } + if deposits.len() == batch_2_size + ) + }) + .expect_event(|e| { + assert_matches!(e, EventType::SubmittedTransaction { signature, slot: event_slot, .. } + if signature == fee_payer_signature_2 && event_slot == slot + ) + }); + events_assert.assert_no_more_events(); +} + +fn setup() { + init_state(); + init_schnorr_master_key(); +} + +fn account(i: u8) -> Account { + Account { + owner: Principal::from_slice(&[i; 29]), + subaccount: None, + } +} + +fn add_funds_to_consolidate(funds: Vec<(Account, u64)>) { + for (i, (account, amount)) in funds.into_iter().enumerate() { + let deposit_id = DepositId { + account, + signature: Signature::from([i as u8; 64]), + }; + mutate_state(|state| { + process_event( + state, + EventType::AcceptedDeposit { + deposit_id, + deposit_amount: amount, + amount_to_mint: amount - DEPOSIT_FEE, + }, + &TestCanisterRuntime::new().with_increasing_time(), + ) + }); + } +} + +fn block() -> ConfirmedBlock { + ConfirmedBlock { + previous_blockhash: Default::default(), + blockhash: solana_hash::Hash::from([0x42; 32]).into(), + parent_slot: 0, + block_time: None, + block_height: None, + signatures: None, + rewards: None, + num_reward_partitions: None, + transactions: None, + } +} diff --git a/minter/src/lib.rs b/minter/src/lib.rs index 34a690c6..98e684b4 100644 --- a/minter/src/lib.rs +++ b/minter/src/lib.rs @@ -5,6 +5,7 @@ mod guard; mod ledger; pub mod lifecycle; mod numeric; +pub mod resubmit; pub mod runtime; mod signer; pub mod sol_transfer; diff --git a/minter/src/main.rs b/minter/src/main.rs index 84650591..c1c02c68 100644 --- a/minter/src/main.rs +++ b/minter/src/main.rs @@ -1,6 +1,7 @@ use candid::Principal; use canlog::{Log, Sort}; use cksol_minter::consolidate::{DEPOSIT_CONSOLIDATION_DELAY, consolidate_deposits}; +use cksol_minter::resubmit::{RESUBMIT_TRANSACTIONS_DELAY, resubmit_transactions}; use cksol_minter::{ address::lazy_get_schnorr_master_key, runtime::IcCanisterRuntime, state::read_state, }; @@ -271,6 +272,9 @@ fn setup_timers() { ic_cdk_timers::set_timer_interval(DEPOSIT_CONSOLIDATION_DELAY, async || { consolidate_deposits(IcCanisterRuntime::new()).await; }); + ic_cdk_timers::set_timer_interval(RESUBMIT_TRANSACTIONS_DELAY, async || { + resubmit_transactions(IcCanisterRuntime::new()).await; + }); } fn main() {} diff --git a/minter/src/resubmit/mod.rs b/minter/src/resubmit/mod.rs new file mode 100644 index 00000000..1e7fd78f --- /dev/null +++ b/minter/src/resubmit/mod.rs @@ -0,0 +1,141 @@ +use crate::{ + address::derivation_path, + guard::TimerGuard, + runtime::CanisterRuntime, + signer::sign_bytes, + state::{TaskType, audit::process_event, event::EventType, mutate_state, read_state}, + transaction::{SubmitTransactionError, get_recent_blockhash, get_slot, submit_transaction}, +}; +use canlog::log; +use cksol_types_internal::log::Priority; +use ic_cdk::management_canister::SignCallError; +use icrc_ledger_types::icrc1::account::Account; +use sol_rpc_types::Slot; +use solana_message::Message; +use solana_signature::Signature; +use solana_transaction::Transaction; +use std::time::Duration; +use thiserror::Error; + +#[cfg(test)] +mod tests; + +pub const RESUBMIT_TRANSACTIONS_DELAY: Duration = Duration::from_secs(60); +const MAX_BLOCKHASH_AGE: Slot = 150; +const MAX_CONCURRENT_TRANSACTIONS: usize = 10; + +pub async fn resubmit_transactions(runtime: R) { + let _guard = match TimerGuard::new(TaskType::ResubmitTransactions) { + Ok(guard) => guard, + Err(_) => return, + }; + + if read_state(|state| state.submitted_transactions().is_empty()) { + return; + } + + let current_slot = match get_slot(&runtime).await { + Ok(slot) => slot, + Err(e) => { + log!(Priority::Info, "Failed to get current slot: {e}"); + return; + } + }; + let mut expired_transactions = read_state(|state| { + state + .submitted_transactions() + .iter() + .filter(|(_, tx)| tx.slot + MAX_BLOCKHASH_AGE < current_slot) + .map(|(sig, tx)| (*sig, tx.message.clone(), tx.signers.clone())) + .collect::>() + }); + + while !expired_transactions.is_empty() { + let new_blockhash = match get_recent_blockhash(&runtime).await { + Ok(blockhash) => blockhash, + Err(e) => { + log!(Priority::Info, "Failed to get recent blockhash: {e}"); + return; + } + }; + let new_slot = match get_slot(&runtime).await { + Ok(slot) => slot, + Err(e) => { + log!(Priority::Info, "Failed to get slot: {e}"); + return; + } + }; + + let batch_size = MAX_CONCURRENT_TRANSACTIONS.min(expired_transactions.len()); + let futures = expired_transactions.drain(..batch_size).map( + async |(old_signature, message, signers)| match resubmit_transaction_with_new_blockhash( + &runtime, + old_signature, + message, + signers, + new_slot, + new_blockhash, + ) + .await + { + Ok(new_signature) => log!( + Priority::Info, + "Resubmitted transaction {old_signature} with new signature {new_signature}" + ), + Err(e) => log!( + Priority::Info, + "Failed to resubmit transaction {old_signature}: {e}" + ), + }, + ); + futures::future::join_all(futures).await; + } +} + +async fn resubmit_transaction_with_new_blockhash( + runtime: &R, + old_signature: Signature, + message: Message, + signers: Vec, + new_slot: Slot, + new_blockhash: solana_hash::Hash, +) -> Result { + let mut message = message; + message.recent_blockhash = new_blockhash; + + let mut transaction = Transaction::new_unsigned(message); + transaction.signatures = sign_bytes( + signers.iter().map(derivation_path), + &runtime.signer(), + transaction.message_data(), + ) + .await?; + + let new_signature = transaction.signatures[0]; + + // Record the resubmission event before submitting the transaction to ensure we don't + // resubmit the same transaction twice in case of a panic during submission. + mutate_state(|state| { + process_event( + state, + EventType::ResubmittedTransaction { + old_signature, + new_signature, + new_slot, + }, + runtime, + ) + }); + + submit_transaction(runtime, transaction).await?; + + Ok(new_signature) +} + +#[derive(Debug, Error)] +enum ResubmitError { + #[error("failed to submit new transaction: {0}")] + Submit(#[from] SubmitTransactionError), + #[error("failed to sign transaction: {0}")] + Signing(#[from] SignCallError), +} diff --git a/minter/src/resubmit/tests.rs b/minter/src/resubmit/tests.rs new file mode 100644 index 00000000..0393aa7c --- /dev/null +++ b/minter/src/resubmit/tests.rs @@ -0,0 +1,232 @@ +use super::{MAX_BLOCKHASH_AGE, resubmit_transactions}; +use crate::{ + address::derive_public_key, + state::{TaskType, audit::process_event, event::EventType, mutate_state, read_state}, + test_fixtures::{ + EventsAssert, MINTER_ACCOUNT, init_schnorr_master_key, init_state, + runtime::TestCanisterRuntime, + }, +}; +use assert_matches::assert_matches; +use ic_ed25519::{PocketIcMasterPublicKeyId, PublicKey}; +use sol_rpc_types::{ConfirmedBlock, MultiRpcResult, RpcError, Slot}; +use solana_hash::Hash; +use solana_message::Message; +use solana_signature::Signature; + +type SlotResult = MultiRpcResult; +type BlockResult = MultiRpcResult; +type SendTransactionResult = MultiRpcResult; + +#[tokio::test] +async fn should_return_early_if_no_transactions_to_resubmit() { + setup(); + + let runtime = TestCanisterRuntime::new().with_increasing_time(); + + resubmit_transactions(runtime).await; + + EventsAssert::assert_no_events_recorded(); +} + +#[tokio::test] +async fn should_return_early_if_task_already_active() { + setup(); + add_submitted_transaction(Signature::from([0x01; 64]), 10); + + mutate_state(|s| { + s.active_tasks_mut().insert(TaskType::ResubmitTransactions); + }); + + resubmit_transactions(TestCanisterRuntime::new()).await; + + // Only SubmittedTransaction event from setup, no resubmission events + EventsAssert::from_recorded() + .expect_event(|e| assert_matches!(e, EventType::SubmittedTransaction { .. })) + .assert_no_more_events(); +} + +#[tokio::test] +async fn should_return_early_if_fetching_current_slot_fails() { + setup(); + add_submitted_transaction(Signature::from([0x01; 64]), 10); + + let error = SlotResult::Consistent(Err(RpcError::ValidationError("Error".to_string()))); + let runtime = TestCanisterRuntime::new() + .add_stub_response(error.clone()) + .add_stub_response(error.clone()) + .add_stub_response(error); + + resubmit_transactions(runtime).await; + + // Only SubmittedTransaction event from setup, no resubmission events + EventsAssert::from_recorded() + .expect_event(|e| assert_matches!(e, EventType::SubmittedTransaction { .. })) + .assert_no_more_events(); +} + +#[tokio::test] +async fn should_not_resubmit_if_transaction_not_expired() { + setup(); + + let original_slot = 100; + add_submitted_transaction(Signature::from([0x01; 64]), original_slot); + + // Current slot is within MAX_BLOCKHASH_AGE of original slot + let current_slot = original_slot + MAX_BLOCKHASH_AGE - 1; + let runtime = TestCanisterRuntime::new() + .with_increasing_time() + .add_stub_response(SlotResult::Consistent(Ok(current_slot))); + + resubmit_transactions(runtime).await; + + // Only SubmittedTransaction event from setup, no resubmission + EventsAssert::from_recorded() + .expect_event(|e| assert_matches!(e, EventType::SubmittedTransaction { .. })) + .assert_no_more_events(); + + // Transaction should still be in submitted_transactions + read_state(|s| { + assert_eq!(s.submitted_transactions().len(), 1); + }); +} + +#[tokio::test] +async fn should_resubmit_single_expired_transaction() { + setup(); + + let old_signature = Signature::from([0x01; 64]); + let original_slot = 10; + add_submitted_transaction(old_signature, original_slot); + + // Current slot is past MAX_BLOCKHASH_AGE + let current_slot = original_slot + MAX_BLOCKHASH_AGE + 1; + let new_slot = current_slot + 5; + let new_signature = Signature::from([0xAA; 64]); + + let runtime = TestCanisterRuntime::new() + .with_increasing_time() + // get_slot for current slot check + .add_stub_response(SlotResult::Consistent(Ok(current_slot))) + // get_recent_blockhash calls + .add_stub_response(SlotResult::Consistent(Ok(new_slot))) + .add_stub_response(BlockResult::Consistent(Ok(block()))) + // get_slot for new slot + .add_stub_response(SlotResult::Consistent(Ok(new_slot))) + // submit_transaction + .add_stub_response(SendTransactionResult::Consistent(Ok(new_signature.into()))) + // Signature for re-signing (only fee payer since message has no other signers) + .add_signature(new_signature.into()); + + resubmit_transactions(runtime).await; + + EventsAssert::from_recorded() + .expect_event(|e| assert_matches!(e, EventType::SubmittedTransaction { .. })) + .expect_event(|e| { + assert_matches!( + e, + EventType::ResubmittedTransaction { + old_signature: old_sig, + new_signature: new_sig, + new_slot: slot, + } if old_sig == old_signature && new_sig == new_signature && slot == new_slot + ) + }) + .assert_no_more_events(); + + // Old transaction should be replaced with new one + read_state(|s| { + assert_eq!(s.submitted_transactions().len(), 1); + assert!(s.submitted_transactions().contains_key(&new_signature)); + assert!(!s.submitted_transactions().contains_key(&old_signature)); + }); +} + +#[tokio::test] +async fn should_record_event_even_if_submission_fails() { + setup(); + + let old_signature = Signature::from([0x01; 64]); + let original_slot = 10; + add_submitted_transaction(old_signature, original_slot); + + let current_slot = original_slot + MAX_BLOCKHASH_AGE + 1; + let new_slot = current_slot + 5; + let new_signature = Signature::from([0xAA; 64]); + + let runtime = TestCanisterRuntime::new() + .with_increasing_time() + // get_slot for current slot check + .add_stub_response(SlotResult::Consistent(Ok(current_slot))) + // get_recent_blockhash calls + .add_stub_response(SlotResult::Consistent(Ok(new_slot))) + .add_stub_response(BlockResult::Consistent(Ok(block()))) + // get_slot for new slot + .add_stub_response(SlotResult::Consistent(Ok(new_slot))) + // submit_transaction fails + .add_stub_response(SendTransactionResult::Inconsistent(vec![])) + .add_signature(new_signature.into()); + + resubmit_transactions(runtime).await; + + // ResubmittedTransaction event should still be recorded + EventsAssert::from_recorded() + .expect_event(|e| assert_matches!(e, EventType::SubmittedTransaction { .. })) + .expect_event(|e| { + assert_matches!( + e, + EventType::ResubmittedTransaction { + old_signature: old_sig, + new_signature: new_sig, + new_slot: slot, + } if old_sig == old_signature && new_sig == new_signature && slot == new_slot + ) + }) + .assert_no_more_events(); +} + +fn setup() { + init_state(); + init_schnorr_master_key(); +} + +fn minter_address() -> solana_address::Address { + use crate::state::SchnorrPublicKey; + let master_key = SchnorrPublicKey { + public_key: PublicKey::pocketic_key(PocketIcMasterPublicKeyId::Key1), + chain_code: [1; 32], + }; + derive_public_key(&master_key, vec![]) + .serialize_raw() + .into() +} + +fn add_submitted_transaction(signature: Signature, slot: Slot) { + let message = Message::new_with_blockhash(&[], Some(&minter_address()), &Hash::default()); + mutate_state(|state| { + process_event( + state, + EventType::SubmittedTransaction { + signature, + transaction: message, + signers: vec![MINTER_ACCOUNT], + slot, + }, + &TestCanisterRuntime::new().with_increasing_time(), + ) + }); +} + +fn block() -> ConfirmedBlock { + ConfirmedBlock { + previous_blockhash: Default::default(), + blockhash: Hash::from([0x42; 32]).into(), + parent_slot: 0, + block_time: None, + block_height: None, + signatures: None, + rewards: None, + num_reward_partitions: None, + transactions: None, + } +} diff --git a/minter/src/runtime/mod.rs b/minter/src/runtime/mod.rs index 7f985762..fc9660fa 100644 --- a/minter/src/runtime/mod.rs +++ b/minter/src/runtime/mod.rs @@ -1,10 +1,12 @@ use crate::signer::{IcSchnorrSigner, SchnorrSigner}; +use candid::Principal; use ic_canister_runtime::{IcRuntime, Runtime}; use std::{fmt::Debug, time::Duration}; pub trait CanisterRuntime: Clone + 'static { fn inter_canister_call_runtime(&self) -> impl Runtime; fn signer(&self) -> impl SchnorrSigner; + fn canister_self(&self) -> Principal; fn time(&self) -> u64; fn instruction_counter(&self) -> u64; fn msg_cycles_accept(&self, amount: u128) -> u128; @@ -35,6 +37,10 @@ impl CanisterRuntime for IcCanisterRuntime { IcSchnorrSigner } + fn canister_self(&self) -> Principal { + ic_cdk::api::canister_self() + } + fn time(&self) -> u64 { ic_cdk::api::time() } diff --git a/minter/src/sol_transfer/mod.rs b/minter/src/sol_transfer/mod.rs index 06f9b178..646efc7c 100644 --- a/minter/src/sol_transfer/mod.rs +++ b/minter/src/sol_transfer/mod.rs @@ -13,6 +13,9 @@ use solana_transaction::{Instruction, Message, Transaction}; use std::{collections::BTreeMap, iter}; use thiserror::Error; +#[cfg(test)] +mod tests; + pub const MAX_SIGNATURES: u64 = 10; pub const MAX_TX_SIZE: usize = 1_232; const BYTES_PER_SIGNATURE: usize = 64; @@ -25,9 +28,6 @@ pub enum CreateTransferError { SigningFailed(SignCallError), } -#[cfg(test)] -mod tests; - /// Creates a signed Solana transaction that transfers lamports from /// each minter-controlled address (identified by its account) to the /// destination account's derived address. diff --git a/minter/src/state/mod.rs b/minter/src/state/mod.rs index 876d3cd4..9864f99b 100644 --- a/minter/src/state/mod.rs +++ b/minter/src/state/mod.rs @@ -147,6 +147,10 @@ impl State { &self.funds_to_consolidate } + pub fn submitted_transactions(&self) -> &BTreeMap { + &self.submitted_transactions + } + pub fn deposit_status(&self, deposit_id: &DepositId) -> Option { if self.quarantined_deposits.contains_key(deposit_id) { return Some(DepositStatus::Quarantined(deposit_id.signature.into())); @@ -505,6 +509,7 @@ pub struct MintedDeposit { pub enum TaskType { DepositConsolidation, Mint, + ResubmitTransactions, } #[derive(Clone, Debug, PartialEq, Eq)] diff --git a/minter/src/test_fixtures/mod.rs b/minter/src/test_fixtures/mod.rs index 443afd0e..c758ac88 100644 --- a/minter/src/test_fixtures/mod.rs +++ b/minter/src/test_fixtures/mod.rs @@ -30,7 +30,7 @@ pub const DEPOSIT_FEE: Lamport = 10_000_000; // 0.01 SOL pub const WITHDRAWAL_FEE: Lamport = 5_000_000; // 0.005 SOL pub const MINIMUM_WITHDRAWAL_AMOUNT: Lamport = 10_000_000; // 0.01 SOL pub const MINTER_ACCOUNT: Account = Account { - owner: Principal::from_slice(&[1u8; 10]), + owner: runtime::TEST_CANISTER_ID, subaccount: None, }; pub const MINIMUM_DEPOSIT_AMOUNT: Lamport = 10_000_000; // 0.01 SOL diff --git a/minter/src/test_fixtures/runtime.rs b/minter/src/test_fixtures/runtime.rs index b593b1c9..c2aeaa64 100644 --- a/minter/src/test_fixtures/runtime.rs +++ b/minter/src/test_fixtures/runtime.rs @@ -1,9 +1,11 @@ use super::{signer::MockSchnorrSigner, stubs::Stubs}; use crate::{runtime::CanisterRuntime, signer::SchnorrSigner}; -use candid::CandidType; +use candid::{CandidType, Principal}; use ic_canister_runtime::{IcError, Runtime, StubRuntime}; use std::time::Duration; +pub const TEST_CANISTER_ID: Principal = Principal::from_slice(&[0xCA; 10]); + #[derive(Clone, Default)] pub struct TestCanisterRuntime { inter_canister_call_runtime: StubRuntime, @@ -50,6 +52,11 @@ impl TestCanisterRuntime { self.msg_cycles_refunded = self.msg_cycles_refunded.add(value); self } + + pub fn add_signature(mut self, signature: [u8; 64]) -> Self { + self.signer = self.signer.add_signature(signature); + self + } } impl CanisterRuntime for TestCanisterRuntime { @@ -62,6 +69,10 @@ impl CanisterRuntime for TestCanisterRuntime { self.signer.clone() } + fn canister_self(&self) -> Principal { + TEST_CANISTER_ID + } + fn time(&self) -> u64 { self.times.next() }