diff --git a/cycles-ledger/cycles-ledger.did b/cycles-ledger/cycles-ledger.did index 956efdf..ac59592 100644 --- a/cycles-ledger/cycles-ledger.did +++ b/cycles-ledger/cycles-ledger.did @@ -133,22 +133,22 @@ type Value = variant { }; type GetArchivesArgs = record { - // The last archive seen by the client. - // The Ledger will return archives coming - // after this one if set, otherwise it - // will return the first archives. - from : opt principal; + // The last archive seen by the client. + // The ledger will return archives coming + // after this one if set, otherwise it + // will return the first archives. + from : opt principal; }; type GetArchivesResult = vec record { - // The id of the archive - canister_id : principal; + // The id of the archive + canister_id : principal; - // The first block in the archive - start : nat; + // The first block in the archive + start : nat; - // The last block in the archive - end : nat; + // The last block in the archive + end : nat; }; type GetBlocksArgs = vec record { start : nat; length : nat }; @@ -203,6 +203,14 @@ type CreateCanisterArgs = record { creation_args : opt CmcCreateCanisterArgs; }; +type CreateCanisterFromArgs = record { + from : Account; + spender_subaccount : opt vec nat8; + created_at_time : opt nat64; + amount : nat; + creation_args : opt CmcCreateCanisterArgs; +}; + type CmcCreateCanisterArgs = record { // Optional canister settings that, if set, are applied to the newly created canister. // If not specified, the caller is the controller of the canister and the other settings are set to default values. @@ -256,6 +264,27 @@ type CreateCanisterError = variant { GenericError : record { message : text; error_code : nat }; }; +type CreateCanisterFromError = variant { + InsufficientFunds : record { balance : nat }; + InsufficientAllowance : record { allowance : nat }; + TooOld; + CreatedInFuture : record { ledger_time : nat64 }; + TemporarilyUnavailable; + Duplicate : record { + duplicate_of : nat; + // If the original transaction created a canister then this field will contain the canister id. + canister_id : opt principal; + }; + FailedToCreateFrom : record { + create_from_block : opt BlockIndex; + refund_block : opt BlockIndex; + approval_refund_block : opt BlockIndex; + rejection_code : RejectionCode; + rejection_reason : text; + }; + GenericError : record { message : text; error_code : nat }; +}; + type MetadataValue = variant { Nat : nat; Int : int; @@ -298,4 +327,5 @@ service : (ledger_args : LedgerArgs) -> { withdraw : (WithdrawArgs) -> (variant { Ok : BlockIndex; Err : WithdrawError }); withdraw_from : (WithdrawFromArgs) -> (variant { Ok : BlockIndex; Err : WithdrawFromError }); create_canister : (CreateCanisterArgs) -> (variant { Ok : CreateCanisterSuccess; Err : CreateCanisterError }); + create_canister_from : (CreateCanisterFromArgs) -> (variant { Ok : CreateCanisterSuccess; Err : CreateCanisterFromError }); }; diff --git a/cycles-ledger/src/endpoints.rs b/cycles-ledger/src/endpoints.rs index fa0dfd2..66f374f 100644 --- a/cycles-ledger/src/endpoints.rs +++ b/cycles-ledger/src/endpoints.rs @@ -279,6 +279,20 @@ pub struct CreateCanisterArgs { pub creation_args: Option, } +#[derive(Debug, Clone, CandidType, Deserialize, PartialEq, Eq)] +pub struct CreateCanisterFromArgs { + pub from: Account, + #[serde(default)] + pub spender_subaccount: Option, + #[serde(default)] + pub created_at_time: Option, + /// Amount of cycles used to create the canister. + /// The new canister will have `amount - canister creation fee` cycles when created. + pub amount: NumCycles, + #[serde(default)] + pub creation_args: Option, +} + /// Error for create_canister endpoint #[derive(Serialize, Deserialize, CandidType, Clone, Debug, PartialEq, Eq)] pub enum CmcCreateCanisterError { @@ -318,6 +332,37 @@ pub enum CreateCanisterError { }, } +/// Error for create_canister endpoint +#[derive(Deserialize, CandidType, Clone, Debug, PartialEq, Eq)] +pub enum CreateCanisterFromError { + InsufficientFunds { + balance: NumCycles, + }, + InsufficientAllowance { + allowance: NumCycles, + }, + TooOld, + CreatedInFuture { + ledger_time: u64, + }, + TemporarilyUnavailable, + Duplicate { + duplicate_of: BlockIndex, + canister_id: Option, + }, + FailedToCreateFrom { + create_from_block: Option, + refund_block: Option, + approval_refund_block: Option, + rejection_code: RejectionCode, + rejection_reason: String, + }, + GenericError { + error_code: Nat, + message: String, + }, +} + impl CreateCanisterError { pub const BAD_FEE_ERROR: u64 = 100_001; } diff --git a/cycles-ledger/src/lib.rs b/cycles-ledger/src/lib.rs index 9f2c4c6..c71bf39 100644 --- a/cycles-ledger/src/lib.rs +++ b/cycles-ledger/src/lib.rs @@ -2,7 +2,7 @@ use std::collections::BTreeMap; use anyhow::{bail, Context}; use ciborium::Value as CiboriumValue; -use endpoints::{WithdrawError, WithdrawFromError}; +use endpoints::{CreateCanisterError, CreateCanisterFromError, WithdrawError, WithdrawFromError}; use icrc_ledger_types::{ icrc::generic_value::Value, icrc1::transfer::TransferError, icrc2::transfer_from::TransferFromError, @@ -216,6 +216,51 @@ pub fn withdraw_from_error_to_withdraw_error(e: WithdrawFromError) -> WithdrawEr } } +// Traps if the error is InsufficientAllowance +pub fn create_canister_from_error_to_create_canister_error( + e: CreateCanisterFromError, +) -> CreateCanisterError { + match e { + CreateCanisterFromError::InsufficientFunds { balance } => { + CreateCanisterError::InsufficientFunds { balance } + } + CreateCanisterFromError::InsufficientAllowance { .. } => { + ic_cdk::trap("InsufficientAllowance error should not happen for create_canister") + } + CreateCanisterFromError::TooOld => CreateCanisterError::TooOld, + CreateCanisterFromError::CreatedInFuture { ledger_time } => { + CreateCanisterError::CreatedInFuture { ledger_time } + } + CreateCanisterFromError::TemporarilyUnavailable => { + CreateCanisterError::TemporarilyUnavailable + } + CreateCanisterFromError::Duplicate { + duplicate_of, + canister_id, + } => CreateCanisterError::Duplicate { + duplicate_of, + canister_id, + }, + CreateCanisterFromError::FailedToCreateFrom { + create_from_block, + refund_block, + rejection_reason, + .. + } => CreateCanisterError::FailedToCreate { + fee_block: create_from_block, + refund_block, + error: rejection_reason, + }, + CreateCanisterFromError::GenericError { + error_code, + message, + } => CreateCanisterError::GenericError { + error_code, + message, + }, + } +} + #[cfg(test)] mod tests { use ciborium::{value::Integer, Value}; diff --git a/cycles-ledger/src/main.rs b/cycles-ledger/src/main.rs index f529b8e..c60652b 100644 --- a/cycles-ledger/src/main.rs +++ b/cycles-ledger/src/main.rs @@ -1,7 +1,7 @@ use candid::{candid_method, Nat, Principal}; use cycles_ledger::endpoints::{ - DataCertificate, GetArchivesArgs, GetArchivesResult, GetBlocksArgs, GetBlocksResult, - LedgerArgs, SupportedBlockType, WithdrawError, WithdrawFromError, + CmcCreateCanisterArgs, DataCertificate, GetArchivesArgs, GetArchivesResult, GetBlocksArgs, + GetBlocksResult, LedgerArgs, SupportedBlockType, WithdrawError, WithdrawFromError, }; use cycles_ledger::logs::{Log, LogEntry, Priority}; use cycles_ledger::logs::{P0, P1}; @@ -9,8 +9,8 @@ use cycles_ledger::storage::{ balance_of, mutate_config, mutate_state, prune, read_config, read_state, }; use cycles_ledger::{ - config, endpoints, storage, transfer_from_error_to_transfer_error, - withdraw_from_error_to_withdraw_error, + config, create_canister_from_error_to_create_canister_error, endpoints, storage, + transfer_from_error_to_transfer_error, withdraw_from_error_to_withdraw_error, }; use ic_canister_log::export as export_logs; use ic_canisters_http_types::{HttpRequest, HttpResponse, HttpResponseBuilder}; @@ -315,6 +315,22 @@ async fn withdraw_from(args: endpoints::WithdrawFromArgs) -> Result, + amount: Nat, + created_at_time: Option, + creation_args: Option, +) -> Result { + let Some(amount) = amount.0.to_u128() else { + return Err(endpoints::CreateCanisterFromError::InsufficientFunds { + balance: Nat::from(balance_of(&from)), + }); + }; + let now = ic_cdk::api::time(); + storage::create_canister(from, spender, amount, now, created_at_time, creation_args).await +} + #[update] #[candid_method] async fn create_canister( @@ -325,16 +341,30 @@ async fn create_canister( subaccount: args.from_subaccount, }; - let Some(amount) = args.amount.0.to_u128() else { - return Err(endpoints::CreateCanisterError::InsufficientFunds { - balance: Nat::from(balance_of(&from)), - }); - }; - - storage::create_canister( + execute_create_canister( from, - amount, - ic_cdk::api::time(), + None, + args.amount, + args.created_at_time, + args.creation_args, + ) + .await + .map_err(create_canister_from_error_to_create_canister_error) +} + +#[update] +#[candid_method] +async fn create_canister_from( + args: endpoints::CreateCanisterFromArgs, +) -> Result { + let spender = Account { + owner: ic_cdk::caller(), + subaccount: args.spender_subaccount, + }; + execute_create_canister( + args.from, + Some(spender), + args.amount, args.created_at_time, args.creation_args, ) diff --git a/cycles-ledger/src/storage.rs b/cycles-ledger/src/storage.rs index ed84677..6e60778 100644 --- a/cycles-ledger/src/storage.rs +++ b/cycles-ledger/src/storage.rs @@ -1,7 +1,7 @@ use crate::config::{Config, REMOTE_FUTURE}; use crate::endpoints::{ - CmcCreateCanisterArgs, CmcCreateCanisterError, CreateCanisterError, CreateCanisterSuccess, - DataCertificate, DepositResult, WithdrawError, WithdrawFromError, + CmcCreateCanisterArgs, CmcCreateCanisterError, CreateCanisterError, CreateCanisterFromError, + CreateCanisterSuccess, DataCertificate, DepositResult, WithdrawError, WithdrawFromError, }; use crate::logs::{P0, P1}; use crate::memo::{encode_withdraw_memo, validate_memo}; @@ -1184,12 +1184,25 @@ mod create_canister { use super::transfer_from::UNKNOWN_GENERIC_ERROR; - pub fn anyhow_error(error: anyhow::Error) -> CreateCanisterError { + pub fn unknown_generic_error(message: String) -> CreateCanisterError { + CreateCanisterError::GenericError { + error_code: Nat::from(UNKNOWN_GENERIC_ERROR), + message, + } + } +} + +mod create_canister_from { + use super::transfer_from::UNKNOWN_GENERIC_ERROR; + use crate::endpoints::CreateCanisterFromError; + use candid::Nat; + + pub fn anyhow_error(error: anyhow::Error) -> CreateCanisterFromError { unknown_generic_error(format!("{:#}", error)) } - pub fn unknown_generic_error(message: String) -> CreateCanisterError { - CreateCanisterError::GenericError { + pub fn unknown_generic_error(message: String) -> CreateCanisterFromError { + CreateCanisterFromError::GenericError { error_code: Nat::from(UNKNOWN_GENERIC_ERROR), message, } @@ -1296,6 +1309,31 @@ impl From for CreateCanisterError { } } +impl From for CreateCanisterFromError { + fn from(error: ProcessTransactionError) -> Self { + use ProcessTransactionError::*; + + match error { + BadFee { expected_fee } => Self::GenericError { + error_code: CreateCanisterError::BAD_FEE_ERROR.into(), + message: format!( + "BadFee. Expected fee: {}. Should never happen.", + expected_fee + ), + }, + Duplicate { + duplicate_of, + canister_id, + } => Self::Duplicate { + duplicate_of: Nat::from(duplicate_of), + canister_id, + }, + InvalidCreatedAtTime(err) => err.into(), + GenericError(err) => create_canister_from::unknown_generic_error(format!("{:#}", err)), + } + } +} + #[derive(Debug)] enum UseAllowanceError { CannotDeduceZero, @@ -1347,6 +1385,24 @@ impl From for WithdrawFromError { } } +impl From for CreateCanisterFromError { + fn from(value: UseAllowanceError) -> Self { + use UseAllowanceError::*; + + match value { + CannotDeduceZero => { + ic_cdk::trap("CannotDeduceZero should not happen for create_canister") + } + ExpiredApproval { .. } => Self::InsufficientAllowance { + allowance: Nat::from(0_u8), + }, + InsufficientAllowance { allowance } => Self::InsufficientAllowance { + allowance: allowance.into(), + }, + } + } +} + // Validates the suggested fee and returns the effective fee. // If the validation fails then return Err with the expected fee. fn validate_suggested_fee(op: &Operation) -> Result, u128> { @@ -1470,6 +1526,17 @@ impl From for CreateCanisterError { } } +impl From for CreateCanisterFromError { + fn from(value: CreatedAtTimeValidationError) -> Self { + match value { + CreatedAtTimeValidationError::TooOld => Self::TooOld, + CreatedAtTimeValidationError::InTheFuture { ledger_time } => { + Self::CreatedInFuture { ledger_time } + } + } + } +} + impl From for ApproveError { fn from(value: CreatedAtTimeValidationError) -> Self { match value { @@ -1674,7 +1741,7 @@ pub async fn withdraw( rejection_reason, }); } - match reimburse(from, amount_to_reimburse, now) { + match reimburse(from, amount_to_reimburse, now, PENALIZE_MEMO) { Ok(fee_block) => { prune(now); if let Some(spender) = spender { @@ -1725,17 +1792,18 @@ pub async fn withdraw( pub async fn create_canister( from: Account, + spender: Option, amount: u128, now: u64, created_at_time: Option, argument: Option, -) -> Result { - use CreateCanisterError::*; +) -> Result { + use CreateCanisterFromError::*; let transaction = Transaction { operation: Operation::Burn { from, - spender: None, + spender, amount, }, created_at_time, @@ -1750,6 +1818,18 @@ pub async fn create_canister( }); }; + // check allowance + let mut old_expires_at = None; + if let Some(spender) = spender { + if spender != from { + let (_, expiry) = + read_state(|state| check_allowance(state, &from, &spender, amount_with_fee, now))?; + if expiry > 0 { + old_expires_at = Some(expiry); + } + } + } + // check that the `from` account has enough funds read_state(|state| state.check_debit_from_account(&from, amount_with_fee)).map_err( |balance: u128| InsufficientFunds { @@ -1760,7 +1840,7 @@ pub async fn create_canister( // sanity check that the total_supply won't underflow read_state(|state| state.check_total_supply_decrease(amount_with_fee)) .with_context(|| format!("Unable to deduct {} cycles from {}", amount, from)) - .map_err(create_canister::anyhow_error)?; + .map_err(create_canister_from::anyhow_error)?; // The canister creation process involves 3 steps: // 1. burn cycles + fee @@ -1771,6 +1851,20 @@ pub async fn create_canister( let block_index = process_block(transaction.clone(), now, Some(config::FEE))?; + if let Some(spender) = spender { + if spender != from { + if let Err(err) = + mutate_state(|state| use_allowance(state, &from, &spender, amount_with_fee, now)) + { + let err = anyhow!(err).context(format!( + "unable to perform create_canister: {:?}", + transaction + )); + ic_cdk::trap(&format!("{err:#}")); + } + } + } + if let Err(err) = mutate_state(|state| state.debit(&from, amount_with_fee)) { let err = err.context(format!("Unable to perform create_canister {transaction}")); ic_cdk::trap(&format!("{err:#}")); @@ -1806,30 +1900,78 @@ pub async fn create_canister( // 3. if 2. fails then mint cycles - match create_canister_result { - Err((rejection_code, rejection_reason)) => { + let flat_create_canister_result = match create_canister_result { + Ok(not_rejected) => match not_rejected { + (Ok(success),) => Ok(success), + (Err(err),) => match err { + CmcCreateCanisterError::Refunded { + refund_amount, + create_error, + } => Err((RejectionCode::CanisterError, refund_amount, create_error)), + CmcCreateCanisterError::RefundFailed { + create_error, + refund_error, + } => Err(( + RejectionCode::CanisterError, + 0, + format!("create_error: {create_error}, refund error: {refund_error}"), + )), + }, + }, + Err((rejection_code, rejection_reason)) => Err((rejection_code, amount, rejection_reason)), + }; + + match flat_create_canister_result { + Err((rejection_code, returned_amount, rejection_reason)) => { // subtract the fee to pay for the reimburse block - let amount_to_reimburse = amount.saturating_sub(config::FEE); + let amount_to_reimburse = returned_amount.saturating_sub(config::FEE); if amount_to_reimburse.is_zero() { - return Err(FailedToCreate { - fee_block: Some(Nat::from(block_index)), + return Err(FailedToCreateFrom { + create_from_block: Some(Nat::from(block_index)), refund_block: None, - error: format!( - "CMC rejected canister creation with code {:?} and reason {}", - rejection_code, rejection_reason - ), + approval_refund_block: None, + rejection_code, + rejection_reason, }); } - match reimburse(from, amount_to_reimburse, now) { - Ok(fee_block) => { + match reimburse(from, amount_to_reimburse, now, REFUND_MEMO) { + Ok(refund_block) => { prune(now); - Err(FailedToCreate { - fee_block: Some(Nat::from(block_index)), - refund_block: Some(Nat::from(fee_block)), - error: format!( - "CMC rejected canister creation with code {:?} and reason {}", - rejection_code, rejection_reason - ), + if let Some(spender) = spender { + // charge FEE for every block: withdraw attempt, refund, refund approval + if spender != from && amount_to_reimburse > config::FEE { + match reimburse_approval( + from, + spender, + amount_to_reimburse.saturating_sub(config::FEE), + old_expires_at, + now, + ) { + Ok(approval_refund_block) => { + return Err(FailedToCreateFrom { + create_from_block: Some(block_index.into()), + refund_block: Some(refund_block.into()), + approval_refund_block: Some(approval_refund_block), + rejection_code, + rejection_reason, + }); + } + Err(err) => { + // this is a critical error that should not happen because approving should never fail. + ic_cdk::trap(&format!( + "Unable to reimburse approval: {:#?}", + err + )); + } + } + } + } + Err(FailedToCreateFrom { + create_from_block: Some(block_index.into()), + refund_block: Some(refund_block.into()), + approval_refund_block: None, + rejection_code, + rejection_reason, }) } Err(err) => { @@ -1839,91 +1981,40 @@ pub async fn create_canister( } } } - Ok((cmc_result,)) => match cmc_result { - Ok(canister_id) => { - if let Ok(tx_hash) = transaction.hash() { - mutate_state(|state| { - if state.transaction_hashes.contains_key(&tx_hash) { - state - .transaction_hashes - .insert(tx_hash, (block_index, Some(canister_id))); - } - }); - } else { - // this should not happen because processing the transaction already checks if it can be hashed - ic_cdk::trap(&format!("Bug: Transaction in block {block_index} was processed correctly but suddenly cannot be hashed anymore.")); - } - Ok(CreateCanisterSuccess { - block_id: Nat::from(block_index), - canister_id, - }) - } - Err(err) => match err { - CmcCreateCanisterError::Refunded { - refund_amount, - create_error, - } => { - let refund_amount_to_reimburse = refund_amount.saturating_sub(config::FEE); - if refund_amount_to_reimburse.is_zero() { - return Err(CreateCanisterError::FailedToCreate { - fee_block: Some(Nat::from(block_index)), - refund_block: None, - error: create_error, - }); + Ok(canister_id) => { + if let Ok(tx_hash) = transaction.hash() { + mutate_state(|state| { + if state.transaction_hashes.contains_key(&tx_hash) { + state + .transaction_hashes + .insert(tx_hash, (block_index, Some(canister_id))); } - let transaction = Transaction { - operation: Operation::Mint { - to: from, - amount: refund_amount_to_reimburse, - }, - created_at_time: None, - memo: Some(Memo::from(ByteBuf::from(REFUND_MEMO))), - }; - - let refund_index = process_transaction(transaction.clone(), now)?; - - if let Err(err) = - mutate_state(|state| state.credit(&from, refund_amount_to_reimburse)) - { - let err = err.context(format!( - "Unable to refund create_canister: \ - {transaction:?}" - )); - ic_cdk::trap(&format!("{err:#}")) - }; - - prune(now); - - Err(CreateCanisterError::FailedToCreate { - fee_block: Some(Nat::from(block_index)), - refund_block: Some(Nat::from(refund_index)), - error: create_error, - }) - } - CmcCreateCanisterError::RefundFailed { - create_error, - refund_error, - } => Err(FailedToCreate { - fee_block: Some(Nat::from(block_index)), - refund_block: None, - error: format!( - "create_canister error: {}, refund error: {}", - create_error, refund_error - ), - }), - }, - }, + }); + } else { + // this should not happen because processing the transaction already checks if it can be hashed + ic_cdk::trap(&format!("Bug: Transaction in block {block_index} was processed correctly but suddenly cannot be hashed anymore.")); + } + Ok(CreateCanisterSuccess { + block_id: Nat::from(block_index), + canister_id, + }) + } } } // Reimburse an account with a given amount // This panics if a mint block has been recorded but the credit // function didn't go through. -fn reimburse(acc: Account, amount: u128, now: u64) -> Result { +fn reimburse( + acc: Account, + amount: u128, + now: u64, + memo: [u8; MAX_MEMO_LENGTH as usize], +) -> Result { let transaction = Transaction { operation: Operation::Mint { to: acc, amount }, created_at_time: None, - memo: Some(Memo::from(ByteBuf::from(PENALIZE_MEMO))), + memo: Some(Memo::from(ByteBuf::from(memo))), }; let block_index = process_transaction(transaction.clone(), now)?; diff --git a/cycles-ledger/tests/client.rs b/cycles-ledger/tests/client.rs index f424f41..b4a0df3 100644 --- a/cycles-ledger/tests/client.rs +++ b/cycles-ledger/tests/client.rs @@ -4,9 +4,9 @@ use std::collections::BTreeMap; use candid::{CandidType, Decode, Encode, Nat, Principal}; use cycles_ledger::{ endpoints::{ - self, CmcCreateCanisterError, CreateCanisterArgs, CreateCanisterSuccess, DataCertificate, - DepositResult, GetBlocksArg, GetBlocksArgs, GetBlocksResult, WithdrawArgs, - WithdrawFromArgs, + self, CmcCreateCanisterError, CreateCanisterArgs, CreateCanisterFromArgs, + CreateCanisterSuccess, DataCertificate, DepositResult, GetBlocksArg, GetBlocksArgs, + GetBlocksResult, WithdrawArgs, WithdrawFromArgs, }, storage::{Block, CMC_PRINCIPAL}, }; @@ -156,6 +156,15 @@ pub fn create_canister( update_or_panic(env, ledger_id, caller, "create_canister", args) } +pub fn create_canister_from( + env: &StateMachine, + ledger_id: Principal, + caller: Principal, + args: CreateCanisterFromArgs, +) -> Result { + update_or_panic(env, ledger_id, caller, "create_canister_from", args) +} + pub fn canister_status( env: &StateMachine, canister_id: Principal, diff --git a/cycles-ledger/tests/tests.rs b/cycles-ledger/tests/tests.rs index 359a30f..998dce7 100644 --- a/cycles-ledger/tests/tests.rs +++ b/cycles-ledger/tests/tests.rs @@ -12,7 +12,8 @@ use client::deposit; use cycles_ledger::{ config::{self, Config as LedgerConfig, FEE, MAX_MEMO_LENGTH}, endpoints::{ - BlockWithId, ChangeIndexId, DataCertificate, DepositResult, GetBlocksResult, LedgerArgs, + BlockWithId, ChangeIndexId, CmcCreateCanisterError, CreateCanisterFromArgs, + CreateCanisterFromError, DataCertificate, DepositResult, GetBlocksResult, LedgerArgs, UpgradeArgs, WithdrawArgs, WithdrawError, WithdrawFromArgs, WithdrawFromError, }, memo::encode_withdraw_memo, @@ -33,7 +34,10 @@ use depositor::endpoints::InitArg as DepositorInitArg; use escargot::CargoBuild; use gen::{CyclesLedgerCall, CyclesLedgerInMemory}; use ic_cbor::CertificateToCbor; -use ic_cdk::api::{call::RejectionCode, management_canister::provisional::CanisterSettings}; +use ic_cdk::api::{ + call::RejectionCode, + management_canister::{main::CanisterStatusResponse, provisional::CanisterSettings}, +}; use ic_certificate_verification::VerifyCertificate; use ic_certification::{ hash_tree::{HashTreeNode, SubtreeLookupResult}, @@ -194,7 +198,7 @@ fn install_depositor(env: &StateMachine, ledger_id: Principal) -> Principal { canister } -fn install_fake_cmc(env: &StateMachine) { +fn install_fake_cmc(env: &StateMachine) -> Principal { #[derive(CandidType, Default)] struct ProvisionalCreateArg { specified_id: Option, @@ -226,6 +230,7 @@ fn install_fake_cmc(env: &StateMachine) { Encode!(&Vec::::new()).unwrap(), None, ); + CMC_PRINCIPAL } /** Create an ICRC-1 Account from two numbers by using their big-endian representation */ @@ -244,30 +249,39 @@ struct TestEnv { pub state_machine: StateMachine, pub ledger_id: Principal, pub depositor_id: Principal, + #[allow(dead_code)] + pub cmc_id: Principal, } impl TestEnv { fn setup() -> Self { let state_machine = new_state_machine(); + let cmc_id = install_fake_cmc(&state_machine); let ledger_id = install_ledger(&state_machine); let depositor_id = install_depositor(&state_machine, ledger_id); Self { state_machine, ledger_id, depositor_id, + cmc_id, } } fn setup_with_ledger_conf(conf: LedgerConfig) -> Self { let state_machine = new_state_machine(); + let cmc_id = install_fake_cmc(&state_machine); let ledger_id = install_ledger_with_conf(&state_machine, conf); let depositor_id = install_depositor(&state_machine, ledger_id); Self { state_machine, ledger_id, depositor_id, + cmc_id, } } + fn fail_next_create_canister_with(&self, error: CmcCreateCanisterError) { + client::fail_next_create_canister_with(&self.state_machine, error) + } fn upgrade_ledger(&self, args: Option) -> Result<(), CallError> { let arg = Encode!(&Some(LedgerArgs::Upgrade(args))).unwrap(); @@ -283,6 +297,31 @@ impl TestEnv { client::create_canister(&self.state_machine, self.ledger_id, caller, args) } + fn create_canister_from( + &self, + caller: Principal, + args: CreateCanisterFromArgs, + ) -> Result { + client::create_canister_from(&self.state_machine, self.ledger_id, caller, args) + } + + fn create_canister_from_or_trap( + &self, + caller: Principal, + args: CreateCanisterFromArgs, + ) -> CreateCanisterSuccess { + client::create_canister_from(&self.state_machine, self.ledger_id, caller, args.clone()) + .unwrap_or_else(|err| { + panic!( + "Call to create_canister_from({args:?}) from caller {caller} failed with error {err:?}" + ) + }) + } + + fn canister_status(&self, caller: Principal, canister_id: Principal) -> CanisterStatusResponse { + client::canister_status(&self.state_machine, canister_id, caller) + } + fn deposit(&self, to: Account, amount: u128, memo: Option) -> DepositResult { client::deposit(&self.state_machine, self.depositor_id, to, amount, memo) } @@ -5300,7 +5339,6 @@ fn test_create_canister_duplicate() { fn test_create_canister_fail() { let env = TestEnv::setup(); let account1 = account(1, None); - install_fake_cmc(&env.state_machine); let _ = env.deposit(account1, 1_000_000_000_000_000_000, None); @@ -5452,6 +5490,986 @@ fn test_create_canister_fail() { assert_vec_display_eq(blocks, env.get_all_blocks_with_ids()); } +#[test] +fn test_create_canister_from() { + const CREATE_CANISTER_CYCLES: u128 = 1_000_000_000_000; + let env = TestEnv::setup(); + let account1 = account(1, None); + let account1_1 = account(1, Some(1)); + let account1_2 = account(1, Some(2)); + let account1_3 = account(1, Some(3)); + let withdrawer1 = account(102, None); + let withdrawer1_1 = account(102, Some(1)); + + // make deposits to the user and check the result + let _deposit_res = env.deposit(account1, 100 * CREATE_CANISTER_CYCLES, None); + let _deposit_res = env.deposit(account1_1, 100 * CREATE_CANISTER_CYCLES, None); + let _deposit_res = env.deposit(account1_2, 100 * CREATE_CANISTER_CYCLES, None); + let _deposit_res = env.deposit(account1_3, 100 * CREATE_CANISTER_CYCLES, None); + let mut expected_total_supply = 400 * CREATE_CANISTER_CYCLES; + assert_eq!(env.icrc1_total_supply(), expected_total_supply); + + // successful create + env.icrc2_approve_or_trap( + account1.owner, + ApproveArgs { + from_subaccount: None, + spender: withdrawer1, + amount: Nat::from(u128::MAX), + expected_allowance: None, + expires_at: None, + fee: None, + memo: None, + created_at_time: None, + }, + ); + expected_total_supply -= FEE; + let mut expected_balance = env.icrc1_balance_of(account1); + let mut expected_allowance = u128::MAX; + let CreateCanisterSuccess { + canister_id, + block_id, + } = env.create_canister_from_or_trap( + withdrawer1.owner, + CreateCanisterFromArgs { + from: account1, + created_at_time: None, + amount: CREATE_CANISTER_CYCLES.into(), + creation_args: None, + spender_subaccount: None, + }, + ); + expected_balance -= CREATE_CANISTER_CYCLES + FEE; + expected_allowance -= CREATE_CANISTER_CYCLES + FEE; + expected_total_supply -= CREATE_CANISTER_CYCLES + FEE; + let status = env.canister_status(withdrawer1.owner, canister_id); + assert_eq!(expected_balance, env.icrc1_balance_of(account1)); + assert_eq!( + expected_allowance, + env.icrc2_allowance(account1, withdrawer1).allowance + ); + assert_eq!(env.icrc1_total_supply(), expected_total_supply); + // no canister creation fee on system subnet (where the StateMachine is by default) + assert_eq!(CREATE_CANISTER_CYCLES, status.cycles); + // If `CanisterSettings` do not specify a controller, the caller should still control the resulting canister + assert_eq!(vec![withdrawer1.owner], status.settings.controllers); + // check that the burn block created is correct + assert_display_eq( + &env.get_block(block_id.clone()), + &Block { + // The new block parent hash is the hash of the last deposit. + phash: Some(env.get_block(block_id - 1u8).hash().unwrap()), + // The effective fee of a burn block created by a withdrawal + // is the fee of the ledger. This is different from burn in + // other ledgers because the operation transfers cycles. + effective_fee: Some(env.icrc1_fee()), + // The timestamp is set by the ledger. + timestamp: env.nanos_since_epoch_u64(), + transaction: Transaction { + // The created_at_time was not set. + created_at_time: None, + // The memo is the canister ID receiving the cycles + // encoded in cbor as object with a 'receiver' field marked as 0. + memo: Some(Memo::from(ByteBuf::from(CREATE_CANISTER_MEMO))), + // Withdrawals are recorded as burns. + operation: Operation::Burn { + from: account1, + spender: Some(withdrawer1), + // The operation amount is the withdrawn amount. + amount: CREATE_CANISTER_CYCLES, + }, + }, + }, + ); + + let canister_settings = CanisterSettings { + controllers: Some(vec![account1.owner, Principal::anonymous()]), + compute_allocation: Some(Nat::from(7_u128)), + memory_allocation: Some(Nat::from(8_u128)), + freezing_threshold: Some(Nat::from(9_u128)), + reserved_cycles_limit: Some(Nat::from(10_u128)), + }; + let CreateCanisterSuccess { + canister_id, + block_id, + } = env.create_canister_from_or_trap( + withdrawer1.owner, + CreateCanisterFromArgs { + from: account1, + created_at_time: None, + amount: CREATE_CANISTER_CYCLES.into(), + creation_args: Some(CmcCreateCanisterArgs { + subnet_selection: None, + settings: Some(canister_settings.clone()), + }), + spender_subaccount: None, + }, + ); + expected_balance -= CREATE_CANISTER_CYCLES + FEE; + expected_allowance -= CREATE_CANISTER_CYCLES + FEE; + expected_total_supply -= CREATE_CANISTER_CYCLES + FEE; + let status = env.canister_status(account1.owner, canister_id); + assert_eq!(expected_balance, env.icrc1_balance_of(account1)); + assert_eq!( + expected_allowance, + env.icrc2_allowance(account1, withdrawer1).allowance + ); + assert_eq!(env.icrc1_total_supply(), expected_total_supply); + assert_eq!(CREATE_CANISTER_CYCLES, status.cycles); + // order is not guaranteed + assert_eq!( + HashSet::::from_iter(status.settings.controllers.iter().cloned()), + HashSet::from_iter(canister_settings.controllers.unwrap().iter().cloned()) + ); + assert_eq!( + status.settings.freezing_threshold, + canister_settings.freezing_threshold.unwrap() + ); + assert_eq!( + status.settings.compute_allocation, + canister_settings.compute_allocation.unwrap() + ); + assert_eq!( + status.settings.memory_allocation, + canister_settings.memory_allocation.unwrap() + ); + assert_eq!( + status.settings.reserved_cycles_limit, + canister_settings.reserved_cycles_limit.unwrap() + ); + // check that the burn block created is correct + assert_display_eq( + &env.get_block(block_id.clone()), + &Block { + // The new block parent hash is the hash of the last deposit. + phash: Some(env.get_block(block_id - 1u8).hash().unwrap()), + // The effective fee of a burn block created by a withdrawal + // is the fee of the ledger. This is different from burn in + // other ledgers because the operation transfers cycles. + effective_fee: Some(env.icrc1_fee()), + // The timestamp is set by the ledger. + timestamp: env.nanos_since_epoch_u64(), + transaction: Transaction { + // The created_at_time was not set. + created_at_time: None, + // The memo is the canister ID receiving the cycles + // encoded in cbor as object with a 'receiver' field marked as 0. + memo: Some(Memo::from(ByteBuf::from(CREATE_CANISTER_MEMO))), + // Withdrawals are recorded as burns. + operation: Operation::Burn { + from: account1, + spender: Some(withdrawer1), + // The operation amount is the withdrawn amount. + amount: CREATE_CANISTER_CYCLES, + }, + }, + }, + ); + + // create from subaccount + env.icrc2_approve_or_trap( + account1_1.owner, + ApproveArgs { + from_subaccount: account1_1.subaccount, + spender: withdrawer1, + amount: u128::MAX.into(), + expected_allowance: None, + expires_at: None, + fee: None, + memo: None, + created_at_time: None, + }, + ); + expected_total_supply -= FEE; + let mut expected_balance = env.icrc1_balance_of(account1_1); + let mut expected_allowance = u128::MAX; + let CreateCanisterSuccess { block_id, .. } = env.create_canister_from_or_trap( + withdrawer1.owner, + CreateCanisterFromArgs { + from: account1_1, + created_at_time: None, + amount: CREATE_CANISTER_CYCLES.into(), + creation_args: None, + spender_subaccount: None, + }, + ); + expected_balance -= CREATE_CANISTER_CYCLES + FEE; + expected_allowance -= CREATE_CANISTER_CYCLES + FEE; + expected_total_supply -= CREATE_CANISTER_CYCLES + FEE; + assert_eq!(expected_balance, env.icrc1_balance_of(account1_1)); + assert_eq!( + expected_allowance, + env.icrc2_allowance(account1_1, withdrawer1).allowance + ); + assert_eq!(env.icrc1_total_supply(), expected_total_supply); + // check that the burn block created is correct + assert_display_eq( + &env.get_block(block_id.clone()), + &Block { + // The new block parent hash is the hash of the last deposit. + phash: Some(env.get_block(block_id - 1u8).hash().unwrap()), + // The effective fee of a burn block created by a withdrawal + // is the fee of the ledger. This is different from burn in + // other ledgers because the operation transfers cycles. + effective_fee: Some(env.icrc1_fee()), + // The timestamp is set by the ledger. + timestamp: env.nanos_since_epoch_u64(), + transaction: Transaction { + // The created_at_time was not set. + created_at_time: None, + // The memo is the canister ID receiving the cycles + // encoded in cbor as object with a 'receiver' field marked as 0. + memo: Some(Memo::from(ByteBuf::from(CREATE_CANISTER_MEMO))), + // Withdrawals are recorded as burns. + operation: Operation::Burn { + from: account1_1, + spender: Some(withdrawer1), + // The operation amount is the withdrawn amount. + amount: CREATE_CANISTER_CYCLES, + }, + }, + }, + ); + + // create from subaccount with created_at_time set + let created_at_time = Some(env.nanos_since_epoch_u64()); + env.icrc2_approve_or_trap( + account1_2.owner, + ApproveArgs { + from_subaccount: account1_2.subaccount, + spender: withdrawer1, + amount: u128::MAX.into(), + expected_allowance: None, + expires_at: None, + fee: None, + memo: None, + created_at_time: None, + }, + ); + expected_total_supply -= FEE; + let mut expected_balance = env.icrc1_balance_of(account1_2); + let mut expected_allowance = u128::MAX; + let CreateCanisterSuccess { block_id, .. } = env.create_canister_from_or_trap( + withdrawer1.owner, + CreateCanisterFromArgs { + from: account1_2, + created_at_time, + amount: CREATE_CANISTER_CYCLES.into(), + creation_args: None, + spender_subaccount: None, + }, + ); + expected_balance -= CREATE_CANISTER_CYCLES + FEE; + expected_allowance -= CREATE_CANISTER_CYCLES + FEE; + expected_total_supply -= CREATE_CANISTER_CYCLES + FEE; + assert_eq!(expected_balance, env.icrc1_balance_of(account1_2)); + assert_eq!( + expected_allowance, + env.icrc2_allowance(account1_2, withdrawer1).allowance + ); + assert_eq!(env.icrc1_total_supply(), expected_total_supply); + // check that the burn block created is correct + assert_display_eq( + &env.get_block(block_id.clone()), + &Block { + // The new block parent hash is the hash of the last deposit. + phash: Some(env.get_block(block_id - 1u8).hash().unwrap()), + // The effective fee of a burn block created by a withdrawal + // is the fee of the ledger. This is different from burn in + // other ledgers because the operation transfers cycles. + effective_fee: Some(env.icrc1_fee()), + // The timestamp is set by the ledger. + timestamp: env.nanos_since_epoch_u64(), + transaction: Transaction { + // The created_at_time was set. + created_at_time, + // The memo is the canister ID receiving the cycles + // encoded in cbor as object with a 'receiver' field marked as 0. + memo: Some(Memo::from(ByteBuf::from(CREATE_CANISTER_MEMO))), + // Withdrawals are recorded as burns. + operation: Operation::Burn { + from: account1_2, + spender: Some(withdrawer1), + // The operation amount is the withdrawn amount. + amount: CREATE_CANISTER_CYCLES, + }, + }, + }, + ); + + // create using spender subaccount + env.icrc2_approve_or_trap( + account1_3.owner, + ApproveArgs { + from_subaccount: account1_3.subaccount, + spender: withdrawer1_1, + amount: u128::MAX.into(), + expected_allowance: None, + expires_at: None, + fee: None, + memo: None, + created_at_time: None, + }, + ); + expected_total_supply -= FEE; + let mut expected_balance = env.icrc1_balance_of(account1_3); + let mut expected_allowance = u128::MAX; + let CreateCanisterSuccess { block_id, .. } = env.create_canister_from_or_trap( + withdrawer1_1.owner, + CreateCanisterFromArgs { + from: account1_3, + created_at_time: None, + amount: CREATE_CANISTER_CYCLES.into(), + creation_args: None, + spender_subaccount: withdrawer1_1.subaccount, + }, + ); + expected_balance -= CREATE_CANISTER_CYCLES + FEE; + expected_allowance -= CREATE_CANISTER_CYCLES + FEE; + expected_total_supply -= CREATE_CANISTER_CYCLES + FEE; + assert_eq!(expected_balance, env.icrc1_balance_of(account1_3)); + assert_eq!( + expected_allowance, + env.icrc2_allowance(account1_3, withdrawer1_1).allowance + ); + assert_eq!(env.icrc1_total_supply(), expected_total_supply); + // check that the burn block created is correct + assert_display_eq( + &env.get_block(block_id.clone()), + &Block { + // The new block parent hash is the hash of the last deposit. + phash: Some(env.get_block(block_id - 1u8).hash().unwrap()), + // The effective fee of a burn block created by a withdrawal + // is the fee of the ledger. This is different from burn in + // other ledgers because the operation transfers cycles. + effective_fee: Some(env.icrc1_fee()), + // The timestamp is set by the ledger. + timestamp: env.nanos_since_epoch_u64(), + transaction: Transaction { + // The created_at_time was not set. + created_at_time: None, + // The memo is the canister ID receiving the cycles + // encoded in cbor as object with a 'receiver' field marked as 0. + memo: Some(Memo::from(ByteBuf::from(CREATE_CANISTER_MEMO))), + // Withdrawals are recorded as burns. + operation: Operation::Burn { + from: account1_3, + spender: Some(withdrawer1_1), + // The operation amount is the withdrawn amount. + amount: CREATE_CANISTER_CYCLES, + }, + }, + }, + ); +} + +#[test] +fn test_create_canister_from_fail() { + const CREATE_CANISTER_CYCLES: u128 = 1_000_000_000_000; + let env = TestEnv::setup(); + let withdrawer1 = account(101, None); + let account1 = account(1, None); + let account1_1 = account(1, Some(1)); + let account1_2 = account(1, Some(2)); + let account1_3 = account(1, Some(3)); + let account1_4 = account(1, Some(4)); + let account1_5 = account(1, Some(5)); + let account1_6 = account(1, Some(6)); + let account1_7 = account(1, Some(7)); + + // make the first deposit to the user and check the result + let _deposit_res = env.deposit(account1, CREATE_CANISTER_CYCLES / 2, None); + let _deposit_res = env.deposit(account1_1, 100 * CREATE_CANISTER_CYCLES, None); + let _deposit_res = env.deposit(account1_2, 100 * CREATE_CANISTER_CYCLES, None); + let _deposit_res = env.deposit(account1_3, 100 * CREATE_CANISTER_CYCLES, None); + let _deposit_res = env.deposit(account1_4, 100 * CREATE_CANISTER_CYCLES, None); + let _deposit_res = env.deposit(account1_5, 100 * CREATE_CANISTER_CYCLES, None); + let _deposit_res = env.deposit(account1_6, 100 * CREATE_CANISTER_CYCLES, None); + let _deposit_res = env.deposit(account1_7, 100 * CREATE_CANISTER_CYCLES, None); + let mut expected_total_supply = env.icrc1_total_supply(); + + // create with more than available in account + env.icrc2_approve_or_trap( + account1.owner, + ApproveArgs { + from_subaccount: account1.subaccount, + spender: withdrawer1, + amount: u128::MAX.into(), + expected_allowance: None, + expires_at: None, + fee: None, + memo: None, + created_at_time: None, + }, + ); + expected_total_supply -= FEE; + let expected_balance = env.icrc1_balance_of(account1); + let expected_allowance = u128::MAX; + let blocks = env.icrc3_get_blocks(vec![(u64::MIN, u64::MAX)]).blocks; + let error = env + .create_canister_from( + withdrawer1.owner, + CreateCanisterFromArgs { + from: account1, + created_at_time: None, + amount: CREATE_CANISTER_CYCLES.into(), + creation_args: None, + spender_subaccount: None, + }, + ) + .unwrap_err(); + assert_eq!( + error, + CreateCanisterFromError::InsufficientFunds { + balance: expected_balance.into() + } + ); + assert_eq!(expected_balance, env.icrc1_balance_of(account1)); + assert_eq!( + expected_allowance, + env.icrc2_allowance(account1, withdrawer1).allowance + ); + assert_eq!(env.icrc1_total_supply(), expected_total_supply); + // check that no new block was added. + assert_vec_display_eq(blocks, env.get_all_blocks_with_ids()); + + // if refund_amount is <= env.icrc1_fee() then + // 1. the error doesn't have the refund_block + // 2. the user has been charged the full amount + // 3. only one block was created + env.icrc2_approve_or_trap( + account1_2.owner, + ApproveArgs { + from_subaccount: account1_2.subaccount, + spender: withdrawer1, + amount: u128::MAX.into(), + expected_allowance: None, + expires_at: None, + fee: None, + memo: None, + created_at_time: None, + }, + ); + expected_total_supply -= FEE; + let mut expected_balance = env.icrc1_balance_of(account1_2); + let mut expected_allowance = u128::MAX; + let blocks = env.icrc3_get_blocks(vec![(u64::MIN, u64::MAX)]).blocks; + env.fail_next_create_canister_with(CmcCreateCanisterError::Refunded { + refund_amount: FEE / 2, + create_error: "Error while creating".to_string(), + }); + let error = env + .create_canister_from( + withdrawer1.owner, + CreateCanisterFromArgs { + from: account1_2, + created_at_time: None, + amount: CREATE_CANISTER_CYCLES.into(), + creation_args: None, + spender_subaccount: None, + }, + ) + .unwrap_err(); + assert_eq!( + error, + CreateCanisterFromError::FailedToCreateFrom { + create_from_block: Some(blocks.len().into()), + refund_block: None, + approval_refund_block: None, + rejection_code: RejectionCode::CanisterError, + rejection_reason: "Error while creating".to_string(), + } + ); + expected_balance -= CREATE_CANISTER_CYCLES + FEE; + expected_allowance -= CREATE_CANISTER_CYCLES + FEE; + expected_total_supply -= CREATE_CANISTER_CYCLES + FEE; + assert_eq!(expected_balance, env.icrc1_balance_of(account1_2)); + assert_eq!( + expected_allowance, + env.icrc2_allowance(account1_2, withdrawer1).allowance + ); + assert_eq!(env.icrc1_total_supply(), expected_total_supply); + assert_eq!(blocks.len() + 1, env.number_of_blocks()); + let burn_block = BlockWithId { + id: Nat::from(blocks.len()), + block: Block { + phash: Some(env.get_block(Nat::from(blocks.len()) - 1u8).hash().unwrap()), + effective_fee: Some(FEE), + timestamp: env.nanos_since_epoch_u64(), + transaction: Transaction { + created_at_time: None, + memo: Some(Memo::from(ByteBuf::from(CREATE_CANISTER_MEMO))), + operation: Operation::Burn { + from: account1_2, + spender: Some(withdrawer1), + amount: CREATE_CANISTER_CYCLES, + }, + }, + } + .to_value() + .unwrap(), + }; + let blocks = blocks.into_iter().chain([burn_block]).collect::>(); + assert_vec_display_eq(blocks, env.get_all_blocks_with_ids()); + + // if env.icrc1_fee() < refund_amount < 2 * env.irc1_fee() then + // 1. the user receives a refund + // 2. the error contains a refund block + // 3. the allowance does not get refunded + env.icrc2_approve_or_trap( + account1_3.owner, + ApproveArgs { + from_subaccount: account1_3.subaccount, + spender: withdrawer1, + amount: u128::MAX.into(), + expected_allowance: None, + expires_at: None, + fee: None, + memo: None, + created_at_time: None, + }, + ); + expected_total_supply -= FEE; + let mut expected_balance = env.icrc1_balance_of(account1_3); + let mut expected_allowance = u128::MAX; + let blocks = env.icrc3_get_blocks(vec![(u64::MIN, u64::MAX)]).blocks; + env.fail_next_create_canister_with(CmcCreateCanisterError::Refunded { + refund_amount: FEE + FEE / 2, + create_error: "Error while creating".to_string(), + }); + let error = env + .create_canister_from( + withdrawer1.owner, + CreateCanisterFromArgs { + from: account1_3, + created_at_time: None, + amount: CREATE_CANISTER_CYCLES.into(), + creation_args: None, + spender_subaccount: None, + }, + ) + .unwrap_err(); + assert_eq!( + error, + CreateCanisterFromError::FailedToCreateFrom { + create_from_block: Some(blocks.len().into()), + refund_block: Some(Nat::from(blocks.len() + 1)), + approval_refund_block: None, + rejection_code: RejectionCode::CanisterError, + rejection_reason: "Error while creating".to_string(), + } + ); + expected_balance -= CREATE_CANISTER_CYCLES + FEE - (FEE / 2); + expected_allowance -= CREATE_CANISTER_CYCLES + FEE; + expected_total_supply -= CREATE_CANISTER_CYCLES + FEE - (FEE / 2); + assert_eq!(expected_balance, env.icrc1_balance_of(account1_3)); + assert_eq!( + expected_allowance, + env.icrc2_allowance(account1_3, withdrawer1).allowance + ); + assert_eq!(env.icrc1_total_supply(), expected_total_supply); + assert_eq!(blocks.len() + 2, env.number_of_blocks()); + let burn_block = BlockWithId { + id: Nat::from(blocks.len()), + block: Block { + phash: Some(env.get_block(Nat::from(blocks.len()) - 1u8).hash().unwrap()), + effective_fee: Some(FEE), + timestamp: env.nanos_since_epoch_u64(), + transaction: Transaction { + created_at_time: None, + memo: Some(Memo::from(ByteBuf::from(CREATE_CANISTER_MEMO))), + operation: Operation::Burn { + from: account1_3, + spender: Some(withdrawer1), + amount: CREATE_CANISTER_CYCLES, + }, + }, + } + .to_value() + .unwrap(), + }; + let refund_block = BlockWithId { + id: Nat::from(blocks.len() + 1), + block: Block { + phash: Some(burn_block.block.clone().hash()), + effective_fee: Some(0), + timestamp: env.nanos_since_epoch_u64(), + transaction: Transaction { + created_at_time: None, + memo: Some(Memo::from(ByteBuf::from(REFUND_MEMO))), + operation: Operation::Mint { + to: account1_3, + amount: FEE / 2, + }, + }, + } + .to_value() + .unwrap(), + }; + let blocks = blocks + .into_iter() + .chain([burn_block, refund_block]) + .collect::>(); + assert_vec_display_eq(blocks, env.get_all_blocks_with_ids()); + + // if refund_amount > 2 * env.irc1_fee() then + // 1. the user receives a refund + // 2. the error contains a refund block + // 3. the allowance does get refunded + // 4. the error contains an allowance refund block + env.icrc2_approve_or_trap( + account1_4.owner, + ApproveArgs { + from_subaccount: account1_4.subaccount, + spender: withdrawer1, + amount: u128::MAX.into(), + expected_allowance: None, + expires_at: None, + fee: None, + memo: None, + created_at_time: None, + }, + ); + expected_total_supply -= FEE; + let mut expected_balance = env.icrc1_balance_of(account1_4); + let mut expected_allowance = u128::MAX; + let blocks = env.icrc3_get_blocks(vec![(u64::MIN, u64::MAX)]).blocks; + env.fail_next_create_canister_with(CmcCreateCanisterError::Refunded { + refund_amount: 2 * FEE + FEE / 2, + create_error: "Error while creating".to_string(), + }); + let error = env + .create_canister_from( + withdrawer1.owner, + CreateCanisterFromArgs { + from: account1_4, + created_at_time: None, + amount: CREATE_CANISTER_CYCLES.into(), + creation_args: None, + spender_subaccount: None, + }, + ) + .unwrap_err(); + assert_eq!( + error, + CreateCanisterFromError::FailedToCreateFrom { + create_from_block: Some(blocks.len().into()), + refund_block: Some(Nat::from(blocks.len() + 1)), + approval_refund_block: Some(Nat::from(blocks.len() + 2)), + rejection_code: RejectionCode::CanisterError, + rejection_reason: "Error while creating".to_string(), + } + ); + expected_balance -= CREATE_CANISTER_CYCLES + FEE - (FEE / 2); + expected_allowance -= CREATE_CANISTER_CYCLES + FEE - (FEE / 2); + expected_total_supply -= CREATE_CANISTER_CYCLES + FEE - (FEE / 2); + assert_eq!(expected_balance, env.icrc1_balance_of(account1_4)); + assert_eq!( + expected_allowance, + env.icrc2_allowance(account1_4, withdrawer1).allowance + ); + assert_eq!(env.icrc1_total_supply(), expected_total_supply); + assert_eq!(blocks.len() + 3, env.number_of_blocks()); + let burn_block = BlockWithId { + id: Nat::from(blocks.len()), + block: Block { + phash: Some(env.get_block(Nat::from(blocks.len()) - 1u8).hash().unwrap()), + effective_fee: Some(FEE), + timestamp: env.nanos_since_epoch_u64(), + transaction: Transaction { + created_at_time: None, + memo: Some(Memo::from(ByteBuf::from(CREATE_CANISTER_MEMO))), + operation: Operation::Burn { + from: account1_4, + spender: Some(withdrawer1), + amount: CREATE_CANISTER_CYCLES, + }, + }, + } + .to_value() + .unwrap(), + }; + let refund_block = BlockWithId { + id: Nat::from(blocks.len() + 1), + block: Block { + phash: Some(burn_block.block.clone().hash()), + effective_fee: Some(0), + timestamp: env.nanos_since_epoch_u64(), + transaction: Transaction { + created_at_time: None, + memo: Some(Memo::from(ByteBuf::from(REFUND_MEMO))), + operation: Operation::Mint { + to: account1_4, + amount: FEE + FEE / 2, + }, + }, + } + .to_value() + .unwrap(), + }; + let approval_refund_block = BlockWithId { + id: Nat::from(blocks.len() + 2), + block: Block { + phash: Some(refund_block.block.hash()), + effective_fee: Some(env.icrc1_fee()), + timestamp: env.nanos_since_epoch_u64(), + transaction: Transaction { + operation: Operation::Approve { + from: account1_4, + spender: withdrawer1, + amount: expected_allowance, + expected_allowance: None, + expires_at: None, + fee: None, + }, + created_at_time: None, + memo: Some(Memo(ByteBuf::from(PENALIZE_MEMO))), + }, + } + .to_value() + .unwrap(), + }; + let blocks = blocks + .into_iter() + .chain([burn_block, refund_block, approval_refund_block]) + .collect::>(); + assert_vec_display_eq(blocks, env.get_all_blocks_with_ids()); + + // duplicate + let created_at_time = Some(env.nanos_since_epoch_u64()); + env.icrc2_approve_or_trap( + account1_5.owner, + ApproveArgs { + from_subaccount: account1_5.subaccount, + spender: withdrawer1, + amount: u128::MAX.into(), + expected_allowance: None, + expires_at: None, + fee: None, + memo: None, + created_at_time: None, + }, + ); + expected_total_supply -= FEE; + let mut expected_balance = env.icrc1_balance_of(account1_5); + let mut expected_allowance = u128::MAX; + let blocks = env.icrc3_get_blocks(vec![(u64::MIN, u64::MAX)]).blocks; + let create_arg = CreateCanisterFromArgs { + from: account1_5, + created_at_time, + amount: CREATE_CANISTER_CYCLES.into(), + creation_args: None, + spender_subaccount: None, + }; + let CreateCanisterSuccess { + block_id, + canister_id, + } = env.create_canister_from_or_trap(withdrawer1.owner, create_arg.clone()); + let error = env + .create_canister_from(withdrawer1.owner, create_arg) + .unwrap_err(); + assert_eq!( + error, + CreateCanisterFromError::Duplicate { + duplicate_of: block_id.clone(), + canister_id: Some(canister_id) + } + ); + expected_balance -= CREATE_CANISTER_CYCLES + FEE; + expected_allowance -= CREATE_CANISTER_CYCLES + FEE; + expected_total_supply -= CREATE_CANISTER_CYCLES + FEE; + assert_eq!(expected_balance, env.icrc1_balance_of(account1_5)); + assert_eq!( + expected_allowance, + env.icrc2_allowance(account1_5, withdrawer1).allowance + ); + assert_eq!(env.icrc1_total_supply(), expected_total_supply); + assert_eq!(blocks.len() + 1, env.number_of_blocks()); + // check that the burn block created is correct + assert_display_eq( + &env.get_block(block_id.clone()), + &Block { + // The new block parent hash is the hash of the last deposit. + phash: Some(env.get_block(block_id - 1u8).hash().unwrap()), + // The effective fee of a burn block created by a withdrawal + // is the fee of the ledger. This is different from burn in + // other ledgers because the operation transfers cycles. + effective_fee: Some(env.icrc1_fee()), + // The timestamp is set by the ledger. + timestamp: env.nanos_since_epoch_u64(), + transaction: Transaction { + // The created_at_time was set. + created_at_time, + // The memo is the canister ID receiving the cycles + // encoded in cbor as object with a 'receiver' field marked as 0. + memo: Some(Memo::from(ByteBuf::from(CREATE_CANISTER_MEMO))), + // Withdrawals are recorded as burns. + operation: Operation::Burn { + from: account1_5, + spender: Some(withdrawer1), + // The operation amount is the withdrawn amount. + amount: CREATE_CANISTER_CYCLES, + }, + }, + }, + ); + + // approval refund does not affect expires_at + let expires_at = Some(env.nanos_since_epoch_u64() + 100_000_000); + env.icrc2_approve_or_trap( + account1_6.owner, + ApproveArgs { + from_subaccount: account1_6.subaccount, + spender: withdrawer1, + amount: u128::MAX.into(), + expected_allowance: None, + expires_at, + fee: None, + memo: None, + created_at_time: None, + }, + ); + expected_total_supply -= FEE; + let mut expected_balance = env.icrc1_balance_of(account1_6); + let mut expected_allowance = u128::MAX; + let blocks = env.icrc3_get_blocks(vec![(u64::MIN, u64::MAX)]).blocks; + env.fail_next_create_canister_with(CmcCreateCanisterError::Refunded { + refund_amount: 2 * FEE + FEE / 2, + create_error: "Error while creating".to_string(), + }); + let error = env + .create_canister_from( + withdrawer1.owner, + CreateCanisterFromArgs { + from: account1_6, + created_at_time: None, + amount: CREATE_CANISTER_CYCLES.into(), + creation_args: None, + spender_subaccount: None, + }, + ) + .unwrap_err(); + assert_eq!( + error, + CreateCanisterFromError::FailedToCreateFrom { + create_from_block: Some(blocks.len().into()), + refund_block: Some(Nat::from(blocks.len() + 1)), + approval_refund_block: Some(Nat::from(blocks.len() + 2)), + rejection_code: RejectionCode::CanisterError, + rejection_reason: "Error while creating".to_string(), + } + ); + expected_balance -= CREATE_CANISTER_CYCLES + FEE - (FEE / 2); + expected_allowance -= CREATE_CANISTER_CYCLES + FEE - (FEE / 2); + expected_total_supply -= CREATE_CANISTER_CYCLES + FEE - (FEE / 2); + assert_eq!(expected_balance, env.icrc1_balance_of(account1_6)); + assert_eq!( + expected_allowance, + env.icrc2_allowance(account1_6, withdrawer1).allowance + ); + assert_eq!(env.icrc1_total_supply(), expected_total_supply); + assert_eq!(blocks.len() + 3, env.number_of_blocks()); + let approval_refund_block = Block { + phash: Some(env.get_block(Nat::from(blocks.len() + 1)).hash().unwrap()), + effective_fee: Some(env.icrc1_fee()), + timestamp: env.nanos_since_epoch_u64(), + transaction: Transaction { + operation: Operation::Approve { + from: account1_6, + spender: withdrawer1, + amount: expected_allowance, + expected_allowance: None, + expires_at, + fee: None, + }, + created_at_time: None, + memo: Some(Memo(ByteBuf::from(PENALIZE_MEMO))), + }, + }; + assert_eq!( + approval_refund_block, + env.get_block(Nat::from(blocks.len() + 2)) + ); + + // refund fails + env.icrc2_approve_or_trap( + account1_7.owner, + ApproveArgs { + from_subaccount: account1_7.subaccount, + spender: withdrawer1, + amount: u128::MAX.into(), + expected_allowance: None, + expires_at: None, + fee: None, + memo: None, + created_at_time: None, + }, + ); + expected_total_supply -= FEE; + let mut expected_balance = env.icrc1_balance_of(account1_7); + let mut expected_allowance = u128::MAX; + let blocks = env.icrc3_get_blocks(vec![(u64::MIN, u64::MAX)]).blocks; + env.fail_next_create_canister_with(CmcCreateCanisterError::RefundFailed { + create_error: "Error while creating".to_string(), + refund_error: "Error while refunding".to_string(), + }); + let error = env + .create_canister_from( + withdrawer1.owner, + CreateCanisterFromArgs { + from: account1_7, + created_at_time: None, + amount: CREATE_CANISTER_CYCLES.into(), + creation_args: None, + spender_subaccount: None, + }, + ) + .unwrap_err(); + assert_eq!( + error, + CreateCanisterFromError::FailedToCreateFrom { + create_from_block: Some(blocks.len().into()), + refund_block: None, + approval_refund_block: None, + rejection_code: RejectionCode::CanisterError, + rejection_reason: + "create_error: Error while creating, refund error: Error while refunding" + .to_string(), + } + ); + expected_balance -= CREATE_CANISTER_CYCLES + FEE; + expected_allowance -= CREATE_CANISTER_CYCLES + FEE; + expected_total_supply -= CREATE_CANISTER_CYCLES + FEE; + assert_eq!(expected_balance, env.icrc1_balance_of(account1_7)); + assert_eq!( + expected_allowance, + env.icrc2_allowance(account1_7, withdrawer1).allowance + ); + assert_eq!(env.icrc1_total_supply(), expected_total_supply); + assert_eq!(blocks.len() + 1, env.number_of_blocks()); + let burn_block = BlockWithId { + id: Nat::from(blocks.len()), + block: Block { + phash: Some(env.get_block(Nat::from(blocks.len()) - 1u8).hash().unwrap()), + effective_fee: Some(FEE), + timestamp: env.nanos_since_epoch_u64(), + transaction: Transaction { + created_at_time: None, + memo: Some(Memo::from(ByteBuf::from(CREATE_CANISTER_MEMO))), + operation: Operation::Burn { + from: account1_7, + spender: Some(withdrawer1), + amount: CREATE_CANISTER_CYCLES, + }, + }, + } + .to_value() + .unwrap(), + }; + let blocks = blocks.into_iter().chain([burn_block]).collect::>(); + assert_vec_display_eq(blocks, env.get_all_blocks_with_ids()); +} + #[test] #[should_panic(expected = "memo length exceeds the maximum")] fn test_deposit_invalid_memo() {