From 796729475c0849ef18c860e3218892862c94ecd8 Mon Sep 17 00:00:00 2001 From: Louis Pahlavi Date: Fri, 20 Mar 2026 08:09:26 +0100 Subject: [PATCH 01/13] test: add unit tests for deposit consolidation timer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add tests for the consolidate_deposits function covering: - Early return when no funds to consolidate - Early return when task already active (guard) - Early return when fetching blockhash fails - Single consolidation request with event assertions - Multiple consolidation batches (11 accounts → 2 batches) Also adds canister_self() to CanisterRuntime trait to enable mocking in tests, and extracts MAX_TRANSFERS_PER_CONSOLIDATION constant for test visibility. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- minter/src/consolidate/mod.rs | 9 +- minter/src/consolidate/tests.rs | 228 ++++++++++++++++++++++++++++ minter/src/runtime/mod.rs | 6 + minter/src/test_fixtures/runtime.rs | 14 +- 4 files changed, 253 insertions(+), 4 deletions(-) create mode 100644 minter/src/consolidate/tests.rs diff --git a/minter/src/consolidate/mod.rs b/minter/src/consolidate/mod.rs index 3aeaffd2..e6ed6787 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) { @@ -33,8 +37,7 @@ pub async fn consolidate_deposits(runtime: R) { .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() }); @@ -95,7 +98,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( diff --git a/minter/src/consolidate/tests.rs b/minter/src/consolidate/tests.rs new file mode 100644 index 00000000..89cc5b22 --- /dev/null +++ b/minter/src/consolidate/tests.rs @@ -0,0 +1,228 @@ +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)]); + + let consolidation_signature = Signature::from([0xAA; 64]); + let runtime = TestCanisterRuntime::new() + .with_increasing_time() + .add_stub_response(SlotResult::Consistent(Ok(100))) + .add_stub_response(BlockResult::Consistent(Ok(block()))) + .add_stub_response(SendTransactionResult::Consistent(Ok( + consolidation_signature.into(), + ))) + // Two signatures needed: fee payer (minter) + source (deposit account) + .add_signature([0x11; 64]) + .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, .. } + if signature == consolidation_signature + ) + }) + .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 + + let batch_1_signature = Signature::from([0xAA; 64]); + let batch_2_signature = Signature::from([0xBB; 64]); + let mut runtime = TestCanisterRuntime::new() + .with_increasing_time() + .add_stub_response(SlotResult::Consistent(Ok(100))) + .add_stub_response(BlockResult::Consistent(Ok(block()))) + .add_stub_response(SendTransactionResult::Consistent(Ok(batch_1_signature.into()))) + .add_stub_response(SendTransactionResult::Consistent(Ok(batch_2_signature.into()))); + + // Signatures needed: fee payer + each source account per batch + // Batch 1: 1 fee payer + 9 sources = 10 signatures + // Batch 2: 1 fee payer + 2 sources = 3 signatures + 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, .. } + if signature == batch_1_signature + ) + }); + // 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, .. } + if signature == batch_2_signature + ) + }); + 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/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/test_fixtures/runtime.rs b/minter/src/test_fixtures/runtime.rs index b593b1c9..d5d0ba5f 100644 --- a/minter/src/test_fixtures/runtime.rs +++ b/minter/src/test_fixtures/runtime.rs @@ -1,9 +1,12 @@ 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; +/// A fixed principal used for the minter canister in tests. +pub const TEST_CANISTER_ID: Principal = Principal::from_slice(&[0xCA; 10]); + #[derive(Clone, Default)] pub struct TestCanisterRuntime { inter_canister_call_runtime: StubRuntime, @@ -50,6 +53,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 +70,10 @@ impl CanisterRuntime for TestCanisterRuntime { self.signer.clone() } + fn canister_self(&self) -> Principal { + TEST_CANISTER_ID + } + fn time(&self) -> u64 { self.times.next() } From 64b0b8f25d5fd2618517a2bc172a4d9a5c06f3e4 Mon Sep 17 00:00:00 2001 From: Louis Pahlavi Date: Fri, 20 Mar 2026 08:29:47 +0100 Subject: [PATCH 02/13] test: verify events recorded even when transaction submission fails MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Record `ConsolidatedDeposits` and `SubmittedTransaction` events before calling `submit_transaction` to ensure they persist for resubmission even if the RPC call fails. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- minter/src/consolidate/mod.rs | 10 ++++- minter/src/consolidate/tests.rs | 68 +++++++++++++++++++++++++++------ 2 files changed, 65 insertions(+), 13 deletions(-) diff --git a/minter/src/consolidate/mod.rs b/minter/src/consolidate/mod.rs index e6ed6787..0b3d522c 100644 --- a/minter/src/consolidate/mod.rs +++ b/minter/src/consolidate/mod.rs @@ -110,9 +110,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, @@ -135,5 +137,11 @@ async fn submit_consolidation_transaction( ) }); + let result = submit_transaction(runtime, transaction).await?; + assert_eq!( + signature, result, + "BUG: Expected transaction signature to be {signature}, but got {result}" + ); + Ok(signature) } diff --git a/minter/src/consolidate/tests.rs b/minter/src/consolidate/tests.rs index 89cc5b22..3f1ba78d 100644 --- a/minter/src/consolidate/tests.rs +++ b/minter/src/consolidate/tests.rs @@ -75,16 +75,17 @@ async fn should_submit_single_consolidation_request() { let deposit_amount = 1_000_000_000_u64; add_funds_to_consolidate(vec![(deposit_account, deposit_amount)]); - let consolidation_signature = Signature::from([0xAA; 64]); + // Fee payer signature is first in the transaction and becomes the transaction ID + let fee_payer_signature = Signature::from([0x11; 64]); let runtime = TestCanisterRuntime::new() .with_increasing_time() .add_stub_response(SlotResult::Consistent(Ok(100))) .add_stub_response(BlockResult::Consistent(Ok(block()))) .add_stub_response(SendTransactionResult::Consistent(Ok( - consolidation_signature.into(), + fee_payer_signature.into() ))) // Two signatures needed: fee payer (minter) + source (deposit account) - .add_signature([0x11; 64]) + .add_signature(fee_payer_signature.into()) .add_signature([0x22; 64]); consolidate_deposits(runtime).await; @@ -107,7 +108,46 @@ async fn should_submit_single_consolidation_request() { }) .expect_event(|e| { assert_matches!(e, EventType::SubmittedTransaction { signature, .. } - if signature == consolidation_signature + if signature == fee_payer_signature + ) + }) + .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 runtime = TestCanisterRuntime::new() + .with_increasing_time() + .add_stub_response(SlotResult::Consistent(Ok(100))) + .add_stub_response(BlockResult::Consistent(Ok(block()))) + // 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, .. } + if signature == fee_payer_signature ) }) .assert_no_more_events(); @@ -127,18 +167,22 @@ async fn should_submit_multiple_consolidation_batches() { let batch_1_size = MAX_TRANSFERS_PER_CONSOLIDATION; // 9 accounts let batch_2_size = NUM_DEPOSITS - batch_1_size; // 2 accounts - let batch_1_signature = Signature::from([0xAA; 64]); - let batch_2_signature = Signature::from([0xBB; 64]); + // 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 mut runtime = TestCanisterRuntime::new() .with_increasing_time() .add_stub_response(SlotResult::Consistent(Ok(100))) .add_stub_response(BlockResult::Consistent(Ok(block()))) - .add_stub_response(SendTransactionResult::Consistent(Ok(batch_1_signature.into()))) - .add_stub_response(SendTransactionResult::Consistent(Ok(batch_2_signature.into()))); + .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 - // Batch 1: 1 fee payer + 9 sources = 10 signatures - // Batch 2: 1 fee payer + 2 sources = 3 signatures for i in 0..13 { runtime = runtime.add_signature([i as u8; 64]); } @@ -163,7 +207,7 @@ async fn should_submit_multiple_consolidation_batches() { }) .expect_event(|e| { assert_matches!(e, EventType::SubmittedTransaction { signature, .. } - if signature == batch_1_signature + if signature == fee_payer_signature_1 ) }); // Batch 2: 2 deposits consolidated together @@ -175,7 +219,7 @@ async fn should_submit_multiple_consolidation_batches() { }) .expect_event(|e| { assert_matches!(e, EventType::SubmittedTransaction { signature, .. } - if signature == batch_2_signature + if signature == fee_payer_signature_2 ) }); events_assert.assert_no_more_events(); From e5dc18594ba86f26388d140cb87e7e3701e1658e Mon Sep 17 00:00:00 2001 From: Louis Pahlavi Date: Fri, 20 Mar 2026 09:00:45 +0100 Subject: [PATCH 03/13] Remove panic in case of signature mismatch --- minter/src/consolidate/mod.rs | 6 +---- minter/src/consolidate/tests.rs | 40 +++++++++++++++++++++------------ 2 files changed, 27 insertions(+), 19 deletions(-) diff --git a/minter/src/consolidate/mod.rs b/minter/src/consolidate/mod.rs index 0b3d522c..0c2eb2d7 100644 --- a/minter/src/consolidate/mod.rs +++ b/minter/src/consolidate/mod.rs @@ -137,11 +137,7 @@ async fn submit_consolidation_transaction( ) }); - let result = submit_transaction(runtime, transaction).await?; - assert_eq!( - signature, result, - "BUG: Expected transaction signature to be {signature}, but got {result}" - ); + submit_transaction(runtime, transaction).await?; Ok(signature) } diff --git a/minter/src/consolidate/tests.rs b/minter/src/consolidate/tests.rs index 3f1ba78d..5f619baa 100644 --- a/minter/src/consolidate/tests.rs +++ b/minter/src/consolidate/tests.rs @@ -77,12 +77,16 @@ async fn should_submit_single_consolidation_request() { // 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() - .add_stub_response(SlotResult::Consistent(Ok(100))) + // 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() + fee_payer_signature.into(), ))) // Two signatures needed: fee payer (minter) + source (deposit account) .add_signature(fee_payer_signature.into()) @@ -107,8 +111,8 @@ async fn should_submit_single_consolidation_request() { ) }) .expect_event(|e| { - assert_matches!(e, EventType::SubmittedTransaction { signature, .. } - if signature == fee_payer_signature + assert_matches!(e, EventType::SubmittedTransaction { signature, slot: event_slot, .. } + if signature == fee_payer_signature && event_slot == slot ) }) .assert_no_more_events(); @@ -123,10 +127,14 @@ async fn should_record_events_even_if_transaction_submission_fails() { 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() - .add_stub_response(SlotResult::Consistent(Ok(100))) + // 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()) @@ -146,8 +154,8 @@ async fn should_record_events_even_if_transaction_submission_fails() { ) }) .expect_event(|e| { - assert_matches!(e, EventType::SubmittedTransaction { signature, .. } - if signature == fee_payer_signature + assert_matches!(e, EventType::SubmittedTransaction { signature, slot: event_slot, .. } + if signature == fee_payer_signature && event_slot == slot ) }) .assert_no_more_events(); @@ -170,16 +178,20 @@ async fn should_submit_multiple_consolidation_batches() { // 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() - .add_stub_response(SlotResult::Consistent(Ok(100))) + // 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() + fee_payer_signature_1.into(), ))) .add_stub_response(SendTransactionResult::Consistent(Ok( - fee_payer_signature_2.into() + fee_payer_signature_2.into(), ))); // Signatures needed: fee payer + each source account per batch @@ -206,8 +218,8 @@ async fn should_submit_multiple_consolidation_batches() { ) }) .expect_event(|e| { - assert_matches!(e, EventType::SubmittedTransaction { signature, .. } - if signature == fee_payer_signature_1 + assert_matches!(e, EventType::SubmittedTransaction { signature, slot: event_slot, .. } + if signature == fee_payer_signature_1 && event_slot == slot ) }); // Batch 2: 2 deposits consolidated together @@ -218,8 +230,8 @@ async fn should_submit_multiple_consolidation_batches() { ) }) .expect_event(|e| { - assert_matches!(e, EventType::SubmittedTransaction { signature, .. } - if signature == fee_payer_signature_2 + assert_matches!(e, EventType::SubmittedTransaction { signature, slot: event_slot, .. } + if signature == fee_payer_signature_2 && event_slot == slot ) }); events_assert.assert_no_more_events(); From 60daac31d53ccb3a588c57dd960d5a67b314f52b Mon Sep 17 00:00:00 2001 From: Louis Pahlavi Date: Fri, 20 Mar 2026 09:16:09 +0100 Subject: [PATCH 04/13] Clippy --- minter/src/consolidate/tests.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/minter/src/consolidate/tests.rs b/minter/src/consolidate/tests.rs index 5f619baa..7f74d946 100644 --- a/minter/src/consolidate/tests.rs +++ b/minter/src/consolidate/tests.rs @@ -86,7 +86,7 @@ async fn should_submit_single_consolidation_request() { // get_slot call .add_stub_response(SlotResult::Consistent(Ok(slot))) .add_stub_response(SendTransactionResult::Consistent(Ok( - fee_payer_signature.into(), + fee_payer_signature.into() ))) // Two signatures needed: fee payer (minter) + source (deposit account) .add_signature(fee_payer_signature.into()) @@ -188,10 +188,10 @@ async fn should_submit_multiple_consolidation_batches() { // get_slot call .add_stub_response(SlotResult::Consistent(Ok(slot))) .add_stub_response(SendTransactionResult::Consistent(Ok( - fee_payer_signature_1.into(), + fee_payer_signature_1.into() ))) .add_stub_response(SendTransactionResult::Consistent(Ok( - fee_payer_signature_2.into(), + fee_payer_signature_2.into() ))); // Signatures needed: fee payer + each source account per batch From e5bd1d8206283c3066ed2e03aee920646d72af84 Mon Sep 17 00:00:00 2001 From: Louis Pahlavi Date: Fri, 20 Mar 2026 11:54:54 +0100 Subject: [PATCH 05/13] Clippy --- minter/src/test_fixtures/runtime.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/minter/src/test_fixtures/runtime.rs b/minter/src/test_fixtures/runtime.rs index d5d0ba5f..c2aeaa64 100644 --- a/minter/src/test_fixtures/runtime.rs +++ b/minter/src/test_fixtures/runtime.rs @@ -4,7 +4,6 @@ use candid::{CandidType, Principal}; use ic_canister_runtime::{IcError, Runtime, StubRuntime}; use std::time::Duration; -/// A fixed principal used for the minter canister in tests. pub const TEST_CANISTER_ID: Principal = Principal::from_slice(&[0xCA; 10]); #[derive(Clone, Default)] From 4f85344a2b8322e4cb4bf7fcd91453453c1a9676 Mon Sep 17 00:00:00 2001 From: Louis Pahlavi Date: Wed, 18 Mar 2026 17:07:57 +0100 Subject: [PATCH 06/13] feat: resubmit transactions with new blockhash MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add timer task that resubmits all pending transactions every 60 seconds with a fresh blockhash. Uses ResubmittedTransaction event to track the signature change and updated slot. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- minter/src/lib.rs | 1 + minter/src/main.rs | 4 + minter/src/resubmit.rs | 129 +++++++++++++++++++++++++++++++++ minter/src/sol_transfer/mod.rs | 27 ++++++- minter/src/state/mod.rs | 5 ++ 5 files changed, 164 insertions(+), 2 deletions(-) create mode 100644 minter/src/resubmit.rs 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.rs b/minter/src/resubmit.rs new file mode 100644 index 00000000..d78adaca --- /dev/null +++ b/minter/src/resubmit.rs @@ -0,0 +1,129 @@ +use crate::{ + address::derivation_path, + guard::TimerGuard, + runtime::CanisterRuntime, + sol_transfer::{CreateTransferError, IcSchnorrSigner, sign_message_bytes}, + state::{TaskType, audit::process_event, event::EventType, mutate_state, read_state}, + transaction::{ + GetRecentBlockhashError, 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; + +pub const RESUBMIT_TRANSACTIONS_DELAY: Duration = Duration::from_secs(60); + +/// Solana blockhashes are valid for approximately 150 slots. +/// We use a slightly lower threshold to ensure we resubmit before expiration. +const BLOCKHASH_EXPIRY_SLOTS: Slot = 150; + +pub async fn resubmit_transactions(runtime: R) { + let _guard = match TimerGuard::new(TaskType::ResubmitTransactions) { + Ok(guard) => guard, + Err(_) => 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 expired_transactions: Vec<_> = read_state(|state| { + state + .submitted_transactions() + .iter() + .filter(|(_, tx)| current_slot.saturating_sub(tx.slot) >= BLOCKHASH_EXPIRY_SLOTS) + .map(|(sig, tx)| (*sig, tx.message.clone(), tx.signers.clone())) + .collect() + }); + + if expired_transactions.is_empty() { + return; + } + + for (signature, message, signers) in expired_transactions { + if let Err(e) = resubmit_transaction(&runtime, signature, message, signers).await { + log!( + Priority::Info, + "Failed to resubmit transaction {signature}: {e}" + ); + } + } +} + +#[derive(Debug, Error)] +enum ResubmitError { + #[error("failed to get recent blockhash: {0}")] + GetBlockhash(#[from] GetRecentBlockhashError), + #[error("failed to sign transaction: {0}")] + Signing(#[from] CreateTransferError), + #[error("failed to submit transaction: {0}")] + Submit(#[from] SubmitTransactionError), + #[error("failed to sign transaction: {0}")] + SignError(#[from] SignCallError), +} + +async fn resubmit_transaction( + runtime: &R, + old_signature: Signature, + message: Message, + signers: Vec, +) -> Result<(), ResubmitError> { + let (new_slot, new_blockhash) = get_recent_blockhash(runtime).await?; + let new_signature = + resubmit_with_new_blockhash(runtime, &message, &signers, new_blockhash).await?; + + // Record the resubmission event (replaces old signature with new one and updates slot) + mutate_state(|state| { + process_event( + state, + EventType::ResubmittedTransaction { + old_signature, + new_signature, + new_slot, + }, + runtime, + ) + }); + + log!( + Priority::Info, + "Resubmitted transaction {old_signature} with new signature {new_signature}" + ); + + Ok(()) +} + +/// Resubmits a transaction with a new blockhash. +async fn resubmit_with_new_blockhash( + runtime: &R, + original_message: &Message, + signers: &[Account], + new_blockhash: solana_hash::Hash, +) -> Result { + let mut new_message = original_message.clone(); + new_message.recent_blockhash = new_blockhash; + + let mut transaction = Transaction::new_unsigned(new_message.clone()); + transaction.signatures = sign_message_bytes( + signers.iter().map(derivation_path), + &IcSchnorrSigner, + transaction.message_data(), + ) + .await?; + + let new_signature = submit_transaction(runtime, transaction).await?; + Ok(new_signature) +} diff --git a/minter/src/sol_transfer/mod.rs b/minter/src/sol_transfer/mod.rs index 06f9b178..6e6fba8f 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,8 +28,28 @@ pub enum CreateTransferError { SigningFailed(SignCallError), } -#[cfg(test)] -mod tests; +pub async fn sign_message_bytes( + derivation_paths: impl IntoIterator, + signer: &impl SchnorrSigner, + message_bytes: Vec, +) -> Result, SignCallError> { + fn signature_from_bytes(bytes: Vec) -> Signature { + <[u8; 64]>::try_from(bytes.as_slice()) + .unwrap_or_else(|_| { + panic!("BUG: expected 64-byte signature, got {} bytes", bytes.len()) + }) + .into() + } + let futures = derivation_paths + .into_iter() + .map(|derivation_path| signer.sign(message_bytes.clone(), derivation_path)); + let signatures = futures::future::try_join_all(futures) + .await? + .into_iter() + .map(signature_from_bytes) + .collect(); + Ok(signatures) +} /// Creates a signed Solana transaction that transfers lamports from /// each minter-controlled address (identified by its account) to the 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)] From 503153b6d35aa499bf46611177e9f27bcee4b466 Mon Sep 17 00:00:00 2001 From: Louis Pahlavi Date: Thu, 19 Mar 2026 11:44:01 +0100 Subject: [PATCH 07/13] Manual fixups --- minter/src/resubmit.rs | 129 ------------------------------ minter/src/resubmit/mod.rs | 139 +++++++++++++++++++++++++++++++++ minter/src/sol_transfer/mod.rs | 23 ------ 3 files changed, 139 insertions(+), 152 deletions(-) delete mode 100644 minter/src/resubmit.rs create mode 100644 minter/src/resubmit/mod.rs diff --git a/minter/src/resubmit.rs b/minter/src/resubmit.rs deleted file mode 100644 index d78adaca..00000000 --- a/minter/src/resubmit.rs +++ /dev/null @@ -1,129 +0,0 @@ -use crate::{ - address::derivation_path, - guard::TimerGuard, - runtime::CanisterRuntime, - sol_transfer::{CreateTransferError, IcSchnorrSigner, sign_message_bytes}, - state::{TaskType, audit::process_event, event::EventType, mutate_state, read_state}, - transaction::{ - GetRecentBlockhashError, 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; - -pub const RESUBMIT_TRANSACTIONS_DELAY: Duration = Duration::from_secs(60); - -/// Solana blockhashes are valid for approximately 150 slots. -/// We use a slightly lower threshold to ensure we resubmit before expiration. -const BLOCKHASH_EXPIRY_SLOTS: Slot = 150; - -pub async fn resubmit_transactions(runtime: R) { - let _guard = match TimerGuard::new(TaskType::ResubmitTransactions) { - Ok(guard) => guard, - Err(_) => 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 expired_transactions: Vec<_> = read_state(|state| { - state - .submitted_transactions() - .iter() - .filter(|(_, tx)| current_slot.saturating_sub(tx.slot) >= BLOCKHASH_EXPIRY_SLOTS) - .map(|(sig, tx)| (*sig, tx.message.clone(), tx.signers.clone())) - .collect() - }); - - if expired_transactions.is_empty() { - return; - } - - for (signature, message, signers) in expired_transactions { - if let Err(e) = resubmit_transaction(&runtime, signature, message, signers).await { - log!( - Priority::Info, - "Failed to resubmit transaction {signature}: {e}" - ); - } - } -} - -#[derive(Debug, Error)] -enum ResubmitError { - #[error("failed to get recent blockhash: {0}")] - GetBlockhash(#[from] GetRecentBlockhashError), - #[error("failed to sign transaction: {0}")] - Signing(#[from] CreateTransferError), - #[error("failed to submit transaction: {0}")] - Submit(#[from] SubmitTransactionError), - #[error("failed to sign transaction: {0}")] - SignError(#[from] SignCallError), -} - -async fn resubmit_transaction( - runtime: &R, - old_signature: Signature, - message: Message, - signers: Vec, -) -> Result<(), ResubmitError> { - let (new_slot, new_blockhash) = get_recent_blockhash(runtime).await?; - let new_signature = - resubmit_with_new_blockhash(runtime, &message, &signers, new_blockhash).await?; - - // Record the resubmission event (replaces old signature with new one and updates slot) - mutate_state(|state| { - process_event( - state, - EventType::ResubmittedTransaction { - old_signature, - new_signature, - new_slot, - }, - runtime, - ) - }); - - log!( - Priority::Info, - "Resubmitted transaction {old_signature} with new signature {new_signature}" - ); - - Ok(()) -} - -/// Resubmits a transaction with a new blockhash. -async fn resubmit_with_new_blockhash( - runtime: &R, - original_message: &Message, - signers: &[Account], - new_blockhash: solana_hash::Hash, -) -> Result { - let mut new_message = original_message.clone(); - new_message.recent_blockhash = new_blockhash; - - let mut transaction = Transaction::new_unsigned(new_message.clone()); - transaction.signatures = sign_message_bytes( - signers.iter().map(derivation_path), - &IcSchnorrSigner, - transaction.message_data(), - ) - .await?; - - let new_signature = submit_transaction(runtime, transaction).await?; - Ok(new_signature) -} diff --git a/minter/src/resubmit/mod.rs b/minter/src/resubmit/mod.rs new file mode 100644 index 00000000..7925b80e --- /dev/null +++ b/minter/src/resubmit/mod.rs @@ -0,0 +1,139 @@ +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; + +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, + }; + + 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, + ) + }); + + let result = submit_transaction(runtime, transaction).await?; + assert_eq!( + new_signature, result, + "BUG: Expected new transaction signature to be {new_signature}, but got {result}" + ); + + 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/sol_transfer/mod.rs b/minter/src/sol_transfer/mod.rs index 6e6fba8f..646efc7c 100644 --- a/minter/src/sol_transfer/mod.rs +++ b/minter/src/sol_transfer/mod.rs @@ -28,29 +28,6 @@ pub enum CreateTransferError { SigningFailed(SignCallError), } -pub async fn sign_message_bytes( - derivation_paths: impl IntoIterator, - signer: &impl SchnorrSigner, - message_bytes: Vec, -) -> Result, SignCallError> { - fn signature_from_bytes(bytes: Vec) -> Signature { - <[u8; 64]>::try_from(bytes.as_slice()) - .unwrap_or_else(|_| { - panic!("BUG: expected 64-byte signature, got {} bytes", bytes.len()) - }) - .into() - } - let futures = derivation_paths - .into_iter() - .map(|derivation_path| signer.sign(message_bytes.clone(), derivation_path)); - let signatures = futures::future::try_join_all(futures) - .await? - .into_iter() - .map(signature_from_bytes) - .collect(); - Ok(signatures) -} - /// Creates a signed Solana transaction that transfers lamports from /// each minter-controlled address (identified by its account) to the /// destination account's derived address. From 39855fc5e90b2593f7272aeee11f1f56980aa11e Mon Sep 17 00:00:00 2001 From: Louis Pahlavi Date: Fri, 20 Mar 2026 09:28:14 +0100 Subject: [PATCH 08/13] test: add unit tests for transaction resubmission MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add tests for the resubmit_transactions function covering: - Early return when no transactions to resubmit - Early return when task already active (guard) - Early return when fetching current slot fails - No resubmission when transaction not expired - Single expired transaction resubmission with event assertions - Event recorded even when submission fails 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- minter/src/resubmit/mod.rs | 3 + minter/src/resubmit/tests.rs | 235 ++++++++++++++++++++++++++++++++ minter/src/test_fixtures/mod.rs | 2 +- 3 files changed, 239 insertions(+), 1 deletion(-) create mode 100644 minter/src/resubmit/tests.rs diff --git a/minter/src/resubmit/mod.rs b/minter/src/resubmit/mod.rs index 7925b80e..ca5121a9 100644 --- a/minter/src/resubmit/mod.rs +++ b/minter/src/resubmit/mod.rs @@ -17,6 +17,9 @@ 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; diff --git a/minter/src/resubmit/tests.rs b/minter/src/resubmit/tests.rs new file mode 100644 index 00000000..7499fa1d --- /dev/null +++ b/minter/src/resubmit/tests.rs @@ -0,0 +1,235 @@ +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() + // get_slot for current slot check + .add_stub_response(SlotResult::Consistent(Ok(200))); + + 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/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 From ef1339852e88cbb96aacf4484640f3c2694f0790 Mon Sep 17 00:00:00 2001 From: Louis Pahlavi Date: Mon, 23 Mar 2026 13:37:16 +0100 Subject: [PATCH 09/13] Don't call `getSlot` if `submitted_transactions` is empty --- minter/src/consolidate/mod.rs | 7 +++---- minter/src/resubmit/mod.rs | 11 +++++------ 2 files changed, 8 insertions(+), 10 deletions(-) diff --git a/minter/src/consolidate/mod.rs b/minter/src/consolidate/mod.rs index 0c2eb2d7..1afa6769 100644 --- a/minter/src/consolidate/mod.rs +++ b/minter/src/consolidate/mod.rs @@ -27,10 +27,6 @@ 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() @@ -41,6 +37,9 @@ pub async fn consolidate_deposits(runtime: R) { .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 { diff --git a/minter/src/resubmit/mod.rs b/minter/src/resubmit/mod.rs index ca5121a9..1e7fd78f 100644 --- a/minter/src/resubmit/mod.rs +++ b/minter/src/resubmit/mod.rs @@ -30,6 +30,10 @@ pub async fn resubmit_transactions(runtime: R) { 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) => { @@ -37,7 +41,6 @@ pub async fn resubmit_transactions(runtime: R) { return; } }; - let mut expired_transactions = read_state(|state| { state .submitted_transactions() @@ -124,11 +127,7 @@ async fn resubmit_transaction_with_new_blockhash( ) }); - let result = submit_transaction(runtime, transaction).await?; - assert_eq!( - new_signature, result, - "BUG: Expected new transaction signature to be {new_signature}, but got {result}" - ); + submit_transaction(runtime, transaction).await?; Ok(new_signature) } From 4e5743c4a06738cbf69ff03b53cab46ce6ef2338 Mon Sep 17 00:00:00 2001 From: Louis Pahlavi Date: Mon, 23 Mar 2026 13:40:22 +0100 Subject: [PATCH 10/13] Remove unnecessary stub for early return --- minter/src/resubmit/tests.rs | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/minter/src/resubmit/tests.rs b/minter/src/resubmit/tests.rs index 7499fa1d..0393aa7c 100644 --- a/minter/src/resubmit/tests.rs +++ b/minter/src/resubmit/tests.rs @@ -22,10 +22,7 @@ type SendTransactionResult = MultiRpcResult; async fn should_return_early_if_no_transactions_to_resubmit() { setup(); - let runtime = TestCanisterRuntime::new() - .with_increasing_time() - // get_slot for current slot check - .add_stub_response(SlotResult::Consistent(Ok(200))); + let runtime = TestCanisterRuntime::new().with_increasing_time(); resubmit_transactions(runtime).await; From f7f93ee4d7d4a4f7c8249b798d6a6d3adc60ac82 Mon Sep 17 00:00:00 2001 From: Louis Pahlavi Date: Tue, 24 Mar 2026 07:13:53 +0100 Subject: [PATCH 11/13] Add TODO --- minter/src/resubmit/mod.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/minter/src/resubmit/mod.rs b/minter/src/resubmit/mod.rs index 1e7fd78f..d259cc1a 100644 --- a/minter/src/resubmit/mod.rs +++ b/minter/src/resubmit/mod.rs @@ -51,6 +51,8 @@ pub async fn resubmit_transactions(runtime: R) { }); while !expired_transactions.is_empty() { + // TODO DEFI-2670: Combine these two calls once `sol_rpc_client::SolRpcClient` + // `get_recent_block` method is released. let new_blockhash = match get_recent_blockhash(&runtime).await { Ok(blockhash) => blockhash, Err(e) => { From ce5532a2a2f4b5fce5f1937b66dcc838ca89df10 Mon Sep 17 00:00:00 2001 From: Louis Pahlavi Date: Tue, 24 Mar 2026 13:16:31 +0100 Subject: [PATCH 12/13] refactor: rename resubmit module to monitor Rename `resubmit` to `monitor`, `resubmit_transactions` to `monitor_submitted_transactions`, and `ResubmitTransactions` to `MonitorSubmittedTransactions`. No logic changes. Co-Authored-By: Claude Opus 4.6 (1M context) --- minter/src/lib.rs | 2 +- minter/src/main.rs | 6 +++--- minter/src/{resubmit => monitor}/mod.rs | 6 +++--- minter/src/{resubmit => monitor}/tests.rs | 17 +++++++++-------- minter/src/state/mod.rs | 2 +- 5 files changed, 17 insertions(+), 16 deletions(-) rename minter/src/{resubmit => monitor}/mod.rs (94%) rename minter/src/{resubmit => monitor}/tests.rs (94%) diff --git a/minter/src/lib.rs b/minter/src/lib.rs index 98e684b4..a2ad246b 100644 --- a/minter/src/lib.rs +++ b/minter/src/lib.rs @@ -4,8 +4,8 @@ mod cycles; mod guard; mod ledger; pub mod lifecycle; +pub mod monitor; 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 c1c02c68..88af3bec 100644 --- a/minter/src/main.rs +++ b/minter/src/main.rs @@ -1,7 +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::monitor::{MONITOR_SUBMITTED_TRANSACTIONS_DELAY, monitor_submitted_transactions}; use cksol_minter::{ address::lazy_get_schnorr_master_key, runtime::IcCanisterRuntime, state::read_state, }; @@ -272,8 +272,8 @@ 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; + ic_cdk_timers::set_timer_interval(MONITOR_SUBMITTED_TRANSACTIONS_DELAY, async || { + monitor_submitted_transactions(IcCanisterRuntime::new()).await; }); } diff --git a/minter/src/resubmit/mod.rs b/minter/src/monitor/mod.rs similarity index 94% rename from minter/src/resubmit/mod.rs rename to minter/src/monitor/mod.rs index d259cc1a..97992193 100644 --- a/minter/src/resubmit/mod.rs +++ b/minter/src/monitor/mod.rs @@ -20,12 +20,12 @@ use thiserror::Error; #[cfg(test)] mod tests; -pub const RESUBMIT_TRANSACTIONS_DELAY: Duration = Duration::from_secs(60); +pub const MONITOR_SUBMITTED_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) { +pub async fn monitor_submitted_transactions(runtime: R) { + let _guard = match TimerGuard::new(TaskType::MonitorSubmittedTransactions) { Ok(guard) => guard, Err(_) => return, }; diff --git a/minter/src/resubmit/tests.rs b/minter/src/monitor/tests.rs similarity index 94% rename from minter/src/resubmit/tests.rs rename to minter/src/monitor/tests.rs index 0393aa7c..08cc4958 100644 --- a/minter/src/resubmit/tests.rs +++ b/minter/src/monitor/tests.rs @@ -1,4 +1,4 @@ -use super::{MAX_BLOCKHASH_AGE, resubmit_transactions}; +use super::{MAX_BLOCKHASH_AGE, monitor_submitted_transactions}; use crate::{ address::derive_public_key, state::{TaskType, audit::process_event, event::EventType, mutate_state, read_state}, @@ -24,7 +24,7 @@ async fn should_return_early_if_no_transactions_to_resubmit() { let runtime = TestCanisterRuntime::new().with_increasing_time(); - resubmit_transactions(runtime).await; + monitor_submitted_transactions(runtime).await; EventsAssert::assert_no_events_recorded(); } @@ -35,10 +35,11 @@ async fn should_return_early_if_task_already_active() { add_submitted_transaction(Signature::from([0x01; 64]), 10); mutate_state(|s| { - s.active_tasks_mut().insert(TaskType::ResubmitTransactions); + s.active_tasks_mut() + .insert(TaskType::MonitorSubmittedTransactions); }); - resubmit_transactions(TestCanisterRuntime::new()).await; + monitor_submitted_transactions(TestCanisterRuntime::new()).await; // Only SubmittedTransaction event from setup, no resubmission events EventsAssert::from_recorded() @@ -57,7 +58,7 @@ async fn should_return_early_if_fetching_current_slot_fails() { .add_stub_response(error.clone()) .add_stub_response(error); - resubmit_transactions(runtime).await; + monitor_submitted_transactions(runtime).await; // Only SubmittedTransaction event from setup, no resubmission events EventsAssert::from_recorded() @@ -78,7 +79,7 @@ async fn should_not_resubmit_if_transaction_not_expired() { .with_increasing_time() .add_stub_response(SlotResult::Consistent(Ok(current_slot))); - resubmit_transactions(runtime).await; + monitor_submitted_transactions(runtime).await; // Only SubmittedTransaction event from setup, no resubmission EventsAssert::from_recorded() @@ -118,7 +119,7 @@ async fn should_resubmit_single_expired_transaction() { // Signature for re-signing (only fee payer since message has no other signers) .add_signature(new_signature.into()); - resubmit_transactions(runtime).await; + monitor_submitted_transactions(runtime).await; EventsAssert::from_recorded() .expect_event(|e| assert_matches!(e, EventType::SubmittedTransaction { .. })) @@ -167,7 +168,7 @@ async fn should_record_event_even_if_submission_fails() { .add_stub_response(SendTransactionResult::Inconsistent(vec![])) .add_signature(new_signature.into()); - resubmit_transactions(runtime).await; + monitor_submitted_transactions(runtime).await; // ResubmittedTransaction event should still be recorded EventsAssert::from_recorded() diff --git a/minter/src/state/mod.rs b/minter/src/state/mod.rs index 9864f99b..586d8324 100644 --- a/minter/src/state/mod.rs +++ b/minter/src/state/mod.rs @@ -509,7 +509,7 @@ pub struct MintedDeposit { pub enum TaskType { DepositConsolidation, Mint, - ResubmitTransactions, + MonitorSubmittedTransactions, } #[derive(Clone, Debug, PartialEq, Eq)] From 95a510eb296909f66fd4a4e9eca68b14d7919bd8 Mon Sep 17 00:00:00 2001 From: Louis Pahlavi Date: Tue, 24 Mar 2026 15:15:38 +0100 Subject: [PATCH 13/13] fix: restore consolidation changes from main after bad merge Co-Authored-By: Claude Opus 4.6 (1M context) --- minter/src/consolidate/mod.rs | 9 +++++---- minter/src/consolidate/tests.rs | 22 +++++++++++----------- 2 files changed, 16 insertions(+), 15 deletions(-) diff --git a/minter/src/consolidate/mod.rs b/minter/src/consolidate/mod.rs index 1afa6769..4e6b8951 100644 --- a/minter/src/consolidate/mod.rs +++ b/minter/src/consolidate/mod.rs @@ -27,6 +27,10 @@ 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() @@ -37,9 +41,6 @@ pub async fn consolidate_deposits(runtime: R) { .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 { @@ -113,7 +114,7 @@ async fn submit_consolidation_transaction( let message = transaction.message.clone(); // Record events before trying to submit the transaction to ensure we don't - // resubmit the same transaction twice in case submission fails. + // resubmit the same transaction twice in case of a failed submission. mutate_state(|state| { process_event( state, diff --git a/minter/src/consolidate/tests.rs b/minter/src/consolidate/tests.rs index 7f74d946..629f48c6 100644 --- a/minter/src/consolidate/tests.rs +++ b/minter/src/consolidate/tests.rs @@ -163,7 +163,7 @@ async fn should_record_events_even_if_transaction_submission_fails() { #[tokio::test] async fn should_submit_multiple_consolidation_batches() { - const NUM_DEPOSITS: usize = 11; + const NUM_DEPOSITS: usize = MAX_TRANSFERS_PER_CONSOLIDATION + 1; setup(); let funds: Vec<_> = (0..NUM_DEPOSITS) @@ -172,12 +172,12 @@ async fn should_submit_multiple_consolidation_batches() { 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 + const BATCH_1_SIZE: usize = MAX_TRANSFERS_PER_CONSOLIDATION; + const BATCH_2_SIZE: usize = NUM_DEPOSITS - BATCH_1_SIZE; // 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 fee_payer_signature_1 = Signature::from([0; 64]); + let fee_payer_signature_2 = Signature::from([(BATCH_1_SIZE + 1) as u8; 64]); let slot = 100; let mut runtime = TestCanisterRuntime::new() @@ -194,8 +194,8 @@ async fn should_submit_multiple_consolidation_batches() { fee_payer_signature_2.into() ))); - // Signatures needed: fee payer + each source account per batch - for i in 0..13 { + // Signatures needed: 2 x for fee payer (1 for each batch) + 1x for each source account + for i in 0..(2 + NUM_DEPOSITS) { runtime = runtime.add_signature([i as u8; 64]); } @@ -210,11 +210,11 @@ async fn should_submit_multiple_consolidation_batches() { ) }); } - // Batch 1: 9 deposits consolidated together + // Batch 1: events_assert = events_assert .expect_event(|e| { assert_matches!(e, EventType::ConsolidatedDeposits { deposits } - if deposits.len() == batch_1_size + if deposits.len() == BATCH_1_SIZE ) }) .expect_event(|e| { @@ -222,11 +222,11 @@ async fn should_submit_multiple_consolidation_batches() { if signature == fee_payer_signature_1 && event_slot == slot ) }); - // Batch 2: 2 deposits consolidated together + // Batch 2: events_assert = events_assert .expect_event(|e| { assert_matches!(e, EventType::ConsolidatedDeposits { deposits } - if deposits.len() == batch_2_size + if deposits.len() == BATCH_2_SIZE ) }) .expect_event(|e| {