From 66504b902003485cebc477c89d5148eaf3459ca7 Mon Sep 17 00:00:00 2001 From: Max Murphy Date: Tue, 10 Sep 2024 09:54:57 +0200 Subject: [PATCH] ++ --- src/api/src/lib.rs | 17 ++- src/example/paid_service/src/lib.rs | 12 +- src/example/paid_service/tests/it/icrc2.rs | 124 +++++++++++++++++---- src/guard/src/guards/icrc2_cycles.rs | 1 + 4 files changed, 127 insertions(+), 27 deletions(-) diff --git a/src/api/src/lib.rs b/src/api/src/lib.rs index faa80bb..1f6f8c3 100644 --- a/src/api/src/lib.rs +++ b/src/api/src/lib.rs @@ -1,6 +1,7 @@ use candid::{CandidType, Deserialize, Principal}; pub use cycles_ledger_client::Account; use cycles_ledger_client::WithdrawFromError; +use serde_bytes::ByteBuf; #[derive(Debug, CandidType, Deserialize, Clone, Eq, PartialEq)] #[non_exhaustive] @@ -19,7 +20,7 @@ pub enum PaymentError { }, } -#[derive(Debug, CandidType, Deserialize, Clone, Eq, PartialEq)] +#[derive(Debug, CandidType, Deserialize, Copy, Clone, Eq, PartialEq)] #[non_exhaustive] pub enum PaymentType { /// The caller is paying with cycles attached to the call. @@ -28,7 +29,19 @@ pub enum PaymentType { /// /// Note: The API does not require additional arguments to support this payment type. AttachedCycles, - Icrc2Cycles(Icrc2Payer), + /// The caller is paying with cycles from their main account on the (by default cycles) ledger. + CallerIcrc2, + /// A patron is paying, on behalf of the caller, from their main account on the (by default cycles) ledger. + PatronIcrc2(Principal), +} + +pub fn principal2account(principal: &Principal) -> ByteBuf { + // TODO: This is NOT the right way. + let mut ans = principal.as_slice().to_vec(); + while ans.len() < 32 { + ans.push(0); + } + ByteBuf::from(ans) } /// User's payment details for an ICRC2 payment. diff --git a/src/example/paid_service/src/lib.rs b/src/example/paid_service/src/lib.rs index 40e8bfa..ef3500e 100644 --- a/src/example/paid_service/src/lib.rs +++ b/src/example/paid_service/src/lib.rs @@ -3,11 +3,12 @@ mod state; use example_paid_service_api::InitArgs; use ic_cdk::init; use ic_cdk_macros::{export_candid, update}; -use ic_papi_api::{PaymentError, PaymentType}; +use ic_papi_api::{principal2account, PaymentError, PaymentType}; use ic_papi_guard::guards::PaymentGuard; use ic_papi_guard::guards::{ attached_cycles::AttachedCyclesPayment, icrc2_cycles::Icrc2CyclesPaymentGuard, }; +use serde_bytes::ByteBuf; use state::{payment_ledger, set_init_args}; #[init] @@ -46,11 +47,18 @@ async fn cost_1b(payment: PaymentType) -> Result { PaymentType::AttachedCycles => { AttachedCyclesPayment::default().deduct(fee).await?; } - PaymentType::Icrc2Cycles(_payer) => { + PaymentType::CallerIcrc2 => { let mut guard = Icrc2CyclesPaymentGuard::new(); guard.ledger_canister_id = payment_ledger(); guard.deduct(fee).await?; } + PaymentType::PatronIcrc2(patron) => { + let mut guard = Icrc2CyclesPaymentGuard::new(); + guard.ledger_canister_id = payment_ledger(); + guard.payer_account.owner = patron; + guard.spender_subaccount = Some(principal2account(&ic_cdk::caller())); + guard.deduct(fee).await?; + } _ => return Err(PaymentError::UnsupportedPaymentType), }; Ok("Yes, you paid 1 billion cycles!".to_string()) diff --git a/src/example/paid_service/tests/it/icrc2.rs b/src/example/paid_service/tests/it/icrc2.rs index ec1f51b..941bdb7 100644 --- a/src/example/paid_service/tests/it/icrc2.rs +++ b/src/example/paid_service/tests/it/icrc2.rs @@ -5,8 +5,9 @@ use crate::util::cycles_ledger::{ use crate::util::pic_canister::{PicCanister, PicCanisterBuilder, PicCanisterTrait}; use candid::{encode_one, Nat, Principal}; use example_paid_service_api::InitArgs; -use ic_papi_api::{Icrc2Payer, PaymentError, PaymentType}; +use ic_papi_api::{principal2account, Icrc2Payer, PaymentError, PaymentType}; use pocket_ic::PocketIc; +use serde_bytes::ByteBuf; use std::sync::Arc; pub struct CallerPaysWithIcRc2TestSetup { @@ -352,19 +353,7 @@ fn caller_pays_by_named_icrc2() { // Call the API let response: Result = setup .paid_service - .update( - setup.user, - api_method, - (PaymentType::Icrc2Cycles(Icrc2Payer { - account: Some(ic_papi_api::Account { - owner: setup.user, - subaccount: None, - }), - spender_subaccount: None, - ledger_canister_id: None, - created_at_time: None, - })), - ) + .update(setup.user, api_method, PaymentType::CallerIcrc2) .expect("Failed to call the paid service"); assert_eq!( response, @@ -390,15 +379,7 @@ fn caller_pays_by_named_icrc2() { .update( setup.unauthorized_user, api_method, - (PaymentType::Icrc2Cycles(Icrc2Payer { - account: Some(ic_papi_api::Account { - owner: setup.user, - subaccount: None, - }), - spender_subaccount: None, - ledger_canister_id: None, - created_at_time: None, - })), + PaymentType::CallerIcrc2, ) .expect("Failed to call the paid service"); assert_eq!( @@ -419,3 +400,100 @@ fn caller_pays_by_named_icrc2() { } } } + +/// Here `user`` is a patron, and pays on behalf of `user2`. +#[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 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: Some(principal2account(&setup.user2)), + }, + 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 payment_arg = PaymentType::PatronIcrc2(setup.user); + let response: Result = setup + .paid_service + .update(setup.user2, 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 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, 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 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(), + ); + } + } +} diff --git a/src/guard/src/guards/icrc2_cycles.rs b/src/guard/src/guards/icrc2_cycles.rs index eeb33ba..31bd775 100644 --- a/src/guard/src/guards/icrc2_cycles.rs +++ b/src/guard/src/guards/icrc2_cycles.rs @@ -28,6 +28,7 @@ impl Icrc2CyclesPaymentGuard { ) .expect("Failed to parse cycles ledger canister ID") } + /// A default payment guard for ICRC-2 cycles. pub fn new() -> Self { Self { payer_account: Self::default_account(),