From 2871b55442925c49ac37ccbc0e48dd22d40d08e8 Mon Sep 17 00:00:00 2001 From: Yan Liu Date: Sat, 25 Jan 2025 02:34:39 +0100 Subject: [PATCH 1/2] Support for ICRC-2 --- common/src/account_transfer_approvals.rs | 191 +++++++++++++++++++ common/src/account_transfers.rs | 13 +- common/src/ledger_refresh.rs | 19 +- common/src/lib.rs | 3 + ic-canister/decent_cloud.did | 62 +++++++ ic-canister/src/canister_backend/icrc1.rs | 4 + ic-canister/src/canister_backend/icrc2.rs | 195 ++++++++++++++++++++ ic-canister/src/canister_endpoints/icrc2.rs | 23 ++- 8 files changed, 498 insertions(+), 12 deletions(-) create mode 100644 common/src/account_transfer_approvals.rs diff --git a/common/src/account_transfer_approvals.rs b/common/src/account_transfer_approvals.rs new file mode 100644 index 0000000..1b7f1bb --- /dev/null +++ b/common/src/account_transfer_approvals.rs @@ -0,0 +1,191 @@ +use crate::{AHashMap, IcrcCompatibleAccount, TokenAmountE9s, LABEL_DC_TOKEN_APPROVAL}; +use base64::engine::general_purpose::STANDARD as BASE64; +use base64::Engine; +use borsh::{BorshDeserialize, BorshSerialize}; +use icrc_ledger_types::{icrc1::account::Account, icrc2::allowance::Allowance}; +use ledger_map::{LedgerError, LedgerMap}; +use sha2::Digest; + +thread_local! { + static APPROVALS: std::cell::RefCell> = std::cell::RefCell::new(AHashMap::default()); +} + +pub fn approval_update(account: Account, spender: Account, allowance: Allowance) { + APPROVALS.with(|approvals| { + let mut approvals = approvals.borrow_mut(); + if allowance.allowance > 0u32 { + approvals.insert((account, spender), allowance); + } else { + approvals.remove(&(account, spender)); + } + }) +} + +pub fn approval_get(account: Account, spender: Account) -> Option { + APPROVALS.with(|approvals| { + let approvals = approvals.borrow(); + approvals.get(&(account, spender)).cloned() + }) +} + +#[derive(BorshSerialize, BorshDeserialize, Clone, Debug, PartialEq, Eq)] +pub struct FundsTransferApprovalV1 { + approver: IcrcCompatibleAccount, + spender: IcrcCompatibleAccount, + allowance: TokenAmountE9s, + expires_at: Option, + fee: TokenAmountE9s, + memo: Vec, +} + +impl FundsTransferApprovalV1 { + pub fn new( + approver: IcrcCompatibleAccount, + spender: IcrcCompatibleAccount, + allowance: TokenAmountE9s, + expires_at: Option, + fee: TokenAmountE9s, + memo: Vec, + ) -> Self { + Self { + approver, + spender, + allowance, + expires_at, + fee, + memo, + } + } + + pub fn to_tx_id(&self) -> [u8; 32] { + let mut hasher = sha2::Sha256::new(); + hasher.update(borsh::to_vec(self).unwrap()); + let result = hasher.finalize(); + let mut tx_id = [0u8; 32]; + tx_id.copy_from_slice(&result[..32]); + tx_id + } + + pub fn spender(&self) -> &IcrcCompatibleAccount { + &self.spender + } + + pub fn approver(&self) -> &IcrcCompatibleAccount { + &self.approver + } + + pub fn allowance(&self) -> Allowance { + Allowance { + allowance: self.allowance.into(), + expires_at: self.expires_at, + } + } +} + +impl std::fmt::Display for FundsTransferApprovalV1 { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + f, + "FundsTransferApprovalV1 {{ approver={} spender={} allowance={} expires_at={:?} fee={} memo={} }}", + self.spender(), + self.approver(), + self.allowance, + self.expires_at, + self.fee, + match String::try_from_slice(&self.memo) { + Ok(memo) => memo, + Err(_) => BASE64.encode(&self.memo), + }, + ) + } +} + +#[derive(BorshSerialize, BorshDeserialize, Clone, Debug, PartialEq, Eq)] +pub enum FundsTransferApproval { + V1(FundsTransferApprovalV1), +} + +impl FundsTransferApproval { + pub fn new( + approver: IcrcCompatibleAccount, + spender: IcrcCompatibleAccount, + allowance: TokenAmountE9s, + expires_at: Option, + fee: TokenAmountE9s, + memo: Vec, + ) -> Self { + Self::V1(FundsTransferApprovalV1 { + approver, + spender, + allowance, + expires_at, + fee, + memo, + }) + } + + pub fn to_tx_id(&self) -> [u8; 32] { + match self { + FundsTransferApproval::V1(v1) => v1.to_tx_id(), + } + } + + pub fn deserialize(bytes: &[u8]) -> Result { + borsh::from_slice(bytes) + } + + pub fn add_to_ledger(&self, ledger: &mut LedgerMap) -> Result<(), LedgerError> { + ledger.upsert( + LABEL_DC_TOKEN_APPROVAL, + self.to_tx_id(), + borsh::to_vec(self).unwrap(), + ) + } + + pub fn approver(&self) -> &IcrcCompatibleAccount { + match self { + FundsTransferApproval::V1(v1) => v1.approver(), + } + } + + pub fn spender(&self) -> &IcrcCompatibleAccount { + match self { + FundsTransferApproval::V1(v1) => v1.spender(), + } + } + + pub fn allowance(&self) -> Allowance { + match self { + FundsTransferApproval::V1(v1) => v1.allowance(), + } + } +} + +impl std::fmt::Display for FundsTransferApproval { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + FundsTransferApproval::V1(v1) => write!(f, "{}", v1), + } + } +} + +#[allow(clippy::too_many_arguments)] +pub fn ledger_funds_transfer_approve( + ledger: &mut LedgerMap, + approver: &IcrcCompatibleAccount, + spender: &IcrcCompatibleAccount, + allowance: TokenAmountE9s, + expires_at: Option, + fee: TokenAmountE9s, + memo: Vec, +) -> Result<(), LedgerError> { + let approval = FundsTransferApproval::new( + approver.clone(), + spender.clone(), + allowance, + expires_at, + fee, + memo, + ); + approval.add_to_ledger(ledger) +} diff --git a/common/src/account_transfers.rs b/common/src/account_transfers.rs index 4136abd..262ed9c 100644 --- a/common/src/account_transfers.rs +++ b/common/src/account_transfers.rs @@ -1,3 +1,9 @@ +use crate::{ + account_balance_add, account_balance_get, account_balance_sub, amount_as_string, + get_pubkey_from_principal, get_timestamp_ns, ledger_add_reputation_change, + slice_to_32_bytes_array, DccIdentity, TokenAmountE9s, TransferError, LABEL_DC_TOKEN_TRANSFER, + MINTING_ACCOUNT, MINTING_ACCOUNT_PRINCIPAL, +}; use base64::engine::general_purpose::STANDARD as BASE64; use base64::Engine; use borsh::{BorshDeserialize, BorshSerialize}; @@ -13,13 +19,6 @@ use ledger_map::{info, LedgerMap}; use sha2::{Digest, Sha256}; use std::cell::RefCell; -use crate::{ - account_balance_add, account_balance_get, account_balance_sub, amount_as_string, - get_pubkey_from_principal, get_timestamp_ns, ledger_add_reputation_change, - slice_to_32_bytes_array, DccIdentity, TokenAmountE9s, TransferError, LABEL_DC_TOKEN_TRANSFER, - MINTING_ACCOUNT, MINTING_ACCOUNT_PRINCIPAL, -}; - thread_local! { static FEES_SINK_ACCOUNTS: RefCell>> = const { RefCell::new(None) }; } diff --git a/common/src/ledger_refresh.rs b/common/src/ledger_refresh.rs index 4e4dc9f..75d2701 100644 --- a/common/src/ledger_refresh.rs +++ b/common/src/ledger_refresh.rs @@ -1,3 +1,4 @@ +use crate::account_transfer_approvals::{approval_update, FundsTransferApproval}; use crate::account_transfers::FundsTransfer; use crate::cache_transactions::RecentCache; use crate::{ @@ -6,9 +7,9 @@ use crate::{ reputations_apply_changes, reputations_clear, set_num_providers, set_num_users, set_offering_num_per_provider, AHashMap, ContractSignRequest, ContractSignRequestPayload, ReputationAge, ReputationChange, UpdateOfferingPayload, CACHE_TXS_NUM_COMMITTED, - LABEL_CONTRACT_SIGN_REPLY, LABEL_CONTRACT_SIGN_REQUEST, LABEL_DC_TOKEN_TRANSFER, - LABEL_NP_OFFERING, LABEL_NP_REGISTER, LABEL_REPUTATION_AGE, LABEL_REPUTATION_CHANGE, - LABEL_USER_REGISTER, PRINCIPAL_MAP, + LABEL_CONTRACT_SIGN_REPLY, LABEL_CONTRACT_SIGN_REQUEST, LABEL_DC_TOKEN_APPROVAL, + LABEL_DC_TOKEN_TRANSFER, LABEL_NP_OFFERING, LABEL_NP_REGISTER, LABEL_REPUTATION_AGE, + LABEL_REPUTATION_CHANGE, LABEL_USER_REGISTER, PRINCIPAL_MAP, }; use borsh::BorshDeserialize; use candid::Principal; @@ -75,6 +76,18 @@ pub fn refresh_caches_from_ledger(ledger: &LedgerMap) -> anyhow::Result<()> { RecentCache::add_entry(num_txs, transfer.into()); num_txs += 1; } + LABEL_DC_TOKEN_APPROVAL => { + let approval = + FundsTransferApproval::deserialize(entry.value()).map_err(|e| { + error!("Failed to deserialize approval {:?} ==> {:?}", entry, e); + e + })?; + approval_update( + approval.approver().into(), + approval.spender().into(), + approval.allowance(), + ); + } LABEL_NP_REGISTER | LABEL_USER_REGISTER => { if let Ok(dcc_identity) = dcc_identity::DccIdentity::new_verifying_from_bytes(entry.key()) diff --git a/common/src/lib.rs b/common/src/lib.rs index d2ced58..44dcd61 100644 --- a/common/src/lib.rs +++ b/common/src/lib.rs @@ -1,6 +1,7 @@ #[cfg(target_arch = "wasm32")] #[allow(unused_imports)] use ic_cdk::println; +pub mod account_transfer_approvals; pub mod account_transfers; pub mod account_transfers_errors; pub mod cache_balances; @@ -17,6 +18,7 @@ pub mod profiles; pub mod registration; pub mod rewards; +pub use account_transfer_approvals::*; pub use account_transfers::*; pub use account_transfers_errors::TransferError; pub use cache_balances::*; @@ -84,6 +86,7 @@ pub const ED25519_SIGNATURE_LENGTH: usize = 64; pub const ED25519_SIGN_CONTEXT: &[u8] = b"decent-cloud"; pub const FETCH_SIZE_BYTES_DEFAULT: u64 = 1024 * 1024; pub const KEY_LAST_REWARD_DISTRIBUTION_TS: &[u8] = b"LastRewardNs"; +pub const LABEL_DC_TOKEN_APPROVAL: &str = "DCTokenApproval"; pub const LABEL_DC_TOKEN_TRANSFER: &str = "DCTokenTransfer"; pub const LABEL_NP_CHECK_IN: &str = "NPCheckIn"; pub const LABEL_NP_OFFERING: &str = "NPOffering"; diff --git a/ic-canister/decent_cloud.did b/ic-canister/decent_cloud.did index 0a594ed..68852c1 100644 --- a/ic-canister/decent_cloud.did +++ b/ic-canister/decent_cloud.did @@ -320,6 +320,63 @@ type TransferError = variant { GenericError : record { error_code : nat; message : text }; }; +type ApproveArgs = record { + from_subaccount : opt blob; + spender : Account; + amount : nat; + expected_allowance : opt nat; + expires_at : opt nat64; + fee : opt nat; + memo : opt blob; + created_at_time : opt nat64; +}; + +type ApproveError = variant { + BadFee : record { expected_fee : nat }; + InsufficientFunds : record { balance : nat }; + AllowanceChanged : record { current_allowance : nat }; + Expired : record { ledger_time : Timestamp }; + TooOld; + CreatedInFuture : record { ledger_time : Timestamp }; + Duplicate : record { duplicate_of : nat }; + TemporarilyUnavailable; + GenericError : record { error_code : nat; message : text }; +}; + +type TransferFromError = variant { + BadFee : record { expected_fee : nat }; + BadBurn : record { min_burn_amount : nat }; + // The [from] account does not hold enough funds for the transfer. + InsufficientFunds : record { balance : nat }; + // The caller exceeded its allowance. + InsufficientAllowance : record { allowance : nat }; + TooOld; + CreatedInFuture: record { ledger_time : nat64 }; + Duplicate : record { duplicate_of : nat }; + TemporarilyUnavailable; + GenericError : record { error_code : nat; message : text }; +}; + +type TransferFromArgs = record { + spender_subaccount : opt blob; + from : Account; + to : Account; + amount : nat; + fee : opt nat; + memo : opt blob; + created_at_time : opt nat64; +}; + +type AllowanceArgs = record { + account : Account; + spender : Account; +}; + +type Allowance = record { + allowance : nat; + expires_at : opt Timestamp; +}; + type MetadataValue = variant { Nat : nat; Int : int; @@ -355,6 +412,11 @@ type OfferingEntry = record { }; service : { + // ICRC-2 endpoints + icrc2_approve : (ApproveArgs) -> (variant { Ok : nat; Err : ApproveError }); + icrc2_transfer_from : (TransferFromArgs) -> (variant { Ok : nat; Err : TransferFromError }); + icrc2_allowance : (AllowanceArgs) -> (Allowance) query; + // Node Provider (NP) management operations // crypto_sig can be used to cryptographically verify the authenticity of the message node_provider_register: (pubkey_bytes: vec nat8, crypto_sig: vec nat8) -> (ResultString); diff --git a/ic-canister/src/canister_backend/icrc1.rs b/ic-canister/src/canister_backend/icrc1.rs index cba5d1c..05be4ba 100644 --- a/ic-canister/src/canister_backend/icrc1.rs +++ b/ic-canister/src/canister_backend/icrc1.rs @@ -67,6 +67,10 @@ pub fn _icrc1_supported_standards() -> Vec { name: "ICRC-1".to_string(), url: "https://github.com/dfinity/ICRC-1/tree/main/standards/ICRC-1".to_string(), }, + Icrc1StandardRecord { + name: "ICRC-2".to_string(), + url: "https://github.com/dfinity/ICRC-1/tree/main/standards/ICRC-2".to_string(), + }, // Icrc1StandardRecord { // name: "ICRC-3".to_string(), // url: "https://github.com/dfinity/ICRC-1/tree/main/standards/ICRC-3".to_string(), diff --git a/ic-canister/src/canister_backend/icrc2.rs b/ic-canister/src/canister_backend/icrc2.rs index 8b13789..1a6f021 100644 --- a/ic-canister/src/canister_backend/icrc2.rs +++ b/ic-canister/src/canister_backend/icrc2.rs @@ -1 +1,196 @@ +use crate::{canister_backend::generic::LEDGER_MAP, DC_TOKEN_TRANSFER_FEE_E9S}; +use candid::Nat; +use dcc_common::{ + account_balance_get, approval_get, approval_update, get_timestamp_ns, ledger_funds_transfer, + nat_to_balance, FundsTransfer, FundsTransferApproval, IcrcCompatibleAccount, TokenAmountE9s, +}; +use ic_cdk::caller; +use icrc_ledger_types::icrc1::account::Account; +use icrc_ledger_types::icrc2::allowance::{Allowance, AllowanceArgs}; +use icrc_ledger_types::icrc2::approve::{ApproveArgs, ApproveError}; +use icrc_ledger_types::icrc2::transfer_from::{TransferFromArgs, TransferFromError}; +pub fn _icrc2_approve(args: ApproveArgs) -> Result { + // Validate fee + let fee = nat_to_balance(&args.fee.unwrap_or_default()); + if fee != DC_TOKEN_TRANSFER_FEE_E9S { + return Err(ApproveError::BadFee { + expected_fee: DC_TOKEN_TRANSFER_FEE_E9S.into(), + }); + } + + let caller_principal = caller(); + let from = + IcrcCompatibleAccount::new(caller_principal, args.from_subaccount.map(|s| s.to_vec())); + + // Prevent self-approval + if caller_principal == args.spender.owner { + return Err(ApproveError::GenericError { + error_code: 1u32.into(), + message: "Cannot approve transfers to self".to_string(), + }); + } + + // Check if caller has sufficient balance for fee + let balance = account_balance_get(&from); + if balance < DC_TOKEN_TRANSFER_FEE_E9S { + return Err(ApproveError::InsufficientFunds { + balance: balance.into(), + }); + } + + let now = get_timestamp_ns(); + + // Check expiration + if let Some(expires_at) = args.expires_at { + if expires_at <= now { + return Err(ApproveError::Expired { ledger_time: now }); + } + } + + // Check created_at_time + if let Some(created_at) = args.created_at_time { + if created_at > now { + return Err(ApproveError::CreatedInFuture { ledger_time: now }); + } + } + + let key = ( + Account { + owner: caller_principal, + subaccount: args.from_subaccount, + }, + args.spender, + ); + + // Check expected_allowance if provided + if let Some(expected) = args.expected_allowance { + let current = approval_get(key.0, key.1) + .map(|a| a.allowance.clone()) + .unwrap_or(Nat::from(0u32)); + if current != expected { + return Err(ApproveError::AllowanceChanged { + current_allowance: current.clone(), + }); + } + } + + // Update approval + approval_update( + key.0, + key.1, + Allowance { + allowance: args.amount.clone(), + expires_at: args.expires_at, + }, + ); + + // Record approval in ledger + let approval = FundsTransferApproval::new( + from.clone(), + args.spender.into(), + args.amount + .min(TokenAmountE9s::MAX.into()) + .0 + .to_u64_digits()[0], + args.expires_at, + DC_TOKEN_TRANSFER_FEE_E9S, + args.memo.map(|m| m.0.to_vec()).unwrap_or_default(), + ); + + LEDGER_MAP.with(|ledger| { + let mut ledger = ledger.borrow_mut(); + approval + .add_to_ledger(&mut ledger) + .map(|_| ledger.get_blocks_count().into()) + .map_err(|e| ApproveError::GenericError { + error_code: 0u32.into(), + message: e.to_string(), + }) + }) +} + +pub fn _icrc2_transfer_from(args: TransferFromArgs) -> Result { + let caller_principal = caller(); + let spender = Account { + owner: caller_principal, + subaccount: args.spender_subaccount, + }; + + let from: IcrcCompatibleAccount = args.from.into(); + let to: IcrcCompatibleAccount = args.to.into(); + let amount = nat_to_balance(&args.amount); + let fee = nat_to_balance(&args.fee.unwrap_or_default()); + + // Check allowance + let approval = approval_get(from.clone().into(), spender); + let mut allowed_amount = approval + .as_ref() + .map(|a| { + if let Some(expires_at) = a.expires_at { + if expires_at <= get_timestamp_ns() { + return 0; + } + } + nat_to_balance(&a.allowance) + }) + .unwrap_or_default(); + + if allowed_amount < amount + fee { + return Err(TransferFromError::InsufficientAllowance { + allowance: allowed_amount.into(), + }); + } + + // Check balance + let balance = account_balance_get(&from); + if balance < amount + fee { + return Err(TransferFromError::InsufficientFunds { + balance: balance.into(), + }); + } + + // Update allowance + allowed_amount -= amount + fee; + approval_update( + from.clone().into(), + spender, + Allowance { + allowance: allowed_amount.into(), + expires_at: approval.map(|a| a.expires_at).unwrap_or(None), + }, + ); + + // Execute transfer + LEDGER_MAP.with(|ledger| { + let mut ledger = ledger.borrow_mut(); + let balance_from_after = balance - amount - fee; + let balance_to_after = account_balance_get(&to) + amount; + + let transfer = FundsTransfer::new( + from.clone(), + to.clone(), + Some(fee), + None, + Some(args.created_at_time.unwrap_or(get_timestamp_ns())), + args.memo.map(|m| m.0.to_vec()).unwrap_or_default(), + amount, + balance_from_after, + balance_to_after, + ); + + ledger_funds_transfer(&mut ledger, transfer) + .map(|_| ledger.get_blocks_count().into()) + .map_err(|e| TransferFromError::GenericError { + error_code: 0u32.into(), + message: e.to_string(), + }) + }) +} + +pub fn _icrc2_allowance(args: AllowanceArgs) -> Allowance { + approval_get(args.account, args.spender).unwrap_or(Allowance { + allowance: 0u32.into(), + expires_at: None, + }) +} diff --git a/ic-canister/src/canister_endpoints/icrc2.rs b/ic-canister/src/canister_endpoints/icrc2.rs index 7923e53..7585e8d 100644 --- a/ic-canister/src/canister_endpoints/icrc2.rs +++ b/ic-canister/src/canister_endpoints/icrc2.rs @@ -1,2 +1,21 @@ -#[allow(unused_imports)] -use ic_cdk::println; +use crate::canister_backend::icrc2::{_icrc2_allowance, _icrc2_approve, _icrc2_transfer_from}; +use candid::Nat; +use ic_cdk::{query, update}; +use icrc_ledger_types::icrc2::allowance::{Allowance, AllowanceArgs}; +use icrc_ledger_types::icrc2::approve::{ApproveArgs, ApproveError}; +use icrc_ledger_types::icrc2::transfer_from::{TransferFromArgs, TransferFromError}; + +#[update] +fn icrc2_approve(args: ApproveArgs) -> Result { + _icrc2_approve(args) +} + +#[update] +fn icrc2_transfer_from(args: TransferFromArgs) -> Result { + _icrc2_transfer_from(args) +} + +#[query] +fn icrc2_allowance(args: AllowanceArgs) -> Allowance { + _icrc2_allowance(args) +} From 8efbec246b88c6e1a0b9da8fa37aca99a0cb9034 Mon Sep 17 00:00:00 2001 From: Yan Liu Date: Sat, 25 Jan 2025 16:11:56 +0100 Subject: [PATCH 2/2] ICRC-2 tests --- ic-canister/tests/test_canister.rs | 14 +- ic-canister/tests/test_icrc2.rs | 536 +++++++++++++++++++++++++++++ 2 files changed, 546 insertions(+), 4 deletions(-) create mode 100644 ic-canister/tests/test_icrc2.rs diff --git a/ic-canister/tests/test_canister.rs b/ic-canister/tests/test_canister.rs index f833063..b43d4d3 100644 --- a/ic-canister/tests/test_canister.rs +++ b/ic-canister/tests/test_canister.rs @@ -214,10 +214,16 @@ fn test_icrc1_compatibility() { no_args.clone(), Vec ), - vec![Icrc1StandardRecord { - name: "ICRC-1".to_string(), - url: "https://github.com/dfinity/ICRC-1/tree/main/standards/ICRC-1".to_string(), - }] + vec![ + Icrc1StandardRecord { + name: "ICRC-1".to_string(), + url: "https://github.com/dfinity/ICRC-1/tree/main/standards/ICRC-1".to_string(), + }, + Icrc1StandardRecord { + name: "ICRC-2".to_string(), + url: "https://github.com/dfinity/ICRC-1/tree/main/standards/ICRC-2".to_string(), + } + ] ); // The following two methods are tested in test_balances_and_transfers() // icrc1_balance_of : (Account) -> (nat) query; diff --git a/ic-canister/tests/test_icrc2.rs b/ic-canister/tests/test_icrc2.rs new file mode 100644 index 0000000..5599531 --- /dev/null +++ b/ic-canister/tests/test_icrc2.rs @@ -0,0 +1,536 @@ +use candid::{encode_one, Nat, Principal}; +use dcc_common::{BLOCK_INTERVAL_SECS, FIRST_BLOCK_TIMESTAMP_NS}; +use icrc_ledger_types::icrc1::account::Account; +use icrc_ledger_types::icrc1::transfer::Memo; +use icrc_ledger_types::icrc2::allowance::AllowanceArgs; +use icrc_ledger_types::icrc2::approve::{ApproveArgs, ApproveError}; +use icrc_ledger_types::icrc2::transfer_from::{TransferFromArgs, TransferFromError}; +use once_cell::sync::Lazy; +use pocket_ic::{PocketIc, WasmResult}; +use std::path::{Path, PathBuf}; +use std::process::Command; + +// Reuse test infrastructure from ICRC-1 tests +fn workspace_dir() -> PathBuf { + let output = std::process::Command::new(env!("CARGO")) + .arg("locate-project") + .arg("--workspace") + .arg("--message-format=plain") + .output() + .unwrap() + .stdout; + let cargo_path = Path::new(std::str::from_utf8(&output).unwrap().trim()); + cargo_path.parent().unwrap().to_path_buf() +} + +static CANISTER_WASM: Lazy> = Lazy::new(|| { + let mut path = workspace_dir(); + Command::new("dfx") + .arg("build") + .current_dir(path.join("ic-canister")) + .output() + .unwrap(); + path.push("target/wasm32-unknown-unknown/release/decent_cloud_canister.wasm"); + fs_err::read(path).unwrap() +}); + +macro_rules! query_check_and_decode { + ($pic:expr, $can:expr, $method_name:expr, $method_arg:expr, $decode_type:ty) => {{ + let reply = $pic + .query_call( + $can, + Principal::anonymous(), + $method_name, + $method_arg.clone(), + ) + .expect("Failed to run query call on the canister"); + let reply = match reply { + WasmResult::Reply(reply) => reply, + WasmResult::Reject(_) => panic!("Received a reject"), + }; + + candid::decode_one::<$decode_type>(&reply).expect("Failed to decode") + }}; +} + +macro_rules! update_check_and_decode { + ($pic:expr, $can:expr, $sender:expr, $method_name:expr, $method_arg:expr, $decode_type:ty) => {{ + let reply = $pic + .update_call($can, $sender, $method_name, $method_arg.clone()) + .expect("Failed to run update call on the canister"); + let reply = match reply { + WasmResult::Reply(reply) => reply, + WasmResult::Reject(_) => panic!("Received a reject"), + }; + + candid::decode_one::<$decode_type>(&reply).expect("Failed to decode") + }}; +} + +fn create_test_canister() -> (PocketIc, Principal) { + let pic = PocketIc::new(); + let canister_id = pic.create_canister(); + pic.add_cycles(canister_id, 20_000_000_000_000); + + pic.install_canister( + canister_id, + CANISTER_WASM.clone(), + encode_one(true).expect("failed to encode"), + None, + ); + + // Ensure deterministic timestamp + let ts_ns = FIRST_BLOCK_TIMESTAMP_NS + 100 * BLOCK_INTERVAL_SECS * 1_000_000_000; + let ts_1 = encode_one(ts_ns).unwrap(); + update_check_and_decode!( + pic, + canister_id, + Principal::anonymous(), + "set_timestamp_ns", + ts_1, + () + ); + + (pic, canister_id) +} + +fn mint_tokens_for_test(pic: &PocketIc, can_id: Principal, acct: &Account, amount: u64) -> Nat { + update_check_and_decode!( + pic, + can_id, + acct.owner, + "mint_tokens_for_test", + candid::encode_args((acct, amount, None::>)).unwrap(), + Nat + ) +} + +fn get_timestamp_ns(pic: &PocketIc, can: Principal) -> u64 { + query_check_and_decode!(pic, can, "get_timestamp_ns", encode_one(()).unwrap(), u64) +} + +fn get_transfer_fee(pic: &PocketIc, can: &Principal) -> Nat { + query_check_and_decode!( + pic, + *can, + "icrc1_fee", + encode_one(()).expect("failed to encode"), + Nat + ) +} + +#[test] +fn test_basic_approve() { + let (pic, can_id) = create_test_canister(); + + let owner = Account { + owner: Principal::from_slice(&[1; 29]), + subaccount: None, + }; + let spender = Account { + owner: Principal::from_slice(&[2; 29]), + subaccount: None, + }; + + // Mint tokens to owner + mint_tokens_for_test(&pic, can_id, &owner, 1_000_000_000); + + // Get current timestamp and fee + let ts = get_timestamp_ns(&pic, can_id); + let fee = get_transfer_fee(&pic, &can_id); + + // Approve spending + let approve_args = ApproveArgs { + from_subaccount: None, + spender: spender.clone(), + amount: 500_000_000u64.into(), + expected_allowance: None, + expires_at: None, + fee: Some(fee), + memo: None, + created_at_time: Some(ts), + }; + + let result = update_check_and_decode!( + pic, + can_id, + owner.owner, + "icrc2_approve", + candid::encode_one(approve_args).unwrap(), + Result + ); + assert!(result.is_ok()); + + // Check allowance + let allowance_args = AllowanceArgs { + account: owner.clone(), + spender: spender.clone(), + }; + + let allowance = query_check_and_decode!( + pic, + can_id, + "icrc2_allowance", + candid::encode_one(allowance_args).unwrap(), + icrc_ledger_types::icrc2::allowance::Allowance + ); + + assert_eq!(allowance.allowance, Nat::from(500_000_000u64)); + assert_eq!(allowance.expires_at, None); +} + +#[test] +fn test_approve_with_expiration() { + let (pic, can_id) = create_test_canister(); + + let owner = Account { + owner: Principal::from_slice(&[3; 29]), + subaccount: None, + }; + let spender = Account { + owner: Principal::from_slice(&[4; 29]), + subaccount: None, + }; + + // Mint tokens to owner + mint_tokens_for_test(&pic, can_id, &owner, 1_000_000_000); + + // Get current timestamp and fee + let ts = get_timestamp_ns(&pic, can_id); + let fee = get_transfer_fee(&pic, &can_id); + let expires_at = ts + 1_000_000_000; // 1 second in the future + + // Approve spending with expiration + let approve_args = ApproveArgs { + from_subaccount: None, + spender: spender.clone(), + amount: 500_000_000u64.into(), + expected_allowance: None, + expires_at: Some(expires_at), + fee: Some(fee), + memo: None, + created_at_time: Some(ts), + }; + + let result = update_check_and_decode!( + pic, + can_id, + owner.owner, + "icrc2_approve", + candid::encode_one(approve_args).unwrap(), + Result + ); + assert!(result.is_ok()); + + // Check allowance + let allowance_args = AllowanceArgs { + account: owner, + spender, + }; + + let allowance = query_check_and_decode!( + pic, + can_id, + "icrc2_allowance", + candid::encode_one(allowance_args).unwrap(), + icrc_ledger_types::icrc2::allowance::Allowance + ); + + assert_eq!(allowance.allowance, Nat::from(500_000_000u64)); + assert_eq!(allowance.expires_at, Some(expires_at)); +} + +#[test] +fn test_transfer_from() { + let (pic, can_id) = create_test_canister(); + + let owner = Account { + owner: Principal::from_slice(&[5; 29]), + subaccount: None, + }; + let spender = Account { + owner: Principal::from_slice(&[6; 29]), + subaccount: None, + }; + let recipient = Account { + owner: Principal::from_slice(&[7; 29]), + subaccount: None, + }; + + // Mint tokens to owner + mint_tokens_for_test(&pic, can_id, &owner, 1_000_000_000); + + // Get current timestamp and fee + let ts = get_timestamp_ns(&pic, can_id); + let fee = get_transfer_fee(&pic, &can_id); + + // Approve spending + let approve_args = ApproveArgs { + from_subaccount: None, + spender: spender.clone(), + amount: 500_000_000u64.into(), + expected_allowance: None, + expires_at: None, + fee: Some(fee.clone()), + memo: None, + created_at_time: Some(ts), + }; + + let result = update_check_and_decode!( + pic, + can_id, + owner.owner, + "icrc2_approve", + candid::encode_one(approve_args).unwrap(), + Result + ); + assert!(result.is_ok()); + + // Transfer from owner to recipient using spender's allowance + let transfer_from_args = TransferFromArgs { + spender_subaccount: None, + from: owner.clone(), + to: recipient.clone(), + amount: 300_000_000u64.into(), + fee: Some(fee.clone()), + memo: None, + created_at_time: Some(ts), + }; + + let result = update_check_and_decode!( + pic, + can_id, + spender.owner, + "icrc2_transfer_from", + candid::encode_one(transfer_from_args).unwrap(), + Result + ); + assert!(result.is_ok()); + + // Check remaining allowance + let allowance_args = AllowanceArgs { + account: owner, + spender, + }; + + let allowance = query_check_and_decode!( + pic, + can_id, + "icrc2_allowance", + candid::encode_one(allowance_args).unwrap(), + icrc_ledger_types::icrc2::allowance::Allowance + ); + + // Remaining allowance should be initial amount minus transfer amount and fee + assert_eq!( + allowance.allowance, + Nat::from(500_000_000u64) - Nat::from(300_000_000u64) - fee + ); +} + +#[test] +fn test_expired_allowance() { + let (pic, can_id) = create_test_canister(); + + let owner = Account { + owner: Principal::from_slice(&[8; 29]), + subaccount: None, + }; + let spender = Account { + owner: Principal::from_slice(&[9; 29]), + subaccount: None, + }; + + // Mint tokens to owner + mint_tokens_for_test(&pic, can_id, &owner, 1_000_000_000); + + // Get current timestamp and fee + let ts = get_timestamp_ns(&pic, can_id); + let fee = get_transfer_fee(&pic, &can_id); + + // Approve with immediate expiration + let approve_args = ApproveArgs { + from_subaccount: None, + spender: spender.clone(), + amount: 500_000_000u64.into(), + expected_allowance: None, + expires_at: Some(ts), // Expires immediately + fee: Some(fee), + memo: None, + created_at_time: Some(ts), + }; + + let result = update_check_and_decode!( + pic, + can_id, + owner.owner, + "icrc2_approve", + candid::encode_one(approve_args).unwrap(), + Result + ); + assert!(matches!( + result, + Err(ApproveError::Expired { ledger_time: _ }) + )); +} + +#[test] +fn test_insufficient_funds_for_approval() { + let (pic, can_id) = create_test_canister(); + + let owner = Account { + owner: Principal::from_slice(&[10; 29]), + subaccount: None, + }; + let spender = Account { + owner: Principal::from_slice(&[11; 29]), + subaccount: None, + }; + + // Mint very small amount of tokens to owner (less than fee) + mint_tokens_for_test(&pic, can_id, &owner, 100); + + // Get current timestamp and fee + let ts = get_timestamp_ns(&pic, can_id); + let fee = get_transfer_fee(&pic, &can_id); + + // Try to approve + let approve_args = ApproveArgs { + from_subaccount: None, + spender, + amount: 50u64.into(), + expected_allowance: None, + expires_at: None, + fee: Some(fee), + memo: None, + created_at_time: Some(ts), + }; + + let result = update_check_and_decode!( + pic, + can_id, + owner.owner, + "icrc2_approve", + candid::encode_one(approve_args).unwrap(), + Result + ); + assert!(matches!( + result, + Err(ApproveError::InsufficientFunds { balance: _ }) + )); +} + +#[test] +fn test_self_approval_prevention() { + let (pic, can_id) = create_test_canister(); + + let owner = Account { + owner: Principal::from_slice(&[12; 29]), + subaccount: None, + }; + + // Mint tokens to owner + mint_tokens_for_test(&pic, can_id, &owner, 1_000_000_000); + + // Get current timestamp and fee + let ts = get_timestamp_ns(&pic, can_id); + let fee = get_transfer_fee(&pic, &can_id); + + // Try to approve self + let approve_args = ApproveArgs { + from_subaccount: None, + spender: owner.clone(), // Same as owner + amount: 500_000_000u64.into(), + expected_allowance: None, + expires_at: None, + fee: Some(fee), + memo: None, + created_at_time: Some(ts), + }; + + let result = update_check_and_decode!( + pic, + can_id, + owner.owner, + "icrc2_approve", + candid::encode_one(approve_args).unwrap(), + Result + ); + assert!(matches!( + result, + Err(ApproveError::GenericError { + error_code: _, + message: _ + }) + )); +} + +#[test] +fn test_expected_allowance() { + let (pic, can_id) = create_test_canister(); + + let owner = Account { + owner: Principal::from_slice(&[13; 29]), + subaccount: None, + }; + let spender = Account { + owner: Principal::from_slice(&[14; 29]), + subaccount: None, + }; + + // Mint tokens to owner + mint_tokens_for_test(&pic, can_id, &owner, 1_000_000_000); + + // Get current timestamp and fee + let ts = get_timestamp_ns(&pic, can_id); + let fee = get_transfer_fee(&pic, &can_id); + + // First approval + let approve_args = ApproveArgs { + from_subaccount: None, + spender: spender.clone(), + amount: 500_000_000u64.into(), + expected_allowance: Some(0u64.into()), // Expect no existing allowance + expires_at: None, + fee: Some(fee.clone()), + memo: None, + created_at_time: Some(ts), + }; + + let result = update_check_and_decode!( + pic, + can_id, + owner.owner, + "icrc2_approve", + candid::encode_one(approve_args).unwrap(), + Result + ); + assert!(result.is_ok()); + + // Second approval with wrong expected allowance + let approve_args = ApproveArgs { + from_subaccount: None, + spender: spender.clone(), + amount: 300_000_000u64.into(), + expected_allowance: Some(0u64.into()), // Wrong expectation + expires_at: None, + fee: Some(fee), + memo: None, + created_at_time: Some(ts), + }; + + let result = update_check_and_decode!( + pic, + can_id, + owner.owner, + "icrc2_approve", + candid::encode_one(approve_args).unwrap(), + Result + ); + assert!(matches!( + result, + Err(ApproveError::AllowanceChanged { + current_allowance: _ + }) + )); +}