diff --git a/minter/src/constants.rs b/minter/src/constants.rs index 09ac540c..42b6198a 100644 --- a/minter/src/constants.rs +++ b/minter/src/constants.rs @@ -6,6 +6,9 @@ pub const MAX_CONCURRENT_RPC_CALLS: usize = 10; /// https://docs.internetcomputer.org/references/ic-interface-spec#ic-http_request pub const MAX_HTTP_OUTCALL_RESPONSE_BYTES: u64 = 2_000_000; +/// Cycles to attach for `getTransaction` RPC calls. +pub const GET_TRANSACTION_CYCLES: u128 = 50_000_000_000; + /// Cycles to attach for `getSignatureStatuses` RPC calls. pub const GET_SIGNATURE_STATUSES_CYCLES: u128 = 1_000_000_000_000; diff --git a/minter/src/deposit/manual/mod.rs b/minter/src/deposit/manual/mod.rs index eb92edbc..767f6c4c 100644 --- a/minter/src/deposit/manual/mod.rs +++ b/minter/src/deposit/manual/mod.rs @@ -1,10 +1,9 @@ use crate::{ - address::{account_address, lazy_get_schnorr_master_key}, + constants::GET_TRANSACTION_CYCLES, cycles::{charge_caller_cycles, check_caller_available_cycles}, - deposit::get_deposit_amount_to_address, + deposit::fetch_and_validate_deposit, guard::process_deposit_guard, ledger::mint, - rpc::get_transaction, runtime::CanisterRuntime, state::{ Deposit, @@ -35,7 +34,7 @@ pub async fn process_deposit( deposit_amount, amount_to_mint, } = match read_state(|state| state.deposit_status(&deposit_id)) { - None => try_accept_deposit(&runtime, account, signature, deposit_id).await?, + None => try_accept_deposit(&runtime, account, signature).await?, Some(DepositStatus::Processing { deposit_amount, amount_to_mint, @@ -70,57 +69,29 @@ async fn try_accept_deposit( runtime: &R, account: Account, signature: Signature, - deposit_id: DepositId, ) -> Result { - let (cycles_to_attach, deposit_consolidation_fee) = read_state(|state| { + let (cycles_to_attach, deposit_consolidation_fee, fee) = read_state(|state| { ( state.process_deposit_required_cycles(), state.deposit_consolidation_fee(), + state.manual_deposit_fee(), ) }); check_caller_available_cycles(runtime, cycles_to_attach)?; - // Reserve the consolidation fee and forward the rest to the HTTP outcall - let cycles_for_rpc = cycles_to_attach.saturating_sub(deposit_consolidation_fee); - let maybe_transaction = get_transaction(runtime, signature, cycles_for_rpc) - .await - .map_err(|e| { - log!( - Priority::Info, - "Error fetching transaction for deposit {deposit_id:?}: {e}" - ); - ProcessDepositError::from(e) - })?; + let result = fetch_and_validate_deposit(runtime, account, signature, fee).await; - // Charge the actual RPC cost plus the consolidation fee - let rpc_cost = cycles_for_rpc.saturating_sub(runtime.msg_cycles_refunded()); - charge_caller_cycles(runtime, rpc_cost + deposit_consolidation_fee); + // Always charge for the RPC call; additionally charge the consolidation fee if a deposit is found + let rpc_cost = GET_TRANSACTION_CYCLES.saturating_sub(runtime.msg_cycles_refunded()); + let cycles_to_charge = rpc_cost + + if result.is_ok() { + deposit_consolidation_fee + } else { + 0 + }; + charge_caller_cycles(runtime, cycles_to_charge); - let transaction = match maybe_transaction { - Some(transaction) => Ok(transaction), - None => Err(ProcessDepositError::TransactionNotFound), - }?; - - let master_key = lazy_get_schnorr_master_key(runtime).await; - let deposit_address = account_address(&master_key, &account); - let deposit_amount = - get_deposit_amount_to_address(transaction, deposit_address).map_err(|e| { - log!( - Priority::Info, - "Error parsing deposit transaction with signature {signature}: {e}" - ); - ProcessDepositError::InvalidDepositTransaction(e.to_string()) - })?; - let minimum_deposit_amount = read_state(|state| state.minimum_deposit_amount()); - if deposit_amount < minimum_deposit_amount { - return Err(ProcessDepositError::ValueTooSmall { - minimum_deposit_amount, - deposit_amount, - }); - } - let amount_to_mint = deposit_amount - .checked_sub(read_state(|state| state.manual_deposit_fee())) - .expect("BUG: deposit amount is less than manual deposit fee"); + let (deposit_id, deposit_amount, amount_to_mint) = result?; mutate_state(|state| { process_event( diff --git a/minter/src/deposit/manual/tests.rs b/minter/src/deposit/manual/tests.rs index 219c6184..997523f4 100644 --- a/minter/src/deposit/manual/tests.rs +++ b/minter/src/deposit/manual/tests.rs @@ -1,4 +1,5 @@ use crate::{ + constants::GET_TRANSACTION_CYCLES, deposit::manual::process_deposit, state::event::{DepositId, EventType}, storage::reset_events, @@ -29,428 +30,440 @@ use icrc_ledger_types::icrc1::{ transfer::{BlockIndex, TransferError}, }; use sol_rpc_types::{EncodedConfirmedTransactionWithStatusMeta, Lamport, MultiRpcResult}; -use std::panic; type GetTransactionResult = MultiRpcResult>; +type MintResult = Result; -#[tokio::test] -async fn should_fail_if_insufficient_cycles_attached() { - init_state(); - - let runtime = - TestCanisterRuntime::new().add_msg_cycles_available(PROCESS_DEPOSIT_REQUIRED_CYCLES - 1); - - let result = process_deposit( - runtime, - DEPOSITOR_ACCOUNT, - legacy_deposit_transaction_signature(), - ) - .await; - - assert_eq!( - result, - Err(ProcessDepositError::InsufficientCycles( - InsufficientCyclesError { - expected: PROCESS_DEPOSIT_REQUIRED_CYCLES, - received: PROCESS_DEPOSIT_REQUIRED_CYCLES - 1, - } - )) - ); - EventsAssert::assert_no_events_recorded(); -} +mod process_deposit_tests { + use super::*; -#[tokio::test] -async fn should_return_error_if_get_transaction_fails() { - init_state(); - init_schnorr_master_key(); - - let runtime = runtime_with_time_and_cycles().add_stub_error(IcError::CallPerformFailed); - - let result = process_deposit( - runtime, - DEPOSITOR_ACCOUNT, - legacy_deposit_transaction_signature(), - ) - .await; - - assert_matches!( - result, - Err(ProcessDepositError::TemporarilyUnavailable(e)) => assert!(e.contains("Inter-canister call perform failed")) - ); - EventsAssert::assert_no_events_recorded(); -} + #[tokio::test] + async fn should_fail_if_insufficient_cycles_attached() { + init_state(); -#[tokio::test] -async fn should_return_error_if_transaction_not_found() { - init_state(); - init_schnorr_master_key(); + let runtime = TestCanisterRuntime::new() + .add_msg_cycles_available(PROCESS_DEPOSIT_REQUIRED_CYCLES - 1); - let runtime = runtime_with_time_and_cycles() - .add_stub_response(GetTransactionResult::Consistent(Ok(None))); + let result = process_deposit( + runtime, + DEPOSITOR_ACCOUNT, + legacy_deposit_transaction_signature(), + ) + .await; - let result = process_deposit( - runtime, - DEPOSITOR_ACCOUNT, - legacy_deposit_transaction_signature(), - ) - .await; + assert_eq!( + result, + Err(ProcessDepositError::InsufficientCycles( + InsufficientCyclesError { + expected: PROCESS_DEPOSIT_REQUIRED_CYCLES, + received: PROCESS_DEPOSIT_REQUIRED_CYCLES - 1, + } + )) + ); + EventsAssert::assert_no_events_recorded(); + } - assert_eq!(result, Err(ProcessDepositError::TransactionNotFound)); - EventsAssert::assert_no_events_recorded(); -} + #[tokio::test] + async fn should_return_error_if_get_transaction_fails() { + init_state(); + init_schnorr_master_key(); -#[tokio::test] -async fn should_return_error_if_transaction_not_valid_deposit() { - init_state(); - init_schnorr_master_key(); - - let get_transaction_response = GetTransactionResult::Consistent(Ok(Some( - deposit_transaction_to_wrong_address().try_into().unwrap(), - ))); - let runtime = runtime_with_time_and_cycles().add_stub_response(get_transaction_response); - - let result = process_deposit( - runtime, - DEPOSITOR_ACCOUNT, - deposit_transaction_to_wrong_address_signature(), - ) - .await; - - assert_matches!( - result, - Err(ProcessDepositError::InvalidDepositTransaction(e)) => assert!(e.contains("Transaction must target deposit address")) - ); - EventsAssert::assert_no_events_recorded(); -} + let runtime = rejected_runtime().add_stub_error(IcError::CallPerformFailed); -#[tokio::test] -async fn should_fail_if_deposit_amount_is_below_minimum() { - const MINIMUM_DEPOSIT_AMOUNT: Lamport = 2 * DEPOSIT_AMOUNT; - init_state_with_args(InitArgs { - minimum_deposit_amount: MINIMUM_DEPOSIT_AMOUNT, - ..valid_init_args() - }); - init_schnorr_master_key(); - - let get_transaction_response = GetTransactionResult::Consistent(Ok(Some( - legacy_deposit_transaction().try_into().unwrap(), - ))); - let runtime = runtime_with_time_and_cycles().add_stub_response(get_transaction_response); - - let result = process_deposit( - runtime, - DEPOSITOR_ACCOUNT, - legacy_deposit_transaction_signature(), - ) - .await; - - assert_eq!( - result, - Err(ProcessDepositError::ValueTooSmall { - deposit_amount: DEPOSIT_AMOUNT, - minimum_deposit_amount: MINIMUM_DEPOSIT_AMOUNT, - }) - ); - EventsAssert::assert_no_events_recorded(); -} + let result = process_deposit( + runtime, + DEPOSITOR_ACCOUNT, + legacy_deposit_transaction_signature(), + ) + .await; -#[tokio::test] -async fn should_return_processing_if_mint_fails() { - init_state(); - init_schnorr_master_key(); - - let get_transaction_response = GetTransactionResult::Consistent(Ok(Some( - legacy_deposit_transaction().try_into().unwrap(), - ))); - let runtime = runtime_with_time_and_cycles() - .add_stub_response(get_transaction_response) - .add_stub_response(Err::( - TransferError::TemporarilyUnavailable, - )); - - let result = process_deposit( - runtime, - DEPOSITOR_ACCOUNT, - legacy_deposit_transaction_signature(), - ) - .await; - - assert_eq!(result, Ok(deposit_status_processing())); - - EventsAssert::from_recorded() - .expect_event_eq(accepted_deposit_event()) - .assert_no_more_events(); -} + assert_matches!( + result, + Err(ProcessDepositError::TemporarilyUnavailable(e)) => assert!(e.contains("Inter-canister call perform failed")) + ); + EventsAssert::assert_no_events_recorded(); + } -#[tokio::test] -async fn should_successfully_mint_on_second_call() { - init_state(); - init_schnorr_master_key(); - - // First call: makes JSON-RPC call and attempts to mint - let get_transaction_response = GetTransactionResult::Consistent(Ok(Some( - legacy_deposit_transaction().try_into().unwrap(), - ))); - let runtime = runtime_with_time_and_cycles() - .add_stub_response(get_transaction_response) - .add_stub_response(Err::( - TransferError::TemporarilyUnavailable, - )); - let result = process_deposit( - runtime, - DEPOSITOR_ACCOUNT, - legacy_deposit_transaction_signature(), - ) - .await; - assert_eq!(result, Ok(deposit_status_processing())); - - // Second call: fetches status from minter state, and mints successfully without making any - // additional JSON-RPC calls - let runtime = TestCanisterRuntime::new() - .with_increasing_time() - .add_stub_response(Ok::(BLOCK_INDEX.into())); - let result = process_deposit( - runtime, - DEPOSITOR_ACCOUNT, - legacy_deposit_transaction_signature(), - ) - .await; - assert_eq!(result, Ok(deposit_status_minted())); - - EventsAssert::from_recorded() - .expect_event_eq(accepted_deposit_event()) - .expect_event_eq(minted_event(BLOCK_INDEX)) - .assert_no_more_events(); -} + #[tokio::test] + async fn should_return_error_if_transaction_not_found() { + init_state(); + init_schnorr_master_key(); + + let runtime = rejected_runtime().add_get_transaction_not_found_response(); + + let result = process_deposit( + runtime, + DEPOSITOR_ACCOUNT, + legacy_deposit_transaction_signature(), + ) + .await; -#[tokio::test] -async fn should_succeed_with_valid_deposit_transaction() { - init_state(); - init_schnorr_master_key(); + assert_eq!(result, Err(ProcessDepositError::TransactionNotFound)); + EventsAssert::assert_no_events_recorded(); + } + + #[tokio::test] + async fn should_return_error_if_transaction_not_valid_deposit() { + init_state(); + init_schnorr_master_key(); + + let runtime = + rejected_runtime().add_get_transaction_response(deposit_transaction_to_wrong_address()); + + let result = process_deposit( + runtime, + DEPOSITOR_ACCOUNT, + deposit_transaction_to_wrong_address_signature(), + ) + .await; - for (block_index, transaction, signature) in [ - ( - BLOCK_INDEX, - legacy_deposit_transaction(), + assert_matches!( + result, + Err(ProcessDepositError::InvalidDepositTransaction(e)) => assert!(e.contains("Transaction must target deposit address")) + ); + EventsAssert::assert_no_events_recorded(); + } + + #[tokio::test] + async fn should_fail_if_deposit_amount_is_below_minimum() { + const MINIMUM_DEPOSIT_AMOUNT: Lamport = 2 * DEPOSIT_AMOUNT; + init_state_with_args(InitArgs { + minimum_deposit_amount: MINIMUM_DEPOSIT_AMOUNT, + ..valid_init_args() + }); + init_schnorr_master_key(); + + let runtime = rejected_runtime().add_get_transaction_response(legacy_deposit_transaction()); + + let result = process_deposit( + runtime, + DEPOSITOR_ACCOUNT, legacy_deposit_transaction_signature(), - ), - ( - BLOCK_INDEX + 1, - v0_deposit_transaction(), - v0_deposit_transaction_signature(), - ), - ] { - reset_events(); - - let get_transaction_response = - GetTransactionResult::Consistent(Ok(Some(transaction.try_into().unwrap()))); - let runtime = runtime_with_time_and_cycles() - .add_stub_response(get_transaction_response) - .add_stub_response(Ok::(block_index.into())); - - let result = process_deposit(runtime, DEPOSITOR_ACCOUNT, signature).await; + ) + .await; assert_eq!( result, - Ok(DepositStatus::Minted { - block_index, - minted_amount: DEPOSIT_AMOUNT - MANUAL_DEPOSIT_FEE, - deposit_id: cksol_types::DepositId { - signature: signature.into(), - account: DEPOSITOR_ACCOUNT, - }, + Err(ProcessDepositError::ValueTooSmall { + deposit_amount: DEPOSIT_AMOUNT, + minimum_deposit_amount: MINIMUM_DEPOSIT_AMOUNT, }) ); + EventsAssert::assert_no_events_recorded(); + } + + #[tokio::test] + async fn should_return_processing_if_mint_fails() { + init_state(); + init_schnorr_master_key(); + + let runtime = runtime(legacy_deposit_transaction()) + .add_mint_response(Err(TransferError::TemporarilyUnavailable)); + + let result = process_deposit( + runtime, + DEPOSITOR_ACCOUNT, + legacy_deposit_transaction_signature(), + ) + .await; + + assert_eq!(result, Ok(deposit_status_processing())); EventsAssert::from_recorded() - .expect_event_eq(EventType::AcceptedManualDeposit { - deposit_id: DepositId { - signature, - account: DEPOSITOR_ACCOUNT, - }, - deposit_amount: DEPOSIT_AMOUNT, - amount_to_mint: DEPOSIT_AMOUNT - MANUAL_DEPOSIT_FEE, - }) - .expect_event_eq(EventType::Minted { - deposit_id: DepositId { - signature, - account: DEPOSITOR_ACCOUNT, - }, - mint_block_index: block_index.into(), - }) + .expect_event_eq(accepted_deposit_event()) .assert_no_more_events(); } -} -#[tokio::test] -async fn should_not_double_mint() { - init_state(); - init_schnorr_master_key(); - - // Successful mint - let get_transaction_response = GetTransactionResult::Consistent(Ok(Some( - legacy_deposit_transaction().try_into().unwrap(), - ))); - let runtime = runtime_with_time_and_cycles() - .add_stub_response(get_transaction_response) - .add_stub_response(Ok::(BLOCK_INDEX.into())); - let result = process_deposit( - runtime, - DEPOSITOR_ACCOUNT, - legacy_deposit_transaction_signature(), - ) - .await; - assert_eq!(result, Ok(deposit_status_minted())); - - // Second call: returns the same status - let runtime = TestCanisterRuntime::new(); - let result = process_deposit( - runtime, - DEPOSITOR_ACCOUNT, - legacy_deposit_transaction_signature(), - ) - .await; - assert_eq!(result, Ok(deposit_status_minted())); - - // Only one mint event recorded - EventsAssert::from_recorded() - .expect_event_eq(accepted_deposit_event()) - .expect_event_eq(minted_event(BLOCK_INDEX)) - .assert_no_more_events(); -} + #[tokio::test] + async fn should_successfully_mint_on_second_call() { + init_state(); + init_schnorr_master_key(); -#[tokio::test] -async fn should_quarantine_deposit() { - init_state(); - init_schnorr_master_key(); - - // Don't mock the ledger response so the runtime panics when calling it to mint - let get_transaction_response = GetTransactionResult::Consistent(Ok(Some( - legacy_deposit_transaction().try_into().unwrap(), - ))); - let runtime = || runtime_with_time_and_cycles().add_stub_response(get_transaction_response); - let first_result = tokio::spawn(async move { - process_deposit( - runtime(), + // First call: makes JSON-RPC call and attempts to mint + let runtime = runtime(legacy_deposit_transaction()) + .add_mint_response(Err(TransferError::TemporarilyUnavailable)); + let result = process_deposit( + runtime, DEPOSITOR_ACCOUNT, legacy_deposit_transaction_signature(), ) - .await - }) - .await; - assert!(first_result.is_err_and(|e| e.is_panic())); - - // On the second call, the deposit should have been quarantined - let runtime = TestCanisterRuntime::new(); - let second_result = process_deposit( - runtime, - DEPOSITOR_ACCOUNT, - legacy_deposit_transaction_signature(), - ) - .await; - assert_eq!(second_result, Ok(deposit_status_quarantined())); - - // Calling `process_deposit` again for the same deposit should return the same status - let runtime = TestCanisterRuntime::new(); - let third_result = process_deposit( - runtime, - DEPOSITOR_ACCOUNT, - legacy_deposit_transaction_signature(), - ) - .await; - assert_eq!(third_result, second_result); - - // Only one mint event recorded - EventsAssert::from_recorded() - .expect_event_eq(accepted_deposit_event()) - .expect_event_eq(quarantined_deposit_event()) - .assert_no_more_events(); -} + .await; + assert_eq!(result, Ok(deposit_status_processing())); -#[tokio::test] -async fn should_allow_deposits_to_multiple_accounts_with_single_transaction() { - const ACCOUNTS: [Account; 3] = [ - Account { - owner: DEPOSITOR_PRINCIPAL, - subaccount: None, - }, - Account { - owner: DEPOSITOR_PRINCIPAL, - subaccount: Some([1; 32]), - }, - Account { - owner: Principal::from_slice(&[0xa; 29]), - subaccount: Some([2; 32]), - }, - ]; - const DEPOSIT_AMOUNTS: [Lamport; 3] = [ - 100_000_000, // 0.1 SOL - 200_000_000, // 0.2 SOL - 300_000_000, // 0.3 SOL - ]; - const BLOCK_INDEXES: [u64; 3] = [79853, 79854, 79855]; - - init_state(); - init_schnorr_master_key(); - - let get_transaction_response = GetTransactionResult::Consistent(Ok(Some( - deposit_transaction_to_multiple_accounts() - .try_into() - .unwrap(), - ))); - - for i in 0..3 { - let runtime = runtime_with_time_and_cycles() - .add_stub_response(get_transaction_response.clone()) - .add_stub_response(Ok::(BLOCK_INDEXES[i].into())); + // Second call: fetches status from minter state, and mints successfully without making any + // additional JSON-RPC calls + let runtime = TestCanisterRuntime::new() + .with_increasing_time() + .add_mint_response(Ok(BLOCK_INDEX.into())); let result = process_deposit( runtime, - ACCOUNTS[i], - deposit_transaction_to_multiple_accounts_signature(), + DEPOSITOR_ACCOUNT, + legacy_deposit_transaction_signature(), ) .await; - assert_eq!( - result, - Ok(DepositStatus::Minted { - block_index: BLOCK_INDEXES[i], - minted_amount: DEPOSIT_AMOUNTS[i] - MANUAL_DEPOSIT_FEE, - deposit_id: cksol_types::DepositId { - signature: deposit_transaction_to_multiple_accounts_signature().into(), - account: ACCOUNTS[i], - }, - }) - ); + assert_eq!(result, Ok(deposit_status_minted())); + + EventsAssert::from_recorded() + .expect_event_eq(accepted_deposit_event()) + .expect_event_eq(minted_event(BLOCK_INDEX)) + .assert_no_more_events(); } - let mut events_assert = EventsAssert::from_recorded(); - for i in 0..3 { - let deposit_id = DepositId { - signature: deposit_transaction_to_multiple_accounts_signature(), - account: ACCOUNTS[i], - }; - events_assert = events_assert - .expect_event_eq(EventType::AcceptedManualDeposit { - deposit_id, - deposit_amount: DEPOSIT_AMOUNTS[i], - amount_to_mint: DEPOSIT_AMOUNTS[i] - MANUAL_DEPOSIT_FEE, - }) - .expect_event_eq(EventType::Minted { - deposit_id, - mint_block_index: BLOCK_INDEXES[i].into(), - }) + #[tokio::test] + async fn should_succeed_with_valid_deposit_transaction() { + init_state(); + init_schnorr_master_key(); + + for (block_index, transaction, signature) in [ + ( + BLOCK_INDEX, + legacy_deposit_transaction(), + legacy_deposit_transaction_signature(), + ), + ( + BLOCK_INDEX + 1, + v0_deposit_transaction(), + v0_deposit_transaction_signature(), + ), + ] { + reset_events(); + + let runtime = runtime(transaction).add_mint_response(Ok(block_index.into())); + + let result = process_deposit(runtime, DEPOSITOR_ACCOUNT, signature).await; + + assert_eq!( + result, + Ok(DepositStatus::Minted { + block_index, + minted_amount: DEPOSIT_AMOUNT - MANUAL_DEPOSIT_FEE, + deposit_id: cksol_types::DepositId { + signature: signature.into(), + account: DEPOSITOR_ACCOUNT, + }, + }) + ); + + EventsAssert::from_recorded() + .expect_event_eq(EventType::AcceptedManualDeposit { + deposit_id: DepositId { + signature, + account: DEPOSITOR_ACCOUNT, + }, + deposit_amount: DEPOSIT_AMOUNT, + amount_to_mint: DEPOSIT_AMOUNT - MANUAL_DEPOSIT_FEE, + }) + .expect_event_eq(EventType::Minted { + deposit_id: DepositId { + signature, + account: DEPOSITOR_ACCOUNT, + }, + mint_block_index: block_index.into(), + }) + .assert_no_more_events(); + } } - events_assert.assert_no_more_events(); -} -fn runtime_with_time_and_cycles() -> TestCanisterRuntime { - // Cycles forwarded to the RPC call = total - consolidation fee - let cycles_for_rpc = PROCESS_DEPOSIT_REQUIRED_CYCLES - DEPOSIT_CONSOLIDATION_FEE; - // Simulate the RPC canister refunding most of the forwarded cycles - let refunded: u128 = cycles_for_rpc - 100_000_000_000; - let rpc_cost = cycles_for_rpc - refunded; - TestCanisterRuntime::new() - .with_increasing_time() - .add_msg_cycles_available(PROCESS_DEPOSIT_REQUIRED_CYCLES) - .add_msg_cycles_accept(rpc_cost + DEPOSIT_CONSOLIDATION_FEE) - .add_msg_cycles_refunded(refunded) + #[tokio::test] + async fn should_not_double_mint() { + init_state(); + init_schnorr_master_key(); + + // Successful mint + let runtime = + runtime(legacy_deposit_transaction()).add_mint_response(Ok(BLOCK_INDEX.into())); + let result = process_deposit( + runtime, + DEPOSITOR_ACCOUNT, + legacy_deposit_transaction_signature(), + ) + .await; + assert_eq!(result, Ok(deposit_status_minted())); + + // Second call: returns the same status + let runtime = TestCanisterRuntime::new(); + let result = process_deposit( + runtime, + DEPOSITOR_ACCOUNT, + legacy_deposit_transaction_signature(), + ) + .await; + assert_eq!(result, Ok(deposit_status_minted())); + + // Only one mint event recorded + EventsAssert::from_recorded() + .expect_event_eq(accepted_deposit_event()) + .expect_event_eq(minted_event(BLOCK_INDEX)) + .assert_no_more_events(); + } + + #[tokio::test] + async fn should_quarantine_deposit() { + init_state(); + init_schnorr_master_key(); + + // Don't mock the ledger response so the runtime panics when calling it to mint + let runtime = runtime(legacy_deposit_transaction()); + let first_result = tokio::spawn(async move { + process_deposit( + runtime, + DEPOSITOR_ACCOUNT, + legacy_deposit_transaction_signature(), + ) + .await + }) + .await; + assert!(first_result.is_err_and(|e| e.is_panic())); + + // On the second call, the deposit should have been quarantined + let runtime = TestCanisterRuntime::new(); + let second_result = process_deposit( + runtime, + DEPOSITOR_ACCOUNT, + legacy_deposit_transaction_signature(), + ) + .await; + assert_eq!(second_result, Ok(deposit_status_quarantined())); + + // Calling `process_deposit` again for the same deposit should return the same status + let runtime = TestCanisterRuntime::new(); + let third_result = process_deposit( + runtime, + DEPOSITOR_ACCOUNT, + legacy_deposit_transaction_signature(), + ) + .await; + assert_eq!(third_result, second_result); + + // Only one mint event recorded + EventsAssert::from_recorded() + .expect_event_eq(accepted_deposit_event()) + .expect_event_eq(quarantined_deposit_event()) + .assert_no_more_events(); + } + + #[tokio::test] + async fn should_allow_deposits_to_multiple_accounts_with_single_transaction() { + const ACCOUNTS: [Account; 3] = [ + Account { + owner: DEPOSITOR_PRINCIPAL, + subaccount: None, + }, + Account { + owner: DEPOSITOR_PRINCIPAL, + subaccount: Some([1; 32]), + }, + Account { + owner: Principal::from_slice(&[0xa; 29]), + subaccount: Some([2; 32]), + }, + ]; + const DEPOSIT_AMOUNTS: [Lamport; 3] = [ + 100_000_000, // 0.1 SOL + 200_000_000, // 0.2 SOL + 300_000_000, // 0.3 SOL + ]; + const BLOCK_INDEXES: [u64; 3] = [79853, 79854, 79855]; + + init_state(); + init_schnorr_master_key(); + + for i in 0..3 { + let runtime = runtime(deposit_transaction_to_multiple_accounts()) + .add_mint_response(Ok(BLOCK_INDEXES[i].into())); + let result = process_deposit( + runtime, + ACCOUNTS[i], + deposit_transaction_to_multiple_accounts_signature(), + ) + .await; + assert_eq!( + result, + Ok(DepositStatus::Minted { + block_index: BLOCK_INDEXES[i], + minted_amount: DEPOSIT_AMOUNTS[i] - MANUAL_DEPOSIT_FEE, + deposit_id: cksol_types::DepositId { + signature: deposit_transaction_to_multiple_accounts_signature().into(), + account: ACCOUNTS[i], + }, + }) + ); + } + + let mut events_assert = EventsAssert::from_recorded(); + for i in 0..3 { + let deposit_id = DepositId { + signature: deposit_transaction_to_multiple_accounts_signature(), + account: ACCOUNTS[i], + }; + events_assert = events_assert + .expect_event_eq(EventType::AcceptedManualDeposit { + deposit_id, + deposit_amount: DEPOSIT_AMOUNTS[i], + amount_to_mint: DEPOSIT_AMOUNTS[i] - MANUAL_DEPOSIT_FEE, + }) + .expect_event_eq(EventType::Minted { + deposit_id, + mint_block_index: BLOCK_INDEXES[i].into(), + }) + } + events_assert.assert_no_more_events(); + } + + /// Half the `getTransaction` RPC budget; the other half is refunded by the RPC provider. + const RPC_COST: u128 = GET_TRANSACTION_CYCLES / 2; + + /// Runtime for a `process_deposit` call that accepts the given transaction. + /// Charges the RPC cost + consolidation fee and bakes in the transaction as the `getTransaction` stub. + fn runtime( + get_transaction_result: impl TryInto, + ) -> TestCanisterRuntime { + base_runtime(DEPOSIT_CONSOLIDATION_FEE).add_get_transaction_response(get_transaction_result) + } + + /// Runtime for a `process_deposit` call that does not accept a deposit. + /// Charges only the RPC cost; caller chains the stub response or error. + fn rejected_runtime() -> TestCanisterRuntime { + base_runtime(0) + } + + /// Shared cycles setup used by both `runtime` and `rejected_runtime`. + fn base_runtime(consolidation_fee: u128) -> TestCanisterRuntime { + TestCanisterRuntime::new() + .with_increasing_time() + .add_msg_cycles_available(PROCESS_DEPOSIT_REQUIRED_CYCLES) + .add_msg_cycles_refunded(GET_TRANSACTION_CYCLES - RPC_COST) + .add_msg_cycles_accept(RPC_COST + consolidation_fee) + } + + trait DepositRuntimeExt: Sized { + fn add_get_transaction_response( + self, + tx: impl TryInto, + ) -> Self; + fn add_get_transaction_not_found_response(self) -> Self; + fn add_mint_response(self, result: MintResult) -> Self; + } + + impl DepositRuntimeExt for TestCanisterRuntime { + fn add_get_transaction_response( + self, + response: impl TryInto, + ) -> Self { + self.add_stub_response(GetTransactionResult::Consistent(Ok(Some( + response + .try_into() + .ok() + .expect("failed to convert transaction"), + )))) + } + + fn add_get_transaction_not_found_response(self) -> Self { + self.add_stub_response(GetTransactionResult::Consistent(Ok(None))) + } + + fn add_mint_response(self, result: MintResult) -> Self { + self.add_stub_response(result) + } + } } diff --git a/minter/src/deposit/mod.rs b/minter/src/deposit/mod.rs index c4b7f5c3..2561f9cb 100644 --- a/minter/src/deposit/mod.rs +++ b/minter/src/deposit/mod.rs @@ -1,5 +1,16 @@ +use crate::{ + address::{account_address, lazy_get_schnorr_master_key}, + rpc::get_transaction, + runtime::CanisterRuntime, + state::{event::DepositId, read_state}, +}; +use canlog::log; +use cksol_types::ProcessDepositError; +use cksol_types_internal::log::Priority; +use icrc_ledger_types::icrc1::account::Account; use sol_rpc_types::Lamport; use solana_address::Address; +use solana_signature::Signature; use solana_transaction_status_client_types::{ EncodedConfirmedTransactionWithStatusMeta, UiTransactionError, }; @@ -11,6 +22,53 @@ mod tests; pub mod automatic; pub mod manual; +pub async fn fetch_and_validate_deposit( + runtime: &R, + account: Account, + signature: Signature, + fee: Lamport, +) -> Result<(DepositId, Lamport, Lamport), ProcessDepositError> { + let deposit_id = DepositId { account, signature }; + let master_key = lazy_get_schnorr_master_key(runtime).await; + let deposit_address = account_address(&master_key, &account); + + let maybe_transaction = get_transaction(runtime, signature).await.map_err(|e| { + log!( + Priority::Info, + "Error fetching transaction for deposit {deposit_id:?}: {e}" + ); + ProcessDepositError::from(e) + })?; + + let transaction = match maybe_transaction { + Some(t) => t, + None => return Err(ProcessDepositError::TransactionNotFound), + }; + + let deposit_amount = + get_deposit_amount_to_address(transaction, deposit_address).map_err(|e| { + log!( + Priority::Info, + "Error parsing deposit transaction with signature {signature}: {e}" + ); + ProcessDepositError::InvalidDepositTransaction(e.to_string()) + })?; + + let minimum_deposit_amount = read_state(|s| s.minimum_deposit_amount()); + if deposit_amount < minimum_deposit_amount { + return Err(ProcessDepositError::ValueTooSmall { + minimum_deposit_amount, + deposit_amount, + }); + } + + let amount_to_mint = deposit_amount + .checked_sub(fee) + .expect("BUG: deposit amount is less than fee"); + + Ok((deposit_id, deposit_amount, amount_to_mint)) +} + pub fn get_deposit_amount_to_address( transaction: EncodedConfirmedTransactionWithStatusMeta, deposit_address: Address, diff --git a/minter/src/rpc/mod.rs b/minter/src/rpc/mod.rs index cbbfc860..37236c80 100644 --- a/minter/src/rpc/mod.rs +++ b/minter/src/rpc/mod.rs @@ -1,5 +1,7 @@ use crate::{ - constants::{GET_SIGNATURE_STATUSES_CYCLES, MAX_HTTP_OUTCALL_RESPONSE_BYTES}, + constants::{ + GET_SIGNATURE_STATUSES_CYCLES, GET_TRANSACTION_CYCLES, MAX_HTTP_OUTCALL_RESPONSE_BYTES, + }, runtime::CanisterRuntime, state::read_state, }; @@ -22,7 +24,6 @@ mod tests; pub async fn get_transaction( runtime: &R, signature: Signature, - cycles_to_attach: u128, ) -> Result, GetTransactionError> { let result = read_state(|state| state.sol_rpc_client(runtime.inter_canister_call_runtime())) .get_transaction(signature) @@ -30,7 +31,7 @@ pub async fn get_transaction( .with_commitment(CommitmentLevel::Finalized) .with_max_supported_transaction_version(0) .with_response_size_estimate(MAX_HTTP_OUTCALL_RESPONSE_BYTES) - .with_cycles(cycles_to_attach) + .with_cycles(GET_TRANSACTION_CYCLES) .try_send() .await; match result? { diff --git a/minter/src/rpc/tests.rs b/minter/src/rpc/tests.rs index e909016b..b0252e18 100644 --- a/minter/src/rpc/tests.rs +++ b/minter/src/rpc/tests.rs @@ -5,7 +5,7 @@ use crate::{ get_transaction, submit_transaction, }, test_fixtures::{ - PROCESS_DEPOSIT_REQUIRED_CYCLES, confirmed_block, + confirmed_block, deposit::{legacy_deposit_transaction, legacy_deposit_transaction_signature}, init_state, runtime::TestCanisterRuntime, @@ -28,16 +28,9 @@ mod get_transaction_tests { async fn should_fail_if_get_transaction_fails() { init_state(); - let runtime = TestCanisterRuntime::new() - .add_msg_cycles_available(PROCESS_DEPOSIT_REQUIRED_CYCLES) - .add_stub_error(IcError::CallPerformFailed); + let runtime = TestCanisterRuntime::new().add_stub_error(IcError::CallPerformFailed); - let result = get_transaction( - &runtime, - legacy_deposit_transaction_signature(), - PROCESS_DEPOSIT_REQUIRED_CYCLES, - ) - .await; + let result = get_transaction(&runtime, legacy_deposit_transaction_signature()).await; assert_eq!( result, @@ -56,15 +49,9 @@ mod get_transaction_tests { }); let runtime = TestCanisterRuntime::new() - .add_msg_cycles_available(PROCESS_DEPOSIT_REQUIRED_CYCLES) .add_stub_response(MultiRpcResult::Consistent(Err(rpc_error.clone()))); - let result = get_transaction( - &runtime, - legacy_deposit_transaction_signature(), - PROCESS_DEPOSIT_REQUIRED_CYCLES, - ) - .await; + let result = get_transaction(&runtime, legacy_deposit_transaction_signature()).await; assert_eq!(result, Err(GetTransactionError::RpcError(rpc_error))); } @@ -84,16 +71,10 @@ mod get_transaction_tests { ), ]; - let runtime = TestCanisterRuntime::new() - .add_msg_cycles_available(PROCESS_DEPOSIT_REQUIRED_CYCLES) - .add_stub_response(MultiRpcResult::Inconsistent(results)); + let runtime = + TestCanisterRuntime::new().add_stub_response(MultiRpcResult::Inconsistent(results)); - let result = get_transaction( - &runtime, - legacy_deposit_transaction_signature(), - PROCESS_DEPOSIT_REQUIRED_CYCLES, - ) - .await; + let result = get_transaction(&runtime, legacy_deposit_transaction_signature()).await; assert_eq!(result, Err(GetTransactionError::InconsistentRpcResults)); } @@ -102,16 +83,10 @@ mod get_transaction_tests { async fn should_return_empty_if_transaction_not_found() { init_state(); - let runtime = TestCanisterRuntime::new() - .add_msg_cycles_available(PROCESS_DEPOSIT_REQUIRED_CYCLES) - .add_stub_response(MultiRpcResult::Consistent(Ok(None))); + let runtime = + TestCanisterRuntime::new().add_stub_response(MultiRpcResult::Consistent(Ok(None))); - let result = get_transaction( - &runtime, - legacy_deposit_transaction_signature(), - PROCESS_DEPOSIT_REQUIRED_CYCLES, - ) - .await; + let result = get_transaction(&runtime, legacy_deposit_transaction_signature()).await; assert_eq!(result, Ok(None)) } @@ -120,18 +95,11 @@ mod get_transaction_tests { async fn should_return_transaction() { init_state(); - let runtime = TestCanisterRuntime::new() - .add_msg_cycles_available(PROCESS_DEPOSIT_REQUIRED_CYCLES) - .add_stub_response(MultiRpcResult::Consistent(Ok(Some( - legacy_deposit_transaction().try_into().unwrap(), - )))); + let runtime = TestCanisterRuntime::new().add_stub_response(MultiRpcResult::Consistent(Ok( + Some(legacy_deposit_transaction().try_into().unwrap()), + ))); - let result = get_transaction( - &runtime, - legacy_deposit_transaction_signature(), - PROCESS_DEPOSIT_REQUIRED_CYCLES, - ) - .await; + let result = get_transaction(&runtime, legacy_deposit_transaction_signature()).await; assert_eq!(result, Ok(Some(legacy_deposit_transaction()))) }