diff --git a/Cargo.lock b/Cargo.lock index 9bd3e65575..1ece858690 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1055,6 +1055,7 @@ dependencies = [ "canister_agent_utils", "clap", "cycles_dispenser_canister", + "exchange_bot_canister", "futures", "group_canister", "group_index_canister", @@ -1137,6 +1138,7 @@ dependencies = [ "canister_agent_utils", "clap", "cycles_dispenser_canister", + "exchange_bot_canister", "group_canister", "group_index_canister", "group_index_canister_client", @@ -2083,6 +2085,57 @@ version = "2.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0206175f82b8d6bf6652ff7d71a1e27fd2e4efde587fd368662814d6ec1d9ce0" +[[package]] +name = "exchange_bot_canister" +version = "0.1.0" +dependencies = [ + "bot_api", + "candid", + "candid_gen", + "human_readable", + "serde", + "types", + "user_index_canister", +] + +[[package]] +name = "exchange_bot_canister_impl" +version = "0.1.0" +dependencies = [ + "async-trait", + "candid", + "canister_api_macros", + "canister_logger", + "canister_state_macros", + "canister_tracing_macros", + "exchange_bot_canister", + "futures", + "http_request", + "human_readable", + "ic-cdk", + "ic-cdk-macros", + "ic-cdk-timers", + "ic-ledger-types", + "ic-stable-structures", + "icpswap_client", + "icrc1_ledger_canister_c2c_client", + "itertools 0.11.0", + "lazy_static", + "ledger_utils", + "local_user_index_canister_c2c_client", + "msgpack", + "rand", + "regex-lite", + "serde", + "serializer", + "tracing", + "types", + "user_canister", + "user_canister_c2c_client", + "user_index_canister_c2c_client", + "utils", +] + [[package]] name = "fake" version = "2.8.0" @@ -3182,6 +3235,19 @@ dependencies = [ "types", ] +[[package]] +name = "icpswap_client" +version = "0.1.0" +dependencies = [ + "candid", + "ic-cdk", + "icpswap_swap_pool_canister", + "icpswap_swap_pool_canister_c2c_client", + "ledger_utils", + "serde", + "types", +] + [[package]] name = "icpswap_swap_pool_canister" version = "0.1.0" @@ -3209,7 +3275,6 @@ version = "0.1.0" dependencies = [ "candid", "candid_gen", - "ic-ledger-types", "serde", "serde_bytes", "types", @@ -3522,6 +3587,7 @@ dependencies = [ "serde_cbor", "sha2 0.10.7", "sha256", + "test-case", "types", ] diff --git a/Cargo.toml b/Cargo.toml index fde517948f..094872e2af 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,6 +18,8 @@ members = [ "backend/canisters/community/impl", "backend/canisters/cycles_dispenser/api", "backend/canisters/cycles_dispenser/impl", + "backend/canisters/exchange_bot/api", + "backend/canisters/exchange_bot/impl", "backend/canisters/group/api", "backend/canisters/group/c2c_client", "backend/canisters/group/client", @@ -103,6 +105,7 @@ members = [ "backend/libraries/human_readable", "backend/libraries/human_readable_derive", "backend/libraries/icdex_client", + "backend/libraries/icpswap_client", "backend/libraries/index_store", "backend/libraries/instruction_counts_log", "backend/libraries/ledger_utils", diff --git a/backend/bots/examples/icp_dispenser/impl/src/lifecycle/heartbeat.rs b/backend/bots/examples/icp_dispenser/impl/src/lifecycle/heartbeat.rs index 4e8c013a66..c7c8ba7312 100644 --- a/backend/bots/examples/icp_dispenser/impl/src/lifecycle/heartbeat.rs +++ b/backend/bots/examples/icp_dispenser/impl/src/lifecycle/heartbeat.rs @@ -68,7 +68,11 @@ mod process_pending_actions { async fn send_messages(recipient: UserId, messages: Vec) { let bot_name = read_state(|state| state.data.bot_name.clone()); - let args = user_canister::c2c_handle_bot_messages::Args { bot_name, messages }; + let args = user_canister::c2c_handle_bot_messages::Args { + bot_name, + bot_display_name: None, + messages, + }; let response = user_canister_c2c_client::c2c_handle_bot_messages(recipient.into(), &args).await; if response.is_err() { 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 36cac68a83..f839bfaaff 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 @@ -57,6 +57,7 @@ async fn process_action(action: Action) { CanisterId::from(user_id), &user_canister::c2c_handle_bot_messages::Args { bot_name: read_state(|state| state.data.username.clone()), + bot_display_name: None, messages: messages .into_iter() .map(|m| BotMessage { diff --git a/backend/canister_installer/Cargo.toml b/backend/canister_installer/Cargo.toml index 1cd4066d37..e0da7923aa 100644 --- a/backend/canister_installer/Cargo.toml +++ b/backend/canister_installer/Cargo.toml @@ -10,6 +10,7 @@ candid = { workspace = true } canister_agent_utils = { path = "../libraries/canister_agent_utils" } clap = { workspace = true, features = ["derive"] } cycles_dispenser_canister = { path = "../canisters/cycles_dispenser/api" } +exchange_bot_canister = { path = "../canisters/exchange_bot/api" } futures = { workspace = true } group_canister = { path = "../canisters/group/api" } group_index_canister = { path = "../canisters/group_index/api" } diff --git a/backend/canister_installer/local-bin/exchange_bot.wasm.gz b/backend/canister_installer/local-bin/exchange_bot.wasm.gz new file mode 120000 index 0000000000..a159383582 --- /dev/null +++ b/backend/canister_installer/local-bin/exchange_bot.wasm.gz @@ -0,0 +1 @@ +../../../wasms/exchange_bot.wasm.gz \ No newline at end of file diff --git a/backend/canister_installer/src/lib.rs b/backend/canister_installer/src/lib.rs index 2831fef3d4..c3541562cb 100644 --- a/backend/canister_installer/src/lib.rs +++ b/backend/canister_installer/src/lib.rs @@ -31,7 +31,8 @@ async fn install_service_canisters_impl( set_controllers(management_canister, &canister_ids.storage_index, controllers.clone()), set_controllers(management_canister, &canister_ids.cycles_dispenser, controllers.clone()), set_controllers(management_canister, &canister_ids.registry, controllers.clone()), - set_controllers(management_canister, &canister_ids.market_maker, controllers), + set_controllers(management_canister, &canister_ids.market_maker, controllers.clone()), + set_controllers(management_canister, &canister_ids.exchange_bot, controllers), set_controllers( management_canister, &canister_ids.local_user_index, @@ -170,6 +171,16 @@ async fn install_service_canisters_impl( test_mode, }; + let exchange_bot_canister_wasm = get_canister_wasm(CanisterName::ExchangeBot, version); + let exchange_bot_init_args = exchange_bot_canister::init::Args { + governance_principals: vec![principal], + user_index_canister_id: canister_ids.user_index, + local_user_index_canister_id: canister_ids.local_user_index, + cycles_dispenser_canister_id: canister_ids.cycles_dispenser, + wasm_version: version, + test_mode, + }; + futures::future::join5( install_wasm( management_canister, @@ -204,7 +215,7 @@ async fn install_service_canisters_impl( ) .await; - futures::future::join4( + futures::future::join5( install_wasm( management_canister, &canister_ids.storage_index, @@ -229,6 +240,12 @@ async fn install_service_canisters_impl( &market_maker_canister_wasm.module, market_maker_init_args, ), + install_wasm( + management_canister, + &canister_ids.exchange_bot, + &exchange_bot_canister_wasm.module, + exchange_bot_init_args, + ), ) .await; diff --git a/backend/canister_installer/src/main.rs b/backend/canister_installer/src/main.rs index a5e231f262..215a3c42b3 100644 --- a/backend/canister_installer/src/main.rs +++ b/backend/canister_installer/src/main.rs @@ -20,6 +20,7 @@ async fn main() { cycles_dispenser: opts.cycles_dispenser, registry: opts.registry, market_maker: opts.market_maker, + exchange_bot: opts.exchange_bot, nns_root: opts.nns_root, nns_governance: opts.nns_governance, nns_internet_identity: opts.nns_internet_identity, @@ -80,6 +81,9 @@ struct Opts { #[arg(long)] market_maker: CanisterId, + #[arg(long)] + exchange_bot: CanisterId, + #[arg(long)] nns_root: CanisterId, diff --git a/backend/canister_upgrader/Cargo.toml b/backend/canister_upgrader/Cargo.toml index 8874247b39..45449b1f7c 100644 --- a/backend/canister_upgrader/Cargo.toml +++ b/backend/canister_upgrader/Cargo.toml @@ -10,6 +10,7 @@ candid = { workspace = true } canister_agent_utils = { path = "../libraries/canister_agent_utils" } clap = { workspace = true, features = ["derive"] } cycles_dispenser_canister = { path = "../canisters/cycles_dispenser/api" } +exchange_bot_canister = { path = "../canisters/exchange_bot/api" } group_canister = { path = "../canisters/group/api" } group_index_canister = { path = "../canisters/group_index/api" } group_index_canister_client = { path = "../canisters/group_index/client" } diff --git a/backend/canister_upgrader/local-bin/exchange_bot.wasm.gz b/backend/canister_upgrader/local-bin/exchange_bot.wasm.gz new file mode 120000 index 0000000000..a159383582 --- /dev/null +++ b/backend/canister_upgrader/local-bin/exchange_bot.wasm.gz @@ -0,0 +1 @@ +../../../wasms/exchange_bot.wasm.gz \ No newline at end of file diff --git a/backend/canister_upgrader/src/lib.rs b/backend/canister_upgrader/src/lib.rs index df893769b7..ed4d2faacf 100644 --- a/backend/canister_upgrader/src/lib.rs +++ b/backend/canister_upgrader/src/lib.rs @@ -178,6 +178,25 @@ pub async fn upgrade_market_maker_canister( println!("Market maker canister upgraded"); } +pub async fn upgrade_exchange_bot_canister( + identity: Box, + url: String, + exchange_bot_canister_id: CanisterId, + version: BuildVersion, +) { + upgrade_top_level_canister( + identity, + url, + exchange_bot_canister_id, + version, + exchange_bot_canister::post_upgrade::Args { wasm_version: version }, + CanisterName::ExchangeBot, + ) + .await; + + println!("Exchange bot canister upgraded"); +} + pub async fn upgrade_local_group_index_canister( identity: Box, url: String, diff --git a/backend/canister_upgrader/src/main.rs b/backend/canister_upgrader/src/main.rs index 8573a4e548..08c01f7c5f 100644 --- a/backend/canister_upgrader/src/main.rs +++ b/backend/canister_upgrader/src/main.rs @@ -14,6 +14,7 @@ async fn main() { CanisterName::CyclesDispenser => { upgrade_cycles_dispenser_canister(identity, opts.url, opts.cycles_dispenser, opts.version).await } + CanisterName::ExchangeBot => upgrade_exchange_bot_canister(identity, opts.url, opts.exchange_bot, opts.version).await, CanisterName::Group => upgrade_group_canister(identity, opts.url, opts.group_index, opts.version).await, CanisterName::LocalGroupIndex => { upgrade_local_group_index_canister(identity, opts.url, opts.group_index, opts.version).await @@ -80,6 +81,9 @@ struct Opts { #[arg(long)] market_maker: CanisterId, + #[arg(long)] + exchange_bot: CanisterId, + #[arg(long)] canister_to_upgrade: CanisterName, diff --git a/backend/canisters/community/impl/src/jobs/mod.rs b/backend/canisters/community/impl/src/jobs/mod.rs index a63f7fa12b..b6203a40c9 100644 --- a/backend/canisters/community/impl/src/jobs/mod.rs +++ b/backend/canisters/community/impl/src/jobs/mod.rs @@ -1,7 +1,7 @@ -pub mod import_groups; - use crate::RuntimeState; +pub mod import_groups; + pub(crate) fn start(state: &RuntimeState) { import_groups::start_job_if_required(state); } diff --git a/backend/canisters/exchange_bot/CHANGELOG.md b/backend/canisters/exchange_bot/CHANGELOG.md new file mode 100644 index 0000000000..8646827d5a --- /dev/null +++ b/backend/canisters/exchange_bot/CHANGELOG.md @@ -0,0 +1,6 @@ +# Changelog +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). + +## [unreleased] diff --git a/backend/canisters/exchange_bot/api/Cargo.toml b/backend/canisters/exchange_bot/api/Cargo.toml new file mode 100644 index 0000000000..95a38136fd --- /dev/null +++ b/backend/canisters/exchange_bot/api/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "exchange_bot_canister" +version = "0.1.0" +edition = "2021" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +bot_api = { path = "../../../bots/api" } +candid = { workspace = true } +candid_gen = { path = "../../../libraries/candid_gen" } +human_readable = { path = "../../../libraries/human_readable" } +serde = { workspace = true } +types = { path = "../../../libraries/types" } +user_index_canister = { path = "../../user_index/api" } \ No newline at end of file diff --git a/backend/canisters/exchange_bot/api/can.did b/backend/canisters/exchange_bot/api/can.did new file mode 100644 index 0000000000..41d622634d --- /dev/null +++ b/backend/canisters/exchange_bot/api/can.did @@ -0,0 +1,13 @@ +import "../../../libraries/types/can.did"; + +type InitArgs = record { + governance_principals : vec CanisterId; + user_index_canister_id : CanisterId; + local_user_index_canister_id : CanisterId; + cycles_dispenser_canister_id : CanisterId; + wasm_version : BuildVersion; + test_mode : bool; +}; + +service : { +}; diff --git a/backend/canisters/exchange_bot/api/src/lib.rs b/backend/canisters/exchange_bot/api/src/lib.rs new file mode 100644 index 0000000000..dc85062b07 --- /dev/null +++ b/backend/canisters/exchange_bot/api/src/lib.rs @@ -0,0 +1,22 @@ +use candid::CandidType; +use serde::{Deserialize, Serialize}; +use std::fmt::{Display, Formatter}; + +mod lifecycle; +mod updates; + +pub use lifecycle::*; +pub use updates::*; + +#[derive(CandidType, Serialize, Deserialize, Clone, Copy, Debug, Eq, PartialEq)] +pub enum ExchangeId { + ICPSwap, +} + +impl Display for ExchangeId { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + match self { + ExchangeId::ICPSwap => f.write_str("ICPSwap"), + } + } +} diff --git a/backend/canisters/exchange_bot/api/src/lifecycle/init.rs b/backend/canisters/exchange_bot/api/src/lifecycle/init.rs new file mode 100644 index 0000000000..6338a53fd0 --- /dev/null +++ b/backend/canisters/exchange_bot/api/src/lifecycle/init.rs @@ -0,0 +1,13 @@ +use candid::{CandidType, Principal}; +use serde::{Deserialize, Serialize}; +use types::{BuildVersion, CanisterId}; + +#[derive(CandidType, Serialize, Deserialize, Debug)] +pub struct Args { + pub governance_principals: Vec, + pub user_index_canister_id: CanisterId, + pub local_user_index_canister_id: CanisterId, + pub cycles_dispenser_canister_id: CanisterId, + pub wasm_version: BuildVersion, + pub test_mode: bool, +} diff --git a/backend/canisters/exchange_bot/api/src/lifecycle/mod.rs b/backend/canisters/exchange_bot/api/src/lifecycle/mod.rs new file mode 100644 index 0000000000..70bd4f5a23 --- /dev/null +++ b/backend/canisters/exchange_bot/api/src/lifecycle/mod.rs @@ -0,0 +1,2 @@ +pub mod init; +pub mod post_upgrade; diff --git a/backend/canisters/exchange_bot/api/src/lifecycle/post_upgrade.rs b/backend/canisters/exchange_bot/api/src/lifecycle/post_upgrade.rs new file mode 100644 index 0000000000..470a25ac40 --- /dev/null +++ b/backend/canisters/exchange_bot/api/src/lifecycle/post_upgrade.rs @@ -0,0 +1,8 @@ +use candid::CandidType; +use serde::{Deserialize, Serialize}; +use types::BuildVersion; + +#[derive(CandidType, Serialize, Deserialize, Debug)] +pub struct Args { + pub wasm_version: BuildVersion, +} diff --git a/backend/canisters/exchange_bot/api/src/main.rs b/backend/canisters/exchange_bot/api/src/main.rs new file mode 100644 index 0000000000..37e8c25054 --- /dev/null +++ b/backend/canisters/exchange_bot/api/src/main.rs @@ -0,0 +1,5 @@ +#[allow(deprecated)] +fn main() { + candid::export_service!(); + std::print!("{}", __export_service()); +} diff --git a/backend/canisters/exchange_bot/api/src/updates/handle_direct_message.rs b/backend/canisters/exchange_bot/api/src/updates/handle_direct_message.rs new file mode 100644 index 0000000000..924a492d2c --- /dev/null +++ b/backend/canisters/exchange_bot/api/src/updates/handle_direct_message.rs @@ -0,0 +1 @@ +pub use bot_api::handle_direct_message::{Response::*, *}; diff --git a/backend/canisters/exchange_bot/api/src/updates/mod.rs b/backend/canisters/exchange_bot/api/src/updates/mod.rs new file mode 100644 index 0000000000..0689567506 --- /dev/null +++ b/backend/canisters/exchange_bot/api/src/updates/mod.rs @@ -0,0 +1,2 @@ +pub mod handle_direct_message; +pub mod register_bot; diff --git a/backend/canisters/exchange_bot/api/src/updates/register_bot.rs b/backend/canisters/exchange_bot/api/src/updates/register_bot.rs new file mode 100644 index 0000000000..c93500aef5 --- /dev/null +++ b/backend/canisters/exchange_bot/api/src/updates/register_bot.rs @@ -0,0 +1 @@ +pub use user_index_canister::c2c_register_bot::*; diff --git a/backend/canisters/exchange_bot/impl/Cargo.toml b/backend/canisters/exchange_bot/impl/Cargo.toml new file mode 100644 index 0000000000..94b2b66268 --- /dev/null +++ b/backend/canisters/exchange_bot/impl/Cargo.toml @@ -0,0 +1,44 @@ +[package] +name = "exchange_bot_canister_impl" +version = "0.1.0" +edition = "2021" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[lib] +path = "src/lib.rs" +crate-type = ["cdylib"] + +[dependencies] +async-trait = { workspace = true } +candid = { workspace = true } +canister_api_macros = { path = "../../../libraries/canister_api_macros" } +canister_logger = { path = "../../../libraries/canister_logger" } +canister_state_macros = { path = "../../../libraries/canister_state_macros" } +canister_tracing_macros = { path = "../../../libraries/canister_tracing_macros" } +exchange_bot_canister = { path = "../api" } +futures = { workspace = true } +http_request = { path = "../../../libraries/http_request" } +human_readable = { path = "../../../libraries/human_readable" } +ic-cdk = { workspace = true } +ic-cdk-macros = { workspace = true } +ic-cdk-timers = { workspace = true } +ic-ledger-types = { workspace = true } +ic-stable-structures = { workspace = true } +icpswap_client = { path = "../../../libraries/icpswap_client" } +icrc1_ledger_canister_c2c_client = { path = "../../../external_canisters/icrc1_ledger/c2c_client" } +itertools = { workspace = true } +lazy_static = { workspace = true } +ledger_utils = { path = "../../../libraries/ledger_utils" } +local_user_index_canister_c2c_client = { path = "../../local_user_index/c2c_client" } +msgpack = { path = "../../../libraries/msgpack" } +rand = { workspace = true } +regex-lite = { workspace = true } +serde = { workspace = true } +serializer = { path = "../../../libraries/serializer" } +tracing = { workspace = true } +types = { path = "../../../libraries/types" } +user_canister = { path = "../../user/api" } +user_canister_c2c_client = { path = "../../user/c2c_client" } +user_index_canister_c2c_client = { path = "../../user_index/c2c_client" } +utils = { path = "../../../libraries/utils" } diff --git a/backend/canisters/exchange_bot/impl/src/commands/balance.rs b/backend/canisters/exchange_bot/impl/src/commands/balance.rs new file mode 100644 index 0000000000..9c0c354049 --- /dev/null +++ b/backend/canisters/exchange_bot/impl/src/commands/balance.rs @@ -0,0 +1,88 @@ +use crate::commands::common_errors::CommonErrors; +use crate::commands::sub_tasks::check_user_balance::check_user_balance; +use crate::commands::{Command, CommandParser, CommandSubTaskResult, ParseMessageResult}; +use crate::{mutate_state, RuntimeState}; +use lazy_static::lazy_static; +use rand::Rng; +use regex_lite::{Regex, RegexBuilder}; +use serde::{Deserialize, Serialize}; +use types::{CanisterId, MessageContent, MessageId, TimestampMillis, TokenInfo, UserId}; + +lazy_static! { + static ref REGEX: Regex = RegexBuilder::new(r"^balance\s+(?\S+)$") + .case_insensitive(true) + .build() + .unwrap(); +} + +pub struct BalanceCommandParser; + +impl CommandParser for BalanceCommandParser { + fn help_text() -> &'static str { + "**BALANCE** + +format: 'balance $Token' +eg. 'balance CHAT'" + } + + fn try_parse(message: &MessageContent, state: &mut RuntimeState) -> ParseMessageResult { + let text = message.text().unwrap_or_default(); + + if !REGEX.is_match(text) { + return ParseMessageResult::DoesNotMatch; + } + + let matches = REGEX.captures_iter(text).next().unwrap(); + let token = &matches["token"]; + + let token = if let Some(t) = state.data.get_token(token) { + t + } else { + let error = CommonErrors::UnsupportedTokens(vec![token.to_string()]); + return ParseMessageResult::Error(error.build_response_message(&state.data)); + }; + + let command = BalanceCommand::build(token, state); + ParseMessageResult::Success(Command::Balance(Box::new(command))) + } +} + +#[derive(Serialize, Deserialize)] +pub struct BalanceCommand { + pub created: TimestampMillis, + pub user_id: UserId, + pub token: TokenInfo, + pub message_id: MessageId, + pub result: CommandSubTaskResult, +} + +impl BalanceCommand { + pub(crate) fn build(token: TokenInfo, state: &mut RuntimeState) -> BalanceCommand { + BalanceCommand { + created: state.env.now(), + user_id: state.env.caller().into(), + token, + message_id: state.env.rng().gen(), + result: CommandSubTaskResult::Pending, + } + } + + pub(crate) fn process(self, state: &mut RuntimeState) { + ic_cdk::spawn(self.check_user_balance(state.env.canister_id())); + } + + pub fn build_message_text(&self) -> String { + let symbol = self.token.token.token_symbol(); + let status = self.result.to_string(); + format!("Checking {symbol} balance: {status}") + } + + async fn check_user_balance(mut self, this_canister_id: CanisterId) { + self.result = check_user_balance(self.user_id, &self.token, this_canister_id).await; + + mutate_state(|state| { + let message_text = self.build_message_text(); + state.enqueue_message_edit(self.user_id, self.message_id, message_text); + }); + } +} diff --git a/backend/canisters/exchange_bot/impl/src/commands/common_errors.rs b/backend/canisters/exchange_bot/impl/src/commands/common_errors.rs new file mode 100644 index 0000000000..5053960b57 --- /dev/null +++ b/backend/canisters/exchange_bot/impl/src/commands/common_errors.rs @@ -0,0 +1,27 @@ +use crate::Data; + +pub enum CommonErrors { + UnsupportedTokens(Vec), + PairNotSupported, +} + +impl CommonErrors { + pub(crate) fn build_response_message(&self, data: &Data) -> String { + match self { + CommonErrors::UnsupportedTokens(tokens) => { + let mut message = "The following inputs were not recognised as supported tokens:".to_string(); + for token in tokens { + message.push_str(&format!("\n{token}")); + } + + message.push_str("\n\nSupported tokens:"); + for token in data.supported_tokens() { + message.push_str(&format!("\n{token}")); + } + + message + } + CommonErrors::PairNotSupported => todo!(), + } + } +} diff --git a/backend/canisters/exchange_bot/impl/src/commands/mod.rs b/backend/canisters/exchange_bot/impl/src/commands/mod.rs new file mode 100644 index 0000000000..afe8c144d9 --- /dev/null +++ b/backend/canisters/exchange_bot/impl/src/commands/mod.rs @@ -0,0 +1,107 @@ +use crate::commands::balance::BalanceCommand; +use crate::commands::quote::QuoteCommand; +use crate::commands::swap::SwapCommand; +use crate::commands::withdraw::WithdrawCommand; +use crate::RuntimeState; +use serde::{Deserialize, Serialize}; +use std::fmt::{Display, Formatter}; +use types::{MessageContent, MessageId}; + +pub mod balance; +pub mod common_errors; +pub mod quote; +mod sub_tasks; +pub mod swap; +pub mod withdraw; + +pub(crate) trait CommandParser { + fn help_text() -> &'static str; + + fn try_parse(message: &MessageContent, state: &mut RuntimeState) -> ParseMessageResult; +} + +#[derive(Serialize, Deserialize)] +pub enum Command { + Balance(Box), + Quote(Box), + Swap(Box), + Withdraw(Box), +} + +impl Command { + pub fn message_id(&self) -> MessageId { + match self { + Command::Balance(b) => b.message_id, + Command::Quote(q) => q.message_id, + Command::Swap(s) => s.message_id, + Command::Withdraw(w) => w.message_id, + } + } + + pub(crate) fn process(self, state: &mut RuntimeState) { + match self { + Command::Balance(b) => b.process(state), + Command::Quote(q) => q.process(state), + Command::Swap(s) => s.process(state), + Command::Withdraw(w) => w.process(state), + } + } + + pub fn build_message_text(&self) -> String { + match self { + Command::Balance(b) => b.build_message_text(), + Command::Quote(q) => q.build_message_text(), + Command::Swap(s) => s.build_message_text(), + Command::Withdraw(w) => w.build_message_text(), + } + } +} + +#[allow(clippy::large_enum_variant)] +pub enum ParseMessageResult { + Success(Command), + Error(String), + DoesNotMatch, +} + +#[derive(Serialize, Deserialize, Default)] +pub enum CommandSubTaskResult { + NotRequired, + #[default] + Pending, + Complete(T, Option), + Failed(String), +} + +impl CommandSubTaskResult { + pub fn is_pending(&self) -> bool { + matches!(self, Self::Pending) + } + + pub fn is_completed(&self) -> bool { + matches!(self, Self::Complete(..)) + } + + pub fn is_failed(&self) -> bool { + matches!(self, Self::Failed(_)) + } + + pub fn value(&self) -> Option<&T> { + if let CommandSubTaskResult::Complete(v, _) = self { + Some(v) + } else { + None + } + } +} + +impl Display for CommandSubTaskResult { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + match self { + CommandSubTaskResult::NotRequired => f.write_str("not required"), + CommandSubTaskResult::Pending => f.write_str("pending"), + CommandSubTaskResult::Complete(_, s) => f.write_str(s.as_deref().unwrap_or("completed")), + CommandSubTaskResult::Failed(e) => write!(f, "failed ({e})"), + } + } +} diff --git a/backend/canisters/exchange_bot/impl/src/commands/quote.rs b/backend/canisters/exchange_bot/impl/src/commands/quote.rs new file mode 100644 index 0000000000..3a619c6afa --- /dev/null +++ b/backend/canisters/exchange_bot/impl/src/commands/quote.rs @@ -0,0 +1,150 @@ +use crate::commands::common_errors::CommonErrors; +use crate::commands::sub_tasks::get_quotes::get_quotes; +use crate::commands::{Command, CommandParser, CommandSubTaskResult, ParseMessageResult}; +use crate::swap_client::SwapClient; +use crate::{mutate_state, RuntimeState}; +use exchange_bot_canister::ExchangeId; +use lazy_static::lazy_static; +use ledger_utils::format_crypto_amount; +use rand::Rng; +use regex_lite::{Regex, RegexBuilder}; +use serde::{Deserialize, Serialize}; +use std::str::FromStr; +use types::{MessageContent, MessageId, TimestampMillis, TokenInfo, UserId}; + +lazy_static! { + static ref REGEX: Regex = + RegexBuilder::new(r"^quote\s+(?\S+)\s+(?\S+)(\s+(?[\d.,]+))?$") + .case_insensitive(true) + .build() + .unwrap(); +} + +pub struct QuoteCommandParser; + +impl CommandParser for QuoteCommandParser { + fn help_text() -> &'static str { + "**QUOTE** + +format: 'quote $InputToken $OutputToken $Amount' +eg. 'quote ICP CHAT 100' +$Amount will default to 1 if not provided." + } + + fn try_parse(message: &MessageContent, state: &mut RuntimeState) -> ParseMessageResult { + let text = message.text().unwrap_or_default(); + + if !REGEX.is_match(text) { + return ParseMessageResult::DoesNotMatch; + } + + let matches = REGEX.captures_iter(text).next().unwrap(); + let input_token = &matches["input_token"]; + let output_token = &matches["output_token"]; + let amount_decimal = matches + .name("amount") + .map(|m| f64::from_str(m.as_str()).unwrap()) + .unwrap_or(1.0); + + let (input_token, output_token) = match state.data.get_token_pair(input_token, output_token) { + Ok((i, o)) => (i, o), + Err(tokens) => { + let error = CommonErrors::UnsupportedTokens(tokens); + return ParseMessageResult::Error(error.build_response_message(&state.data)); + } + }; + + let amount = (amount_decimal * 10u128.pow(input_token.decimals as u32) as f64) as u128; + + match QuoteCommand::build(input_token, output_token, amount, state) { + Ok(command) => ParseMessageResult::Success(Command::Quote(Box::new(command))), + Err(error) => ParseMessageResult::Error(error.build_response_message(&state.data)), + } + } +} + +#[derive(Serialize, Deserialize)] +pub struct QuoteCommand { + pub created: TimestampMillis, + pub user_id: UserId, + pub input_token: TokenInfo, + pub output_token: TokenInfo, + pub amount: u128, + pub exchange_ids: Vec, + pub message_id: MessageId, + pub results: Vec<(ExchangeId, CommandSubTaskResult)>, +} + +impl QuoteCommand { + pub(crate) fn build( + input_token: TokenInfo, + output_token: TokenInfo, + amount: u128, + state: &mut RuntimeState, + ) -> Result { + let clients = state.get_all_swap_clients(input_token.clone(), output_token.clone()); + + if !clients.is_empty() { + let quote_statuses = clients + .iter() + .map(|c| (c.exchange_id(), CommandSubTaskResult::Pending)) + .collect(); + + Ok(QuoteCommand { + created: state.env.now(), + user_id: state.env.caller().into(), + input_token, + output_token, + amount, + exchange_ids: clients.iter().map(|c| c.exchange_id()).collect(), + message_id: state.env.rng().gen(), + results: quote_statuses, + }) + } else { + Err(CommonErrors::PairNotSupported) + } + } + + pub(crate) fn process(self, state: &mut RuntimeState) { + let amount = self.amount; + let clients: Vec<_> = self + .exchange_ids + .iter() + .filter_map(|e| state.get_swap_client(*e, self.input_token.clone(), self.output_token.clone())) + .collect(); + + ic_cdk::spawn(self.get_quotes(clients, amount)); + } + + pub fn build_message_text(&self) -> String { + let mut text = format!( + "Quotes ({} {} to {}):", + format_crypto_amount(self.amount, self.input_token.decimals), + self.input_token.token.token_symbol(), + self.output_token.token.token_symbol() + ); + for (exchange_id, status) in self.results.iter() { + let exchange_name = exchange_id.to_string(); + let status_text = status.to_string(); + text.push_str(&format!("\n{exchange_name}: {status_text}")); + } + text + } + + async fn get_quotes(mut self, clients: Vec>, amount: u128) { + get_quotes(clients, amount, |exchange_id, result| { + self.set_quote_result(exchange_id, result); + let message_text = self.build_message_text(); + mutate_state(|state| { + state.enqueue_message_edit(self.user_id, self.message_id, message_text); + }); + }) + .await + } + + fn set_quote_result(&mut self, exchange_id: ExchangeId, result: CommandSubTaskResult) { + if let Some(r) = self.results.iter_mut().find(|(e, _)| *e == exchange_id).map(|(_, s)| s) { + *r = result; + } + } +} diff --git a/backend/canisters/exchange_bot/impl/src/commands/sub_tasks/check_user_balance.rs b/backend/canisters/exchange_bot/impl/src/commands/sub_tasks/check_user_balance.rs new file mode 100644 index 0000000000..fde32da302 --- /dev/null +++ b/backend/canisters/exchange_bot/impl/src/commands/sub_tasks/check_user_balance.rs @@ -0,0 +1,30 @@ +use crate::commands::CommandSubTaskResult; +use ledger_utils::{convert_to_subaccount, format_crypto_amount}; +use types::icrc1::Account; +use types::{CanisterId, TokenInfo, UserId}; + +pub(crate) async fn check_user_balance( + user_id: UserId, + token: &TokenInfo, + this_canister_id: CanisterId, +) -> CommandSubTaskResult { + let account = Account { + owner: this_canister_id, + subaccount: Some(convert_to_subaccount(&user_id.into()).0), + }; + + match icrc1_ledger_canister_c2c_client::icrc1_balance_of(token.ledger, &account) + .await + .map(|a| u128::try_from(a.0).unwrap()) + { + Ok(amount) => { + let text = format!( + "{} {}", + format_crypto_amount(amount, token.decimals), + token.token.token_symbol() + ); + CommandSubTaskResult::Complete(amount, Some(text)) + } + Err(error) => CommandSubTaskResult::Failed(format!("{error:?}")), + } +} diff --git a/backend/canisters/exchange_bot/impl/src/commands/sub_tasks/get_quotes.rs b/backend/canisters/exchange_bot/impl/src/commands/sub_tasks/get_quotes.rs new file mode 100644 index 0000000000..f26d581da3 --- /dev/null +++ b/backend/canisters/exchange_bot/impl/src/commands/sub_tasks/get_quotes.rs @@ -0,0 +1,45 @@ +use crate::commands::CommandSubTaskResult; +use crate::swap_client::SwapClient; +use exchange_bot_canister::ExchangeId; +use futures::stream::FuturesUnordered; +use futures::StreamExt; +use ledger_utils::format_crypto_amount; +use std::future::ready; + +pub(crate) async fn get_quotes)>( + clients: Vec>, + amount: u128, + mut callback: C, +) { + let futures = FuturesUnordered::new(); + for client in clients { + futures.push(get_quote(client, amount)); + } + + futures + .for_each(|(exchange_id, result)| { + callback(exchange_id, result); + ready(()) + }) + .await; +} + +async fn get_quote(client: Box, amount: u128) -> (ExchangeId, CommandSubTaskResult) { + let response = client.quote(amount).await; + + let result = match response { + Ok(amount_out) => { + let output_token = client.output_token(); + let text = format!( + "{} {}", + format_crypto_amount(amount_out, output_token.decimals), + output_token.token.token_symbol() + ); + CommandSubTaskResult::Complete(amount_out, Some(text)) + } + Err(error) => CommandSubTaskResult::Failed(format!("{error:?}")), + }; + + let exchange_id = client.exchange_id(); + (exchange_id, result) +} diff --git a/backend/canisters/exchange_bot/impl/src/commands/sub_tasks/mod.rs b/backend/canisters/exchange_bot/impl/src/commands/sub_tasks/mod.rs new file mode 100644 index 0000000000..c869384d3a --- /dev/null +++ b/backend/canisters/exchange_bot/impl/src/commands/sub_tasks/mod.rs @@ -0,0 +1,3 @@ +pub mod check_user_balance; +pub mod get_quotes; +pub mod withdraw; diff --git a/backend/canisters/exchange_bot/impl/src/commands/sub_tasks/withdraw.rs b/backend/canisters/exchange_bot/impl/src/commands/sub_tasks/withdraw.rs new file mode 100644 index 0000000000..cb999680bc --- /dev/null +++ b/backend/canisters/exchange_bot/impl/src/commands/sub_tasks/withdraw.rs @@ -0,0 +1,108 @@ +use crate::commands::CommandSubTaskResult; +use crate::model::messages_pending::MessagePending; +use crate::mutate_state; +use candid::{Nat, Principal}; +use ic_cdk::api::call::CallResult; +use ic_ledger_types::{AccountIdentifier, Memo, Timestamp, Tokens, TransferArgs, DEFAULT_SUBACCOUNT}; +use ledger_utils::{calculate_transaction_hash, convert_to_subaccount, default_ledger_account}; +use rand::Rng; +use types::icrc1::{Account, BlockIndex, CryptoAccount, TransferArg, TransferError}; +use types::{ + icrc1, nns, CompletedCryptoTransaction, CryptoContent, CryptoTransaction, Cryptocurrency, MessageContentInitial, + TimestampNanos, TokenInfo, UserId, +}; + +pub async fn withdraw( + user_id: UserId, + token: &TokenInfo, + amount: u128, + default_subaccount: bool, + now_nanos: TimestampNanos, +) -> CommandSubTaskResult { + match transfer_to_user(user_id, token, amount, default_subaccount, now_nanos).await { + Ok(Ok(block_index)) => CommandSubTaskResult::Complete(block_index, None), + Ok(Err(error)) => CommandSubTaskResult::Failed(format!("{error:?}")), + Err(error) => CommandSubTaskResult::Failed(format!("{error:?}")), + } +} + +async fn transfer_to_user( + user_id: UserId, + token: &TokenInfo, + amount: u128, + default_subaccount: bool, + now_nanos: TimestampNanos, +) -> CallResult> { + let subaccount = if default_subaccount { DEFAULT_SUBACCOUNT } else { convert_to_subaccount(&user_id.into()) }; + let response = icrc1_ledger_canister_c2c_client::icrc1_transfer( + token.ledger, + &TransferArg { + from_subaccount: Some(subaccount.0), + to: Account::from(Principal::from(user_id)), + fee: Some(token.fee.into()), + created_at_time: Some(now_nanos), + memo: None, + amount: amount.into(), + }, + ) + .await; + + if let Ok(Ok(block_index)) = &response { + mutate_state(|state| { + let this_canister_id = state.env.canister_id(); + let transaction = if matches!(token.token, Cryptocurrency::InternetComputer) { + let transfer_args = TransferArgs { + memo: Memo(0), + amount: Tokens::from_e8s(amount.try_into().unwrap()), + fee: Tokens::from_e8s(Cryptocurrency::InternetComputer.fee().unwrap().try_into().unwrap()), + from_subaccount: Some(subaccount), + to: default_ledger_account(user_id.into()), + created_at_time: Some(Timestamp { + timestamp_nanos: now_nanos, + }), + }; + let transaction_hash = calculate_transaction_hash(this_canister_id, &transfer_args); + CompletedCryptoTransaction::NNS(nns::CompletedCryptoTransaction { + ledger: token.ledger, + token: token.token.clone(), + amount: Tokens::from_e8s(amount.try_into().unwrap()), + fee: Tokens::from_e8s(token.fee.try_into().unwrap()), + from: nns::CryptoAccount::Account(AccountIdentifier::new(&this_canister_id, &subaccount)), + to: nns::CryptoAccount::Account(default_ledger_account(user_id.into())), + memo: Memo(0), + created: now_nanos, + transaction_hash, + block_index: block_index.0.clone().try_into().unwrap(), + }) + } else { + CompletedCryptoTransaction::ICRC1(icrc1::CompletedCryptoTransaction { + ledger: token.ledger, + token: token.token.clone(), + amount, + from: CryptoAccount::Account(Account { + owner: this_canister_id, + subaccount: Some(subaccount.0), + }), + to: CryptoAccount::Account(Account::from(Principal::from(user_id))), + fee: token.fee, + memo: None, + created: now_nanos, + block_index: block_index.0.clone().try_into().unwrap(), + }) + }; + let message_id = state.env.rng().gen(); + state.enqueue_message( + user_id, + message_id, + MessagePending::Send(MessageContentInitial::Crypto(CryptoContent { + recipient: user_id, + transfer: CryptoTransaction::Completed(transaction), + caption: None, + })), + false, + ); + }); + } + + response +} diff --git a/backend/canisters/exchange_bot/impl/src/commands/swap.rs b/backend/canisters/exchange_bot/impl/src/commands/swap.rs new file mode 100644 index 0000000000..6225112269 --- /dev/null +++ b/backend/canisters/exchange_bot/impl/src/commands/swap.rs @@ -0,0 +1,409 @@ +use crate::commands::common_errors::CommonErrors; +use crate::commands::sub_tasks::check_user_balance::check_user_balance; +use crate::commands::sub_tasks::get_quotes::get_quotes; +use crate::commands::sub_tasks::withdraw::withdraw; +use crate::commands::{Command, CommandParser, CommandSubTaskResult, ParseMessageResult}; +use crate::swap_client::SwapClient; +use crate::{mutate_state, RuntimeState}; +use candid::Principal; +use exchange_bot_canister::ExchangeId; +use lazy_static::lazy_static; +use ledger_utils::{convert_to_subaccount, format_crypto_amount}; +use rand::Rng; +use regex_lite::{Regex, RegexBuilder}; +use serde::{Deserialize, Serialize}; +use std::str::FromStr; +use tracing::{error, trace}; +use types::icrc1::{BlockIndex, TransferArg}; +use types::{CanisterId, MessageContent, MessageId, TimestampMillis, TimestampNanos, TokenInfo, UserId}; + +lazy_static! { + static ref REGEX: Regex = RegexBuilder::new(r"swap\s+(?\S+)\s+(?\S+)(\s+(?[\d.,]+))?") + .case_insensitive(true) + .build() + .unwrap(); +} + +pub struct SwapCommandParser; + +impl CommandParser for SwapCommandParser { + fn help_text() -> &'static str { + "**SWAP** + +format: 'swap $InputToken $OutputToken $Amount' + +eg. 'swap ICP CHAT 100' + +If $Amount is not provided, the full balance of $InputTokens will be swapped." + } + + fn try_parse(message: &MessageContent, state: &mut RuntimeState) -> ParseMessageResult { + let text = message.text().unwrap_or_default(); + + if !REGEX.is_match(text) { + return ParseMessageResult::DoesNotMatch; + } + + let matches = REGEX.captures_iter(text).next().unwrap(); + let input_token = &matches["input_token"]; + let output_token = &matches["output_token"]; + let amount_decimal = matches.name("amount").map(|m| f64::from_str(m.as_str()).unwrap()); + + let (input_token, output_token) = match state.data.get_token_pair(input_token, output_token) { + Ok((i, o)) => (i, o), + Err(tokens) => { + let error = CommonErrors::UnsupportedTokens(tokens); + return ParseMessageResult::Error(error.build_response_message(&state.data)); + } + }; + + let amount = amount_decimal.map(|a| (a * 10u128.pow(input_token.decimals as u32) as f64) as u128); + + match SwapCommand::build(input_token, output_token, amount, state) { + Ok(command) => ParseMessageResult::Success(Command::Swap(Box::new(command))), + Err(error) => ParseMessageResult::Error(error.build_response_message(&state.data)), + } + } +} + +#[derive(Serialize, Deserialize)] +pub struct SwapCommand { + pub created: TimestampMillis, + pub user_id: UserId, + pub input_token: TokenInfo, + pub output_token: TokenInfo, + pub amount_provided: Option, + pub message_id: MessageId, + pub exchange_ids: Vec, + pub quotes: Vec<(ExchangeId, CommandSubTaskResult)>, + pub sub_tasks: SwapCommandSubTasks, +} + +#[derive(Serialize, Deserialize, Default)] +pub struct SwapCommandSubTasks { + pub check_user_balance: CommandSubTaskResult, + pub quotes: CommandSubTaskResult, + pub transfer_to_dex: CommandSubTaskResult, + pub notify_dex: CommandSubTaskResult<()>, + pub swap: CommandSubTaskResult, + pub withdraw_from_dex: CommandSubTaskResult, + pub transfer_to_user: CommandSubTaskResult, +} + +impl SwapCommand { + pub(crate) fn build( + input_token: TokenInfo, + output_token: TokenInfo, + amount: Option, + state: &mut RuntimeState, + ) -> Result { + let clients = state.get_all_swap_clients(input_token.clone(), output_token.clone()); + + if !clients.is_empty() { + let quotes = clients + .iter() + .map(|c| (c.exchange_id(), CommandSubTaskResult::Pending)) + .collect(); + + Ok(SwapCommand { + created: state.env.now(), + user_id: state.env.caller().into(), + input_token, + output_token, + amount_provided: amount, + message_id: state.env.rng().gen(), + exchange_ids: clients.iter().map(|c| c.exchange_id()).collect(), + quotes, + sub_tasks: SwapCommandSubTasks { + check_user_balance: if amount.is_some() { + CommandSubTaskResult::NotRequired + } else { + CommandSubTaskResult::Pending + }, + ..Default::default() + }, + }) + } else { + Err(CommonErrors::PairNotSupported) + } + } + + pub(crate) fn process(self, state: &mut RuntimeState) { + let message_id = self.message_id; + + if self.is_finished() { + trace!(%message_id, "Finished"); + return; + } + + if self.sub_tasks.check_user_balance.is_pending() { + trace!(%message_id, "Checking user balance"); + ic_cdk::spawn(self.check_user_balance(state.env.canister_id())); + } else if let Some(amount_to_dex) = self.amount() { + match self.sub_tasks.quotes { + CommandSubTaskResult::Pending => { + let clients: Vec<_> = self + .exchange_ids + .iter() + .filter_map(|e| state.get_swap_client(*e, self.input_token.clone(), self.output_token.clone())) + .collect(); + + trace!(%message_id, "Getting quotes"); + ic_cdk::spawn(self.get_quotes(clients, amount_to_dex)); + } + CommandSubTaskResult::Complete(exchange_id, _) => { + if let Some(client) = + state.get_swap_client(exchange_id, self.input_token.clone(), self.output_token.clone()) + { + if self.sub_tasks.transfer_to_dex.is_pending() { + trace!(%message_id, "Transferring to dex"); + ic_cdk::spawn(self.transfer_to_dex(client, amount_to_dex)); + } else if self.sub_tasks.notify_dex.is_pending() { + trace!(%message_id, "Notifying to dex"); + ic_cdk::spawn(self.notify_dex(client, amount_to_dex)); + } else if self.sub_tasks.swap.is_pending() { + trace!(%message_id, "Performing swap"); + let amount_to_swap = amount_to_dex.saturating_sub(self.input_token.fee); + ic_cdk::spawn(self.perform_swap(client, amount_to_swap)); + } else if self.sub_tasks.withdraw_from_dex.is_pending() { + if let Some(&amount_swapped) = self.sub_tasks.swap.value() { + let amount_out = amount_swapped.saturating_sub(self.output_token.fee); + trace!(%message_id, "Withdrawing from dex"); + ic_cdk::spawn(self.withdraw_from_dex(client, amount_out)); + } + } else if self.sub_tasks.transfer_to_user.is_pending() { + if let Some(&amount_withdrawn_from_dex) = self.sub_tasks.withdraw_from_dex.value() { + let amount_to_user = amount_withdrawn_from_dex.saturating_sub(self.output_token.fee); + trace!(%message_id, "Transferring funds to user"); + ic_cdk::spawn(self.transfer_funds_to_user(amount_to_user, state.env.now_nanos())); + } + } + } + } + _ => {} + } + } + } + + pub fn build_message_text(&self) -> String { + let input_token = self.input_token.token.token_symbol(); + let output_token = self.output_token.token.token_symbol(); + + let mut messages = vec!["Performing Swap:".to_string()]; + if !matches!(self.sub_tasks.check_user_balance, CommandSubTaskResult::NotRequired) { + messages.push(format!( + "Checking {input_token} balance: {}", + self.sub_tasks.check_user_balance + )); + } + if self.sub_tasks.check_user_balance.is_completed() { + messages.push(format!("Getting quotes: {}", self.sub_tasks.quotes)); + } + if let Some(exchange_id) = self.sub_tasks.quotes.value() { + messages.push(format!( + "Transferring {input_token} to {exchange_id}: {}", + self.sub_tasks.transfer_to_dex + )); + if self.sub_tasks.transfer_to_dex.is_completed() { + messages.push(format!("Notifying {exchange_id} of transfer: {}", self.sub_tasks.notify_dex)); + } + if self.sub_tasks.notify_dex.is_completed() { + messages.push(format!("Swapping {input_token} for {output_token}: {}", self.sub_tasks.swap)); + } + if self.sub_tasks.swap.is_completed() { + messages.push(format!( + "Withdrawing {output_token} from {exchange_id}: {}", + self.sub_tasks.withdraw_from_dex + )); + } + if self.sub_tasks.withdraw_from_dex.is_completed() { + messages.push(format!( + "Transferring {output_token} to user: {}", + self.sub_tasks.transfer_to_user + )); + } + } + messages.join("\n") + } + + async fn check_user_balance(mut self, this_canister_id: CanisterId) { + self.sub_tasks.check_user_balance = check_user_balance(self.user_id, &self.input_token, this_canister_id).await; + + mutate_state(|state| self.on_updated(state)); + } + + async fn get_quotes(mut self, clients: Vec>, amount: u128) { + get_quotes(clients, amount, |exchange_id, result| { + self.set_quote_result(exchange_id, result); + let message_text = self.build_message_text(); + mutate_state(|state| { + state.enqueue_message_edit(self.user_id, self.message_id, message_text); + }); + }) + .await; + + if let Some((exchange_id, CommandSubTaskResult::Complete(..))) = self.quotes.iter().max_by_key(|(_, r)| r.value()) { + self.sub_tasks.quotes = CommandSubTaskResult::Complete(*exchange_id, Some(format!("{exchange_id} is best"))); + } else { + self.sub_tasks.quotes = CommandSubTaskResult::Failed("Failed to get any valid quotes".to_string()); + } + + mutate_state(|state| self.on_updated(state)); + } + + async fn transfer_to_dex(mut self, client: Box, amount: u128) { + self.sub_tasks.transfer_to_dex = match client.deposit_account().await { + Ok((ledger, account)) => { + match icrc1_ledger_canister_c2c_client::icrc1_transfer( + ledger, + &TransferArg { + from_subaccount: Some(convert_to_subaccount(&Principal::from(self.user_id)).0), + to: account, + fee: None, + created_at_time: None, + memo: None, + amount: amount.into(), + }, + ) + .await + { + Ok(Ok(block_index)) => CommandSubTaskResult::Complete(block_index, None), + Ok(Err(error)) => CommandSubTaskResult::Failed(format!("{error:?}")), + Err(error) => CommandSubTaskResult::Failed(format!("{error:?}")), + } + } + Err(error) => CommandSubTaskResult::Failed(format!("{error:?}")), + }; + + mutate_state(|state| self.on_updated(state)); + } + + async fn notify_dex(mut self, client: Box, amount: u128) { + self.sub_tasks.notify_dex = match client.deposit(amount).await { + Ok(_) => CommandSubTaskResult::Complete((), None), + Err(error) => { + error!( + error = format!("{error:?}").as_str(), + message_id = %self.message_id, + exchange = %client.exchange_id(), + token = self.input_token.token.token_symbol(), + amount, + "Failed to notify dex, retrying" + ); + CommandSubTaskResult::Pending + } + }; + + mutate_state(|state| self.on_updated(state)); + } + + async fn perform_swap(mut self, client: Box, amount: u128) { + match client.swap(amount).await { + Ok(amount_out) => { + self.sub_tasks.swap = CommandSubTaskResult::Complete( + amount_out, + Some(format_crypto_amount(amount_out, self.output_token.decimals)), + ); + mutate_state(|state| self.on_updated(state)); + } + Err(error) => { + error!( + error = format!("{error:?}").as_str(), + message_id = %self.message_id, + exchange = %client.exchange_id(), + input_token = self.input_token.token.token_symbol(), + output_token = self.output_token.token.token_symbol(), + amount, + "Failed to perform swap, retrying" + ); + mutate_state(|state| self.enqueue(state)); + } + } + } + + async fn withdraw_from_dex(mut self, client: Box, amount: u128) { + match client.withdraw(amount).await { + Ok(amount_out) => { + self.sub_tasks.withdraw_from_dex = CommandSubTaskResult::Complete( + amount_out, + Some(format_crypto_amount(amount_out, self.output_token.decimals)), + ); + mutate_state(|state| self.on_updated(state)) + } + Err(error) => { + error!( + error = format!("{error:?}").as_str(), + message_id = %self.message_id, + exchange = %client.exchange_id(), + token = self.output_token.token.token_symbol(), + amount, + "Failed to withdraw from dex, retrying" + ); + mutate_state(|state| self.enqueue(state)); + } + }; + } + + async fn transfer_funds_to_user(mut self, amount: u128, now_nanos: TimestampNanos) { + match withdraw(self.user_id, &self.output_token, amount, true, now_nanos).await { + CommandSubTaskResult::Failed(error) => { + error!( + error = format!("{error:?}").as_str(), + message_id = %self.message_id, + token = self.output_token.token.token_symbol(), + amount, + "Failed to transfer funds to user, retrying" + ); + mutate_state(|state| self.enqueue(state)); + } + result => { + self.sub_tasks.transfer_to_user = result; + mutate_state(|state| self.on_updated(state)) + } + }; + } + + fn on_updated(self, state: &mut RuntimeState) { + let message_text = self.build_message_text(); + state.enqueue_message_edit(self.user_id, self.message_id, message_text); + self.enqueue(state); + } + + fn enqueue(self, state: &mut RuntimeState) { + if !self.is_finished() { + state.enqueue_command(Command::Swap(Box::new(self))); + } + } + + fn set_quote_result(&mut self, exchange_id: ExchangeId, result: CommandSubTaskResult) { + if let Some(r) = self.quotes.iter_mut().find(|(e, _)| *e == exchange_id).map(|(_, s)| s) { + *r = result; + } + } + + fn amount(&self) -> Option { + if let Some(a) = self.amount_provided { + Some(a) + } else if let CommandSubTaskResult::Complete(a, _) = self.sub_tasks.check_user_balance { + Some(a.saturating_sub(self.input_token.fee)) + } else { + None + } + } + + fn is_finished(&self) -> bool { + self.sub_tasks.any_failed() || !self.sub_tasks.transfer_to_user.is_pending() + } +} + +impl SwapCommandSubTasks { + fn any_failed(&self) -> bool { + self.check_user_balance.is_failed() + || self.quotes.is_failed() + || self.transfer_to_dex.is_failed() + || self.notify_dex.is_failed() + || self.swap.is_failed() + || self.withdraw_from_dex.is_failed() + || self.transfer_to_user.is_failed() + } +} diff --git a/backend/canisters/exchange_bot/impl/src/commands/withdraw.rs b/backend/canisters/exchange_bot/impl/src/commands/withdraw.rs new file mode 100644 index 0000000000..2809a6b41c --- /dev/null +++ b/backend/canisters/exchange_bot/impl/src/commands/withdraw.rs @@ -0,0 +1,163 @@ +use crate::commands::common_errors::CommonErrors; +use crate::commands::sub_tasks::check_user_balance::check_user_balance; +use crate::commands::sub_tasks::withdraw::withdraw; +use crate::commands::{Command, CommandParser, CommandSubTaskResult, ParseMessageResult}; +use crate::{mutate_state, RuntimeState}; +use lazy_static::lazy_static; +use ledger_utils::format_crypto_amount; +use rand::Rng; +use regex_lite::{Regex, RegexBuilder}; +use serde::{Deserialize, Serialize}; +use std::str::FromStr; +use types::icrc1::BlockIndex; +use types::{CanisterId, MessageContent, MessageId, TimestampMillis, TimestampNanos, TokenInfo, UserId}; + +lazy_static! { + static ref REGEX: Regex = RegexBuilder::new(r"^withdraw\s+(?\S+)(\s+(?[\d.,]+))?$") + .case_insensitive(true) + .build() + .unwrap(); +} + +pub struct WithdrawCommandParser; + +impl CommandParser for WithdrawCommandParser { + fn help_text() -> &'static str { + "**WITHDRAW** + +format: 'withdraw $Token $Amount' +eg. 'withdraw CHAT 50' +If $Amount is not provided, your total balance will be withdrawn" + } + + fn try_parse(message: &MessageContent, state: &mut RuntimeState) -> ParseMessageResult { + let text = message.text().unwrap_or_default(); + + if !REGEX.is_match(text) { + return ParseMessageResult::DoesNotMatch; + } + + let matches = REGEX.captures_iter(text).next().unwrap(); + let token = &matches["token"]; + let amount_decimal = matches.name("amount").map(|m| f64::from_str(m.as_str()).unwrap()); + + let token = if let Some(t) = state.data.get_token(token) { + t + } else { + let error = CommonErrors::UnsupportedTokens(vec![token.to_string()]); + return ParseMessageResult::Error(error.build_response_message(&state.data)); + }; + + let amount = amount_decimal.map(|a| (a * 10u128.pow(token.decimals as u32) as f64) as u128); + + let command = WithdrawCommand::build(token, amount, state); + ParseMessageResult::Success(Command::Withdraw(Box::new(command))) + } +} + +#[derive(Serialize, Deserialize)] +pub struct WithdrawCommand { + pub created: TimestampMillis, + pub user_id: UserId, + pub token: TokenInfo, + pub amount_provided: Option, + pub message_id: MessageId, + pub sub_tasks: WithdrawCommandSubTasks, +} + +#[derive(Serialize, Deserialize)] +pub struct WithdrawCommandSubTasks { + pub check_user_balance: CommandSubTaskResult, + pub withdraw: CommandSubTaskResult, +} + +impl WithdrawCommand { + pub(crate) fn build(token: TokenInfo, amount: Option, state: &mut RuntimeState) -> WithdrawCommand { + WithdrawCommand { + created: state.env.now(), + user_id: state.env.caller().into(), + token, + amount_provided: amount, + message_id: state.env.rng().gen(), + sub_tasks: WithdrawCommandSubTasks { + check_user_balance: if amount.is_some() { + CommandSubTaskResult::NotRequired + } else { + CommandSubTaskResult::Pending + }, + withdraw: CommandSubTaskResult::Pending, + }, + } + } + + pub(crate) fn process(self, state: &mut RuntimeState) { + if self.sub_tasks.check_user_balance.is_pending() { + ic_cdk::spawn(self.check_user_balance(state.env.canister_id())); + } else if let Some(amount) = self.amount() { + ic_cdk::spawn(self.withdraw(amount, state.env.now_nanos())); + } + } + + pub fn build_message_text(&self) -> String { + let symbol = self.token.token.token_symbol(); + + let mut messages = Vec::new(); + if self.amount_provided.is_none() { + let status = self.sub_tasks.check_user_balance.to_string(); + messages.push(format!("Checking {symbol} balance: {status}")) + }; + if let Some(amount) = self.amount() { + let formatted = format_crypto_amount(amount, self.token.decimals); + let status = if matches!(self.sub_tasks.withdraw, CommandSubTaskResult::Complete(..)) { + "complete".to_string() + } else { + self.sub_tasks.withdraw.to_string() + }; + messages.push(format!("Withdrawing {formatted} {symbol}: {status}")); + }; + messages.join("\n") + } + + async fn check_user_balance(mut self, this_canister_id: CanisterId) { + self.sub_tasks.check_user_balance = check_user_balance(self.user_id, &self.token, this_canister_id).await; + + if let Some(amount) = self.amount() { + if amount <= self.token.fee { + self.sub_tasks.withdraw = CommandSubTaskResult::NotRequired; + } + } + + mutate_state(|state| self.on_updated(state)); + } + + async fn withdraw(mut self, amount: u128, now_nanos: TimestampNanos) { + self.sub_tasks.withdraw = withdraw(self.user_id, &self.token, amount, false, now_nanos).await; + + mutate_state(|state| self.on_updated(state)); + } + + fn on_updated(self, state: &mut RuntimeState) { + let is_finished = self.is_finished(); + + let message_text = self.build_message_text(); + state.enqueue_message_edit(self.user_id, self.message_id, message_text); + + if !is_finished { + state.enqueue_command(Command::Withdraw(Box::new(self))); + } + } + + fn amount(&self) -> Option { + if let Some(a) = self.amount_provided { + Some(a) + } else if let CommandSubTaskResult::Complete(a, _) = self.sub_tasks.check_user_balance { + Some(a.saturating_sub(self.token.fee)) + } else { + None + } + } + + fn is_finished(&self) -> bool { + !self.sub_tasks.withdraw.is_pending() + } +} diff --git a/backend/canisters/exchange_bot/impl/src/guards.rs b/backend/canisters/exchange_bot/impl/src/guards.rs new file mode 100644 index 0000000000..8b50787738 --- /dev/null +++ b/backend/canisters/exchange_bot/impl/src/guards.rs @@ -0,0 +1,9 @@ +use crate::read_state; + +pub fn caller_is_governance_principal() -> Result<(), String> { + if read_state(|state| state.is_caller_governance_principal()) { + Ok(()) + } else { + Err("Caller is not the service owner".to_owned()) + } +} diff --git a/backend/canisters/exchange_bot/impl/src/icpswap/mod.rs b/backend/canisters/exchange_bot/impl/src/icpswap/mod.rs new file mode 100644 index 0000000000..9b7ff0cf49 --- /dev/null +++ b/backend/canisters/exchange_bot/impl/src/icpswap/mod.rs @@ -0,0 +1,88 @@ +use crate::swap_client::{SwapClient, SwapClientFactory}; +use async_trait::async_trait; +use exchange_bot_canister::ExchangeId; +use ic_cdk::api::call::CallResult; +use icpswap_client::ICPSwapClient; +use types::icrc1::Account; +use types::{CanisterId, Cryptocurrency, TokenInfo}; + +pub struct ICPSwapClientFactory {} + +impl ICPSwapClientFactory { + pub fn new() -> ICPSwapClientFactory { + ICPSwapClientFactory {} + } + + fn lookup_swap_canister_id(&self, token0: &TokenInfo, token1: &TokenInfo) -> Option { + match (token0.token.clone(), token1.token.clone()) { + (Cryptocurrency::CHAT, Cryptocurrency::InternetComputer) => { + Some(CanisterId::from_text("ne2vj-6yaaa-aaaag-qb3ia-cai").unwrap()) + } + _ => None, + } + } +} + +impl SwapClientFactory for ICPSwapClientFactory { + fn build( + &self, + this_canister_id: CanisterId, + input_token: TokenInfo, + output_token: TokenInfo, + ) -> Option> { + if let Some(swap_canister_id) = self.lookup_swap_canister_id(&input_token, &output_token) { + Some(Box::new(ICPSwapClient::new( + this_canister_id, + swap_canister_id, + input_token.clone(), + output_token.clone(), + true, + ))) + } else if let Some(swap_canister_id) = self.lookup_swap_canister_id(&output_token, &input_token) { + Some(Box::new(ICPSwapClient::new( + this_canister_id, + swap_canister_id, + output_token.clone(), + input_token.clone(), + false, + ))) + } else { + None + } + } +} + +#[async_trait] +impl SwapClient for ICPSwapClient { + fn exchange_id(&self) -> ExchangeId { + ExchangeId::ICPSwap + } + + fn input_token(&self) -> &TokenInfo { + self.input_token() + } + + fn output_token(&self) -> &TokenInfo { + self.output_token() + } + + async fn quote(&self, amount: u128) -> CallResult { + self.quote(amount).await + } + + async fn deposit_account(&self) -> CallResult<(CanisterId, Account)> { + Ok(self.deposit_account()) + } + + async fn deposit(&self, amount: u128) -> CallResult<()> { + self.deposit(amount).await.map(|_| ()) + } + + async fn swap(&self, amount: u128) -> CallResult { + self.swap(amount).await + } + + async fn withdraw(&self, amount: u128) -> CallResult { + self.withdraw(amount).await + } +} diff --git a/backend/canisters/exchange_bot/impl/src/jobs/mod.rs b/backend/canisters/exchange_bot/impl/src/jobs/mod.rs new file mode 100644 index 0000000000..03345cc84b --- /dev/null +++ b/backend/canisters/exchange_bot/impl/src/jobs/mod.rs @@ -0,0 +1,9 @@ +use crate::RuntimeState; + +pub mod process_commands; +pub mod process_messages; + +pub(crate) fn start(state: &RuntimeState) { + process_messages::start_job_if_required(state); + process_commands::start_job_if_required(state); +} diff --git a/backend/canisters/exchange_bot/impl/src/jobs/process_commands.rs b/backend/canisters/exchange_bot/impl/src/jobs/process_commands.rs new file mode 100644 index 0000000000..dc8d7250b8 --- /dev/null +++ b/backend/canisters/exchange_bot/impl/src/jobs/process_commands.rs @@ -0,0 +1,43 @@ +use crate::{mutate_state, RuntimeState}; +use ic_cdk_timers::TimerId; +use std::cell::Cell; +use std::time::Duration; +use tracing::trace; + +const MAX_BATCH_SIZE: usize = 10; + +thread_local! { + static TIMER_ID: Cell> = Cell::default(); +} + +pub(crate) fn start_job_if_required(state: &RuntimeState) -> bool { + if TIMER_ID.with(|t| t.get().is_none()) && !state.data.commands_pending.is_empty() { + let timer_id = ic_cdk_timers::set_timer_interval(Duration::ZERO, run); + TIMER_ID.with(|t| t.set(Some(timer_id))); + trace!("'process_commands' job started"); + true + } else { + false + } +} + +fn run() { + if mutate_state(process_next_batch) == 0 { + if let Some(timer_id) = TIMER_ID.with(|t| t.take()) { + ic_cdk_timers::clear_timer(timer_id); + trace!("'process_commands' job stopped"); + } + } +} + +fn process_next_batch(state: &mut RuntimeState) -> usize { + let mut count = 0; + while let Some(next) = state.data.commands_pending.pop() { + next.process(state); + count += 1; + if count == MAX_BATCH_SIZE { + break; + } + } + count +} diff --git a/backend/canisters/exchange_bot/impl/src/jobs/process_messages.rs b/backend/canisters/exchange_bot/impl/src/jobs/process_messages.rs new file mode 100644 index 0000000000..8ca9c6bbc7 --- /dev/null +++ b/backend/canisters/exchange_bot/impl/src/jobs/process_messages.rs @@ -0,0 +1,95 @@ +use crate::model::messages_pending::MessagePending; +use crate::{mutate_state, read_state, RuntimeState}; +use ic_cdk_timers::TimerId; +use std::cell::Cell; +use std::time::Duration; +use tracing::trace; +use types::{BotMessage, MessageId, UserId}; + +const MAX_BATCH_SIZE: usize = 10; + +thread_local! { + static TIMER_ID: Cell> = Cell::default(); +} + +pub(crate) fn start_job_if_required(state: &RuntimeState) -> bool { + if TIMER_ID.with(|t| t.get().is_none()) && !state.data.messages_pending.is_empty() { + let timer_id = ic_cdk_timers::set_timer_interval(Duration::ZERO, run); + TIMER_ID.with(|t| t.set(Some(timer_id))); + trace!("'process_messages' job started"); + true + } else { + false + } +} + +fn run() { + match mutate_state(next_batch) { + Some(batch) => ic_cdk::spawn(process_batch(batch)), + None => { + if let Some(timer_id) = TIMER_ID.with(|t| t.take()) { + ic_cdk_timers::clear_timer(timer_id); + trace!("'process_messages' job stopped"); + } + } + } +} + +fn next_batch(state: &mut RuntimeState) -> Option> { + let mut batch = Vec::new(); + while let Some(next) = state.data.messages_pending.pop() { + batch.push(next); + if batch.len() == MAX_BATCH_SIZE { + break; + } + } + if !batch.is_empty() { + Some(batch) + } else { + None + } +} + +async fn process_batch(batch: Vec<(UserId, MessageId, MessagePending)>) { + let futures: Vec<_> = batch + .into_iter() + .map(|(user_id, message_id, text)| process_single(user_id, message_id, text)) + .collect(); + + futures::future::join_all(futures).await; +} + +async fn process_single(user_id: UserId, message_id: MessageId, message: MessagePending) { + let is_error = match message.clone() { + MessagePending::Send(content) => { + let (username, display_name) = read_state(|state| (state.data.username.clone(), state.data.display_name.clone())); + let args = user_canister::c2c_handle_bot_messages::Args { + bot_name: username, + bot_display_name: display_name, + messages: vec![BotMessage { + content, + message_id: Some(message_id), + }], + }; + user_canister_c2c_client::c2c_handle_bot_messages(user_id.into(), &args) + .await + .is_err() + } + MessagePending::Edit(content) => { + let args = user_canister::c2c_edit_message::Args { + message_id, + content, + correlation_id: 0, + }; + user_canister_c2c_client::c2c_edit_message(user_id.into(), &args) + .await + .is_err() + } + }; + + if is_error { + mutate_state(|state| { + state.enqueue_message(user_id, message_id, message, true); + }); + } +} diff --git a/backend/canisters/exchange_bot/impl/src/lib.rs b/backend/canisters/exchange_bot/impl/src/lib.rs new file mode 100644 index 0000000000..021085677e --- /dev/null +++ b/backend/canisters/exchange_bot/impl/src/lib.rs @@ -0,0 +1,219 @@ +use crate::commands::Command; +use crate::icpswap::ICPSwapClientFactory; +use crate::model::commands_pending::CommandsPending; +use crate::model::messages_pending::{MessagePending, MessagesPending}; +use crate::swap_client::{SwapClient, SwapClientFactory}; +use candid::Principal; +use canister_state_macros::canister_state; +use exchange_bot_canister::ExchangeId; +use itertools::Itertools; +use serde::{Deserialize, Serialize}; +use std::cell::RefCell; +use std::collections::{HashMap, HashSet}; +use types::{ + BuildVersion, CanisterId, Cryptocurrency, Cycles, MessageContent, MessageId, TextContent, TimestampMillis, Timestamped, + TokenInfo, UserId, +}; +use utils::env::Environment; + +mod commands; +mod guards; +mod icpswap; +mod jobs; +mod lifecycle; +mod memory; +mod model; +mod queries; +mod swap_client; +mod updates; + +thread_local! { + static WASM_VERSION: RefCell> = RefCell::default(); +} + +canister_state!(RuntimeState); + +struct RuntimeState { + pub env: Box, + pub data: Data, +} + +impl RuntimeState { + pub fn new(env: Box, data: Data) -> RuntimeState { + RuntimeState { env, data } + } + + pub fn get_all_swap_clients(&self, input_token: TokenInfo, output_token: TokenInfo) -> Vec> { + let this_canister_id = self.env.canister_id(); + + vec![ICPSwapClientFactory::new().build(this_canister_id, input_token, output_token)] + .into_iter() + .flatten() + .collect() + } + + pub fn get_swap_client( + &self, + exchange_id: ExchangeId, + input_token: TokenInfo, + output_token: TokenInfo, + ) -> Option> { + let this_canister_id = self.env.canister_id(); + + match exchange_id { + ExchangeId::ICPSwap => ICPSwapClientFactory::new().build(this_canister_id, input_token, output_token), + } + } + + pub fn is_caller_governance_principal(&self) -> bool { + let caller = self.env.caller(); + self.data.governance_principals.contains(&caller) + } + + pub fn enqueue_command(&mut self, command: Command) { + self.data.commands_pending.push(command); + jobs::process_commands::start_job_if_required(self); + } + + pub fn enqueue_message_edit(&mut self, user_id: UserId, message_id: MessageId, text: String) { + self.enqueue_message( + user_id, + message_id, + MessagePending::Edit(MessageContent::Text(TextContent { text })), + false, + ); + } + + pub fn enqueue_message( + &mut self, + user_id: UserId, + message_id: MessageId, + message: MessagePending, + skip_if_already_queued: bool, + ) { + if !skip_if_already_queued || !self.data.messages_pending.contains(user_id, message_id) { + self.data.messages_pending.push(user_id, message_id, message); + jobs::process_messages::start_job_if_required(self); + } + } + + pub fn metrics(&self) -> Metrics { + Metrics { + memory_used: utils::memory::used(), + now: self.env.now(), + cycles_balance: self.env.cycles_balance(), + wasm_version: WASM_VERSION.with(|v| **v.borrow()), + git_commit_id: utils::git::git_commit_id().to_string(), + governance_principals: self.data.governance_principals.iter().copied().collect(), + queued_commands: self.data.commands_pending.len() as u32, + queued_messages: self.data.messages_pending.len() as u32, + canister_ids: CanisterIds { + local_user_index: self.data.local_user_index_canister_id, + cycles_dispenser: self.data.cycles_dispenser_canister_id, + }, + } + } +} + +#[derive(Serialize, Deserialize)] +struct Data { + governance_principals: HashSet, + user_index_canister_id: CanisterId, + local_user_index_canister_id: CanisterId, + cycles_dispenser_canister_id: CanisterId, + token_info: Vec, + known_callers: HashMap, + commands_pending: CommandsPending, + messages_pending: MessagesPending, + username: String, + display_name: Option, + is_registered: bool, + test_mode: bool, +} + +impl Data { + pub fn new( + governance_principals: HashSet, + user_index_canister_id: CanisterId, + local_user_index_canister_id: CanisterId, + cycles_dispenser_canister_id: CanisterId, + test_mode: bool, + ) -> Data { + Data { + governance_principals, + user_index_canister_id, + local_user_index_canister_id, + cycles_dispenser_canister_id, + token_info: build_token_info(), + known_callers: HashMap::new(), + commands_pending: CommandsPending::default(), + messages_pending: MessagesPending::default(), + username: "".to_string(), + display_name: None, + is_registered: false, + test_mode, + } + } + + pub fn get_token_pair(&self, input_token: &str, output_token: &str) -> Result<(TokenInfo, TokenInfo), Vec> { + match (self.get_token(input_token), self.get_token(output_token)) { + (Some(i), Some(o)) => Ok((i, o)), + (None, Some(_)) => Err(vec![input_token.to_string()]), + (Some(_), None) => Err(vec![output_token.to_string()]), + (None, None) => Err(vec![input_token.to_string(), output_token.to_string()]), + } + } + + pub fn get_token(&self, token: &str) -> Option { + let token_upper = token.to_uppercase(); + + self.token_info + .iter() + .find(|t| t.token.token_symbol().to_uppercase() == token_upper) + .cloned() + } + + pub fn supported_tokens(&self) -> Vec { + self.token_info + .iter() + .map(|t| t.token.token_symbol().to_string()) + .sorted_unstable() + .collect() + } +} + +fn build_token_info() -> Vec { + vec![ + TokenInfo { + token: Cryptocurrency::InternetComputer, + ledger: Cryptocurrency::InternetComputer.ledger_canister_id().unwrap(), + decimals: 8, + fee: 10_000, + }, + TokenInfo { + token: Cryptocurrency::CHAT, + ledger: Cryptocurrency::CHAT.ledger_canister_id().unwrap(), + decimals: 8, + fee: 100_000, + }, + ] +} + +#[derive(Serialize, Debug)] +pub struct Metrics { + pub now: TimestampMillis, + pub memory_used: u64, + pub cycles_balance: Cycles, + pub wasm_version: BuildVersion, + pub git_commit_id: String, + pub governance_principals: Vec, + pub queued_commands: u32, + pub queued_messages: u32, + pub canister_ids: CanisterIds, +} + +#[derive(Serialize, Debug)] +pub struct CanisterIds { + pub local_user_index: CanisterId, + pub cycles_dispenser: CanisterId, +} diff --git a/backend/canisters/exchange_bot/impl/src/lifecycle/init.rs b/backend/canisters/exchange_bot/impl/src/lifecycle/init.rs new file mode 100644 index 0000000000..1787a262b0 --- /dev/null +++ b/backend/canisters/exchange_bot/impl/src/lifecycle/init.rs @@ -0,0 +1,28 @@ +use crate::lifecycle::{init_env, init_state}; +use crate::Data; +use canister_tracing_macros::trace; +use exchange_bot_canister::init::Args; +use ic_cdk_macros::init; +use tracing::info; +use utils::cycles::init_cycles_dispenser_client; + +#[init] +#[trace] +fn init(args: Args) { + canister_logger::init(args.test_mode); + init_cycles_dispenser_client(args.cycles_dispenser_canister_id); + + let env = init_env(); + + let data = Data::new( + args.governance_principals.into_iter().collect(), + args.user_index_canister_id, + args.local_user_index_canister_id, + args.cycles_dispenser_canister_id, + args.test_mode, + ); + + init_state(env, data, args.wasm_version); + + info!(version = %args.wasm_version, "Initialization complete"); +} diff --git a/backend/canisters/exchange_bot/impl/src/lifecycle/inspect_message.rs b/backend/canisters/exchange_bot/impl/src/lifecycle/inspect_message.rs new file mode 100644 index 0000000000..c4d9e188cf --- /dev/null +++ b/backend/canisters/exchange_bot/impl/src/lifecycle/inspect_message.rs @@ -0,0 +1,20 @@ +use crate::{read_state, RuntimeState}; +use ic_cdk_macros::inspect_message; + +#[inspect_message] +fn inspect_message() { + read_state(accept_if_valid); +} + +fn accept_if_valid(state: &RuntimeState) { + let method_name = ic_cdk::api::call::method_name(); + + let is_valid = match method_name.as_str() { + "register_bot" => state.is_caller_governance_principal(), + _ => false, + }; + + if is_valid { + ic_cdk::api::call::accept_message(); + } +} diff --git a/backend/canisters/exchange_bot/impl/src/lifecycle/mod.rs b/backend/canisters/exchange_bot/impl/src/lifecycle/mod.rs new file mode 100644 index 0000000000..883457343d --- /dev/null +++ b/backend/canisters/exchange_bot/impl/src/lifecycle/mod.rs @@ -0,0 +1,38 @@ +use crate::{mutate_state, Data, RuntimeState, WASM_VERSION}; +use std::time::Duration; +use tracing::trace; +use types::{BuildVersion, Timestamped}; +use utils::canister::get_random_seed; +use utils::env::canister::CanisterEnv; +use utils::env::Environment; + +mod init; +mod inspect_message; +mod post_upgrade; +mod pre_upgrade; + +const UPGRADE_BUFFER_SIZE: usize = 1024 * 1024; // 1MB + +fn init_env() -> Box { + ic_cdk_timers::set_timer(Duration::ZERO, reseed_rng); + Box::default() +} + +fn init_state(env: Box, data: Data, wasm_version: BuildVersion) { + let now = env.now(); + let state = RuntimeState::new(env, data); + + crate::jobs::start(&state); + crate::init_state(state); + WASM_VERSION.with(|v| *v.borrow_mut() = Timestamped::new(wasm_version, now)); +} + +fn reseed_rng() { + ic_cdk::spawn(reseed_rng_inner()); + + async fn reseed_rng_inner() { + let seed = get_random_seed().await; + mutate_state(|state| state.env = Box::new(CanisterEnv::new(seed))); + trace!("Successfully reseeded rng"); + } +} diff --git a/backend/canisters/exchange_bot/impl/src/lifecycle/post_upgrade.rs b/backend/canisters/exchange_bot/impl/src/lifecycle/post_upgrade.rs new file mode 100644 index 0000000000..758b3dee6e --- /dev/null +++ b/backend/canisters/exchange_bot/impl/src/lifecycle/post_upgrade.rs @@ -0,0 +1,28 @@ +use crate::lifecycle::{init_env, init_state, UPGRADE_BUFFER_SIZE}; +use crate::memory::get_upgrades_memory; +use crate::Data; +use canister_logger::LogEntry; +use canister_tracing_macros::trace; +use exchange_bot_canister::post_upgrade::Args; +use ic_cdk_macros::post_upgrade; +use ic_stable_structures::reader::{BufferedReader, Reader}; +use tracing::info; +use utils::cycles::init_cycles_dispenser_client; + +#[post_upgrade] +#[trace] +fn post_upgrade(args: Args) { + let env = init_env(); + + let memory = get_upgrades_memory(); + let reader = BufferedReader::new(UPGRADE_BUFFER_SIZE, Reader::new(&memory, 0)); + + let (data, logs, traces): (Data, Vec, Vec) = serializer::deserialize(reader).unwrap(); + + canister_logger::init_with_logs(data.test_mode, logs, traces); + + init_cycles_dispenser_client(data.cycles_dispenser_canister_id); + init_state(env, data, args.wasm_version); + + info!(version = %args.wasm_version, "Post-upgrade complete"); +} diff --git a/backend/canisters/exchange_bot/impl/src/lifecycle/pre_upgrade.rs b/backend/canisters/exchange_bot/impl/src/lifecycle/pre_upgrade.rs new file mode 100644 index 0000000000..080c91ed88 --- /dev/null +++ b/backend/canisters/exchange_bot/impl/src/lifecycle/pre_upgrade.rs @@ -0,0 +1,24 @@ +use crate::lifecycle::UPGRADE_BUFFER_SIZE; +use crate::memory::get_upgrades_memory; +use crate::take_state; +use canister_tracing_macros::trace; +use ic_cdk_macros::pre_upgrade; +use ic_stable_structures::writer::{BufferedWriter, Writer}; +use tracing::info; + +#[pre_upgrade] +#[trace] +fn pre_upgrade() { + info!("Pre-upgrade starting"); + + let state = take_state(); + let logs = canister_logger::export_logs(); + let traces = canister_logger::export_traces(); + + let stable_state = (state.data, logs, traces); + + let mut memory = get_upgrades_memory(); + let writer = BufferedWriter::new(UPGRADE_BUFFER_SIZE, Writer::new(&mut memory, 0)); + + serializer::serialize(stable_state, writer).unwrap(); +} diff --git a/backend/canisters/exchange_bot/impl/src/memory.rs b/backend/canisters/exchange_bot/impl/src/memory.rs new file mode 100644 index 0000000000..d3a1de0b7b --- /dev/null +++ b/backend/canisters/exchange_bot/impl/src/memory.rs @@ -0,0 +1,21 @@ +use ic_stable_structures::{ + memory_manager::{MemoryId, MemoryManager, VirtualMemory}, + DefaultMemoryImpl, +}; + +const UPGRADES: MemoryId = MemoryId::new(0); + +pub type Memory = VirtualMemory; + +thread_local! { + static MEMORY_MANAGER: MemoryManager + = MemoryManager::init(DefaultMemoryImpl::default()); +} + +pub fn get_upgrades_memory() -> Memory { + get_memory(UPGRADES) +} + +fn get_memory(id: MemoryId) -> Memory { + MEMORY_MANAGER.with(|m| m.get(id)) +} diff --git a/backend/canisters/exchange_bot/impl/src/model/commands_pending.rs b/backend/canisters/exchange_bot/impl/src/model/commands_pending.rs new file mode 100644 index 0000000000..36b7016e35 --- /dev/null +++ b/backend/canisters/exchange_bot/impl/src/model/commands_pending.rs @@ -0,0 +1,26 @@ +use crate::commands::Command; +use serde::{Deserialize, Serialize}; +use std::collections::VecDeque; + +#[derive(Serialize, Deserialize, Default)] +pub struct CommandsPending { + commands: VecDeque, +} + +impl CommandsPending { + pub fn push(&mut self, command: Command) { + self.commands.push_back(command); + } + + pub fn pop(&mut self) -> Option { + self.commands.pop_front() + } + + pub fn len(&self) -> usize { + self.commands.len() + } + + pub fn is_empty(&self) -> bool { + self.commands.is_empty() + } +} diff --git a/backend/canisters/exchange_bot/impl/src/model/messages_pending.rs b/backend/canisters/exchange_bot/impl/src/model/messages_pending.rs new file mode 100644 index 0000000000..28c1049bed --- /dev/null +++ b/backend/canisters/exchange_bot/impl/src/model/messages_pending.rs @@ -0,0 +1,36 @@ +use serde::{Deserialize, Serialize}; +use std::collections::BTreeMap; +use types::{MessageContent, MessageContentInitial, MessageId, UserId}; + +#[derive(Serialize, Deserialize, Default)] +pub struct MessagesPending { + messages: BTreeMap<(UserId, MessageId), MessagePending>, +} + +#[derive(Serialize, Deserialize, Clone)] +pub enum MessagePending { + Send(MessageContentInitial), + Edit(MessageContent), +} + +impl MessagesPending { + pub fn push(&mut self, user_id: UserId, message_id: MessageId, message: MessagePending) { + self.messages.insert((user_id, message_id), message); + } + + pub fn pop(&mut self) -> Option<(UserId, MessageId, MessagePending)> { + self.messages.pop_first().map(|((u, id), m)| (u, id, m)) + } + + pub fn contains(&self, user_id: UserId, message_id: MessageId) -> bool { + self.messages.contains_key(&(user_id, message_id)) + } + + pub fn len(&self) -> usize { + self.messages.len() + } + + pub fn is_empty(&self) -> bool { + self.messages.is_empty() + } +} diff --git a/backend/canisters/exchange_bot/impl/src/model/mod.rs b/backend/canisters/exchange_bot/impl/src/model/mod.rs new file mode 100644 index 0000000000..db15f6df4a --- /dev/null +++ b/backend/canisters/exchange_bot/impl/src/model/mod.rs @@ -0,0 +1,2 @@ +pub mod commands_pending; +pub mod messages_pending; diff --git a/backend/canisters/exchange_bot/impl/src/queries/http_request.rs b/backend/canisters/exchange_bot/impl/src/queries/http_request.rs new file mode 100644 index 0000000000..fea6d8d402 --- /dev/null +++ b/backend/canisters/exchange_bot/impl/src/queries/http_request.rs @@ -0,0 +1,26 @@ +use crate::{read_state, RuntimeState}; +use http_request::{build_json_response, encode_logs, extract_route, Route}; +use ic_cdk_macros::query; +use types::{HttpRequest, HttpResponse, TimestampMillis}; + +#[query] +fn http_request(request: HttpRequest) -> HttpResponse { + fn get_logs_impl(since: Option) -> HttpResponse { + encode_logs(canister_logger::export_logs(), since.unwrap_or(0)) + } + + fn get_traces_impl(since: Option) -> HttpResponse { + encode_logs(canister_logger::export_traces(), since.unwrap_or(0)) + } + + fn get_metrics_impl(state: &RuntimeState) -> HttpResponse { + build_json_response(&state.metrics()) + } + + match extract_route(&request.url) { + Route::Logs(since) => get_logs_impl(since), + Route::Traces(since) => get_traces_impl(since), + Route::Metrics => read_state(get_metrics_impl), + _ => HttpResponse::not_found(), + } +} diff --git a/backend/canisters/exchange_bot/impl/src/queries/mod.rs b/backend/canisters/exchange_bot/impl/src/queries/mod.rs new file mode 100644 index 0000000000..1cfa1ad736 --- /dev/null +++ b/backend/canisters/exchange_bot/impl/src/queries/mod.rs @@ -0,0 +1 @@ +mod http_request; diff --git a/backend/canisters/exchange_bot/impl/src/swap_client.rs b/backend/canisters/exchange_bot/impl/src/swap_client.rs new file mode 100644 index 0000000000..3e33432f95 --- /dev/null +++ b/backend/canisters/exchange_bot/impl/src/swap_client.rs @@ -0,0 +1,26 @@ +use async_trait::async_trait; +use exchange_bot_canister::ExchangeId; +use ic_cdk::api::call::CallResult; +use types::icrc1::Account; +use types::{CanisterId, TokenInfo}; + +pub trait SwapClientFactory { + fn build( + &self, + this_canister_id: CanisterId, + input_token: TokenInfo, + output_token: TokenInfo, + ) -> Option>; +} + +#[async_trait] +pub trait SwapClient { + fn exchange_id(&self) -> ExchangeId; + fn input_token(&self) -> &TokenInfo; + fn output_token(&self) -> &TokenInfo; + async fn quote(&self, amount: u128) -> CallResult; + async fn deposit_account(&self) -> CallResult<(CanisterId, Account)>; + async fn deposit(&self, amount: u128) -> CallResult<()>; + async fn swap(&self, amount: u128) -> CallResult; + async fn withdraw(&self, amount: u128) -> CallResult; +} diff --git a/backend/canisters/exchange_bot/impl/src/updates/handle_direct_message.rs b/backend/canisters/exchange_bot/impl/src/updates/handle_direct_message.rs new file mode 100644 index 0000000000..8fa1e8ccc7 --- /dev/null +++ b/backend/canisters/exchange_bot/impl/src/updates/handle_direct_message.rs @@ -0,0 +1,140 @@ +use crate::commands::balance::BalanceCommandParser; +use crate::commands::quote::QuoteCommandParser; +use crate::commands::swap::SwapCommandParser; +use crate::commands::withdraw::WithdrawCommandParser; +use crate::commands::{CommandParser, ParseMessageResult}; +use crate::{mutate_state, read_state, Data, RuntimeState}; +use candid::Principal; +use canister_api_macros::update_msgpack; +use canister_tracing_macros::trace; +use exchange_bot_canister::handle_direct_message::*; +use ledger_utils::format_crypto_amount; +use local_user_index_canister_c2c_client::LookupUserError; +use types::{BotMessage, MessageContent, MessageContentInitial, UserId}; + +#[update_msgpack] +#[trace] +async fn handle_direct_message(args: Args) -> Response { + if let Err(error) = verify_caller().await { + return read_state(|state| build_response(vec![convert_to_message(error)], &state.data)); + }; + + mutate_state(|state| handle_direct_message_impl(args.content, state)) +} + +fn handle_direct_message_impl(message: MessageContent, state: &mut RuntimeState) -> Response { + let mut command = None; + let mut response_messages = Vec::new(); + + if let MessageContent::Crypto(c) = &message { + let token = c.transfer.token(); + response_messages.push(convert_to_message(format!( + "{} {} received", + format_crypto_amount(c.transfer.units(), token.decimals().unwrap_or(8)), + token.token_symbol() + ))); + } + + match BalanceCommandParser::try_parse(&message, state) { + ParseMessageResult::Success(c) => command = Some(c), + ParseMessageResult::Error(e) => response_messages.push(convert_to_message(e)), + ParseMessageResult::DoesNotMatch => {} + }; + + if command.is_none() { + match QuoteCommandParser::try_parse(&message, state) { + ParseMessageResult::Success(c) => command = Some(c), + ParseMessageResult::Error(e) => response_messages.push(convert_to_message(e)), + ParseMessageResult::DoesNotMatch => {} + }; + } + + if command.is_none() { + match SwapCommandParser::try_parse(&message, state) { + ParseMessageResult::Success(c) => command = Some(c), + ParseMessageResult::Error(e) => response_messages.push(convert_to_message(e)), + ParseMessageResult::DoesNotMatch => {} + }; + } + + if command.is_none() { + match WithdrawCommandParser::try_parse(&message, state) { + ParseMessageResult::Success(c) => command = Some(c), + ParseMessageResult::Error(e) => response_messages.push(convert_to_message(e)), + ParseMessageResult::DoesNotMatch => {} + }; + } + + if let Some(command) = command { + response_messages.push(BotMessage { + content: MessageContentInitial::Text(command.build_message_text().into()), + message_id: Some(command.message_id()), + }); + command.process(state); + } + + let add_help_text = response_messages.is_empty(); + if add_help_text { + let mut text = "This bot currently supports the following message formats:\n\n".to_string(); + text.push_str(QuoteCommandParser::help_text()); + text.push_str("\n\n"); + text.push_str(BalanceCommandParser::help_text()); + text.push_str("\n\n"); + text.push_str(SwapCommandParser::help_text()); + text.push_str("\n\n"); + text.push_str(WithdrawCommandParser::help_text()); + response_messages.push(convert_to_message(text)); + } + + build_response(response_messages, &state.data) +} + +fn build_response(messages: Vec, data: &Data) -> Response { + Success(SuccessResult { + bot_name: data.username.clone(), + bot_display_name: data.display_name.clone(), + messages, + }) +} + +fn convert_to_message(text: String) -> BotMessage { + BotMessage { + content: MessageContentInitial::Text(text.into()), + message_id: None, + } +} + +async fn verify_caller() -> Result { + match read_state(check_for_known_caller) { + CheckForKnownCallerResult::KnownUser(u) => Ok(u), + CheckForKnownCallerResult::Unknown(caller, local_user_index_canister_id) => { + match local_user_index_canister_c2c_client::lookup_user(caller, local_user_index_canister_id).await { + Ok(user) => { + mutate_state(|state| state.data.known_callers.insert(caller, true)); + Ok(user.user_id) + } + Err(LookupUserError::UserNotFound) => { + mutate_state(|state| state.data.known_callers.insert(caller, false)); + Err("User not found".to_string()) + } + Err(LookupUserError::InternalError(_)) => Err("An error occurred. Please try again later".to_string()), + } + } + CheckForKnownCallerResult::Blocked => panic!(), + } +} + +enum CheckForKnownCallerResult { + Unknown(Principal, Principal), // Caller, LocalUserIndex + KnownUser(UserId), + Blocked, +} + +fn check_for_known_caller(state: &RuntimeState) -> CheckForKnownCallerResult { + let caller = state.env.caller(); + match state.data.known_callers.get(&caller).copied() { + Some(true) => CheckForKnownCallerResult::KnownUser(caller.into()), + Some(false) => CheckForKnownCallerResult::Blocked, + None => CheckForKnownCallerResult::Unknown(caller, state.data.local_user_index_canister_id), + } +} diff --git a/backend/canisters/exchange_bot/impl/src/updates/mod.rs b/backend/canisters/exchange_bot/impl/src/updates/mod.rs new file mode 100644 index 0000000000..11eb9ee3ce --- /dev/null +++ b/backend/canisters/exchange_bot/impl/src/updates/mod.rs @@ -0,0 +1,3 @@ +mod handle_direct_message; +mod register_bot; +mod wallet_receive; diff --git a/backend/canisters/exchange_bot/impl/src/updates/register_bot.rs b/backend/canisters/exchange_bot/impl/src/updates/register_bot.rs new file mode 100644 index 0000000000..94a8448f90 --- /dev/null +++ b/backend/canisters/exchange_bot/impl/src/updates/register_bot.rs @@ -0,0 +1,35 @@ +use crate::guards::caller_is_governance_principal; +use crate::{mutate_state, read_state}; +use canister_tracing_macros::trace; +use exchange_bot_canister::register_bot::{Response::*, *}; +use ic_cdk_macros::update; +use types::Cycles; + +const BOT_REGISTRATION_FEE: Cycles = 10_000_000_000_000; // 10T + +#[update(guard = "caller_is_governance_principal")] +#[trace] +async fn register_bot(args: Args) -> Response { + let (already_registered, user_index_canister_id) = + read_state(|state| (state.data.is_registered, state.data.user_index_canister_id)); + + if already_registered { + AlreadyRegistered + } else { + let response = + user_index_canister_c2c_client::c2c_register_bot(user_index_canister_id, &args, BOT_REGISTRATION_FEE).await; + + match response { + Ok(Success) => { + mutate_state(|state| { + state.data.username = args.username; + state.data.display_name = args.display_name; + state.data.is_registered = true; + }); + Success + } + Ok(response) => response, + Err(error) => InternalError(format!("{error:?}")), + } + } +} diff --git a/backend/canisters/exchange_bot/impl/src/updates/wallet_receive.rs b/backend/canisters/exchange_bot/impl/src/updates/wallet_receive.rs new file mode 100644 index 0000000000..d08987204c --- /dev/null +++ b/backend/canisters/exchange_bot/impl/src/updates/wallet_receive.rs @@ -0,0 +1,9 @@ +use canister_tracing_macros::trace; +use ic_cdk_macros::update; +use utils::cycles::accept_cycles; + +#[update] +#[trace] +fn wallet_receive() { + accept_cycles(); +} diff --git a/backend/canisters/user/api/src/updates/c2c_handle_bot_messages.rs b/backend/canisters/user/api/src/updates/c2c_handle_bot_messages.rs index 01fb445cc7..87c4878826 100644 --- a/backend/canisters/user/api/src/updates/c2c_handle_bot_messages.rs +++ b/backend/canisters/user/api/src/updates/c2c_handle_bot_messages.rs @@ -5,6 +5,8 @@ use types::{BotMessage, ContentValidationError}; #[derive(CandidType, Serialize, Deserialize, Debug)] pub struct Args { pub bot_name: String, + #[serde(default)] + pub bot_display_name: Option, pub messages: Vec, } diff --git a/backend/canisters/user/impl/src/updates/c2c_send_messages.rs b/backend/canisters/user/impl/src/updates/c2c_send_messages.rs index 472691d536..b78363531d 100644 --- a/backend/canisters/user/impl/src/updates/c2c_send_messages.rs +++ b/backend/canisters/user/impl/src/updates/c2c_send_messages.rs @@ -105,7 +105,7 @@ async fn c2c_handle_bot_messages( message_id: None, sender_message_index: None, sender_name: args.bot_name.clone(), - sender_display_name: None, + sender_display_name: args.bot_display_name.clone(), content: message.content.into(), replies_to: None, forwarding: false, diff --git a/backend/canisters/user_index/c2c_client/src/lib.rs b/backend/canisters/user_index/c2c_client/src/lib.rs index 211ac6e141..c53e4d4b65 100644 --- a/backend/canisters/user_index/c2c_client/src/lib.rs +++ b/backend/canisters/user_index/c2c_client/src/lib.rs @@ -1,5 +1,5 @@ use candid::Principal; -use canister_client::{generate_c2c_call, generate_c2c_call_with_payment, generate_candid_c2c_call}; +use canister_client::{generate_c2c_call, generate_candid_c2c_call, generate_candid_c2c_call_with_payment}; use types::{CanisterId, UserDetails}; use user_index_canister::*; @@ -11,9 +11,9 @@ generate_c2c_call!(user); // Updates generate_c2c_call!(c2c_migrate_user_principal); generate_c2c_call!(c2c_notify_events); +generate_candid_c2c_call_with_payment!(c2c_register_bot); generate_c2c_call!(c2c_set_avatar); generate_c2c_call!(c2c_suspend_users); -generate_c2c_call_with_payment!(c2c_register_bot); #[derive(Debug)] pub enum LookupUserError { diff --git a/backend/external_canisters/icrc1_ledger/api/Cargo.toml b/backend/external_canisters/icrc1_ledger/api/Cargo.toml index f7c636e6d1..5329f6d248 100644 --- a/backend/external_canisters/icrc1_ledger/api/Cargo.toml +++ b/backend/external_canisters/icrc1_ledger/api/Cargo.toml @@ -8,7 +8,6 @@ edition = "2021" [dependencies] candid = { workspace = true } candid_gen = { path = "../../../libraries/candid_gen" } -ic-ledger-types = { workspace = true } serde = { workspace = true } serde_bytes = { workspace = true } types = { path = "../../../libraries/types" } diff --git a/backend/external_canisters/icrc1_ledger/api/src/queries/icrc1_balance_of.rs b/backend/external_canisters/icrc1_ledger/api/src/queries/icrc1_balance_of.rs new file mode 100644 index 0000000000..527d6cad45 --- /dev/null +++ b/backend/external_canisters/icrc1_ledger/api/src/queries/icrc1_balance_of.rs @@ -0,0 +1,5 @@ +use candid::Nat; +use types::icrc1::Account; + +pub type Args = Account; +pub type Response = Nat; diff --git a/backend/external_canisters/icrc1_ledger/api/src/queries/mod.rs b/backend/external_canisters/icrc1_ledger/api/src/queries/mod.rs index 717c82da01..565df08216 100644 --- a/backend/external_canisters/icrc1_ledger/api/src/queries/mod.rs +++ b/backend/external_canisters/icrc1_ledger/api/src/queries/mod.rs @@ -1,3 +1,4 @@ +pub mod icrc1_balance_of; pub mod icrc1_decimals; pub mod icrc1_fee; pub mod icrc1_metadata; diff --git a/backend/external_canisters/icrc1_ledger/c2c_client/src/lib.rs b/backend/external_canisters/icrc1_ledger/c2c_client/src/lib.rs index 29022eeab0..a52fafbd64 100644 --- a/backend/external_canisters/icrc1_ledger/c2c_client/src/lib.rs +++ b/backend/external_canisters/icrc1_ledger/c2c_client/src/lib.rs @@ -2,6 +2,7 @@ use canister_client::{generate_candid_c2c_call, generate_candid_c2c_call_no_args use icrc1_ledger_canister::*; // Queries +generate_candid_c2c_call!(icrc1_balance_of); generate_candid_c2c_call_no_args!(icrc1_decimals); generate_candid_c2c_call_no_args!(icrc1_fee); generate_candid_c2c_call_no_args!(icrc1_metadata); diff --git a/backend/integration_tests/local-bin/exchange_bot.wasm.gz b/backend/integration_tests/local-bin/exchange_bot.wasm.gz new file mode 120000 index 0000000000..a159383582 --- /dev/null +++ b/backend/integration_tests/local-bin/exchange_bot.wasm.gz @@ -0,0 +1 @@ +../../../wasms/exchange_bot.wasm.gz \ No newline at end of file diff --git a/backend/libraries/canister_agent_utils/src/lib.rs b/backend/libraries/canister_agent_utils/src/lib.rs index 853877bc44..d411babfc9 100644 --- a/backend/libraries/canister_agent_utils/src/lib.rs +++ b/backend/libraries/canister_agent_utils/src/lib.rs @@ -14,6 +14,7 @@ use types::{BuildVersion, CanisterId, CanisterWasm}; pub enum CanisterName { Community, CyclesDispenser, + ExchangeBot, Group, GroupIndex, LocalGroupIndex, @@ -37,6 +38,7 @@ impl FromStr for CanisterName { match s { "community" => Ok(CanisterName::Community), "cycles_dispenser" => Ok(CanisterName::CyclesDispenser), + "exchange_bot" => Ok(CanisterName::ExchangeBot), "group" => Ok(CanisterName::Group), "group_index" => Ok(CanisterName::GroupIndex), "local_group_index" => Ok(CanisterName::LocalGroupIndex), @@ -61,6 +63,7 @@ impl Display for CanisterName { let name = match self { CanisterName::Community => "community", CanisterName::CyclesDispenser => "cycles_dispenser", + CanisterName::ExchangeBot => "exchange_bot", CanisterName::Group => "group", CanisterName::GroupIndex => "group_index", CanisterName::LocalGroupIndex => "local_group_index", @@ -95,6 +98,7 @@ pub struct CanisterIds { pub cycles_dispenser: CanisterId, pub registry: CanisterId, pub market_maker: CanisterId, + pub exchange_bot: CanisterId, pub nns_root: CanisterId, pub nns_governance: CanisterId, pub nns_internet_identity: CanisterId, diff --git a/backend/libraries/canister_client_macros/src/lib.rs b/backend/libraries/canister_client_macros/src/lib.rs index 0a017e6359..37e7e62af1 100644 --- a/backend/libraries/canister_client_macros/src/lib.rs +++ b/backend/libraries/canister_client_macros/src/lib.rs @@ -65,40 +65,40 @@ macro_rules! generate_c2c_call { } #[macro_export] -macro_rules! generate_c2c_call_with_payment { +macro_rules! generate_candid_c2c_call { ($method_name:ident) => { pub async fn $method_name( - canister_id: types::CanisterId, + canister_id: ::types::CanisterId, args: &$method_name::Args, - cycles: types::Cycles, - ) -> ic_cdk::api::call::CallResult<$method_name::Response> { - let method_name = concat!(stringify!($method_name), "_msgpack"); + ) -> ::ic_cdk::api::call::CallResult<$method_name::Response> { + let method_name = stringify!($method_name); - canister_client::make_c2c_call_with_payment( - canister_id, - method_name, - args, - msgpack::serialize, - |r| msgpack::deserialize(r), - cycles, - ) + canister_client::make_c2c_call(canister_id, method_name, args, ::candid::encode_one, |r| { + ::candid::decode_one(r) + }) .await } }; } #[macro_export] -macro_rules! generate_candid_c2c_call { +macro_rules! generate_candid_c2c_call_with_payment { ($method_name:ident) => { pub async fn $method_name( canister_id: ::types::CanisterId, args: &$method_name::Args, + cycles: ::types::Cycles, ) -> ::ic_cdk::api::call::CallResult<$method_name::Response> { let method_name = stringify!($method_name); - canister_client::make_c2c_call(canister_id, method_name, args, ::candid::encode_one, |r| { - ::candid::decode_one(r) - }) + canister_client::make_c2c_call_with_payment( + canister_id, + method_name, + args, + ::candid::encode_one, + |r| ::candid::decode_one(r), + cycles, + ) .await } }; diff --git a/backend/libraries/chat_events/src/chat_event_internal.rs b/backend/libraries/chat_events/src/chat_event_internal.rs index 8031d1f03d..41581c563b 100644 --- a/backend/libraries/chat_events/src/chat_event_internal.rs +++ b/backend/libraries/chat_events/src/chat_event_internal.rs @@ -527,7 +527,7 @@ impl From<&MessageContentInternal> for Document { let amount = c.units(); // This is only used for string searching so it's better to default to 8 than to trap let decimals = c.token().decimals().unwrap_or(8); - let amount_string = format_crypto_amount(amount, decimals as u32); + let amount_string = format_crypto_amount(amount, decimals); document.add_field(amount_string, 1.0, false); } diff --git a/backend/libraries/icpswap_client/Cargo.toml b/backend/libraries/icpswap_client/Cargo.toml new file mode 100644 index 0000000000..7055927624 --- /dev/null +++ b/backend/libraries/icpswap_client/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "icpswap_client" +version = "0.1.0" +edition = "2021" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +candid = { workspace = true } +ic-cdk = { workspace = true } +icpswap_swap_pool_canister = { path = "../../external_canisters/icpswap_swap_pool/api" } +icpswap_swap_pool_canister_c2c_client = { path = "../../external_canisters/icpswap_swap_pool/c2c_client" } +ledger_utils = { path = "../ledger_utils" } +serde = { workspace = true } +types = { path = "../types" } diff --git a/backend/libraries/icpswap_client/src/lib.rs b/backend/libraries/icpswap_client/src/lib.rs new file mode 100644 index 0000000000..3311ae0867 --- /dev/null +++ b/backend/libraries/icpswap_client/src/lib.rs @@ -0,0 +1,125 @@ +use candid::Nat; +use ic_cdk::api::call::{CallResult, RejectionCode}; +use icpswap_swap_pool_canister::{ICPSwapError, ICPSwapResult}; +use ledger_utils::convert_to_subaccount; +use serde::{Deserialize, Serialize}; +use types::icrc1::Account; +use types::{CanisterId, TokenInfo}; + +#[derive(Serialize, Deserialize)] +pub struct ICPSwapClient { + this_canister_id: CanisterId, + swap_canister_id: CanisterId, + token0: TokenInfo, + token1: TokenInfo, + zero_for_one: bool, +} + +impl ICPSwapClient { + pub fn new( + this_canister_id: CanisterId, + swap_canister_id: CanisterId, + token0: TokenInfo, + token1: TokenInfo, + zero_for_one: bool, + ) -> Self { + ICPSwapClient { + this_canister_id, + swap_canister_id, + token0, + token1, + zero_for_one, + } + } + + pub fn deposit_account(&self) -> (CanisterId, Account) { + ( + self.get_ledger(self.zero_for_one), + Account { + owner: self.swap_canister_id, + subaccount: Some(convert_to_subaccount(&self.this_canister_id).0), + }, + ) + } + + pub fn input_token(&self) -> &TokenInfo { + if self.zero_for_one { + &self.token0 + } else { + &self.token1 + } + } + + pub fn output_token(&self) -> &TokenInfo { + if self.zero_for_one { + &self.token1 + } else { + &self.token0 + } + } + + pub async fn quote(&self, amount: u128) -> CallResult { + let args = icpswap_swap_pool_canister::quote::Args { + operator: self.this_canister_id, + amount_in: amount.to_string(), + zero_for_one: self.zero_for_one, + amount_out_minimum: "0".to_string(), + }; + + match icpswap_swap_pool_canister_c2c_client::quote(self.swap_canister_id, &args).await? { + ICPSwapResult::Ok(amount_out) => Ok(nat_to_u128(amount_out)), + ICPSwapResult::Err(e) => Err((RejectionCode::CanisterError, format!("{e:?}"))), + } + } + + pub async fn deposit(&self, amount: u128) -> CallResult { + let args = icpswap_swap_pool_canister::deposit::Args { + token: self.get_ledger(self.zero_for_one).to_string(), + amount: amount.into(), + }; + match icpswap_swap_pool_canister_c2c_client::deposit(self.swap_canister_id, &args).await? { + ICPSwapResult::Ok(amount_deposited) => Ok(nat_to_u128(amount_deposited)), + ICPSwapResult::Err(error) => Err(convert_error(error)), + } + } + + pub async fn swap(&self, amount: u128) -> CallResult { + let args = icpswap_swap_pool_canister::swap::Args { + operator: self.this_canister_id, + amount_in: amount.to_string(), + zero_for_one: self.zero_for_one, + amount_out_minimum: "0".to_string(), + }; + match icpswap_swap_pool_canister_c2c_client::swap(self.swap_canister_id, &args).await? { + ICPSwapResult::Ok(amount_out) => Ok(nat_to_u128(amount_out)), + ICPSwapResult::Err(error) => Err(convert_error(error)), + } + } + + pub async fn withdraw(&self, amount: u128) -> CallResult { + let args = icpswap_swap_pool_canister::withdraw::Args { + token: self.get_ledger(!self.zero_for_one).to_string(), + amount: amount.into(), + }; + match icpswap_swap_pool_canister_c2c_client::withdraw(self.swap_canister_id, &args).await? { + ICPSwapResult::Ok(amount_out) => Ok(nat_to_u128(amount_out)), + ICPSwapResult::Err(error) => Err(convert_error(error)), + } + } + + fn get_ledger(&self, token0: bool) -> CanisterId { + if token0 { + self.token0.ledger + } else { + self.token1.ledger + } + } +} + +fn nat_to_u128(value: Nat) -> u128 { + value.0.try_into().unwrap() +} + +fn convert_error(error: ICPSwapError) -> (RejectionCode, String) { + (RejectionCode::Unknown, format!("{error:?}")) +} diff --git a/backend/libraries/ledger_utils/Cargo.toml b/backend/libraries/ledger_utils/Cargo.toml index c9b198bf6f..d72d6abe2e 100644 --- a/backend/libraries/ledger_utils/Cargo.toml +++ b/backend/libraries/ledger_utils/Cargo.toml @@ -15,3 +15,6 @@ serde_cbor = { workspace = true } sha2 = { workspace = true } sha256 = { path = "../sha256" } types = { path = "../types" } + +[dev-dependencies] +test-case = { workspace = true } diff --git a/backend/libraries/ledger_utils/src/lib.rs b/backend/libraries/ledger_utils/src/lib.rs index fe63ed969d..3512f0a261 100644 --- a/backend/libraries/ledger_utils/src/lib.rs +++ b/backend/libraries/ledger_utils/src/lib.rs @@ -1,6 +1,3 @@ -pub mod icrc1; -pub mod nns; - use candid::{CandidType, Principal}; use ic_ledger_types::{AccountIdentifier, Memo, Subaccount, Timestamp, Tokens, TransferArgs, DEFAULT_SUBACCOUNT}; use serde::{Deserialize, Serialize}; @@ -11,6 +8,9 @@ use types::{ PendingCryptoTransaction, TimestampNanos, TransactionHash, UserId, }; +pub mod icrc1; +pub mod nns; + pub fn create_pending_transaction( token: Cryptocurrency, ledger: CanisterId, @@ -64,7 +64,7 @@ pub fn convert_to_subaccount(principal: &Principal) -> Subaccount { } pub fn calculate_transaction_hash(sender: CanisterId, args: &TransferArgs) -> TransactionHash { - let from = default_ledger_account(sender); + let from = AccountIdentifier::new(&sender, &args.from_subaccount.unwrap_or(DEFAULT_SUBACCOUNT)); let transaction = Transaction { operation: Operation::Transfer { @@ -81,10 +81,15 @@ pub fn calculate_transaction_hash(sender: CanisterId, args: &TransferArgs) -> Tr transaction.hash() } -pub fn format_crypto_amount(units: u128, decimals: u32) -> String { - let subdividable_by = 10u128.pow(decimals); +pub fn format_crypto_amount(units: u128, decimals: u8) -> String { + let subdividable_by = 10u128.pow(decimals as u32); + let whole_units = units / subdividable_by; + let fractional = units % subdividable_by; - format!("{}.{:0}", units / subdividable_by, units % subdividable_by) + format!("{whole_units}.{fractional:0decimals$}", decimals = decimals as usize) + .trim_end_matches('0') + .trim_end_matches('.') + .to_string() } /// An operation which modifies account balances @@ -122,3 +127,17 @@ impl Transaction { sha256(&bytes) } } + +#[cfg(test)] +mod tests { + use test_case::test_case; + + #[test_case(1000000, 8, "0.01")] + #[test_case(321000000, 8, "3.21")] + #[test_case(9876543210, 6, "9876.54321")] + #[test_case(123456789, 8, "1.23456789")] + fn format(units: u128, decimals: u8, expected: &str) { + let formatted = super::format_crypto_amount(units, decimals); + assert_eq!(formatted, expected); + } +} diff --git a/backend/libraries/types/src/cryptocurrency.rs b/backend/libraries/types/src/cryptocurrency.rs index 7a38a980bd..7ccfe64719 100644 --- a/backend/libraries/types/src/cryptocurrency.rs +++ b/backend/libraries/types/src/cryptocurrency.rs @@ -26,7 +26,7 @@ impl Cryptocurrency { } } - pub fn decimals(&self) -> Option { + pub fn decimals(&self) -> Option { match self { Cryptocurrency::InternetComputer => Some(8), Cryptocurrency::SNS1 => Some(8), diff --git a/backend/libraries/types/src/message_content.rs b/backend/libraries/types/src/message_content.rs index 2707acd21f..4296ca1234 100644 --- a/backend/libraries/types/src/message_content.rs +++ b/backend/libraries/types/src/message_content.rs @@ -385,6 +385,12 @@ pub struct TextContent { pub text: String, } +impl From for TextContent { + fn from(value: String) -> Self { + TextContent { text: value } + } +} + #[derive(CandidType, Serialize, Deserialize, Clone, Debug)] pub struct ImageContent { pub width: u32, diff --git a/canister_ids.json b/canister_ids.json index 9df3c8fa43..9a78f77b10 100644 --- a/canister_ids.json +++ b/canister_ids.json @@ -8,6 +8,10 @@ "ic": "gonut-hqaaa-aaaaf-aby7a-cai", "ic_test": "mq2tp-baaaa-aaaaf-aucva-cai" }, + "exchange_bot": { + "ic_test": "tz2or-siaaa-aaaaf-biffa-cai", + "ic": "no74g-2qaaa-aaaaf-bihlq-cai" + }, "group_index": { "ic": "4ijyc-kiaaa-aaaaf-aaaja-cai", "ic_test": "7kifq-3yaaa-aaaaf-ab2cq-cai" diff --git a/dfx.json b/dfx.json index 76a2631ad5..b645490fcb 100644 --- a/dfx.json +++ b/dfx.json @@ -91,6 +91,12 @@ "wasm": "wasms/market_maker.wasm.gz", "build": "./scripts/generate-wasm.sh market_maker" }, + "exchange_bot": { + "type": "custom", + "candid": "backend/canisters/exchange_bot/api/can.did", + "wasm": "wasms/exchange_bot.wasm.gz", + "build": "./scripts/generate-wasm.sh exchange_bot" + }, "website": { "source": ["frontend/app/build", "frontend/app/public"], "type": "assets" diff --git a/scripts/deploy-local.sh b/scripts/deploy-local.sh index 29ff9f5d06..aaa9df0caa 100755 --- a/scripts/deploy-local.sh +++ b/scripts/deploy-local.sh @@ -32,6 +32,7 @@ dfx --identity $IDENTITY canister create --no-wallet --with-cycles 1000000000000 dfx --identity $IDENTITY canister create --no-wallet --with-cycles 1000000000000000 cycles_dispenser dfx --identity $IDENTITY canister create --no-wallet --with-cycles 100000000000000 registry dfx --identity $IDENTITY canister create --no-wallet --with-cycles 100000000000000 market_maker +dfx --identity $IDENTITY canister create --no-wallet --with-cycles 100000000000000 exchange_bot # Install the OpenChat canisters ./scripts/deploy.sh local \ diff --git a/scripts/deploy-testnet.sh b/scripts/deploy-testnet.sh index 650c3efcbe..64718a15ee 100755 --- a/scripts/deploy-testnet.sh +++ b/scripts/deploy-testnet.sh @@ -28,6 +28,7 @@ dfx --identity $IDENTITY canister create --provisional-create-canister-effective dfx --identity $IDENTITY canister create --provisional-create-canister-effective-canister-id jrlun-jiaaa-aaaab-aaaaa-cai --network $NETWORK --no-wallet --with-cycles 1000000000000000 cycles_dispenser dfx --identity $IDENTITY canister create --provisional-create-canister-effective-canister-id jrlun-jiaaa-aaaab-aaaaa-cai --network $NETWORK --no-wallet --with-cycles 100000000000000 registry dfx --identity $IDENTITY canister create --provisional-create-canister-effective-canister-id jrlun-jiaaa-aaaab-aaaaa-cai --network $NETWORK --no-wallet --with-cycles 100000000000000 market_maker +dfx --identity $IDENTITY canister create --provisional-create-canister-effective-canister-id jrlun-jiaaa-aaaab-aaaaa-cai --network $NETWORK --no-wallet --with-cycles 100000000000000 exchange_bot # Install the OpenChat canisters ./scripts/deploy.sh $NETWORK \ diff --git a/scripts/deploy.sh b/scripts/deploy.sh index 34461ed045..d51d211a16 100755 --- a/scripts/deploy.sh +++ b/scripts/deploy.sh @@ -39,6 +39,7 @@ STORAGE_INDEX_CANISTER_ID=$(dfx canister --network $NETWORK id storage_index) CYCLES_DISPENSER_CANISTER_ID=$(dfx canister --network $NETWORK id cycles_dispenser) REGISTRY_CANISTER_ID=$(dfx canister --network $NETWORK id registry) MARKET_MAKER_CANISTER_ID=$(dfx canister --network $NETWORK id market_maker) +EXCHANGE_BOT_CANISTER_ID=$(dfx canister --network $NETWORK id exchange_bot) cargo run \ --manifest-path backend/canister_installer/Cargo.toml -- \ @@ -57,6 +58,7 @@ cargo run \ --cycles-dispenser $CYCLES_DISPENSER_CANISTER_ID \ --registry $REGISTRY_CANISTER_ID \ --market-maker $MARKET_MAKER_CANISTER_ID \ + --exchange-bot $EXCHANGE_BOT_CANISTER_ID \ --nns-root $NNS_ROOT_CANISTER_ID \ --nns-governance $NNS_GOVERNANCE_CANISTER_ID \ --nns-internet-identity $NNS_INTERNET_IDENTITY_CANISTER_ID \ diff --git a/scripts/download-all-canister-wasms.sh b/scripts/download-all-canister-wasms.sh index 7e7679dce0..b8568e4b2b 100755 --- a/scripts/download-all-canister-wasms.sh +++ b/scripts/download-all-canister-wasms.sh @@ -15,6 +15,7 @@ echo "Downloading wasms" ./download-canister-wasm.sh community $WASM_SRC || exit 1 ./download-canister-wasm.sh cycles_dispenser $WASM_SRC || exit 1 +./download-canister-wasm.sh exchange_bot $WASM_SRC || exit 1 ./download-canister-wasm.sh group $WASM_SRC || exit 1 ./download-canister-wasm.sh group_index $WASM_SRC || exit 1 ./download-canister-wasm.sh local_group_index $WASM_SRC || exit 1 diff --git a/scripts/generate-all-canister-wasms.sh b/scripts/generate-all-canister-wasms.sh index 885e125a8f..c6b0be5cde 100755 --- a/scripts/generate-all-canister-wasms.sh +++ b/scripts/generate-all-canister-wasms.sh @@ -6,6 +6,7 @@ cd $SCRIPT_DIR/.. ./scripts/generate-wasm.sh community ./scripts/generate-wasm.sh cycles_dispenser +./scripts/generate-wasm.sh exchange_bot ./scripts/generate-wasm.sh group ./scripts/generate-wasm.sh group_index ./scripts/generate-wasm.sh local_group_index diff --git a/scripts/upgrade-canister.sh b/scripts/upgrade-canister.sh index b3cf262b54..a2ec8df58d 100755 --- a/scripts/upgrade-canister.sh +++ b/scripts/upgrade-canister.sh @@ -31,6 +31,7 @@ STORAGE_INDEX_CANISTER_ID=$(dfx canister --network $NETWORK id storage_index) CYCLES_DISPENSER_CANISTER_ID=$(dfx canister --network $NETWORK id cycles_dispenser) REGISTRY_CANISTER_ID=$(dfx canister --network $NETWORK id registry) MARKET_MAKER_CANISTER_ID=$(dfx canister --network $NETWORK id market_maker) +EXCHANGE_BOT_CANISTER_ID=$(dfx canister --network $NETWORK id exchange_bot) cargo run \ --manifest-path backend/canister_upgrader/Cargo.toml -- \ @@ -45,5 +46,6 @@ cargo run \ --cycles-dispenser $CYCLES_DISPENSER_CANISTER_ID \ --registry $REGISTRY_CANISTER_ID \ --market-maker $MARKET_MAKER_CANISTER_ID \ + --exchange-bot $EXCHANGE_BOT_CANISTER_ID \ --canister-to-upgrade $CANISTER_NAME \ --version $VERSION \ \ No newline at end of file