From 12684b21a10fe587b1c02ed526ba1063b1d558c1 Mon Sep 17 00:00:00 2001 From: Max Date: Thu, 26 Sep 2024 16:58:44 +0200 Subject: [PATCH] refactor(guards): Separate cycle and token payment paths (#21) # Motivation The cycle payment path withdraws cycles to the canister. This path is suitable where the purpose of the payment is to fund the canister's own running costs. This path uses `withdraw_from` which is available from the cycles ledger but is not in the ICRC-2 standard. # Changes - Separate code and tests for cycle and token payment # Tests Pocket-ic tests are included for every path. --- Cargo.lock | 1 + dfx.json | 1 + src/api/src/caller.rs | 2 +- src/api/src/cycles.rs | 34 +- src/api/src/error.rs | 8 +- src/declarations/cycles_ledger/src/lib.rs | 2 +- src/example/paid_service/src/lib.rs | 30 +- .../tests/it/caller_pays_icrc2_cycles.rs | 287 +++++++++ .../tests/it/caller_pays_icrc2_tokens.rs | 126 ++++ src/example/paid_service/tests/it/icrc2.rs | 568 ------------------ src/example/paid_service/tests/it/main.rs | 5 +- .../tests/it/patron_pays_icrc2_cycles.rs | 218 +++++++ .../tests/it/patron_pays_icrc2_tokens.rs | 117 ++++ src/example/paid_service/tests/it/util/mod.rs | 1 + .../tests/it/util/pic_canister.rs | 5 - .../tests/it/util/test_environment.rs | 227 +++++++ src/guard/Cargo.toml | 1 + src/guard/src/guards/any.rs | 70 +-- .../src/guards/caller_pays_icrc2_tokens.rs | 58 ++ src/guard/src/guards/icrc2_cycles.rs | 25 +- src/guard/src/guards/mod.rs | 2 + .../src/guards/patron_pays_icrc2_tokens.rs | 68 +++ 22 files changed, 1201 insertions(+), 655 deletions(-) create mode 100644 src/example/paid_service/tests/it/caller_pays_icrc2_cycles.rs create mode 100644 src/example/paid_service/tests/it/caller_pays_icrc2_tokens.rs delete mode 100644 src/example/paid_service/tests/it/icrc2.rs create mode 100644 src/example/paid_service/tests/it/patron_pays_icrc2_cycles.rs create mode 100644 src/example/paid_service/tests/it/patron_pays_icrc2_tokens.rs create mode 100644 src/example/paid_service/tests/it/util/test_environment.rs create mode 100644 src/guard/src/guards/caller_pays_icrc2_tokens.rs create mode 100644 src/guard/src/guards/patron_pays_icrc2_tokens.rs diff --git a/Cargo.lock b/Cargo.lock index 06a4c22..a9e7f42 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -670,6 +670,7 @@ dependencies = [ "cycles-ledger-client", "ic-cdk 0.16.0", "ic-papi-api", + "serde", "serde_bytes", ] diff --git a/dfx.json b/dfx.json index 90b87c8..6d9b31f 100644 --- a/dfx.json +++ b/dfx.json @@ -19,6 +19,7 @@ "candid": "https://github.com/dfinity/cycles-ledger/releases/download/cycles-ledger-v1.0.1/cycles-ledger.did", "wasm": "https://github.com/dfinity/cycles-ledger/releases/download/cycles-ledger-v1.0.1/cycles-ledger.wasm.gz", "init_arg": "( variant { Init = record { index_id = null; max_blocks_per_request = 9_999 : nat64 }},)", + "specified_id": "um5iw-rqaaa-aaaaq-qaaba-cai", "remote": { "id": { "ic": "um5iw-rqaaa-aaaaq-qaaba-cai" diff --git a/src/api/src/caller.rs b/src/api/src/caller.rs index cc62b61..932e58b 100644 --- a/src/api/src/caller.rs +++ b/src/api/src/caller.rs @@ -18,7 +18,7 @@ pub enum PaymentType { PatronPaysIcrc2Cycles(PatronPaysIcrc2Cycles), /// The caller is paying with tokens from their main account on the specified ledger. CallerPaysIcrc2Tokens(CallerPaysIcrc2Tokens), - /// A patron is paying, on behalf of the caller, from their main account on the specified ledger. + /// A patron is paying, on behalf of the caller, from an account on the specified ledger. PatronPaysIcrc2Tokens(PatronPaysIcrc2Tokens), } diff --git a/src/api/src/cycles.rs b/src/api/src/cycles.rs index bf3ab8f..636d674 100644 --- a/src/api/src/cycles.rs +++ b/src/api/src/cycles.rs @@ -1,25 +1,31 @@ use candid::Principal; /// The cycles ledger canister ID on mainnet. -const MAINNET_CYCLES_LEDGER_CANISTER_ID: &str = "um5iw-rqaaa-aaaaq-qaaba-cai"; - -/// The cycles ledger canister ID. /// -/// - If a `cycles_ledger` canister is listed in `dfx.json`, the `dfx build` command will set the -/// environment variable `CANISTER_ID_CYCLES_LEDGER` and we use this to obtain the canister ID. -/// - Otherwise, the mainnet cycles ledger canister ID is used. -const CYCLES_LEDGER_CANISTER_ID: &str = if let Some(id) = option_env!("CANISTER_ID_CYCLES_LEDGER") { - id -} else { - MAINNET_CYCLES_LEDGER_CANISTER_ID -}; +/// Note: This canister ID should also be used in test environments. The dfx.json +/// can implement this with: +/// ``` +/// { "canisters": { +/// "cycles_ledger": { +/// ... +/// "specified_id": "um5iw-rqaaa-aaaaq-qaaba-cai", +/// "remote": { +/// "id": { +/// "ic": "um5iw-rqaaa-aaaaq-qaaba-cai" +/// } +/// } +/// }, +/// ... +/// } +/// ``` +pub const MAINNET_CYCLES_LEDGER_CANISTER_ID: &str = "um5iw-rqaaa-aaaaq-qaaba-cai"; -/// The `CYCLES_LEDGER_CANISTER_ID` as a `Principal`. +/// The `MAINNET_CYCLES_LEDGER_CANISTER_ID` as a `Principal`. /// /// # Panics -/// - If the `CYCLES_LEDGER_CANISTER_ID` is not a valid `Principal`. +/// - If the `MAINNET_CYCLES_LEDGER_CANISTER_ID` is not a valid `Principal`. #[must_use] pub fn cycles_ledger_canister_id() -> Principal { - Principal::from_text(CYCLES_LEDGER_CANISTER_ID) + Principal::from_text(MAINNET_CYCLES_LEDGER_CANISTER_ID) .expect("Invalid cycles ledger canister ID provided at compile time.") } diff --git a/src/api/src/error.rs b/src/api/src/error.rs index b115601..69c5578 100644 --- a/src/api/src/error.rs +++ b/src/api/src/error.rs @@ -1,7 +1,7 @@ //! Payment API error types. use candid::{CandidType, Deserialize, Principal}; pub use cycles_ledger_client::Account; -use cycles_ledger_client::WithdrawFromError; +use cycles_ledger_client::{TransferFromError, WithdrawFromError}; use crate::caller::TokenAmount; @@ -12,10 +12,14 @@ pub enum PaymentError { LedgerUnreachable { ledger: Principal, }, - LedgerError { + LedgerWithdrawFromError { ledger: Principal, error: WithdrawFromError, }, + LedgerTransferFromError { + ledger: Principal, + error: TransferFromError, + }, InsufficientFunds { needed: TokenAmount, available: TokenAmount, diff --git a/src/declarations/cycles_ledger/src/lib.rs b/src/declarations/cycles_ledger/src/lib.rs index be7b23d..4126b18 100644 --- a/src/declarations/cycles_ledger/src/lib.rs +++ b/src/declarations/cycles_ledger/src/lib.rs @@ -261,7 +261,7 @@ pub struct TransferFromArgs { pub created_at_time: Option, pub amount: candid::Nat, } -#[derive(CandidType, Deserialize, Debug, Clone)] +#[derive(CandidType, Deserialize, Debug, Clone, Eq, PartialEq)] pub enum TransferFromError { GenericError { message: String, diff --git a/src/example/paid_service/src/lib.rs b/src/example/paid_service/src/lib.rs index 3678f56..d71a3b9 100644 --- a/src/example/paid_service/src/lib.rs +++ b/src/example/paid_service/src/lib.rs @@ -3,12 +3,15 @@ mod state; use example_paid_service_api::InitArgs; use ic_cdk::init; use ic_cdk_macros::{export_candid, update}; +use ic_papi_api::cycles::cycles_ledger_canister_id; use ic_papi_api::{PaymentError, PaymentType}; use ic_papi_guard::guards::{ - attached_cycles::AttachedCyclesPayment, icrc2_cycles::Icrc2CyclesPaymentGuard, + attached_cycles::AttachedCyclesPayment, + caller_pays_icrc2_tokens::CallerPaysIcrc2TokensPaymentGuard, + icrc2_cycles::Icrc2CyclesPaymentGuard, }; use ic_papi_guard::guards::{PaymentContext, PaymentGuard, PaymentGuard2}; -use state::{payment_ledger, set_init_args, PAYMENT_GUARD}; +use state::{set_init_args, PAYMENT_GUARD}; #[init] fn init(init_args: Option) { @@ -31,15 +34,26 @@ async fn cost_1000_attached_cycles() -> Result { /// An API method that requires 1 billion cycles using an ICRC-2 approve with default parameters. #[update()] -async fn cost_1b_icrc2_from_caller() -> Result { - let guard = Icrc2CyclesPaymentGuard { - ledger_canister_id: payment_ledger(), - ..Icrc2CyclesPaymentGuard::default() - }; - guard.deduct(1_000_000_000).await?; +async fn caller_pays_1b_icrc2_cycles() -> Result { + Icrc2CyclesPaymentGuard::default() + .deduct(1_000_000_000) + .await?; Ok("Yes, you paid 1 billion cycles!".to_string()) } +/// An API method that requires 1 billion tokens (in this case cycles) using an ICRC-2 approve with default parameters. +/// +/// The tokens will be transferred to the vendor's main account on the ledger. +#[update()] +async fn caller_pays_1b_icrc2_tokens() -> Result { + CallerPaysIcrc2TokensPaymentGuard { + ledger: cycles_ledger_canister_id(), + } + .deduct(1_000_000_000) + .await?; + Ok("Yes, you paid 1 billion tokens!".to_string()) +} + /// An API method that requires 1 billion cycles, paid in whatever way the client chooses. #[update()] async fn cost_1b(payment: PaymentType) -> Result { diff --git a/src/example/paid_service/tests/it/caller_pays_icrc2_cycles.rs b/src/example/paid_service/tests/it/caller_pays_icrc2_cycles.rs new file mode 100644 index 0000000..9dab161 --- /dev/null +++ b/src/example/paid_service/tests/it/caller_pays_icrc2_cycles.rs @@ -0,0 +1,287 @@ +//! Tests for the `PaymentType::CallerPaysIcrc2Cycles` payment type. +use crate::util::pic_canister::PicCanisterTrait; +use crate::util::test_environment::{CallerPaysWithIcrc2CyclesTestSetup, PaidMethods, LEDGER_FEE}; +use candid::Nat; +use ic_papi_api::{PaymentError, PaymentType}; + +/// Verifies that the `PaymentType::CallerPaysIcrc2Cycles` payment type works as expected +/// on an API method that has only the corresponding guard. +/// +/// Notes: +/// - The caller does not need to specify any payment arguments. (See `call_paid_service(...)` in the test.) +/// - The caller needs to pay the API cost plus one ledger fee, for the privilege of using this payment type. (See `user_approves_payment_for_paid_service(...)` in the test.) +#[test] +fn caller_pays_cycles_by_icrc2() { + let setup = CallerPaysWithIcrc2CyclesTestSetup::default(); + let mut expected_user_balance = CallerPaysWithIcrc2CyclesTestSetup::USER_INITIAL_BALANCE; + // Ok, now we should be able to make an API call with an ICRC-2 approve. + let method = PaidMethods::Cost1bIcrc2Cycles; + // Pre-approve payment + setup.user_approves_payment_for_paid_service(method.cost() + LEDGER_FEE); + // Check that the user has been charged for the approve. + expected_user_balance -= LEDGER_FEE; + setup.assert_user_balance_eq( + expected_user_balance, + "Expected the user balance to be charged for the ICRC2 approve".to_string(), + ); + // Now make an API call + // Check the canister cycles balance beforehand + let service_canister_cycles_before = setup.pic.cycle_balance(setup.paid_service.canister_id); + // Call the API + let response: Result = setup.call_paid_service(setup.user, method, ()); + assert_eq!( + response, + Ok("Yes, you paid 1 billion cycles!".to_string()), + "Should have succeeded with an accurate prepayment", + ); + let service_canister_cycles_after = setup.pic.cycle_balance(setup.paid_service.canister_id); + assert!( + service_canister_cycles_after > service_canister_cycles_before, + "The service canister needs to charge more to cover its cycle cost! Loss: {}", + service_canister_cycles_before - service_canister_cycles_after + ); + expected_user_balance -= method.cost() + LEDGER_FEE; + setup.assert_user_balance_eq( + expected_user_balance, + "Expected the user balance to be the initial balance minus the ledger and API fees" + .to_string(), + ); +} + +/// Verifies that the `PaymentType::CallerPaysIcrc2Cycles` payment type works as expected +/// on an API method that requires the payment argument to be declared explicitly. +#[test] +fn caller_pays_cycles_by_named_icrc2() { + let setup = CallerPaysWithIcrc2CyclesTestSetup::default(); + let mut expected_user_balance = CallerPaysWithIcrc2CyclesTestSetup::USER_INITIAL_BALANCE; + // Ok, now we should be able to make an API call with an ICRC-2 approve. + let method = PaidMethods::Cost1b; + // Pre-approve payment + setup.user_approves_payment_for_paid_service(method.cost() + LEDGER_FEE); + // Check that the user has been charged for the approve. + expected_user_balance -= LEDGER_FEE; + setup.assert_user_balance_eq( + expected_user_balance, + "Expected the user balance to be charged for the ICRC2 approve".to_string(), + ); + // Now make an API call + // Check the canister cycles balance beforehand + let service_canister_cycles_before = setup.pic.cycle_balance(setup.paid_service.canister_id); + // Call the API + let response: Result = + setup.call_paid_service(setup.user, method, PaymentType::CallerPaysIcrc2Cycles); + assert_eq!( + response, + Ok("Yes, you paid 1 billion cycles!".to_string()), + "Should have succeeded with an accurate prepayment", + ); + let service_canister_cycles_after = setup.pic.cycle_balance(setup.paid_service.canister_id); + assert!( + service_canister_cycles_after > service_canister_cycles_before, + "The service canister needs to charge more to cover its cycle cost! Loss: {}", + service_canister_cycles_before - service_canister_cycles_after + ); + expected_user_balance -= method.cost() + LEDGER_FEE; + setup.assert_user_balance_eq( + expected_user_balance, + "Expected the user balance to be the initial balance minus the ledger and API fees" + .to_string(), + ); +} + +/// Verifies that the `PaymentType::CallerPaysIcrc2Cycles` payment type works as expected with a range of approval amounts near the required amount. +/// +/// - The call should succeed if the ICRC2 approval is greater than or equal to the cost of the method. +/// - The user's main cycles account has cycles deducted when a call succeeds. +/// - The cycle balance of the canister providing the paid service increases when a call succeeds. +/// - Note: Given that the canister consumes cycles as part of the operation, we check that the balance increases but do not check an exact amount. +#[test] +fn caller_pays_icrc2_cycles_works_with_large_enough_approval() { + let setup = CallerPaysWithIcrc2CyclesTestSetup::default(); + let mut expected_user_balance = CallerPaysWithIcrc2CyclesTestSetup::USER_INITIAL_BALANCE; + + // Try calling a method with a range of approval amounts. The call should succeed if the + // ICRC2 approval is greater than or equal to the cost of the method. + let method = PaidMethods::Cost1bIcrc2Cycles; + for payment in (method.cost() - 5)..(method.cost() + 5) { + for _repetition in 0..2 { + // Pre-approve payment + setup.user_approves_payment_for_paid_service(payment + LEDGER_FEE); + // Check that the user has been charged for the approve. + expected_user_balance -= LEDGER_FEE; + setup.assert_user_balance_eq( + expected_user_balance, + "Expected the user balance to be charged for the ICRC2 approve".to_string(), + ); + + // Check the balance beforehand + let service_canister_cycles_before = + setup.pic.cycle_balance(setup.paid_service.canister_id); + // Call the API + let response: Result = + setup.call_paid_service(setup.user, method, ()); + if payment < method.cost() { + assert_eq!( + response, + Err(PaymentError::LedgerWithdrawFromError { + ledger: setup.ledger.canister_id(), + error: cycles_ledger_client::WithdrawFromError::InsufficientAllowance { + allowance: Nat::from(payment + LEDGER_FEE), + } + }), + "Should have failed with only {} cycles attached", + payment + ); + setup.assert_user_balance_eq( + expected_user_balance, + "Expected the user balance to be unchanged by a failed ICRC2".to_string(), + ); + } else { + assert_eq!( + response, + Ok("Yes, you paid 1 billion cycles!".to_string()), + "Should have succeeded with {} cycles attached", + payment + ); + let service_canister_cycles_after = + setup.pic.cycle_balance(setup.paid_service.canister_id); + assert!( + service_canister_cycles_after > service_canister_cycles_before, + "The service canister needs to charge more to cover its cycle cost! Loss: {}", + service_canister_cycles_before - service_canister_cycles_after + ); + expected_user_balance -= method.cost() + LEDGER_FEE; + setup.assert_user_balance_eq( + expected_user_balance, + "Expected the user balance to be the initial balance minus the ledger and API fees" + .to_string(), + ); + } + } + } +} + +/// Verifies that a user can pay for multiple API calls with a single approval. +#[test] +fn caller_pays_icrc2_cycles_supports_multiple_calls_with_a_single_approval() { + let setup = CallerPaysWithIcrc2CyclesTestSetup::default(); + let mut expected_user_balance = CallerPaysWithIcrc2CyclesTestSetup::USER_INITIAL_BALANCE; + + // Exercise the protocol... + // Pre-approve a large sum. + setup.user_approves_payment_for_paid_service(expected_user_balance); + // Check that the user has been charged for the approve. + expected_user_balance -= LEDGER_FEE; + setup.assert_user_balance_eq( + expected_user_balance, + "Expected the user balance to be charged for the ICRC2 approve".to_string(), + ); + // Now make several API calls + let method = PaidMethods::Cost1bIcrc2Cycles; + for _repetition in 0..5 { + // Check the balance beforehand + let service_canister_cycles_before = + setup.pic.cycle_balance(setup.paid_service.canister_id); + // Call the API + let response: Result = + setup.call_paid_service(setup.user, method, ()); + assert_eq!( + response, + Ok("Yes, you paid 1 billion cycles!".to_string()), + "Should have succeeded with a generous prepayment", + ); + let service_canister_cycles_after = setup.pic.cycle_balance(setup.paid_service.canister_id); + assert!( + service_canister_cycles_after > service_canister_cycles_before, + "The service canister needs to charge more to cover its cycle cost! Loss: {}", + service_canister_cycles_before - service_canister_cycles_after + ); + expected_user_balance -= method.cost() + LEDGER_FEE; + setup.assert_user_balance_eq( + expected_user_balance, + "Expected the user balance to be the initial balance minus the ledger and API fees" + .to_string(), + ); + } +} + +/// Verifies that a user cannot pay without an ICRC2 approval. +#[test] +fn caller_needs_to_approve() { + let setup = CallerPaysWithIcrc2CyclesTestSetup::default(); + // Ok, now we should be able to make an API call with an ICRC-2 approve. + let method = PaidMethods::Cost1b; + // Call the API + let response: Result = + setup.call_paid_service(setup.user, method, PaymentType::CallerPaysIcrc2Cycles); + assert_eq!( + response, + Err(PaymentError::LedgerWithdrawFromError { + ledger: setup.ledger.canister_id(), + error: cycles_ledger_client::WithdrawFromError::InsufficientAllowance { + allowance: Nat::default(), + } + }), + "Should have failed without an ICRC2 approve" + ); +} + +/// Verifies that an authorized ICRC2 approval cannot be used by another caller. +#[test] +fn payment_cannot_be_used_by_another_caller() { + let setup = CallerPaysWithIcrc2CyclesTestSetup::default(); + let mut expected_user_balance = CallerPaysWithIcrc2CyclesTestSetup::USER_INITIAL_BALANCE; + // Ok, now we should be able to make an API call with an ICRC-2 approve. + let method = PaidMethods::Cost1b; + // Pre-approve payment + setup.user_approves_payment_for_paid_service(method.cost() + LEDGER_FEE); + // Check that the user has been charged for the approve. + expected_user_balance -= LEDGER_FEE; + setup.assert_user_balance_eq( + expected_user_balance, + "Expected the user balance to be charged for the ICRC2 approve".to_string(), + ); + + // Attempt by another user to make an API call + { + let response: Result = setup.call_paid_service( + setup.unauthorized_user, + method, + PaymentType::CallerPaysIcrc2Cycles, + ); + assert_eq!( + response, + Err(PaymentError::LedgerWithdrawFromError { + ledger: setup.ledger.canister_id(), + error: cycles_ledger_client::WithdrawFromError::InsufficientAllowance { + allowance: Nat::default(), + } + }), + "User should not be able to use another user's ICRC2 approval" + ); + } + + // Now make an API call + // Check the canister cycles balance beforehand + let service_canister_cycles_before = setup.pic.cycle_balance(setup.paid_service.canister_id); + // Call the API + let response: Result = + setup.call_paid_service(setup.user, method, PaymentType::CallerPaysIcrc2Cycles); + assert_eq!( + response, + Ok("Yes, you paid 1 billion cycles!".to_string()), + "Should have succeeded with an accurate prepayment", + ); + let service_canister_cycles_after = setup.pic.cycle_balance(setup.paid_service.canister_id); + assert!( + service_canister_cycles_after > service_canister_cycles_before, + "The service canister needs to charge more to cover its cycle cost! Loss: {}", + service_canister_cycles_before - service_canister_cycles_after + ); + expected_user_balance -= method.cost() + LEDGER_FEE; + setup.assert_user_balance_eq( + expected_user_balance, + "Expected the user balance to be the initial balance minus the ledger and API fees" + .to_string(), + ); +} diff --git a/src/example/paid_service/tests/it/caller_pays_icrc2_tokens.rs b/src/example/paid_service/tests/it/caller_pays_icrc2_tokens.rs new file mode 100644 index 0000000..d371ba9 --- /dev/null +++ b/src/example/paid_service/tests/it/caller_pays_icrc2_tokens.rs @@ -0,0 +1,126 @@ +//! Tests for the `PaymentType::CallerPaysIcrc2Tokens` payment type. +use crate::util::cycles_ledger::Account; +use crate::util::pic_canister::PicCanisterTrait; +use crate::util::test_environment::{CallerPaysWithIcrc2CyclesTestSetup, PaidMethods, LEDGER_FEE}; +use candid::Nat; +use ic_papi_api::caller::CallerPaysIcrc2Tokens; +use ic_papi_api::cycles::cycles_ledger_canister_id; +use ic_papi_api::{PaymentError, PaymentType}; + +/// Verifies that the `PaymentType::CallerPaysIcrc2Cycles` payment type works as expected +/// on an API method that has only the corresponding guard. +/// +/// Notes: +/// - The caller does not need to specify any payment arguments. (See `call_paid_service(...)` in the test.) +/// - The caller needs to pay the API cost plus one ledger fee, for the privilege of using this payment type. (See `user_approves_payment_for_paid_service(...)` in the test.) +/// - The ledger fee may vary depending on the ledger. The customer will need to make an allowance for the fee, either by finding out the exact fee or making an allowance with the maximum the caller is prepared to pay. +/// - This test use the cycles ledger as an ICRC-compliant ledger. +/// - TODO: Test with other ICRC-2 ledgers as well. +#[test] +fn caller_pays_icrc2_tokens() { + let setup = CallerPaysWithIcrc2CyclesTestSetup::default(); + let mut expected_user_balance = CallerPaysWithIcrc2CyclesTestSetup::USER_INITIAL_BALANCE; + // Ok, now we should be able to make an API call with an ICRC-2 approve. + let method = PaidMethods::CallerPays1bIcrc2Tokens; + // Pre-approve payment + setup.user_approves_payment_for_paid_service(method.cost() + LEDGER_FEE); + // Check that the user has been charged for the approve. + expected_user_balance -= LEDGER_FEE; + setup.assert_user_balance_eq( + expected_user_balance, + "Expected the user balance to be charged for the ICRC2 approve".to_string(), + ); + // Now make an API call + // Call the API + let response: Result = setup.call_paid_service(setup.user, method, ()); + assert_eq!( + response, + Ok("Yes, you paid 1 billion tokens!".to_string()), + "Should have succeeded with an accurate prepayment", + ); + expected_user_balance -= method.cost() + LEDGER_FEE; + setup.assert_user_balance_eq( + expected_user_balance, + "Expected the user balance to be the initial balance minus the ledger and API fees" + .to_string(), + ); + + // The service ledger account should have been credited + { + let service_balance = setup + .ledger + .icrc_1_balance_of( + setup.paid_service.canister_id(), + &Account { + owner: setup.paid_service.canister_id(), + subaccount: None, + }, + ) + .expect("Could not get service balance"); + assert_eq!( + service_balance, + Nat::from(method.cost()), + "Expected the service balance to be the cost of the API call" + ); + } +} + +/// Verifies that the caller can pay for an API call with ICRC-2 tokens explicitly. +#[test] +fn caller_pays_icrc2_tokens_explicitly() { + let setup = CallerPaysWithIcrc2CyclesTestSetup::default(); + let mut expected_user_balance = CallerPaysWithIcrc2CyclesTestSetup::USER_INITIAL_BALANCE; + // Ok, now we should be able to make an API call with an ICRC-2 approve. + let method = PaidMethods::Cost1b; + // Pre-approve payment + setup.user_approves_payment_for_paid_service(method.cost() + LEDGER_FEE); + // Check that the user has been charged for the approve. + expected_user_balance -= LEDGER_FEE; + setup.assert_user_balance_eq( + expected_user_balance, + "Expected the user balance to be charged for the ICRC2 approve".to_string(), + ); + // Now make an API call + { + let response: Result = setup.call_paid_service( + setup.user, + method, + PaymentType::CallerPaysIcrc2Tokens(CallerPaysIcrc2Tokens { + ledger: cycles_ledger_canister_id(), + }), + ); + assert_eq!( + response, + Ok("Yes, you paid 1 billion cycles!".to_string()), + "Should have succeeded with an accurate prepayment", + ); + } + // Verifies that the user account has been debited. + { + expected_user_balance -= method.cost() + LEDGER_FEE; + setup.assert_user_balance_eq( + expected_user_balance, + "Expected the user balance to be the initial balance minus the ledger and API fees" + .to_string(), + ); + } + + // Verifies that the canister ledger account has been credited with the payment. + { + let service_balance = setup + .ledger + .icrc_1_balance_of( + setup.paid_service.canister_id(), + &Account { + owner: setup.paid_service.canister_id(), + subaccount: None, + }, + ) + .expect("Could not get service balance"); + assert_eq!( + service_balance, + Nat::from(method.cost()), + "Expected the service balance to be the cost of the API call" + ); + } +} diff --git a/src/example/paid_service/tests/it/icrc2.rs b/src/example/paid_service/tests/it/icrc2.rs deleted file mode 100644 index 60b56bb..0000000 --- a/src/example/paid_service/tests/it/icrc2.rs +++ /dev/null @@ -1,568 +0,0 @@ -use crate::util::cycles_depositor::{self, CyclesDepositorPic}; -use crate::util::cycles_ledger::{ - Account, ApproveArgs, CyclesLedgerPic, InitArgs as LedgerInitArgs, LedgerArgs, -}; -use crate::util::pic_canister::{PicCanister, PicCanisterBuilder, PicCanisterTrait}; -use candid::{encode_one, Nat, Principal}; -use example_paid_service_api::InitArgs; -use ic_papi_api::caller::{CallerPaysIcrc2Tokens, PatronPaysIcrc2Tokens}; -use ic_papi_api::{principal2account, PaymentError, PaymentType}; -use pocket_ic::{PocketIc, PocketIcBuilder}; -use std::sync::Arc; - -pub struct CallerPaysWithIcRc2TestSetup { - /// The PocketIC instance. - #[allow(dead_code)] - // The Arc is used; this makes it accessible without having to refer to a specific canister. - pic: Arc, - /// The canister providing the API. - paid_service: PicCanister, - /// ICRC2 ledger - ledger: CyclesLedgerPic, - /// User - user: Principal, - /// Another user - user2: Principal, - /// We should really put these in an array - users: [Principal; 5], - /// Unauthorized user - unauthorized_user: Principal, - /// User's wallet. We use the cycles wallet so that we can top it up easily, but any source of funds will do, with any ICRC-2 token. - wallet: CyclesDepositorPic, -} -impl Default for CallerPaysWithIcRc2TestSetup { - fn default() -> Self { - let pic = Arc::new( - PocketIcBuilder::new() - .with_fiduciary_subnet() - .with_system_subnet() - .build(), - ); - // WOuld like to create this with the cycles ledger canister ID but currently this yields an error. - let ledger = CyclesLedgerPic::from( - PicCanisterBuilder::default() - .with_wasm(&PicCanister::dfx_wasm_path("cycles_ledger")) - .with_arg( - encode_one(LedgerArgs::Init(LedgerInitArgs { - index_id: None, - max_blocks_per_request: 999, - })) - .expect("Failed to encode ledger init arg"), - ) - .deploy_to(pic.clone()), - ); - let paid_service = PicCanisterBuilder::default() - .with_wasm(&PicCanister::cargo_wasm_path("example_paid_service")) - .with_arg( - encode_one(Some(InitArgs { - ledger: ledger.canister_id(), - })) - .unwrap(), - ) - .deploy_to(pic.clone()); - let user = - Principal::from_text("xzg7k-thc6c-idntg-knmtz-2fbhh-utt3e-snqw6-5xph3-54pbp-7axl5-tae") - .unwrap(); - let user2 = - Principal::from_text("jwhyn-xieqy-drmun-h7uci-jzycw-vnqhj-s62vl-4upsg-cmub3-vakaq-rqe") - .unwrap(); - let users = [ - Principal::from_text("s2xin-cwqnw-sjvht-gp553-an54g-2rhlc-z4c5d-xz5iq-irnbi-sadik-qae") - .unwrap(), - Principal::from_text("dmvof-2tilt-3xmvh-c7tbj-n3whk-k2i6b-2s2ge-xoo3d-wjuw3-ijpuw-eae") - .unwrap(), - Principal::from_text("kjerd-nj73t-u3hhp-jcj4d-g7w56-qlrvb-gguta-45yve-336zs-sunxa-zqe") - .unwrap(), - Principal::from_text("zxhav-yshtx-vhzs2-nvuu3-jrq66-bidn2-put3y-ulwcf-2gb2o-ykfco-sae") - .unwrap(), - Principal::from_text("nggqm-p5ozz-i5hfv-bejmq-2gtow-4dtqw-vjatn-4b4yw-s5mzs-i46su-6ae") - .unwrap(), - ]; - let unauthorized_user = - Principal::from_text("rg3gz-22tjp-jh7hl-migkq-vb7in-i2ylc-6umlc-dtbug-v6jgc-uo24d-nqe") - .unwrap(); - let wallet = PicCanisterBuilder::default() - .with_wasm(&PicCanister::dfx_wasm_path("cycles_depositor")) - .with_controllers(vec![user]) - .with_arg( - encode_one(cycles_depositor::InitArg { - ledger_id: ledger.canister_id, - }) - .unwrap(), - ) - .deploy_to(pic.clone()) - .into(); - - Self { - pic, - paid_service, - ledger, - user, - user2, - users, - unauthorized_user, - wallet, - } - } -} -impl CallerPaysWithIcRc2TestSetup { - const LEDGER_FEE: u128 = 100_000_000; // The documented fee: https://internetcomputer.org/docs/current/developer-docs/defi/cycles/cycles-ledger#fees - - /// Deposit 100 * the ledger fee in the user's ledger wallet. That should be enough to be getting on with. - fn fund_user(&self, megasquigs: u128) { - let initial_balance = self.user_balance(); - // .. Magic cycles into existence (test only - not IRL). - let deposit = megasquigs + Self::LEDGER_FEE; - self.pic.add_cycles(self.wallet.canister_id, deposit); - // .. Send cycles to the cycles ledger. - self.wallet - .deposit( - self.user, - &cycles_depositor::DepositArg { - to: cycles_depositor::Account { - owner: self.user, - subaccount: None, - }, - memo: None, - cycles: candid::Nat::from(deposit), - }, - ) - .expect("Failed to deposit funds in the ledger"); - // .. That should have cost one fee. - let expected_balance = initial_balance.clone() + megasquigs; - self.assert_user_balance_eq(expected_balance.clone(), format!("Expected user balance to be the initial balance ({initial_balance}) plus the requested sum ({megasquigs}) = {expected_balance}")); - } - /// Gets the user balance - fn user_balance(&self) -> Nat { - self.ledger - .icrc_1_balance_of( - self.user, - &Account { - owner: self.user, - subaccount: None, - }, - ) - .expect("Could not get user balance") - } - /// Asserts that the user's ledger balance is a certain value. - fn assert_user_balance_eq(&self, expected_balance: T, message: String) - where - T: Into, - { - assert_eq!(self.user_balance(), expected_balance.into(), "{}", message); - } -} - -#[test] -fn icrc2_test_setup_works() { - let _setup = CallerPaysWithIcRc2TestSetup::default(); -} - -/// Verifies that the `PaymentType::CallerPaysIcrc2Tokens` payment type works as expected. -#[test] -fn caller_pays_icrc2_tokens() { - let setup = CallerPaysWithIcRc2TestSetup::default(); - // Add cycles to the wallet - // .. At first the balance should be zero. - setup.assert_user_balance_eq( - 0u32, - "Initially the user balance in the ledger should be zero".to_string(), - ); - // .. Get enough to play with lots of transactions. - const LEDGER_FEE: u128 = 100_000_000; // The documented fee: https://internetcomputer.org/docs/current/developer-docs/defi/cycles/cycles-ledger#fees - let mut expected_user_balance = 100_000_000_000; // Lots of funds to play with. - setup.fund_user(expected_user_balance); - setup.assert_user_balance_eq( - expected_user_balance, - "Test setup failed when providing the user with funds".to_string(), - ); - // Exercise the protocol... - let api_method = "cost_1b_icrc2_from_caller"; - let api_fee = 1_000_000_000u128; - for payment in (api_fee - 5)..(api_fee + 5) { - for _repetition in 0..2 { - // Pre-approve payment - setup - .ledger - .icrc_2_approve( - setup.user, - &ApproveArgs { - spender: Account { - owner: setup.paid_service.canister_id(), - subaccount: None, - }, - amount: Nat::from(payment + LEDGER_FEE), - ..ApproveArgs::default() - }, - ) - .expect("Failed to call the ledger to approve") - .expect("Failed to approve the paid service to spend the user's ICRC-2 tokens"); - // Check that the user has been charged for the approve. - expected_user_balance -= LEDGER_FEE; - setup.assert_user_balance_eq( - expected_user_balance, - "Expected the user balance to be charged for the ICRC2 approve".to_string(), - ); - - // Check the balance beforehand - let service_canister_cycles_before = - setup.pic.cycle_balance(setup.paid_service.canister_id); - // Call the API - let response: Result = setup - .paid_service - .update(setup.user, api_method, ()) - .expect("Failed to call the paid service"); - if payment < api_fee { - assert_eq!( - response, - Err(PaymentError::LedgerError { - ledger: setup.ledger.canister_id(), - error: cycles_ledger_client::WithdrawFromError::InsufficientAllowance { - allowance: Nat::from(payment + LEDGER_FEE), // TODO: Change up to 128 - } - }), - "Should have failed with only {} cycles attached", - payment - ); - setup.assert_user_balance_eq( - expected_user_balance, - "Expected the user balance to be unchanged by a failed ICRC2".to_string(), - ); - } else { - assert_eq!( - response, - Ok("Yes, you paid 1 billion cycles!".to_string()), - "Should have succeeded with {} cycles attached", - payment - ); - let service_canister_cycles_after = - setup.pic.cycle_balance(setup.paid_service.canister_id); - assert!( - service_canister_cycles_after > service_canister_cycles_before, - "The service canister needs to charge more to cover its cycle cost! Loss: {}", - service_canister_cycles_before - service_canister_cycles_after - ); - expected_user_balance -= api_fee + LEDGER_FEE; - setup.assert_user_balance_eq( - expected_user_balance, - "Expected the user balance to be the initial balance minus the ledger and API fees" - .to_string(), - ); - } - } - } -} - -#[test] -fn caller_pays_by_icrc2_prepayment() { - let setup = CallerPaysWithIcRc2TestSetup::default(); - // Add cycles to the wallet - // .. At first the balance should be zero. - setup.assert_user_balance_eq( - 0u32, - "Initially the user balance in the ledger should be zero".to_string(), - ); - // .. Get enough to play with lots of transactions. - const LEDGER_FEE: u128 = 100_000_000; // The documented fee: https://internetcomputer.org/docs/current/developer-docs/defi/cycles/cycles-ledger#fees - let mut expected_user_balance = 100_000_000_000; // Lots of funds to play with. - setup.fund_user(expected_user_balance); - setup.assert_user_balance_eq( - expected_user_balance, - "Test setup failed when providing the user with funds".to_string(), - ); - // Exercise the protocol... - let api_method = "cost_1b_icrc2_from_caller"; - let api_fee = 1_000_000_000u128; - // Pre-approve payment - setup - .ledger - .icrc_2_approve( - setup.user, - &ApproveArgs { - spender: Account { - owner: setup.paid_service.canister_id(), - subaccount: None, - }, - amount: Nat::from(expected_user_balance), - ..ApproveArgs::default() - }, - ) - .expect("Failed to call the ledger to approve") - .expect("Failed to approve the paid service to spend the user's ICRC-2 tokens"); - // Check that the user has been charged for the approve. - expected_user_balance -= LEDGER_FEE; - setup.assert_user_balance_eq( - expected_user_balance, - "Expected the user balance to be charged for the ICRC2 approve".to_string(), - ); - // Now make several identical API calls - for _repetition in 0..5 { - // Check the balance beforehand - let service_canister_cycles_before = - setup.pic.cycle_balance(setup.paid_service.canister_id); - // Call the API - let response: Result = setup - .paid_service - .update(setup.user, api_method, ()) - .expect("Failed to call the paid service"); - assert_eq!( - response, - Ok("Yes, you paid 1 billion cycles!".to_string()), - "Should have succeeded with a generous prepayment", - ); - let service_canister_cycles_after = setup.pic.cycle_balance(setup.paid_service.canister_id); - assert!( - service_canister_cycles_after > service_canister_cycles_before, - "The service canister needs to charge more to cover its cycle cost! Loss: {}", - service_canister_cycles_before - service_canister_cycles_after - ); - expected_user_balance -= api_fee + LEDGER_FEE; - setup.assert_user_balance_eq( - expected_user_balance, - "Expected the user balance to be the initial balance minus the ledger and API fees" - .to_string(), - ); - } -} - -#[test] -fn caller_pays_by_named_icrc2() { - let setup = CallerPaysWithIcRc2TestSetup::default(); - // Add cycles to the wallet - // .. At first the balance should be zero. - setup.assert_user_balance_eq( - 0u32, - "Initially the user balance in the ledger should be zero".to_string(), - ); - // .. Get enough to play with lots of transactions. - const LEDGER_FEE: u128 = 100_000_000; // The documented fee: https://internetcomputer.org/docs/current/developer-docs/defi/cycles/cycles-ledger#fees - let mut expected_user_balance = 100_000_000_000; // Lots of funds to play with. - setup.fund_user(expected_user_balance); - setup.assert_user_balance_eq( - expected_user_balance, - "Test setup failed when providing the user with funds".to_string(), - ); - // Ok, now we should be able to make an API call with EITHER an ICRC-2 approve or attached cycles, by declaring the payment type. - // In this test, we will exercise the ICRC-2 approve. - let api_method = "cost_1b"; - let api_fee = 1_000_000_000u128; - // Pre-approve payment - setup - .ledger - .icrc_2_approve( - setup.user, - &ApproveArgs { - spender: Account { - owner: setup.paid_service.canister_id(), - subaccount: None, - }, - amount: Nat::from(expected_user_balance), - ..ApproveArgs::default() - }, - ) - .expect("Failed to call the ledger to approve") - .expect("Failed to approve the paid service to spend the user's ICRC-2 tokens"); - // Check that the user has been charged for the approve. - expected_user_balance -= LEDGER_FEE; - setup.assert_user_balance_eq( - expected_user_balance, - "Expected the user balance to be charged for the ICRC2 approve".to_string(), - ); - // Now make several identical API calls - for _repetition in 0..5 { - // Check the balance beforehand - let service_canister_cycles_before = - setup.pic.cycle_balance(setup.paid_service.canister_id); - // Call the API - let response: Result = setup - .paid_service - .update( - setup.user, - api_method, - PaymentType::CallerPaysIcrc2Tokens(CallerPaysIcrc2Tokens { - ledger: setup.ledger.canister_id(), - }), - ) - .expect("Failed to call the paid service"); - assert_eq!( - response, - Ok("Yes, you paid 1 billion cycles!".to_string()), - "Should have succeeded with a generous prepayment", - ); - let service_canister_cycles_after = setup.pic.cycle_balance(setup.paid_service.canister_id); - assert!( - service_canister_cycles_after > service_canister_cycles_before, - "The service canister needs to charge more to cover its cycle cost! Loss: {}", - service_canister_cycles_before - service_canister_cycles_after - ); - expected_user_balance -= api_fee + LEDGER_FEE; - setup.assert_user_balance_eq( - expected_user_balance, - "Expected the user balance to be the initial balance minus the ledger and API fees" - .to_string(), - ); - // But an unauthorized user should not be able to make the same call. - { - let response: Result = setup - .paid_service - .update( - setup.unauthorized_user, - api_method, - PaymentType::CallerPaysIcrc2Tokens(CallerPaysIcrc2Tokens { - ledger: setup.ledger.canister_id(), - }), - ) - .expect("Failed to call the paid service"); - assert_eq!( - response, - Err(PaymentError::LedgerError { - ledger: setup.ledger.canister_id(), - error: cycles_ledger_client::WithdrawFromError::InsufficientAllowance { - allowance: Nat::from(0u32), - } - }), - "Should have succeeded with a generous prepayment", - ); - setup.assert_user_balance_eq( - expected_user_balance, - "The user should not have been charged for unauthorized spending attempts" - .to_string(), - ); - } - } -} - -/// Verifies that the `PaymentType::PatronPaysIcrc2Tokens` payment type works as expected. -/// -/// Here `user` is a patron, and pays on behalf of `users[2..5]`. -/// -/// Only funded users should be able to make calls, and they should be able to make only as many calls as personally approved for them. -#[test] -fn patron_pays_by_named_icrc2() { - let setup = CallerPaysWithIcRc2TestSetup::default(); - // Add cycles to the wallet - // .. At first the balance should be zero. - setup.assert_user_balance_eq( - 0u32, - "Initially the user balance in the ledger should be zero".to_string(), - ); - // .. Get enough to play with lots of transactions. - const LEDGER_FEE: u128 = 100_000_000; // The documented fee: https://internetcomputer.org/docs/current/developer-docs/defi/cycles/cycles-ledger#fees - let mut expected_user_balance = 100_000_000_000; // Lots of funds to play with. - setup.fund_user(expected_user_balance); - setup.assert_user_balance_eq( - expected_user_balance, - "Test setup failed when providing the user with funds".to_string(), - ); - // Ok, now we should be able to make an API call with EITHER an ICRC-2 approve or attached cycles, by declaring the payment type. - // In this test, we will exercise the ICRC-2 approve. - let api_method = "cost_1b"; - let payment_arg = PaymentType::PatronPaysIcrc2Tokens(PatronPaysIcrc2Tokens { - ledger: setup.ledger.canister_id(), - patron: ic_papi_api::Account { - owner: setup.user, - subaccount: None, - }, - }); - let api_fee = 1_000_000_000u128; - let repetitions = 3; - // Pre-approve payments - for caller in setup.users.iter() { - setup - .ledger - .icrc_2_approve( - setup.user, - &ApproveArgs { - spender: Account { - owner: setup.paid_service.canister_id(), - subaccount: Some(principal2account(caller)), - }, - amount: Nat::from((api_fee + LEDGER_FEE) * repetitions), - ..ApproveArgs::default() - }, - ) - .expect("Failed to call the ledger to approve") - .expect("Failed to approve the paid service to spend the user's ICRC-2 tokens"); - // Check that the user has been charged for the approve. - expected_user_balance -= LEDGER_FEE; - setup.assert_user_balance_eq( - expected_user_balance, - "Expected the user balance to be charged for the ICRC2 approve".to_string(), - ); - } - // An unauthorized user should not be able to make a call. - { - let response: Result = setup - .paid_service - .update(setup.unauthorized_user, api_method, &payment_arg) - .expect("Failed to call the paid service"); - assert_eq!( - response, - Err(PaymentError::LedgerError { - ledger: setup.ledger.canister_id(), - error: cycles_ledger_client::WithdrawFromError::InsufficientAllowance { - allowance: Nat::from(0u32), - } - }), - "Unapproved users should not be able to make calls", - ); - setup.assert_user_balance_eq( - expected_user_balance, - "The user should not have been charged for unauthorized spending attempts".to_string(), - ); - } - // Approved users should be able to make several API calls, up to the budget. - let active_users = &setup.users[2..5]; - for repetition in 0..repetitions { - // Check the balance beforehand - let service_canister_cycles_before = - setup.pic.cycle_balance(setup.paid_service.canister_id); - // Call the API - for caller in active_users.iter() { - let response: Result = setup - .paid_service - .update(*caller, api_method, &payment_arg) - .expect("Failed to call the paid service"); - assert_eq!( - response, - Ok("Yes, you paid 1 billion cycles!".to_string()), - "Should have succeeded for user {} on repetition {repetition}", - caller.to_string(), - ); - let service_canister_cycles_after = - setup.pic.cycle_balance(setup.paid_service.canister_id); - assert!( - service_canister_cycles_after > service_canister_cycles_before, - "The service canister needs to charge more to cover its cycle cost! Loss: {}", - service_canister_cycles_before - service_canister_cycles_after - ); - expected_user_balance -= api_fee + LEDGER_FEE; - setup.assert_user_balance_eq( - expected_user_balance, - "Expected the user balance to be the initial balance minus the ledger and API fees" - .to_string(), - ); - } - } - // Also, additional calls by approved users, beyond the funded amount, should fail, even though there are funds left from inactive users. - for caller in active_users.iter() { - let response: Result = setup - .paid_service - .update(*caller, api_method, &payment_arg) - .expect("Failed to call the paid service"); - assert_eq!( - response, - Err(PaymentError::LedgerError { - ledger: setup.ledger.canister_id(), - error: cycles_ledger_client::WithdrawFromError::InsufficientAllowance { - allowance: Nat::from(0u32), - } - }), - "Should not be able to exceed the budget", - ); - setup.assert_user_balance_eq( - expected_user_balance, - "The user should not have been charged for additional spending attempts".to_string(), - ); - } -} diff --git a/src/example/paid_service/tests/it/main.rs b/src/example/paid_service/tests/it/main.rs index 55e71f5..30b991d 100644 --- a/src/example/paid_service/tests/it/main.rs +++ b/src/example/paid_service/tests/it/main.rs @@ -1,3 +1,6 @@ mod attached_cycles; -mod icrc2; +mod caller_pays_icrc2_cycles; +mod caller_pays_icrc2_tokens; +mod patron_pays_icrc2_cycles; +mod patron_pays_icrc2_tokens; mod util; diff --git a/src/example/paid_service/tests/it/patron_pays_icrc2_cycles.rs b/src/example/paid_service/tests/it/patron_pays_icrc2_cycles.rs new file mode 100644 index 0000000..def393b --- /dev/null +++ b/src/example/paid_service/tests/it/patron_pays_icrc2_cycles.rs @@ -0,0 +1,218 @@ +//! Tests for the `PaymentType::PatronPaysIcrc2Cycles` payment type. +use crate::util::cycles_ledger::{Account, ApproveArgs}; +use crate::util::pic_canister::PicCanisterTrait; +use crate::util::test_environment::{CallerPaysWithIcrc2CyclesTestSetup, PaidMethods, LEDGER_FEE}; +use candid::Nat; +use ic_papi_api::{principal2account, PaymentError, PaymentType}; + +/// Verifies that `user` can pay cycles for `user2`: +/// +/// - The patron needs to approve the API cost plus the ledger fee. +/// - An unauthorized user should not be able to use that approval. +/// - `user2` should be able to make the API call. +#[test] +fn user_pays_tokens_for_user2() { + let setup = CallerPaysWithIcrc2CyclesTestSetup::default(); + let mut expected_user_balance = CallerPaysWithIcrc2CyclesTestSetup::USER_INITIAL_BALANCE; + + // Here the user pays for user2. + let patron = setup.user; + let caller = setup.user2; + let method = PaidMethods::Cost1b; + let payment_arg = PaymentType::PatronPaysIcrc2Cycles(ic_papi_api::Account { + owner: setup.user, + subaccount: None, + }); + + // Pre-approve payments + { + setup + .ledger + .icrc_2_approve( + patron, + &ApproveArgs { + spender: Account { + owner: setup.paid_service.canister_id(), + subaccount: Some(principal2account(&caller)), + }, + amount: Nat::from(method.cost() + LEDGER_FEE), + ..ApproveArgs::default() + }, + ) + .expect("Failed to call the ledger to approve") + .expect("Failed to approve the paid service to spend the user's ICRC-2 tokens"); + // Check that the user has been charged for the approve. + expected_user_balance -= LEDGER_FEE; + setup.assert_user_balance_eq( + expected_user_balance, + "Expected the user==patron balance to be charged for the ICRC2 approve".to_string(), + ); + } + // An unauthorized user should not be able to make a call. + { + let response: Result = + setup.call_paid_service(setup.unauthorized_user, method, &payment_arg); + assert_eq!( + response, + Err(PaymentError::LedgerWithdrawFromError { + ledger: setup.ledger.canister_id(), + error: cycles_ledger_client::WithdrawFromError::InsufficientAllowance { + allowance: Nat::from(0u32), + } + }), + "Unapproved users should not be able to make calls", + ); + setup.assert_user_balance_eq( + expected_user_balance, + "The user==patron should not have been charged for unauthorized spending attempts" + .to_string(), + ); + } + // user2 should be able to make the call. + { + // Check the canister cycle balance beforehand + let service_canister_cycles_before = + setup.pic.cycle_balance(setup.paid_service.canister_id); + // Call the API + let response: Result = + setup.call_paid_service(caller, method, &payment_arg); + assert_eq!( + response, + Ok("Yes, you paid 1 billion cycles!".to_string()), + "Should have succeeded for caller {} with patron {}.", + caller.to_string(), + patron.to_string(), + ); + let service_canister_cycles_after = setup.pic.cycle_balance(setup.paid_service.canister_id); + assert!( + service_canister_cycles_after > service_canister_cycles_before, + "The service canister needs to charge more to cover its cycle cost! Loss: {}", + service_canister_cycles_before - service_canister_cycles_after + ); + expected_user_balance -= method.cost() + LEDGER_FEE; + setup.assert_user_balance_eq( + expected_user_balance, + "Expected the user==patron balance to be the initial balance minus the ledger and API fees" + .to_string(), + ); + } +} + +/// Verifies that the `PaymentType::PatronPaysIcrc2Tokens` payment type works as expected. +/// +/// Here `user` is a patron, and pays on behalf of `users[2..5]`. +/// +/// Only funded users should be able to make calls, and they should be able to make only as many calls as personally approved for them. +#[test] +fn user_pays_cycles_for_other_users() { + let setup = CallerPaysWithIcrc2CyclesTestSetup::default(); + let mut expected_user_balance = CallerPaysWithIcrc2CyclesTestSetup::USER_INITIAL_BALANCE; + + // Ok, now we should be able to make an API call with EITHER an ICRC-2 approve or attached cycles, by declaring the payment type. + // In this test, we will exercise the ICRC-2 approve. + let method = PaidMethods::Cost1b; + let payment_arg = PaymentType::PatronPaysIcrc2Cycles(ic_papi_api::Account { + owner: setup.user, + subaccount: None, + }); + let repetitions = 3; + // Pre-approve payments + for caller in setup.users.iter() { + setup + .ledger + .icrc_2_approve( + setup.user, + &ApproveArgs { + spender: Account { + owner: setup.paid_service.canister_id(), + subaccount: Some(principal2account(caller)), + }, + amount: Nat::from((method.cost() + LEDGER_FEE) * repetitions), + ..ApproveArgs::default() + }, + ) + .expect("Failed to call the ledger to approve") + .expect("Failed to approve the paid service to spend the user's ICRC-2 tokens"); + // Check that the user has been charged for the approve. + expected_user_balance -= LEDGER_FEE; + setup.assert_user_balance_eq( + expected_user_balance, + "Expected the user balance to be charged for the ICRC2 approve".to_string(), + ); + } + // An unauthorized user should not be able to make a call. + { + let response: Result = setup + .paid_service + .update(setup.unauthorized_user, method.name(), &payment_arg) + .expect("Failed to call the paid service"); + assert_eq!( + response, + Err(PaymentError::LedgerWithdrawFromError { + ledger: setup.ledger.canister_id(), + error: cycles_ledger_client::WithdrawFromError::InsufficientAllowance { + allowance: Nat::from(0u32), + } + }), + "Unapproved users should not be able to make calls", + ); + setup.assert_user_balance_eq( + expected_user_balance, + "The user should not have been charged for unauthorized spending attempts".to_string(), + ); + } + // Approved users should be able to make several API calls, up to the budget. + let active_users = &setup.users[2..5]; + for repetition in 0..repetitions { + // Check the balance beforehand + let service_canister_cycles_before = + setup.pic.cycle_balance(setup.paid_service.canister_id); + // Call the API + for caller in active_users.iter() { + let response: Result = setup + .paid_service + .update(*caller, method.name(), &payment_arg) + .expect("Failed to call the paid service"); + assert_eq!( + response, + Ok("Yes, you paid 1 billion cycles!".to_string()), + "Should have succeeded for user {} on repetition {repetition}", + caller.to_string(), + ); + let service_canister_cycles_after = + setup.pic.cycle_balance(setup.paid_service.canister_id); + assert!( + service_canister_cycles_after > service_canister_cycles_before, + "The service canister needs to charge more to cover its cycle cost! Loss: {}", + service_canister_cycles_before - service_canister_cycles_after + ); + expected_user_balance -= method.cost() + LEDGER_FEE; + setup.assert_user_balance_eq( + expected_user_balance, + "Expected the user balance to be the initial balance minus the ledger and API fees" + .to_string(), + ); + } + } + // Also, additional calls by approved users, beyond the funded amount, should fail, even though there are funds left from inactive users. + for caller in active_users.iter() { + let response: Result = setup + .paid_service + .update(*caller, method.name(), &payment_arg) + .expect("Failed to call the paid service"); + assert_eq!( + response, + Err(PaymentError::LedgerWithdrawFromError { + ledger: setup.ledger.canister_id(), + error: cycles_ledger_client::WithdrawFromError::InsufficientAllowance { + allowance: Nat::from(0u32), + } + }), + "Should not be able to exceed the budget", + ); + setup.assert_user_balance_eq( + expected_user_balance, + "The user should not have been charged for additional spending attempts".to_string(), + ); + } +} diff --git a/src/example/paid_service/tests/it/patron_pays_icrc2_tokens.rs b/src/example/paid_service/tests/it/patron_pays_icrc2_tokens.rs new file mode 100644 index 0000000..21665d5 --- /dev/null +++ b/src/example/paid_service/tests/it/patron_pays_icrc2_tokens.rs @@ -0,0 +1,117 @@ +//! Tests for the `PaymentType::PatronPaysIcrc2Tokens` payment type. +use crate::util::cycles_ledger::{Account, ApproveArgs}; +use crate::util::pic_canister::PicCanisterTrait; +use crate::util::test_environment::{CallerPaysWithIcrc2CyclesTestSetup, PaidMethods, LEDGER_FEE}; +use candid::Nat; +use ic_papi_api::caller::PatronPaysIcrc2Tokens; +use ic_papi_api::{principal2account, PaymentError, PaymentType}; + +/// Verifies that `user` can pay tokens for `user2`: +/// +/// - The patron needs to approve the API cost plus the ledger fee. +/// - An unauthorized user should not be able to use that approval. +/// - `user2` should be able to make the API call. +#[test] +fn user_pays_tokens_for_user2() { + let setup = CallerPaysWithIcrc2CyclesTestSetup::default(); + let mut expected_user_balance = CallerPaysWithIcrc2CyclesTestSetup::USER_INITIAL_BALANCE; + + // Here the user pays for user2. + let patron = setup.user; + let caller = setup.user2; + let method = PaidMethods::Cost1b; + let payment_arg = PaymentType::PatronPaysIcrc2Tokens(PatronPaysIcrc2Tokens { + ledger: setup.ledger.canister_id(), + patron: ic_papi_api::Account { + owner: setup.user, + subaccount: None, + }, + }); + + // Authorize payment + { + setup + .ledger + .icrc_2_approve( + patron, + &ApproveArgs { + spender: Account { + owner: setup.paid_service.canister_id(), + subaccount: Some(principal2account(&caller)), + }, + amount: Nat::from(method.cost() + LEDGER_FEE), + ..ApproveArgs::default() + }, + ) + .expect("Failed to call the ledger to approve") + .expect("Failed to approve the paid service to spend the user's ICRC-2 tokens"); + // Check that the user has been charged for the approve. + expected_user_balance -= LEDGER_FEE; + setup.assert_user_balance_eq( + expected_user_balance, + "Expected the user==patron balance to be charged for the ICRC2 approve".to_string(), + ); + } + // `unauthorized_user` has not paid so should not be able to make a call. + { + let response: Result = + setup.call_paid_service(setup.unauthorized_user, method, &payment_arg); + assert_eq!( + response, + Err(PaymentError::LedgerTransferFromError { + ledger: setup.ledger.canister_id(), + error: cycles_ledger_client::TransferFromError::InsufficientAllowance { + allowance: Nat::from(0u32), + } + }), + "Users sho have not paid should not be able to make calls", + ); + setup.assert_user_balance_eq( + expected_user_balance, + "The user==patron should not have been charged for unauthorized spending attempts" + .to_string(), + ); + } + // `user2` should be able to make the call. + { + // Call the API + { + let response: Result = + setup.call_paid_service(caller, method, &payment_arg); + assert_eq!( + response, + Ok("Yes, you paid 1 billion cycles!".to_string()), + "Should have succeeded for caller {} with patron {}.", + caller.to_string(), + patron.to_string(), + ); + } + // The patron's account should have been debited. + { + expected_user_balance -= method.cost() + LEDGER_FEE; + setup.assert_user_balance_eq( + expected_user_balance, + "Expected the user==patron balance to be the initial balance minus the ledger and API fees" + .to_string(), + ); + } + // The canister's ledger account should have been credited. + { + let service_balance = setup + .ledger + .icrc_1_balance_of( + setup.paid_service.canister_id(), + &Account { + owner: setup.paid_service.canister_id(), + subaccount: None, + }, + ) + .expect("Could not get service balance"); + assert_eq!( + service_balance, + Nat::from(method.cost()), + "Expected the service balance to be the cost of the API call" + ); + } + } +} diff --git a/src/example/paid_service/tests/it/util/mod.rs b/src/example/paid_service/tests/it/util/mod.rs index f58b736..95e059f 100644 --- a/src/example/paid_service/tests/it/util/mod.rs +++ b/src/example/paid_service/tests/it/util/mod.rs @@ -1,3 +1,4 @@ pub mod cycles_depositor; pub mod cycles_ledger; pub mod pic_canister; +pub mod test_environment; diff --git a/src/example/paid_service/tests/it/util/pic_canister.rs b/src/example/paid_service/tests/it/util/pic_canister.rs index a8b6599..4b2af54 100644 --- a/src/example/paid_service/tests/it/util/pic_canister.rs +++ b/src/example/paid_service/tests/it/util/pic_canister.rs @@ -271,9 +271,4 @@ impl PicCanisterBuilder { canister_id, } } - /// Deploy to a new pic. - pub fn deploy(&mut self) -> PicCanister { - let pic = Arc::new(PocketIc::new()); - self.deploy_to(pic.clone()) - } } diff --git a/src/example/paid_service/tests/it/util/test_environment.rs b/src/example/paid_service/tests/it/util/test_environment.rs new file mode 100644 index 0000000..6271ff4 --- /dev/null +++ b/src/example/paid_service/tests/it/util/test_environment.rs @@ -0,0 +1,227 @@ +use crate::util::cycles_depositor::{self, CyclesDepositorPic}; +use crate::util::cycles_ledger::{ + Account, ApproveArgs, CyclesLedgerPic, InitArgs as LedgerInitArgs, LedgerArgs, +}; +use crate::util::pic_canister::{PicCanister, PicCanisterBuilder, PicCanisterTrait}; +use candid::{encode_one, CandidType, Nat, Principal}; +use example_paid_service_api::InitArgs; +use ic_papi_api::cycles::cycles_ledger_canister_id; +use ic_papi_api::PaymentError; +use pocket_ic::{PocketIc, PocketIcBuilder}; +use std::sync::Arc; + +pub const LEDGER_FEE: u128 = 100_000_000; // The documented fee: https://internetcomputer.org/docs/current/developer-docs/defi/cycles/cycles-ledger#fees + +/// Methods protected by PAPI. +#[derive(Debug, Copy, Clone, Eq, PartialEq)] +pub enum PaidMethods { + Cost1bIcrc2Cycles, + CallerPays1bIcrc2Tokens, + Cost1b, +} +impl PaidMethods { + pub fn name(&self) -> &str { + match self { + Self::Cost1bIcrc2Cycles => "caller_pays_1b_icrc2_cycles", + Self::CallerPays1bIcrc2Tokens => "caller_pays_1b_icrc2_tokens", + Self::Cost1b => "cost_1b", + } + } + pub fn cost(&self) -> u128 { + match self { + Self::Cost1bIcrc2Cycles => 1_000_000_000, + Self::CallerPays1bIcrc2Tokens => 1_000_000_000, + Self::Cost1b => 1_000_000_000, + } + } +} + +pub struct CallerPaysWithIcrc2CyclesTestSetup { + /// The PocketIC instance. + #[allow(dead_code)] + // The Arc is used; this makes it accessible without having to refer to a specific canister. + pub pic: Arc, + /// The canister providing the API. + pub paid_service: PicCanister, + /// ICRC2 ledger + pub ledger: CyclesLedgerPic, + /// User + pub user: Principal, + /// Another user + pub user2: Principal, + /// A crowd + pub users: [Principal; 5], + /// Unauthorized user + pub unauthorized_user: Principal, + /// A canister used to deposit cycles into the ledger. + pub cycles_depositor: CyclesDepositorPic, +} +impl Default for CallerPaysWithIcrc2CyclesTestSetup { + fn default() -> Self { + let pic = Arc::new( + PocketIcBuilder::new() + .with_fiduciary_subnet() + .with_system_subnet() + .with_application_subnet() + .with_ii_subnet() + .with_nns_subnet() + .build(), + ); + let cycles_ledger_canister_id = pic + .create_canister_with_id(None, None, cycles_ledger_canister_id()) + .unwrap(); + + // Would like to create this with the cycles ledger canister ID but currently this yields an error. + let ledger = CyclesLedgerPic::from( + PicCanisterBuilder::default() + .with_canister(cycles_ledger_canister_id) + .with_wasm(&PicCanister::dfx_wasm_path("cycles_ledger")) + .with_arg( + encode_one(LedgerArgs::Init(LedgerInitArgs { + index_id: None, + max_blocks_per_request: 999, + })) + .expect("Failed to encode ledger init arg"), + ) + .deploy_to(pic.clone()), + ); + let paid_service = PicCanisterBuilder::default() + .with_wasm(&PicCanister::cargo_wasm_path("example_paid_service")) + .with_arg( + encode_one(Some(InitArgs { + ledger: ledger.canister_id(), + })) + .unwrap(), + ) + .deploy_to(pic.clone()); + let user = + Principal::from_text("xzg7k-thc6c-idntg-knmtz-2fbhh-utt3e-snqw6-5xph3-54pbp-7axl5-tae") + .unwrap(); + let user2 = + Principal::from_text("jwhyn-xieqy-drmun-h7uci-jzycw-vnqhj-s62vl-4upsg-cmub3-vakaq-rqe") + .unwrap(); + let users = [ + Principal::from_text("s2xin-cwqnw-sjvht-gp553-an54g-2rhlc-z4c5d-xz5iq-irnbi-sadik-qae") + .unwrap(), + Principal::from_text("dmvof-2tilt-3xmvh-c7tbj-n3whk-k2i6b-2s2ge-xoo3d-wjuw3-ijpuw-eae") + .unwrap(), + Principal::from_text("kjerd-nj73t-u3hhp-jcj4d-g7w56-qlrvb-gguta-45yve-336zs-sunxa-zqe") + .unwrap(), + Principal::from_text("zxhav-yshtx-vhzs2-nvuu3-jrq66-bidn2-put3y-ulwcf-2gb2o-ykfco-sae") + .unwrap(), + Principal::from_text("nggqm-p5ozz-i5hfv-bejmq-2gtow-4dtqw-vjatn-4b4yw-s5mzs-i46su-6ae") + .unwrap(), + ]; + let unauthorized_user = + Principal::from_text("rg3gz-22tjp-jh7hl-migkq-vb7in-i2ylc-6umlc-dtbug-v6jgc-uo24d-nqe") + .unwrap(); + let cycles_depositor = PicCanisterBuilder::default() + .with_wasm(&PicCanister::dfx_wasm_path("cycles_depositor")) + .with_controllers(vec![user]) + .with_arg( + encode_one(cycles_depositor::InitArg { + ledger_id: ledger.canister_id, + }) + .unwrap(), + ) + .deploy_to(pic.clone()) + .into(); + + let ans = Self { + pic, + paid_service, + ledger, + user, + user2, + users, + unauthorized_user, + cycles_depositor, + }; + ans.fund_user(Self::USER_INITIAL_BALANCE); + ans + } +} +impl CallerPaysWithIcrc2CyclesTestSetup { + /// The user's initial balance. + pub const USER_INITIAL_BALANCE: u128 = 100_000_000_000; + /// Deposit 100 * the ledger fee in the user's ledger wallet. That should be enough to be getting on with. + pub fn fund_user(&self, megasquigs: u128) { + let initial_balance = self.user_balance(); + // .. Magic cycles into existence (test only - not IRL). + let deposit = megasquigs + LEDGER_FEE; + self.pic + .add_cycles(self.cycles_depositor.canister_id, deposit); + // .. Send cycles to the cycles ledger. + self.cycles_depositor + .deposit( + self.user, + &cycles_depositor::DepositArg { + to: cycles_depositor::Account { + owner: self.user, + subaccount: None, + }, + memo: None, + cycles: candid::Nat::from(deposit), + }, + ) + .expect("Failed to deposit funds in the ledger"); + // .. That should have cost one fee. + let expected_balance = initial_balance.clone() + megasquigs; + self.assert_user_balance_eq(expected_balance.clone(), format!("Expected user balance to be the initial balance ({initial_balance}) plus the requested sum ({megasquigs}) = {expected_balance}")); + } + /// Gets the user balance + pub fn user_balance(&self) -> Nat { + self.ledger + .icrc_1_balance_of( + self.user, + &Account { + owner: self.user, + subaccount: None, + }, + ) + .expect("Could not get user balance") + } + /// Asserts that the user's ledger balance is a certain value. + pub fn assert_user_balance_eq(&self, expected_balance: T, message: String) + where + T: Into, + { + assert_eq!(self.user_balance(), expected_balance.into(), "{}", message); + } + /// User sends an ICRC2 approval with teh paid service as spender. + pub fn user_approves_payment_for_paid_service(&self, amount: T) + where + T: Into, + { + self.ledger + .icrc_2_approve( + self.user, + &ApproveArgs { + spender: Account { + owner: self.paid_service.canister_id(), + subaccount: None, + }, + amount: amount.into(), + ..ApproveArgs::default() + }, + ) + .expect("Failed to call the ledger to approve") + .expect("Failed to approve the paid service to spend the user's ICRC-2 tokens"); + } + /// Calls a paid service. + pub fn call_paid_service( + &self, + caller: Principal, + method: PaidMethods, + arg: impl CandidType, + ) -> Result { + self.paid_service + .update(caller, method.name(), arg) + .expect("Failed to call the paid service") + } +} + +#[test] +fn icrc2_test_setup_works() { + let _setup = CallerPaysWithIcrc2CyclesTestSetup::default(); +} diff --git a/src/guard/Cargo.toml b/src/guard/Cargo.toml index 913c433..b90b739 100644 --- a/src/guard/Cargo.toml +++ b/src/guard/Cargo.toml @@ -8,4 +8,5 @@ candid = { workspace = true } cycles-ledger-client = { workspace = true } ic-cdk = "0.16.0" ic-papi-api = { workspace = true } +serde = { workspace = true } serde_bytes = { workspace = true } diff --git a/src/guard/src/guards/any.rs b/src/guard/src/guards/any.rs index 575ef08..952c5de 100644 --- a/src/guard/src/guards/any.rs +++ b/src/guard/src/guards/any.rs @@ -1,15 +1,17 @@ //! Accepts any payment that the vendor accepts. -use candid::Principal; +use candid::{CandidType, Deserialize, Principal}; use ic_papi_api::{ caller::{CallerPaysIcrc2Tokens, PatronPaysIcrc2Cycles, PatronPaysIcrc2Tokens, TokenAmount}, - cycles::cycles_ledger_canister_id, principal2account, Account, PaymentError, PaymentType, }; use super::{ - attached_cycles::AttachedCyclesPayment, icrc2_cycles::Icrc2CyclesPaymentGuard, PaymentContext, - PaymentGuard, PaymentGuard2, + attached_cycles::AttachedCyclesPayment, + caller_pays_icrc2_tokens::CallerPaysIcrc2TokensPaymentGuard, + icrc2_cycles::Icrc2CyclesPaymentGuard, + patron_pays_icrc2_tokens::PatronPaysIcrc2TokensPaymentGuard, PaymentContext, PaymentGuard, + PaymentGuard2, }; /// A guard that accepts a user-specified payment type, providing the vendor supports it. @@ -26,14 +28,15 @@ pub enum VendorPaymentConfig { CallerPaysIcrc2Cycles, /// Cycles are received by the vendor canister. PatronPaysIcrc2Cycles, - /// Cycles are received by the vendor canister. + /// The caller pays tokens to the vendor's main account on the chosen ledger. CallerPaysIcrc2Tokens { ledger: Principal }, - /// Cycles are received by the vendor canister. + /// A patron pays tokens to a subaccount belonging to the vendor on the chosen ledger. + /// - The vendor needs to move the tokens to their main account. PatronPaysIcrc2Tokens { ledger: Principal }, } /// A user's requested payment type paired with a vendor's configuration. -#[derive(Debug, Clone, Eq, PartialEq)] +#[derive(Debug, Clone, Eq, PartialEq, CandidType, Deserialize)] pub enum PaymentWithConfig { AttachedCycles, CallerPaysIcrc2Cycles, @@ -60,13 +63,11 @@ impl PaymentGuard2 for AnyPaymentGuard { PaymentWithConfig::AttachedCycles => AttachedCyclesPayment {}.deduct(fee).await, PaymentWithConfig::CallerPaysIcrc2Cycles => { Icrc2CyclesPaymentGuard { - ledger_canister_id: cycles_ledger_canister_id(), payer_account: Account { owner: caller, subaccount: None, }, spender_subaccount: None, - created_at_time: None, own_canister_id, } .deduct(fee) @@ -74,7 +75,6 @@ impl PaymentGuard2 for AnyPaymentGuard { } PaymentWithConfig::PatronPaysIcrc2Cycles(patron) => { Icrc2CyclesPaymentGuard { - ledger_canister_id: cycles_ledger_canister_id(), payer_account: patron, spender_subaccount: Some(principal2account(&caller)), ..Icrc2CyclesPaymentGuard::default() @@ -82,26 +82,17 @@ impl PaymentGuard2 for AnyPaymentGuard { .deduct(fee) .await } - PaymentWithConfig::CallerPaysIcrc2Tokens(args) => { - Icrc2CyclesPaymentGuard { - ledger_canister_id: args.ledger, - payer_account: Account { - owner: caller, - subaccount: None, - }, - spender_subaccount: None, - created_at_time: None, - own_canister_id, - } - .deduct(fee) - .await + PaymentWithConfig::CallerPaysIcrc2Tokens(CallerPaysIcrc2Tokens { ledger }) => { + CallerPaysIcrc2TokensPaymentGuard { ledger } + .deduct(fee) + .await } - PaymentWithConfig::PatronPaysIcrc2Tokens(args) => { - Icrc2CyclesPaymentGuard { - ledger_canister_id: args.ledger, - payer_account: args.patron, + PaymentWithConfig::PatronPaysIcrc2Tokens(payment_type) => { + PatronPaysIcrc2TokensPaymentGuard { + ledger: payment_type.ledger, + payer_account: payment_type.patron, spender_subaccount: Some(principal2account(&caller)), - ..Icrc2CyclesPaymentGuard::default() + own_canister_id, } .deduct(fee) .await @@ -111,7 +102,8 @@ impl PaymentGuard2 for AnyPaymentGuard { } impl AnyPaymentGuard { /// Find the vendor configuration for the offered payment type. - fn config(&self, payment: PaymentType) -> Option { + #[must_use] + pub fn config(&self, payment: PaymentType) -> Option { match payment { PaymentType::AttachedCycles => self .supported @@ -128,28 +120,24 @@ impl AnyPaymentGuard { .iter() .find(|&x| *x == VendorPaymentConfig::PatronPaysIcrc2Cycles) .map(|_| PaymentWithConfig::PatronPaysIcrc2Cycles(patron)), - PaymentType::CallerPaysIcrc2Tokens(args) => self + PaymentType::CallerPaysIcrc2Tokens(payment_type) => self .supported .iter() .find(|&x| { - if let VendorPaymentConfig::CallerPaysIcrc2Tokens { ledger } = x { - *ledger == args.ledger - } else { - false + *x == VendorPaymentConfig::CallerPaysIcrc2Tokens { + ledger: payment_type.ledger, } }) - .map(|_| PaymentWithConfig::CallerPaysIcrc2Tokens(args)), - PaymentType::PatronPaysIcrc2Tokens(args) => self + .map(|_| PaymentWithConfig::CallerPaysIcrc2Tokens(payment_type)), + PaymentType::PatronPaysIcrc2Tokens(payment_type) => self .supported .iter() .find(|&x| { - if let VendorPaymentConfig::PatronPaysIcrc2Tokens { ledger } = x { - *ledger == args.ledger - } else { - false + *x == VendorPaymentConfig::PatronPaysIcrc2Tokens { + ledger: payment_type.ledger, } }) - .map(|_| PaymentWithConfig::PatronPaysIcrc2Tokens(args)), + .map(|_| PaymentWithConfig::PatronPaysIcrc2Tokens(payment_type)), _ => None, } } diff --git a/src/guard/src/guards/caller_pays_icrc2_tokens.rs b/src/guard/src/guards/caller_pays_icrc2_tokens.rs new file mode 100644 index 0000000..c9b9ab0 --- /dev/null +++ b/src/guard/src/guards/caller_pays_icrc2_tokens.rs @@ -0,0 +1,58 @@ +//! Code to receive any ICRC-2 token as payment. + +// Well known ICRC-2 tokens +// TODO + +use super::{PaymentError, PaymentGuard}; +use candid::{Nat, Principal}; +use cycles_ledger_client::TransferFromArgs; +use ic_papi_api::{caller::TokenAmount, Account}; + +pub struct CallerPaysIcrc2TokensPaymentGuard { + /// The ledger for that specific token + pub ledger: Principal, +} + +impl PaymentGuard for CallerPaysIcrc2TokensPaymentGuard { + async fn deduct(&self, cost: TokenAmount) -> Result<(), PaymentError> { + let caller = ic_cdk::api::caller(); + cycles_ledger_client::Service(self.ledger) + .icrc_2_transfer_from(&TransferFromArgs { + from: Account { + owner: caller, + subaccount: None, + }, + to: Account { + owner: ic_cdk::api::id(), + subaccount: None, + }, + amount: Nat::from(cost), + spender_subaccount: None, + created_at_time: None, + memo: None, + fee: None, + }) + .await + .map_err(|(rejection_code, string)| { + eprintln!( + "Failed to reach ledger canister at {}: {rejection_code:?}: {string}", + self.ledger + ); + PaymentError::LedgerUnreachable { + ledger: self.ledger, + } + })? + .0 + .map_err(|error| { + eprintln!( + "Failed to withdraw from ledger canister at {}: {error:?}", + self.ledger + ); + PaymentError::LedgerTransferFromError { + ledger: self.ledger, + error, + } + }) + .map(|_| ()) + } +} diff --git a/src/guard/src/guards/icrc2_cycles.rs b/src/guard/src/guards/icrc2_cycles.rs index 56a49d9..84896e6 100644 --- a/src/guard/src/guards/icrc2_cycles.rs +++ b/src/guard/src/guards/icrc2_cycles.rs @@ -1,17 +1,16 @@ +//! Code to receive cycles as payment, credited to the canister, using ICRC-2 and a cycles-ledger specific withdrawal method. use super::{PaymentError, PaymentGuard}; use candid::{Nat, Principal}; use cycles_ledger_client::WithdrawFromArgs; -use ic_papi_api::{caller::TokenAmount, Account}; +use ic_papi_api::{caller::TokenAmount, cycles::cycles_ledger_canister_id, Account}; +/// Accepts cycles using an ICRC-2 approve followed by withdrawing the cycles to the current canister. Withdrawing +/// cycles to the current canister is specific to the cycles ledger canister; it is not part of the ICRC-2 standard. pub struct Icrc2CyclesPaymentGuard { /// The payer pub payer_account: Account, /// The spender, if different from the payer. pub spender_subaccount: Option, - /// The ICRC-2 time, if applicable. - pub created_at_time: Option, - /// The ledger to withdraw the cycles from. - pub ledger_canister_id: Principal, /// Own canister ID pub own_canister_id: Principal, } @@ -45,9 +44,7 @@ impl Default for Icrc2CyclesPaymentGuard { fn default() -> Self { Self { payer_account: Self::default_account(), - ledger_canister_id: Self::default_cycles_ledger(), own_canister_id: ic_cdk::api::id(), - created_at_time: None, spender_subaccount: None, } } @@ -55,32 +52,32 @@ impl Default for Icrc2CyclesPaymentGuard { impl PaymentGuard for Icrc2CyclesPaymentGuard { async fn deduct(&self, fee: TokenAmount) -> Result<(), PaymentError> { - cycles_ledger_client::Service(self.ledger_canister_id) + cycles_ledger_client::Service(cycles_ledger_canister_id()) .withdraw_from(&WithdrawFromArgs { to: self.own_canister_id, amount: Nat::from(fee), from: self.payer_account.clone(), spender_subaccount: self.spender_subaccount.clone(), - created_at_time: self.created_at_time, + created_at_time: None, }) .await .map_err(|(rejection_code, string)| { eprintln!( "Failed to reach ledger canister at {}: {rejection_code:?}: {string}", - self.ledger_canister_id + cycles_ledger_canister_id() ); PaymentError::LedgerUnreachable { - ledger: self.ledger_canister_id, + ledger: cycles_ledger_canister_id(), } })? .0 .map_err(|error| { eprintln!( "Failed to withdraw from ledger canister at {}: {error:?}", - self.ledger_canister_id + cycles_ledger_canister_id() ); - PaymentError::LedgerError { - ledger: self.ledger_canister_id, + PaymentError::LedgerWithdrawFromError { + ledger: cycles_ledger_canister_id(), error, } }) diff --git a/src/guard/src/guards/mod.rs b/src/guard/src/guards/mod.rs index 611d7f6..25c92b7 100644 --- a/src/guard/src/guards/mod.rs +++ b/src/guard/src/guards/mod.rs @@ -4,7 +4,9 @@ use candid::Principal; use ic_papi_api::{caller::TokenAmount, PaymentError, PaymentType}; pub mod any; pub mod attached_cycles; +pub mod caller_pays_icrc2_tokens; pub mod icrc2_cycles; +pub mod patron_pays_icrc2_tokens; #[allow(async_fn_in_trait)] pub trait PaymentGuard { diff --git a/src/guard/src/guards/patron_pays_icrc2_tokens.rs b/src/guard/src/guards/patron_pays_icrc2_tokens.rs new file mode 100644 index 0000000..f982a93 --- /dev/null +++ b/src/guard/src/guards/patron_pays_icrc2_tokens.rs @@ -0,0 +1,68 @@ +//! Code to receive cycles as payment, credited to the canister, using ICRC-2 and a cycles-ledger specific withdrawal method. +use super::{PaymentError, PaymentGuard}; +use candid::{Nat, Principal}; +use cycles_ledger_client::TransferFromArgs; +use ic_papi_api::{caller::TokenAmount, Account}; + +/// Accepts cycles using an ICRC-2 approve followed by withdrawing the cycles to the current canister. Withdrawing +/// cycles to the current canister is specific to the cycles ledger canister; it is not part of the ICRC-2 standard. +pub struct PatronPaysIcrc2TokensPaymentGuard { + /// The ledger for that specific token + pub ledger: Principal, + /// The payer + pub payer_account: Account, + /// The spender, if different from the payer. + pub spender_subaccount: Option, + /// Own canister ID + pub own_canister_id: Principal, +} +impl PatronPaysIcrc2TokensPaymentGuard { + #[must_use] + pub fn default_account() -> Account { + Account { + owner: ic_cdk::caller(), + subaccount: None, + } + } +} + +impl PaymentGuard for PatronPaysIcrc2TokensPaymentGuard { + async fn deduct(&self, cost: TokenAmount) -> Result<(), PaymentError> { + // Note: The cycles ledger client is ICRC-2 compatible so can be used here. + cycles_ledger_client::Service(self.ledger) + .icrc_2_transfer_from(&TransferFromArgs { + from: self.payer_account.clone(), + to: Account { + owner: ic_cdk::api::id(), + subaccount: None, + }, + amount: Nat::from(cost), + spender_subaccount: self.spender_subaccount.clone(), + created_at_time: None, + memo: None, + fee: None, + }) + .await + .map_err(|(rejection_code, string)| { + eprintln!( + "Failed to reach ledger canister at {}: {rejection_code:?}: {string}", + self.ledger + ); + PaymentError::LedgerUnreachable { + ledger: self.ledger, + } + })? + .0 + .map_err(|error| { + eprintln!( + "Failed to withdraw from ledger canister at {}: {error:?}", + self.ledger + ); + PaymentError::LedgerTransferFromError { + ledger: self.ledger, + error, + } + }) + .map(|_| ()) + } +}