From adcdf0d4760be9904e3c1926cc3a7eac30e6361f Mon Sep 17 00:00:00 2001 From: Hamish Peebles Date: Mon, 4 Dec 2023 09:09:49 +0000 Subject: [PATCH 1/4] Transfer out funds once trade is complete (#4906) --- .../impl/src/jobs/process_pending_actions.rs | 4 +- backend/canisters/escrow/CHANGELOG.md | 1 + .../impl/src/jobs/make_pending_payments.rs | 102 ++++++++++++++++++ backend/canisters/escrow/impl/src/jobs/mod.rs | 6 +- backend/canisters/escrow/impl/src/lib.rs | 3 + .../canisters/escrow/impl/src/model/mod.rs | 1 + .../canisters/escrow/impl/src/model/offers.rs | 8 +- .../impl/src/model/pending_payments_queue.rs | 38 +++++++ .../escrow/impl/src/updates/notify_deposit.rs | 20 +++- .../impl/src/jobs/make_btc_miami_payments.rs | 5 +- backend/libraries/ledger_utils/src/icrc1.rs | 8 +- backend/libraries/types/src/cryptocurrency.rs | 6 ++ 12 files changed, 186 insertions(+), 16 deletions(-) create mode 100644 backend/canisters/escrow/impl/src/jobs/make_pending_payments.rs create mode 100644 backend/canisters/escrow/impl/src/model/pending_payments_queue.rs diff --git a/backend/bots/examples/satoshi_dice/impl/src/jobs/process_pending_actions.rs b/backend/bots/examples/satoshi_dice/impl/src/jobs/process_pending_actions.rs index 2a7c2da2b9..4518362f40 100644 --- a/backend/bots/examples/satoshi_dice/impl/src/jobs/process_pending_actions.rs +++ b/backend/bots/examples/satoshi_dice/impl/src/jobs/process_pending_actions.rs @@ -108,8 +108,8 @@ async fn process_action(action: Action) { token: Cryptocurrency::CKBTC, amount: amount as u128, fee: 10, - from: icrc1::CryptoAccount::Account(from), - to: icrc1::CryptoAccount::Account(Account::from(Principal::from(user_id))), + from: from.into(), + to: Account::from(Principal::from(user_id)).into(), memo: None, created: now_nanos, block_index: block_index.0.try_into().unwrap(), diff --git a/backend/canisters/escrow/CHANGELOG.md b/backend/canisters/escrow/CHANGELOG.md index bf133421b9..a02c445d54 100644 --- a/backend/canisters/escrow/CHANGELOG.md +++ b/backend/canisters/escrow/CHANGELOG.md @@ -9,3 +9,4 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - Introduce the Escrow canister for supporting P2P trades ([#4903](https://github.com/open-chat-labs/open-chat/pull/4903)) - Implement `create_offer` and `notify_deposit` ([#4904](https://github.com/open-chat-labs/open-chat/pull/4904)) +- Transfer out funds once trade is complete ([#4906](https://github.com/open-chat-labs/open-chat/pull/4906)) \ No newline at end of file diff --git a/backend/canisters/escrow/impl/src/jobs/make_pending_payments.rs b/backend/canisters/escrow/impl/src/jobs/make_pending_payments.rs new file mode 100644 index 0000000000..1a587fa69a --- /dev/null +++ b/backend/canisters/escrow/impl/src/jobs/make_pending_payments.rs @@ -0,0 +1,102 @@ +use crate::model::pending_payments_queue::{PendingPayment, PendingPaymentReason}; +use crate::{mutate_state, RuntimeState}; +use candid::Principal; +use escrow_canister::deposit_subaccount; +use ic_cdk_timers::TimerId; +use icrc_ledger_types::icrc1::account::Account; +use icrc_ledger_types::icrc1::transfer::TransferArg; +use std::cell::Cell; +use std::time::Duration; +use tracing::{error, trace}; +use types::icrc1::CompletedCryptoTransaction; +use types::CanisterId; +use utils::time::NANOS_PER_MILLISECOND; + +thread_local! { + static TIMER_ID: Cell> = Cell::default(); +} + +pub(crate) fn start_job_if_required(state: &RuntimeState) -> bool { + if TIMER_ID.get().is_none() && !state.data.pending_payments_queue.is_empty() { + let timer_id = ic_cdk_timers::set_timer_interval(Duration::ZERO, run); + TIMER_ID.set(Some(timer_id)); + trace!("'make_pending_payments' job started"); + true + } else { + false + } +} + +pub fn run() { + if let Some(pending_payment) = mutate_state(|state| state.data.pending_payments_queue.pop()) { + ic_cdk::spawn(process_payment(pending_payment)); + } else if let Some(timer_id) = TIMER_ID.take() { + ic_cdk_timers::clear_timer(timer_id); + trace!("'make_pending_payments' job stopped"); + } +} + +async fn process_payment(pending_payment: PendingPayment) { + let from_user = match pending_payment.reason { + PendingPaymentReason::Trade(other_user_id) => other_user_id, + PendingPaymentReason::Refund => pending_payment.user_id, + }; + let created_at_time = pending_payment.timestamp * NANOS_PER_MILLISECOND; + + let args = TransferArg { + from_subaccount: Some(deposit_subaccount(from_user, pending_payment.offer_id)), + to: Principal::from(pending_payment.user_id).into(), + fee: Some(pending_payment.token_info.fee.into()), + created_at_time: Some(created_at_time), + memo: None, + amount: pending_payment.amount.into(), + }; + + match make_payment(pending_payment.token_info.ledger, &args).await { + Ok(block_index) => { + mutate_state(|state| { + if let Some(offer) = state.data.offers.get_mut(pending_payment.offer_id) { + let transfer = CompletedCryptoTransaction { + ledger: pending_payment.token_info.ledger, + token: pending_payment.token_info.token, + amount: pending_payment.amount, + from: Account { + owner: state.env.canister_id(), + subaccount: args.from_subaccount, + } + .into(), + to: Account::from(Principal::from(pending_payment.user_id)).into(), + fee: pending_payment.token_info.fee, + memo: None, + created: created_at_time, + block_index, + }; + offer.transfers_out.push(transfer); + } + }); + } + Err(retry) => { + if retry { + mutate_state(|state| { + state.data.pending_payments_queue.push(pending_payment); + start_job_if_required(state); + }); + } + } + } +} + +// Error response contains a boolean stating if the transfer should be retried +async fn make_payment(ledger_canister_id: CanisterId, args: &TransferArg) -> Result { + match icrc_ledger_canister_c2c_client::icrc1_transfer(ledger_canister_id, args).await { + Ok(Ok(block_index)) => Ok(block_index.0.try_into().unwrap()), + Ok(Err(transfer_error)) => { + error!(?transfer_error, ?args, "Transfer failed"); + Err(false) + } + Err(error) => { + error!(?error, ?args, "Transfer failed"); + Err(true) + } + } +} diff --git a/backend/canisters/escrow/impl/src/jobs/mod.rs b/backend/canisters/escrow/impl/src/jobs/mod.rs index 01f9150d4a..daeac2a616 100644 --- a/backend/canisters/escrow/impl/src/jobs/mod.rs +++ b/backend/canisters/escrow/impl/src/jobs/mod.rs @@ -1,3 +1,7 @@ use crate::RuntimeState; -pub(crate) fn start(_state: &RuntimeState) {} +pub mod make_pending_payments; + +pub(crate) fn start(state: &RuntimeState) { + make_pending_payments::start_job_if_required(state); +} diff --git a/backend/canisters/escrow/impl/src/lib.rs b/backend/canisters/escrow/impl/src/lib.rs index 9211986682..220648cf78 100644 --- a/backend/canisters/escrow/impl/src/lib.rs +++ b/backend/canisters/escrow/impl/src/lib.rs @@ -1,4 +1,5 @@ use crate::model::offers::Offers; +use crate::model::pending_payments_queue::PendingPaymentsQueue; use canister_state_macros::canister_state; use serde::{Deserialize, Serialize}; use std::cell::RefCell; @@ -45,6 +46,7 @@ impl RuntimeState { #[derive(Serialize, Deserialize)] struct Data { pub offers: Offers, + pub pending_payments_queue: PendingPaymentsQueue, pub cycles_dispenser_canister_id: CanisterId, pub rng_seed: [u8; 32], pub test_mode: bool, @@ -54,6 +56,7 @@ impl Data { pub fn new(cycles_dispenser_canister_id: CanisterId, test_mode: bool) -> Data { Data { offers: Offers::default(), + pending_payments_queue: PendingPaymentsQueue::default(), cycles_dispenser_canister_id, rng_seed: [0; 32], test_mode, diff --git a/backend/canisters/escrow/impl/src/model/mod.rs b/backend/canisters/escrow/impl/src/model/mod.rs index 4791300f15..e9ce076028 100644 --- a/backend/canisters/escrow/impl/src/model/mod.rs +++ b/backend/canisters/escrow/impl/src/model/mod.rs @@ -1 +1,2 @@ pub mod offers; +pub mod pending_payments_queue; diff --git a/backend/canisters/escrow/impl/src/model/offers.rs b/backend/canisters/escrow/impl/src/model/offers.rs index 329a8e3713..317aff7aab 100644 --- a/backend/canisters/escrow/impl/src/model/offers.rs +++ b/backend/canisters/escrow/impl/src/model/offers.rs @@ -1,6 +1,6 @@ use serde::{Deserialize, Serialize}; use std::collections::BTreeMap; -use types::{CompletedCryptoTransaction, TimestampMillis, TokenInfo, UserId}; +use types::{icrc1::CompletedCryptoTransaction, TimestampMillis, TokenInfo, UserId}; #[derive(Serialize, Deserialize, Default)] pub struct Offers { @@ -32,8 +32,7 @@ pub struct Offer { pub accepted_by: Option<(UserId, TimestampMillis)>, pub token0_received: bool, pub token1_received: bool, - pub transfer_out0: Option, - pub transfer_out1: Option, + pub transfers_out: Vec, } impl Offer { @@ -50,8 +49,7 @@ impl Offer { accepted_by: None, token0_received: false, token1_received: false, - transfer_out0: None, - transfer_out1: None, + transfers_out: Vec::new(), } } } diff --git a/backend/canisters/escrow/impl/src/model/pending_payments_queue.rs b/backend/canisters/escrow/impl/src/model/pending_payments_queue.rs new file mode 100644 index 0000000000..c9fba60c28 --- /dev/null +++ b/backend/canisters/escrow/impl/src/model/pending_payments_queue.rs @@ -0,0 +1,38 @@ +use serde::{Deserialize, Serialize}; +use std::collections::VecDeque; +use types::{TimestampMillis, TokenInfo, UserId}; + +#[derive(Serialize, Deserialize, Default)] +pub struct PendingPaymentsQueue { + pending_payments: VecDeque, +} + +impl PendingPaymentsQueue { + pub fn push(&mut self, pending_payment: PendingPayment) { + self.pending_payments.push_back(pending_payment); + } + + pub fn pop(&mut self) -> Option { + self.pending_payments.pop_front() + } + + pub fn is_empty(&self) -> bool { + self.pending_payments.is_empty() + } +} + +#[derive(Serialize, Deserialize)] +pub struct PendingPayment { + pub user_id: UserId, + pub timestamp: TimestampMillis, + pub token_info: TokenInfo, + pub amount: u128, + pub offer_id: u32, + pub reason: PendingPaymentReason, +} + +#[derive(Serialize, Deserialize, Clone, Copy)] +pub enum PendingPaymentReason { + Trade(UserId), + Refund, +} diff --git a/backend/canisters/escrow/impl/src/updates/notify_deposit.rs b/backend/canisters/escrow/impl/src/updates/notify_deposit.rs index c49e618695..455bfcc01e 100644 --- a/backend/canisters/escrow/impl/src/updates/notify_deposit.rs +++ b/backend/canisters/escrow/impl/src/updates/notify_deposit.rs @@ -1,3 +1,4 @@ +use crate::model::pending_payments_queue::{PendingPayment, PendingPaymentReason}; use crate::{mutate_state, RuntimeState}; use canister_api_macros::update_msgpack; use canister_tracing_macros::trace; @@ -39,7 +40,24 @@ async fn notify_deposit(args: Args) -> Response { offer.token1_received = true; } if offer.token0_received && offer.token1_received { - // TODO queue up transfers + let accepted_by = offer.accepted_by.unwrap().0; + state.data.pending_payments_queue.push(PendingPayment { + user_id: offer.created_by, + timestamp: now, + token_info: offer.token1.clone(), + amount: offer.amount1, + offer_id: offer.id, + reason: PendingPaymentReason::Trade(accepted_by), + }); + state.data.pending_payments_queue.push(PendingPayment { + user_id: accepted_by, + timestamp: now, + token_info: offer.token0.clone(), + amount: offer.amount0, + offer_id: offer.id, + reason: PendingPaymentReason::Trade(offer.created_by), + }); + crate::jobs::make_pending_payments::start_job_if_required(state); } Success } diff --git a/backend/canisters/local_user_index/impl/src/jobs/make_btc_miami_payments.rs b/backend/canisters/local_user_index/impl/src/jobs/make_btc_miami_payments.rs index bd7b8a9646..229b752ccb 100644 --- a/backend/canisters/local_user_index/impl/src/jobs/make_btc_miami_payments.rs +++ b/backend/canisters/local_user_index/impl/src/jobs/make_btc_miami_payments.rs @@ -7,7 +7,6 @@ use icrc_ledger_types::icrc1::transfer::{BlockIndex, TransferArg}; use std::cell::Cell; use std::time::Duration; use tracing::{error, trace}; -use types::icrc1::CryptoAccount; use types::{ icrc1, CompletedCryptoTransaction, CryptoContent, CryptoTransaction, Cryptocurrency, CustomContent, MessageContent, TextContent, @@ -85,8 +84,8 @@ fn send_oc_bot_messages(pending_payment: &PendingPayment, block_index: BlockInde token: Cryptocurrency::CKBTC, amount, fee: 10, - from: CryptoAccount::Account(Account::from(Principal::from(OPENCHAT_BOT_USER_ID))), - to: CryptoAccount::Account(Account::from(Principal::from(user_id))), + from: Account::from(Principal::from(OPENCHAT_BOT_USER_ID)).into(), + to: Account::from(Principal::from(user_id)).into(), memo: None, created: pending_payment.timestamp, block_index: block_index.0.try_into().unwrap(), diff --git a/backend/libraries/ledger_utils/src/icrc1.rs b/backend/libraries/ledger_utils/src/icrc1.rs index f6c30b75f3..1de8f6ef67 100644 --- a/backend/libraries/ledger_utils/src/icrc1.rs +++ b/backend/libraries/ledger_utils/src/icrc1.rs @@ -25,8 +25,8 @@ pub async fn process_transaction( token: transaction.token.clone(), amount: transaction.amount, fee: transaction.fee, - from: types::icrc1::CryptoAccount::Account(from), - to: types::icrc1::CryptoAccount::Account(transaction.to), + from: from.into(), + to: transaction.to.into(), memo: transaction.memo.clone(), created: transaction.created, block_index: block_index.0.try_into().unwrap(), @@ -45,8 +45,8 @@ pub async fn process_transaction( token: transaction.token, amount: transaction.amount, fee: transaction.fee, - from: types::icrc1::CryptoAccount::Account(from), - to: types::icrc1::CryptoAccount::Account(transaction.to), + from: from.into(), + to: transaction.to.into(), memo: transaction.memo, created: transaction.created, error_message: error, diff --git a/backend/libraries/types/src/cryptocurrency.rs b/backend/libraries/types/src/cryptocurrency.rs index 2c9b332a0a..3ba7155eb8 100644 --- a/backend/libraries/types/src/cryptocurrency.rs +++ b/backend/libraries/types/src/cryptocurrency.rs @@ -402,6 +402,12 @@ pub mod icrc1 { super::FailedCryptoTransaction::ICRC1(value) } } + + impl From for CryptoAccount { + fn from(value: Account) -> Self { + CryptoAccount::Account(value) + } + } } impl From for nns::PendingCryptoTransaction { From 612c1f7e81f5a53a12bf1e8bd1bef89393af01b8 Mon Sep 17 00:00:00 2001 From: Hamish Peebles Date: Mon, 4 Dec 2023 09:15:49 +0000 Subject: [PATCH 2/4] Implement `cancel_offer` (#4907) --- Cargo.lock | 1 + backend/canisters/escrow/CHANGELOG.md | 3 +- backend/canisters/escrow/api/Cargo.toml | 1 + backend/canisters/escrow/api/src/lib.rs | 11 ++-- .../escrow/api/src/updates/cancel_offer.rs | 15 ++++++ .../canisters/escrow/api/src/updates/mod.rs | 1 + .../escrow/api/src/updates/notify_deposit.rs | 1 + .../canisters/escrow/impl/src/model/offers.rs | 2 + .../escrow/impl/src/updates/cancel_offer.rs | 31 +++++++++++ .../canisters/escrow/impl/src/updates/mod.rs | 1 + .../escrow/impl/src/updates/notify_deposit.rs | 53 ++++++++++--------- 11 files changed, 89 insertions(+), 31 deletions(-) create mode 100644 backend/canisters/escrow/api/src/updates/cancel_offer.rs create mode 100644 backend/canisters/escrow/impl/src/updates/cancel_offer.rs diff --git a/Cargo.lock b/Cargo.lock index e83eece733..0c9d4aa03d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2024,6 +2024,7 @@ dependencies = [ "candid_gen", "icrc-ledger-types", "serde", + "sha256", "types", ] diff --git a/backend/canisters/escrow/CHANGELOG.md b/backend/canisters/escrow/CHANGELOG.md index a02c445d54..9b325cc3ac 100644 --- a/backend/canisters/escrow/CHANGELOG.md +++ b/backend/canisters/escrow/CHANGELOG.md @@ -9,4 +9,5 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - Introduce the Escrow canister for supporting P2P trades ([#4903](https://github.com/open-chat-labs/open-chat/pull/4903)) - Implement `create_offer` and `notify_deposit` ([#4904](https://github.com/open-chat-labs/open-chat/pull/4904)) -- Transfer out funds once trade is complete ([#4906](https://github.com/open-chat-labs/open-chat/pull/4906)) \ No newline at end of file +- Transfer out funds once trade is complete ([#4906](https://github.com/open-chat-labs/open-chat/pull/4906)) +- Implement `cancel_offer` ([#4907](https://github.com/open-chat-labs/open-chat/pull/4907)) diff --git a/backend/canisters/escrow/api/Cargo.toml b/backend/canisters/escrow/api/Cargo.toml index 9b670b179b..03c44de93f 100644 --- a/backend/canisters/escrow/api/Cargo.toml +++ b/backend/canisters/escrow/api/Cargo.toml @@ -10,4 +10,5 @@ candid = { workspace = true } candid_gen = { path = "../../../libraries/candid_gen" } icrc-ledger-types = { workspace = true } serde = { workspace = true } +sha256 = { path = "../../../libraries/sha256" } types = { path = "../../../libraries/types" } diff --git a/backend/canisters/escrow/api/src/lib.rs b/backend/canisters/escrow/api/src/lib.rs index 017aede912..074f4e9207 100644 --- a/backend/canisters/escrow/api/src/lib.rs +++ b/backend/canisters/escrow/api/src/lib.rs @@ -1,5 +1,6 @@ use candid::Principal; use icrc_ledger_types::icrc1::account::Subaccount; +use sha256::sha256; use types::UserId; mod lifecycle; @@ -11,10 +12,8 @@ pub use queries::*; pub use updates::*; pub fn deposit_subaccount(user_id: UserId, offer_id: u32) -> Subaccount { - let mut subaccount = [0; 32]; - let principal = Principal::from(user_id); - let user_id_bytes = principal.as_slice(); - subaccount[..user_id_bytes.len()].copy_from_slice(user_id_bytes); - subaccount[28..].copy_from_slice(&offer_id.to_be_bytes()); - subaccount + let mut bytes = Vec::new(); + bytes.extend_from_slice(Principal::from(user_id).as_slice()); + bytes.extend_from_slice(&offer_id.to_be_bytes()); + sha256(&bytes) } diff --git a/backend/canisters/escrow/api/src/updates/cancel_offer.rs b/backend/canisters/escrow/api/src/updates/cancel_offer.rs new file mode 100644 index 0000000000..628629b678 --- /dev/null +++ b/backend/canisters/escrow/api/src/updates/cancel_offer.rs @@ -0,0 +1,15 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Serialize, Deserialize, Debug)] +pub struct Args { + pub offer_id: u32, +} + +#[derive(Serialize, Deserialize, Debug)] +pub enum Response { + Success, + OfferAlreadyAccepted, + OfferExpired, + OfferNotFound, + NotAuthorized, +} diff --git a/backend/canisters/escrow/api/src/updates/mod.rs b/backend/canisters/escrow/api/src/updates/mod.rs index dff0cb6200..7473275f87 100644 --- a/backend/canisters/escrow/api/src/updates/mod.rs +++ b/backend/canisters/escrow/api/src/updates/mod.rs @@ -1,2 +1,3 @@ +pub mod cancel_offer; pub mod create_offer; pub mod notify_deposit; diff --git a/backend/canisters/escrow/api/src/updates/notify_deposit.rs b/backend/canisters/escrow/api/src/updates/notify_deposit.rs index e559888345..216543b122 100644 --- a/backend/canisters/escrow/api/src/updates/notify_deposit.rs +++ b/backend/canisters/escrow/api/src/updates/notify_deposit.rs @@ -12,6 +12,7 @@ pub enum Response { Success, BalanceTooLow(BalanceTooLowResult), OfferAlreadyAccepted, + OfferCancelled, OfferExpired, OfferNotFound, InternalError(String), diff --git a/backend/canisters/escrow/impl/src/model/offers.rs b/backend/canisters/escrow/impl/src/model/offers.rs index 317aff7aab..472f93e2f1 100644 --- a/backend/canisters/escrow/impl/src/model/offers.rs +++ b/backend/canisters/escrow/impl/src/model/offers.rs @@ -29,6 +29,7 @@ pub struct Offer { pub token1: TokenInfo, pub amount1: u128, pub expires_at: TimestampMillis, + pub cancelled_at: Option, pub accepted_by: Option<(UserId, TimestampMillis)>, pub token0_received: bool, pub token1_received: bool, @@ -46,6 +47,7 @@ impl Offer { token1: args.output_token, amount1: args.output_amount, expires_at: args.expires_at, + cancelled_at: None, accepted_by: None, token0_received: false, token1_received: false, diff --git a/backend/canisters/escrow/impl/src/updates/cancel_offer.rs b/backend/canisters/escrow/impl/src/updates/cancel_offer.rs new file mode 100644 index 0000000000..95cd48ff36 --- /dev/null +++ b/backend/canisters/escrow/impl/src/updates/cancel_offer.rs @@ -0,0 +1,31 @@ +use crate::{mutate_state, RuntimeState}; +use canister_api_macros::update_msgpack; +use canister_tracing_macros::trace; +use escrow_canister::cancel_offer::{Response::*, *}; + +#[update_msgpack] +#[trace] +fn cancel_offer(args: Args) -> Response { + mutate_state(|state| cancel_offer_impl(args, state)) +} + +fn cancel_offer_impl(args: Args, state: &mut RuntimeState) -> Response { + if let Some(offer) = state.data.offers.get_mut(args.offer_id) { + let user_id = state.env.caller().into(); + let now = state.env.now(); + if offer.created_by != user_id { + NotAuthorized + } else if offer.accepted_by.is_some() { + OfferAlreadyAccepted + } else if offer.expires_at < now { + OfferExpired + } else { + if offer.cancelled_at.is_none() { + offer.cancelled_at = Some(now); + } + Success + } + } else { + OfferNotFound + } +} diff --git a/backend/canisters/escrow/impl/src/updates/mod.rs b/backend/canisters/escrow/impl/src/updates/mod.rs index 369ee5973c..c36b86af16 100644 --- a/backend/canisters/escrow/impl/src/updates/mod.rs +++ b/backend/canisters/escrow/impl/src/updates/mod.rs @@ -1,3 +1,4 @@ +pub mod cancel_offer; pub mod create_offer; pub mod notify_deposit; pub mod wallet_receive; diff --git a/backend/canisters/escrow/impl/src/updates/notify_deposit.rs b/backend/canisters/escrow/impl/src/updates/notify_deposit.rs index 455bfcc01e..7ffd7f6626 100644 --- a/backend/canisters/escrow/impl/src/updates/notify_deposit.rs +++ b/backend/canisters/escrow/impl/src/updates/notify_deposit.rs @@ -76,39 +76,44 @@ struct PrepareResult { fn prepare(args: &Args, state: &mut RuntimeState) -> Result { let now = state.env.now(); if let Some(offer) = state.data.offers.get_mut(args.offer_id) { - let user_id = args.user_id.unwrap_or_else(|| state.env.caller().into()); - if offer.created_by == user_id { - if offer.token0_received { - Err(Success) + if offer.cancelled_at.is_some() { + Err(OfferCancelled) + } else if offer.expires_at < now { + Err(OfferExpired) + } else { + let user_id = args.user_id.unwrap_or_else(|| state.env.caller().into()); + + if offer.created_by == user_id { + if offer.token0_received { + Err(Success) + } else { + Ok(PrepareResult { + user_id, + ledger: offer.token0.ledger, + account: Account { + owner: state.env.canister_id(), + subaccount: Some(deposit_subaccount(user_id, offer.id)), + }, + balance_required: offer.amount0 + offer.token0.fee, + }) + } + } else if let Some((accepted_by, _)) = offer.accepted_by { + if accepted_by == user_id { + Err(Success) + } else { + Err(OfferAlreadyAccepted) + } } else { Ok(PrepareResult { user_id, - ledger: offer.token0.ledger, + ledger: offer.token1.ledger, account: Account { owner: state.env.canister_id(), subaccount: Some(deposit_subaccount(user_id, offer.id)), }, - balance_required: offer.amount0 + offer.token0.fee, + balance_required: offer.amount1 + offer.token1.fee, }) } - } else if let Some((accepted_by, _)) = offer.accepted_by { - if accepted_by == user_id { - Err(Success) - } else { - Err(OfferAlreadyAccepted) - } - } else if offer.expires_at < now { - Err(OfferExpired) - } else { - Ok(PrepareResult { - user_id, - ledger: offer.token1.ledger, - account: Account { - owner: state.env.canister_id(), - subaccount: Some(deposit_subaccount(user_id, offer.id)), - }, - balance_required: offer.amount1 + offer.token1.fee, - }) } } else { Err(OfferNotFound) From ce16c62bf75c5d72f2d997c1a8e3626edfd263db Mon Sep 17 00:00:00 2001 From: Hamish Peebles Date: Mon, 4 Dec 2023 09:24:08 +0000 Subject: [PATCH 3/4] Add tests to cover upgrading to Diamond by paying in CHAT (#4908) --- backend/integration_tests/src/client/mod.rs | 29 +++------------ .../src/client/user_index.rs | 5 +-- .../src/diamond_membership_tests.rs | 35 +++++++++++-------- backend/integration_tests/src/lib.rs | 1 + .../integration_tests/src/registry_tests.rs | 8 +++-- backend/integration_tests/src/setup.rs | 25 +++++++++++-- 6 files changed, 55 insertions(+), 48 deletions(-) diff --git a/backend/integration_tests/src/client/mod.rs b/backend/integration_tests/src/client/mod.rs index 640aa01857..8c79bd63d1 100644 --- a/backend/integration_tests/src/client/mod.rs +++ b/backend/integration_tests/src/client/mod.rs @@ -32,12 +32,8 @@ pub fn create_canister(env: &mut PocketIc, controller: Principal) -> CanisterId } pub fn create_canister_with_id(env: &mut PocketIc, controller: Principal, canister_id: &str) -> CanisterId { - let canister_id = env - .create_canister_with_id( - Some(controller), - None, - Principal::from_text(canister_id).expect("Invalid canister ID"), - ) + let canister_id = canister_id.try_into().expect("Invalid canister ID"); + env.create_canister_with_id(Some(controller), None, canister_id) .expect("Create canister with ID failed"); env.add_cycles(canister_id, INIT_CYCLES_BALANCE); canister_id @@ -94,25 +90,7 @@ pub fn execute_update_no_response( pub fn register_diamond_user(env: &mut PocketIc, canister_ids: &CanisterIds, controller: Principal) -> User { let user = local_user_index::happy_path::register_user(env, canister_ids.local_user_index); - - icrc1::happy_path::transfer( - env, - controller, - canister_ids.icp_ledger, - user.user_id.into(), - 10_000_000_000u64, - ); - - user_index::happy_path::pay_for_diamond_membership( - env, - user.principal, - canister_ids.user_index, - DiamondMembershipPlanDuration::OneMonth, - true, - ); - - tick_many(env, 3); - + upgrade_user(&user, env, canister_ids, controller); user } @@ -130,6 +108,7 @@ pub fn upgrade_user(user: &User, env: &mut PocketIc, canister_ids: &CanisterIds, user.principal, canister_ids.user_index, DiamondMembershipPlanDuration::OneMonth, + false, true, ); diff --git a/backend/integration_tests/src/client/user_index.rs b/backend/integration_tests/src/client/user_index.rs index ee63ddb3c7..4b8847ce4b 100644 --- a/backend/integration_tests/src/client/user_index.rs +++ b/backend/integration_tests/src/client/user_index.rs @@ -77,6 +77,7 @@ pub mod happy_path { sender: Principal, canister_id: CanisterId, duration: DiamondMembershipPlanDuration, + pay_in_chat: bool, recurring: bool, ) -> DiamondMembershipDetails { let response = super::pay_for_diamond_membership( @@ -85,8 +86,8 @@ pub mod happy_path { canister_id, &user_index_canister::pay_for_diamond_membership::Args { duration, - token: Cryptocurrency::InternetComputer, - expected_price_e8s: duration.icp_price_e8s(), + token: if pay_in_chat { Cryptocurrency::CHAT } else { Cryptocurrency::InternetComputer }, + expected_price_e8s: if pay_in_chat { duration.chat_price_e8s() } else { duration.icp_price_e8s() }, recurring, }, ); diff --git a/backend/integration_tests/src/diamond_membership_tests.rs b/backend/integration_tests/src/diamond_membership_tests.rs index d1c5f703ec..31f0c3a391 100644 --- a/backend/integration_tests/src/diamond_membership_tests.rs +++ b/backend/integration_tests/src/diamond_membership_tests.rs @@ -10,10 +10,12 @@ use types::{Cryptocurrency, DiamondMembershipPlanDuration, DiamondMembershipSubs use utils::consts::SNS_GOVERNANCE_CANISTER_ID; use utils::time::MINUTE_IN_MS; -#[test_case(false)] -#[test_case(true)] +#[test_case(true, false)] +#[test_case(true, true)] +#[test_case(false, false)] +#[test_case(false, true)] #[serial] -fn can_upgrade_to_diamond(lifetime: bool) { +fn can_upgrade_to_diamond(pay_in_chat: bool, lifetime: bool) { let mut wrapper = ENV.deref().get(); let TestEnv { env, @@ -22,17 +24,13 @@ fn can_upgrade_to_diamond(lifetime: bool) { .. } = wrapper.env(); - let init_treasury_balance = client::icrc1::happy_path::balance_of(env, canister_ids.icp_ledger, SNS_GOVERNANCE_CANISTER_ID); + let ledger = if pay_in_chat { canister_ids.chat_ledger } else { canister_ids.icp_ledger }; + + let init_treasury_balance = client::icrc1::happy_path::balance_of(env, ledger, SNS_GOVERNANCE_CANISTER_ID); let user = client::local_user_index::happy_path::register_user(env, canister_ids.local_user_index); - client::icrc1::happy_path::transfer( - env, - *controller, - canister_ids.icp_ledger, - user.user_id.into(), - 1_000_000_000u64, - ); + client::icrc1::happy_path::transfer(env, *controller, ledger, user.user_id.into(), 10_000_000_000u64); let now = now_millis(env); @@ -49,6 +47,7 @@ fn can_upgrade_to_diamond(lifetime: bool) { user.principal, canister_ids.user_index, duration, + pay_in_chat, false, ); @@ -69,14 +68,20 @@ fn can_upgrade_to_diamond(lifetime: bool) { .subscription .is_active()); - let new_balance = client::icrc1::happy_path::balance_of(env, canister_ids.icp_ledger, user.user_id.into()); - assert_eq!(new_balance, 1_000_000_000 - duration.icp_price_e8s()); + let (expected_price, transfer_fee) = if pay_in_chat { + (duration.chat_price_e8s(), Cryptocurrency::CHAT.fee().unwrap()) + } else { + (duration.icp_price_e8s(), Cryptocurrency::InternetComputer.fee().unwrap()) + }; - let treasury_balance = client::icrc1::happy_path::balance_of(env, canister_ids.icp_ledger, SNS_GOVERNANCE_CANISTER_ID); + let new_balance = client::icrc1::happy_path::balance_of(env, ledger, user.user_id.into()); + assert_eq!(new_balance, 10_000_000_000 - expected_price); + + let treasury_balance = client::icrc1::happy_path::balance_of(env, ledger, SNS_GOVERNANCE_CANISTER_ID); assert_eq!( treasury_balance - init_treasury_balance, - duration.icp_price_e8s() - (2 * Cryptocurrency::InternetComputer.fee().unwrap()) as u64 + expected_price - (2 * transfer_fee) as u64 ); } diff --git a/backend/integration_tests/src/lib.rs b/backend/integration_tests/src/lib.rs index 50bacb902d..a04a5e70a7 100644 --- a/backend/integration_tests/src/lib.rs +++ b/backend/integration_tests/src/lib.rs @@ -78,6 +78,7 @@ pub struct CanisterIds { pub cycles_dispenser: CanisterId, pub registry: CanisterId, pub icp_ledger: CanisterId, + pub chat_ledger: CanisterId, pub cycles_minting_canister: CanisterId, } diff --git a/backend/integration_tests/src/registry_tests.rs b/backend/integration_tests/src/registry_tests.rs index 7d4894f449..6e5be4af87 100644 --- a/backend/integration_tests/src/registry_tests.rs +++ b/backend/integration_tests/src/registry_tests.rs @@ -1,6 +1,6 @@ use crate::env::ENV; use crate::rng::{random_principal, random_string}; -use crate::setup::install_icrc1_ledger; +use crate::setup::install_icrc_ledger; use crate::utils::now_millis; use crate::{client, TestEnv}; use registry_canister::TokenStandard; @@ -17,12 +17,13 @@ fn add_token_succeeds() { .. } = wrapper.env(); - let ledger_canister_id = install_icrc1_ledger( + let ledger_canister_id = install_icrc_ledger( env, *controller, "ABC Token".to_string(), "ABC".to_string(), 10_000, + None, Vec::new(), ); @@ -105,12 +106,13 @@ fn update_token_succeeds() { .. } = wrapper.env(); - let ledger_canister_id = install_icrc1_ledger( + let ledger_canister_id = install_icrc_ledger( env, *controller, "ABC Token".to_string(), "ABC".to_string(), 10_000, + None, Vec::new(), ); diff --git a/backend/integration_tests/src/setup.rs b/backend/integration_tests/src/setup.rs index 2b5cf19901..5b59da9d2f 100644 --- a/backend/integration_tests/src/setup.rs +++ b/backend/integration_tests/src/setup.rs @@ -38,7 +38,11 @@ pub fn setup_new_env() -> TestEnv { ", &path, &env::current_dir().map(|x| x.display().to_string()).unwrap_or_else(|_| "an unknown directory".to_string())); } - let mut env = PocketIcBuilder::new().with_nns_subnet().with_application_subnet().build(); + let mut env = PocketIcBuilder::new() + .with_nns_subnet() + .with_sns_subnet() + .with_application_subnet() + .build(); let controller = random_principal(); let canister_ids = install_canisters(&mut env, controller); @@ -56,6 +60,15 @@ fn install_canisters(env: &mut PocketIc, controller: Principal) -> CanisterIds { let cycles_minting_canister_id = create_canister_with_id(env, controller, "rkp4c-7iaaa-aaaaa-aaaca-cai"); let sns_wasm_canister_id = create_canister_with_id(env, controller, "qaa6y-5yaaa-aaaaa-aaafa-cai"); let nns_index_canister_id = create_canister_with_id(env, controller, "qhbym-qaaaa-aaaaa-aaafq-cai"); + let chat_ledger_canister_id = install_icrc_ledger( + env, + controller, + "OpenChat".to_string(), + "CHAT".to_string(), + 100000, + Some("2ouva-viaaa-aaaaq-aaamq-cai"), + Vec::new(), + ); let user_index_canister_id = create_canister(env, controller); let group_index_canister_id = create_canister(env, controller); @@ -364,16 +377,18 @@ fn install_canisters(env: &mut PocketIc, controller: Principal) -> CanisterIds { cycles_dispenser: cycles_dispenser_canister_id, registry: registry_canister_id, icp_ledger: nns_ledger_canister_id, + chat_ledger: chat_ledger_canister_id, cycles_minting_canister: cycles_minting_canister_id, } } -pub fn install_icrc1_ledger( +pub fn install_icrc_ledger( env: &mut PocketIc, controller: Principal, token_name: String, token_symbol: String, transfer_fee: u64, + canister_id: Option<&str>, initial_balances: Vec<(Account, u64)>, ) -> CanisterId { #[derive(CandidType)] @@ -413,7 +428,11 @@ pub fn install_icrc1_ledger( }, }); - let canister_id = create_canister(env, controller); + let canister_id = if let Some(id) = canister_id { + create_canister_with_id(env, controller, id) + } else { + create_canister(env, controller) + }; install_canister(env, controller, canister_id, wasms::ICRC_LEDGER.clone(), args); canister_id From 81d08c6274156584ef4e8abfcd5362e5fec358ed Mon Sep 17 00:00:00 2001 From: Hamish Peebles Date: Mon, 4 Dec 2023 09:27:58 +0000 Subject: [PATCH 4/4] Allow extending Diamond membership even if > 3 month remaining (#4909) --- backend/canisters/user_index/CHANGELOG.md | 1 + backend/canisters/user_index/api/can.did | 5 +--- .../src/updates/pay_for_diamond_membership.rs | 10 ++------ .../src/model/diamond_membership_details.rs | 23 +------------------ .../src/updates/pay_for_diamond_membership.rs | 4 ++-- 5 files changed, 7 insertions(+), 36 deletions(-) diff --git a/backend/canisters/user_index/CHANGELOG.md b/backend/canisters/user_index/CHANGELOG.md index 4a9022a1c3..e74120cc14 100644 --- a/backend/canisters/user_index/CHANGELOG.md +++ b/backend/canisters/user_index/CHANGELOG.md @@ -16,6 +16,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - Top up NNS neuron when users pay ICP for lifetime Diamond membership ([#4880](https://github.com/open-chat-labs/open-chat/pull/4880)) - Add `diamond_membership_status` to user summaries ([#4887](https://github.com/open-chat-labs/open-chat/pull/4887)) +- Allow extending Diamond membership even if > 3 month remaining ([#4909](https://github.com/open-chat-labs/open-chat/pull/4909)) ## [[2.0.952](https://github.com/open-chat-labs/open-chat/releases/tag/v2.0.952-user_index)] - 2023-11-28 diff --git a/backend/canisters/user_index/api/can.did b/backend/canisters/user_index/api/can.did index dbc416c815..e3a864b24f 100644 --- a/backend/canisters/user_index/api/can.did +++ b/backend/canisters/user_index/api/can.did @@ -239,10 +239,7 @@ type PayForDiamondMembershipArgs = record { type PayForDiamondMembershipResponse = variant { Success : DiamondMembershipDetails; - CannotExtend : record { - diamond_membership_expires_at : TimestampMillis; - can_extend_at : TimestampMillis; - }; + AlreadyLifetimeDiamondMember; CurrencyNotSupported; PriceMismatch; PaymentAlreadyInProgress; diff --git a/backend/canisters/user_index/api/src/updates/pay_for_diamond_membership.rs b/backend/canisters/user_index/api/src/updates/pay_for_diamond_membership.rs index 09bde3bdd9..a570f769ef 100644 --- a/backend/canisters/user_index/api/src/updates/pay_for_diamond_membership.rs +++ b/backend/canisters/user_index/api/src/updates/pay_for_diamond_membership.rs @@ -1,6 +1,6 @@ use candid::CandidType; use serde::{Deserialize, Serialize}; -use types::{Cryptocurrency, DiamondMembershipDetails, DiamondMembershipPlanDuration, TimestampMillis}; +use types::{Cryptocurrency, DiamondMembershipDetails, DiamondMembershipPlanDuration}; #[derive(CandidType, Serialize, Deserialize, Debug)] pub struct Args { @@ -13,7 +13,7 @@ pub struct Args { #[derive(CandidType, Serialize, Deserialize, Debug)] pub enum Response { Success(DiamondMembershipDetails), - CannotExtend(CannotExtendResult), + AlreadyLifetimeDiamondMember, CurrencyNotSupported, PriceMismatch, PaymentAlreadyInProgress, @@ -22,9 +22,3 @@ pub enum Response { TransferFailed(String), InternalError(String), } - -#[derive(CandidType, Serialize, Deserialize, Debug)] -pub struct CannotExtendResult { - pub diamond_membership_expires_at: TimestampMillis, - pub can_extend_at: TimestampMillis, -} diff --git a/backend/canisters/user_index/impl/src/model/diamond_membership_details.rs b/backend/canisters/user_index/impl/src/model/diamond_membership_details.rs index 3c730cc848..4f396267ce 100644 --- a/backend/canisters/user_index/impl/src/model/diamond_membership_details.rs +++ b/backend/canisters/user_index/impl/src/model/diamond_membership_details.rs @@ -2,9 +2,8 @@ use serde::{Deserialize, Serialize}; use std::cmp::max; use types::{ Cryptocurrency, DiamondMembershipDetails, DiamondMembershipPlanDuration, DiamondMembershipStatus, - DiamondMembershipStatusFull, DiamondMembershipSubscription, Milliseconds, TimestampMillis, + DiamondMembershipStatusFull, DiamondMembershipSubscription, TimestampMillis, }; -use user_index_canister::pay_for_diamond_membership::CannotExtendResult; use utils::time::DAY_IN_MS; const LIFETIME_TIMESTAMP: TimestampMillis = 30000000000000; // This timestamp is in the year 2920 @@ -62,8 +61,6 @@ pub struct DiamondMembershipPayment { pub manual_payment: bool, } -const THREE_MONTHS: Milliseconds = DiamondMembershipPlanDuration::ThreeMonths.as_millis(); - impl DiamondMembershipDetailsInternal { pub fn expires_at(&self) -> Option { self.expires_at @@ -117,24 +114,6 @@ impl DiamondMembershipDetailsInternal { }) } - pub fn can_extend(&self, now: TimestampMillis) -> Result<(), CannotExtendResult> { - self.expires_at.map_or(Ok(()), |ts| { - let remaining_until_expired = ts.saturating_sub(now); - - // Users can extend when there is < 3 months remaining - let remaining_until_can_extend = remaining_until_expired.saturating_sub(THREE_MONTHS); - - if remaining_until_can_extend == 0 { - Ok(()) - } else { - Err(CannotExtendResult { - can_extend_at: now.saturating_add(remaining_until_can_extend), - diamond_membership_expires_at: ts, - }) - } - }) - } - pub fn is_lifetime_diamond_member(&self) -> bool { self.expires_at > Some(LIFETIME_TIMESTAMP) } diff --git a/backend/canisters/user_index/impl/src/updates/pay_for_diamond_membership.rs b/backend/canisters/user_index/impl/src/updates/pay_for_diamond_membership.rs index 10e5bac995..6dce71fff8 100644 --- a/backend/canisters/user_index/impl/src/updates/pay_for_diamond_membership.rs +++ b/backend/canisters/user_index/impl/src/updates/pay_for_diamond_membership.rs @@ -66,8 +66,8 @@ fn prepare(args: &Args, user_id: UserId, state: &mut RuntimeState) -> Result<(), let diamond_membership = state.data.users.diamond_membership_details_mut(&user_id).unwrap(); if diamond_membership.payment_in_progress() { Err(PaymentAlreadyInProgress) - } else if let Err(result) = diamond_membership.can_extend(state.env.now()) { - Err(CannotExtend(result)) + } else if diamond_membership.is_lifetime_diamond_member() { + Err(AlreadyLifetimeDiamondMember) } else { match args.token { Cryptocurrency::CHAT => {