diff --git a/Cargo.lock b/Cargo.lock index e374c91ffe..d1d02033d7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -10778,6 +10778,25 @@ dependencies = [ "version-compare", ] +[[package]] +name = "taco_exchange_canister" +version = "0.1.0" +dependencies = [ + "candid", + "serde", + "types", +] + +[[package]] +name = "taco_exchange_canister_c2c_client" +version = "0.1.0" +dependencies = [ + "candid", + "canister_client", + "taco_exchange_canister", + "types", +] + [[package]] name = "tao" version = "0.35.2" @@ -12274,6 +12293,8 @@ dependencies = [ "stable_memory", "stable_memory_map", "storage_bucket_client", + "taco_exchange_canister", + "taco_exchange_canister_c2c_client", "test-case", "timer_job_queues", "tracing", diff --git a/Cargo.toml b/Cargo.toml index e6456749a6..addc47351b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -100,6 +100,8 @@ members = [ "backend/external_canisters/sns_swap/c2c_client", "backend/external_canisters/sns_wasm/api", "backend/external_canisters/sns_wasm/c2c_client", + "backend/external_canisters/taco_exchange/api", + "backend/external_canisters/taco_exchange/c2c_client", "backend/integration_tests", "backend/legacy_bots/api", "backend/legacy_bots/c2c_client", diff --git a/backend/canisters/user/api/src/updates/swap_tokens.rs b/backend/canisters/user/api/src/updates/swap_tokens.rs index fe9cf9bb47..d1e710f4ee 100644 --- a/backend/canisters/user/api/src/updates/swap_tokens.rs +++ b/backend/canisters/user/api/src/updates/swap_tokens.rs @@ -19,18 +19,21 @@ pub struct Args { #[derive(Serialize, Deserialize, Clone, Debug)] pub enum ExchangeArgs { ICPSwap(ICPSwapArgs), + Taco(TacoArgs), } impl ExchangeArgs { pub fn exchange_id(&self) -> ExchangeId { match self { ExchangeArgs::ICPSwap(_) => ExchangeId::ICPSwap, + ExchangeArgs::Taco(_) => ExchangeId::Taco, } } pub fn swap_canister_id(&self) -> CanisterId { match self { ExchangeArgs::ICPSwap(a) => a.swap_canister_id, + ExchangeArgs::Taco(a) => a.swap_canister_id, } } } @@ -44,6 +47,18 @@ pub struct ExchangeSwapArgs { pub type ICPSwapArgs = ExchangeSwapArgs; +#[ts_export(user, swap_tokens)] +#[derive(Serialize, Deserialize, Clone, Debug)] +pub struct TacoArgs { + /// The TACO exchange canister that handles swap_multi_hop / swap_split_routes + /// (production: qioex-5iaaa-aaaan-q52ba-cai). + pub swap_canister_id: CanisterId, + /// The exchange-treasury canister that holds deposited tokens — this is the + /// account TACO's `checkReceive` validates the user's ICRC1 transfer against + /// (production: qbnpl-laaaa-aaaan-q52aq-cai). Distinct from swap_canister_id. + pub treasury_canister_id: CanisterId, +} + #[ts_export(user, swap_tokens)] #[derive(Serialize, Deserialize, Debug)] pub enum Response { diff --git a/backend/canisters/user/impl/Cargo.toml b/backend/canisters/user/impl/Cargo.toml index 4fa8622dd8..d2fcd6e190 100644 --- a/backend/canisters/user/impl/Cargo.toml +++ b/backend/canisters/user/impl/Cargo.toml @@ -66,6 +66,8 @@ sns_governance_canister_c2c_client = { path = "../../../external_canisters/sns_g stable_memory = { path = "../../../libraries/stable_memory" } stable_memory_map = { path = "../../../libraries/stable_memory_map" } storage_bucket_client = { path = "../../../libraries/storage_bucket_client" } +taco_exchange_canister = { path = "../../../external_canisters/taco_exchange/api" } +taco_exchange_canister_c2c_client = { path = "../../../external_canisters/taco_exchange/c2c_client" } timer_job_queues = { path = "../../../libraries/timer_job_queues" } tracing = { workspace = true } types = { path = "../../../libraries/types" } diff --git a/backend/canisters/user/impl/src/token_swaps/icpswap.rs b/backend/canisters/user/impl/src/token_swaps/icpswap.rs index 8ecbeba984..c871cf7bf6 100644 --- a/backend/canisters/user/impl/src/token_swaps/icpswap.rs +++ b/backend/canisters/user/impl/src/token_swaps/icpswap.rs @@ -68,7 +68,12 @@ impl SwapClient for ICPSwapClient { } } - async fn swap(&self, amount: u128, min_amount_out: u128) -> Result, C2CError> { + async fn swap( + &self, + amount: u128, + min_amount_out: u128, + _deposit_block_index: Option, + ) -> Result, C2CError> { let args = icpswap_swap_pool_canister::swap::Args { operator: self.this_canister_id, amount_in: amount.to_string(), diff --git a/backend/canisters/user/impl/src/token_swaps/mod.rs b/backend/canisters/user/impl/src/token_swaps/mod.rs index 33a80e6012..dfb0b232b5 100644 --- a/backend/canisters/user/impl/src/token_swaps/mod.rs +++ b/backend/canisters/user/impl/src/token_swaps/mod.rs @@ -5,6 +5,7 @@ use types::{C2CError, CanisterId}; pub mod icpswap; pub mod swap_client; +pub mod taco; fn nat_to_u128(value: Nat) -> u128 { value.0.try_into().unwrap() diff --git a/backend/canisters/user/impl/src/token_swaps/swap_client.rs b/backend/canisters/user/impl/src/token_swaps/swap_client.rs index 0555c9af5a..c1942bb210 100644 --- a/backend/canisters/user/impl/src/token_swaps/swap_client.rs +++ b/backend/canisters/user/impl/src/token_swaps/swap_client.rs @@ -14,7 +14,12 @@ pub trait SwapClient { } async fn deposit_account(&self) -> Result; async fn deposit(&self, amount: u128) -> Result; - async fn swap(&self, amount: u128, min_amount_out: u128) -> Result, C2CError>; + async fn swap( + &self, + amount: u128, + min_amount_out: u128, + deposit_block_index: Option, + ) -> Result, C2CError>; async fn withdraw(&self, successful_swap: bool, amount: u128) -> Result; } diff --git a/backend/canisters/user/impl/src/token_swaps/taco.rs b/backend/canisters/user/impl/src/token_swaps/taco.rs new file mode 100644 index 0000000000..ca19fe423d --- /dev/null +++ b/backend/canisters/user/impl/src/token_swaps/taco.rs @@ -0,0 +1,274 @@ +use super::swap_client::{SwapClient, SwapSuccess}; +use crate::token_swaps::nat_to_u128; +use async_trait::async_trait; +use candid::Nat; +use serde::{Deserialize, Serialize}; +use taco_exchange_canister::get_expected_receive_amount_batch_multi_optimal as optimal; +use taco_exchange_canister::{SplitLeg, SwapHop, SwapResult}; +use types::icrc1::Account; +use types::{C2CError, CanisterId, TokenInfo}; + +#[derive(Serialize, Deserialize)] +pub struct TacoExchangeClient { + swap_canister_id: CanisterId, + treasury_canister_id: CanisterId, + input_token: TokenInfo, + output_token: TokenInfo, +} + +impl TacoExchangeClient { + pub fn new( + swap_canister_id: CanisterId, + treasury_canister_id: CanisterId, + input_token: TokenInfo, + output_token: TokenInfo, + ) -> Self { + TacoExchangeClient { + swap_canister_id, + treasury_canister_id, + input_token, + output_token, + } + } +} + +#[async_trait] +impl SwapClient for TacoExchangeClient { + fn canister_id(&self) -> CanisterId { + self.swap_canister_id + } + + fn auto_withdrawals(&self) -> bool { + // TACO pushes the swap output back to the caller automatically as part + // of swap_multi_hop / swap_split_routes, so OC's separate withdraw step + // is a no-op. + // + // EVENTUAL-CONSISTENCY NOTE — this is NOT the same guarantee ICPSwap + // gives. ICPSwap's withdraw() awaits the actual ICRC1 transfer, so the + // tokens are in the user canister before SuccessResult is returned. + // TACO's transfer queue is async: + // + // 1. swap_multi_hop / swap_split_routes calls treasury.receive + // TransferTasks(queue, immediate = false) at the end. The + // `immediate` flag is `isInAllowedCanisters(caller)` which is + // FALSE for OC user canisters (TACO's allowedCanisters list is + // TACO-internal canisters only). + // 2. With immediate=false, receiveTransferTasks just appends to + // transferQueue and sets a 5-second setTimer to drain a batch + // via transferTimer(false). See + // TACO_Backend/src/exchange/treasury.mo:141-190. + // 3. swap_multi_hop returns to us as soon as receiveTransferTasks + // returns. The actual icrc1_transfer to this canister lands + // ~5-10 seconds later when the timer fires. + // + // Consequence: when OC reports SuccessResult { amount_out } to the + // user, the tokens haven't physically arrived yet. The OC frontend + // reading the wallet balance right after success will briefly see + // stale state — fixed by a polling withdraw() on the OC side, or by + // TACO making delivery synchronous for OC user canisters. Both deferred. + // + // No correctness issue: TACO's BlocksDone guard makes retries + // idempotent, and tokens always arrive eventually via the queued + // transfer. + true + } + + async fn deposit_account(&self) -> Result { + // TACO verifies deposits by inspecting the ledger block, looking for a + // transfer to its `treasury_principal` (the exchange-treasury canister, + // NOT the exchange canister itself — see TACO's checkReceive at + // src/exchange/main.mo line 11235 and the treasury trader's reference + // at src/swap/taco_swap.mo:19,440). + Ok(Account { + owner: self.treasury_canister_id, + subaccount: None, + }) + } + + async fn deposit(&self, amount: u128) -> Result { + // No-op for TACO: block-based verification happens inside the swap call. + Ok(amount) + } + + async fn swap( + &self, + amount: u128, + min_amount_out: u128, + deposit_block_index: Option, + ) -> Result, C2CError> { + let block_index = match deposit_block_index { + Some(b) => b, + None => return Ok(Err("TACO swap requires a deposit block index".to_string())), + }; + + // OC passes `amount = amount_to_dex - input_token.fee`. The ledger block + // recorded `amount_to_dex` being transferred. We assume TACO's per-token + // transfer fee equals the ledger transfer fee. + let transferred = amount.saturating_add(self.input_token.fee); + let tfee = self.input_token.fee; + let usable = transferred.saturating_sub(tfee); + if usable == 0 { + return Ok(Err("TACO swap: deposit too small to cover transfer fee".to_string())); + } + + let token_in = self.input_token.ledger.to_string(); + let token_out = self.output_token.ledger.to_string(); + + // Single call: TACO runs the BatchMulti probe grid (10 fractions × top-5 + // routes) AND the 2/3-leg split-route optimizer internally, then returns + // the chosen plan. Replaces the local build_swap_plan that used to live + // here (and the parallel TS optimizer in the OC frontend). One source of + // truth — when TACO tunes the optimizer (different thresholds, smarter + // pruning), OC picks up the change automatically. + let plan = taco_exchange_canister_c2c_client::get_expected_receive_amount_batch_multi_optimal( + self.swap_canister_id, + (token_in.clone(), token_out.clone(), Nat::from(usable)), + ) + .await?; + + if plan.legs.is_empty() { + return Ok(Err(format!( + "TACO swap: no viable route ({})", + plan.route_description + ))); + } + + // Defensive clamp on tradingFeeBps in case the canister returns a value + // outside its enforced [1, 50] range. + let fee_bps = nat_to_u128(plan.trading_fee_bps).clamp(1, 50); + + // Compute the true amount_in such that the recorded deposit covers + // amount_in × (10000 + fee_bps) / 10000 + tfee ← TACO's checkReceive. + let amount_in_total = usable.saturating_mul(10000) / (10000 + fee_bps); + if amount_in_total == 0 { + return Ok(Err("TACO swap: deposit too small to cover trading fee".to_string())); + } + + if plan.legs.len() == 1 { + let leg = &plan.legs[0]; + execute_swap_multi_hop( + self.swap_canister_id, + token_in, + token_out, + amount_in_total, + leg.route.clone(), + min_amount_out, + block_index, + ) + .await + } else { + let split_legs = make_split_legs_from_optimal(&plan.legs, amount_in_total, min_amount_out); + execute_swap_split_routes( + self.swap_canister_id, + token_in, + token_out, + split_legs, + min_amount_out, + block_index, + ) + .await + } + } + + async fn withdraw(&self, _successful_swap: bool, amount: u128) -> Result { + // auto_withdrawals() == true means swap_tokens.rs skips this call. + Ok(amount) + } +} + +// Map TACO's chosen split-leg plan into the SplitLeg shape swap_split_routes +// expects. Uses remainder-in-last-leg so Σ leg.amount_in == amount_in_total +// exactly (TACO's checkReceive validates this sum against the deposit block). +// Per-leg min_leg_out is pro-rated by bp; sum ≤ min_out_total within +// integer-division floor (TACO's canonical check is the global min anyway). +fn make_split_legs_from_optimal( + legs: &[optimal::OptimalSwapLeg], + total: u128, + min_out_total: u128, +) -> Vec { + let n = legs.len(); + let mut allocated_in: u128 = 0; + let mut allocated_min: u128 = 0; + let mut out = Vec::with_capacity(n); + for (i, leg) in legs.iter().enumerate() { + let bp = nat_to_u128(leg.bp.clone()); + let (amount_in, min_leg_out) = if i + 1 == n { + ( + total.saturating_sub(allocated_in), + min_out_total.saturating_sub(allocated_min), + ) + } else { + let a = total.saturating_mul(bp) / 10000; + let m = min_out_total.saturating_mul(bp) / 10000; + allocated_in = allocated_in.saturating_add(a); + allocated_min = allocated_min.saturating_add(m); + (a, m) + }; + out.push(SplitLeg { + amount_in: amount_in.into(), + route: leg.route.clone(), + min_leg_out: min_leg_out.into(), + }); + } + out +} + +async fn execute_swap_multi_hop( + canister_id: CanisterId, + token_in: String, + token_out: String, + amount_in: u128, + route: Vec, + min_amount_out: u128, + block_index: u64, +) -> Result, C2CError> { + let response = taco_exchange_canister_c2c_client::swap_multi_hop( + canister_id, + ( + token_in, + token_out, + amount_in.into(), + route, + min_amount_out.into(), + block_index.into(), + ), + ) + .await?; + + Ok(map_swap_result(response)) +} + +async fn execute_swap_split_routes( + canister_id: CanisterId, + token_in: String, + token_out: String, + legs: Vec, + min_amount_out: u128, + block_index: u64, +) -> Result, C2CError> { + let response = taco_exchange_canister_c2c_client::swap_split_routes( + canister_id, + ( + token_in, + token_out, + legs, + min_amount_out.into(), + block_index.into(), + ), + ) + .await?; + + Ok(map_swap_result(response)) +} + +fn map_swap_result(result: SwapResult) -> Result { + match result { + SwapResult::Ok(ok) => Ok(SwapSuccess { + amount_out: nat_to_u128(ok.amount_out), + // TACO has already pushed the output to the caller, so OC should + // consider the withdraw step complete. + withdrawal_success: Some(true), + }), + SwapResult::Err(error) => Err(format!("{error:?}")), + } +} diff --git a/backend/canisters/user/impl/src/updates/swap_tokens.rs b/backend/canisters/user/impl/src/updates/swap_tokens.rs index 3e3f4a715d..3f47dcaa22 100644 --- a/backend/canisters/user/impl/src/updates/swap_tokens.rs +++ b/backend/canisters/user/impl/src/updates/swap_tokens.rs @@ -3,6 +3,7 @@ use crate::model::token_swaps::TokenSwap; use crate::timer_job_types::{ProcessTokenSwapJob, TimerJob}; use crate::token_swaps::icpswap::ICPSwapClient; use crate::token_swaps::swap_client::SwapClient; +use crate::token_swaps::taco::TacoExchangeClient; use crate::{Data, RuntimeState, execute_update_async, mutate_state, read_state}; use canister_api_macros::update; use canister_tracing_macros::trace; @@ -173,8 +174,13 @@ pub(crate) async fn process_token_swap( let swap_result = if let Some(r) = extract_result(&token_swap.swap_result).cloned() { r } else { + let deposit_block_index = extract_result(&token_swap.transfer_or_approval).copied(); match swap_client - .swap(amount_to_dex.saturating_sub(args.input_token.fee), args.min_output_amount) + .swap( + amount_to_dex.saturating_sub(args.input_token.fee), + args.min_output_amount, + deposit_block_index, + ) .await { Ok(r) => { @@ -256,6 +262,12 @@ fn build_swap_client(args: &Args, state: &RuntimeState) -> Box { icpswap.zero_for_one, )) } + ExchangeArgs::Taco(taco) => Box::new(TacoExchangeClient::new( + taco.swap_canister_id, + taco.treasury_canister_id, + input_token, + output_token, + )), } } diff --git a/backend/external_canisters/taco_exchange/api/Cargo.toml b/backend/external_canisters/taco_exchange/api/Cargo.toml new file mode 100644 index 0000000000..6fb5ac9421 --- /dev/null +++ b/backend/external_canisters/taco_exchange/api/Cargo.toml @@ -0,0 +1,9 @@ +[package] +name = "taco_exchange_canister" +version.workspace = true +edition.workspace = true + +[dependencies] +candid = { workspace = true } +serde = { workspace = true } +types = { path = "../../../libraries/types" } diff --git a/backend/external_canisters/taco_exchange/api/src/lib.rs b/backend/external_canisters/taco_exchange/api/src/lib.rs new file mode 100644 index 0000000000..8b66eb5718 --- /dev/null +++ b/backend/external_canisters/taco_exchange/api/src/lib.rs @@ -0,0 +1,96 @@ +use candid::{CandidType, Nat}; +use serde::{Deserialize, Serialize}; + +mod queries; +mod updates; + +pub use queries::*; +pub use updates::*; + +#[derive(CandidType, Serialize, Deserialize, Clone, Debug)] +pub struct SwapHop { + #[serde(rename = "tokenIn")] + pub token_in: String, + #[serde(rename = "tokenOut")] + pub token_out: String, +} + +#[derive(CandidType, Serialize, Deserialize, Clone, Debug)] +pub struct SplitLeg { + #[serde(rename = "amountIn")] + pub amount_in: Nat, + pub route: Vec, + #[serde(rename = "minLegOut")] + pub min_leg_out: Nat, +} + +#[derive(CandidType, Serialize, Deserialize, Clone, Debug)] +pub struct SwapOk { + #[serde(rename = "amountIn")] + pub amount_in: Nat, + #[serde(rename = "amountOut")] + pub amount_out: Nat, + pub fee: Nat, + #[serde(rename = "firstHopOrderbookMatch")] + pub first_hop_orderbook_match: bool, + pub hops: Nat, + #[serde(rename = "lastHopAMMOnly")] + pub last_hop_amm_only: bool, + pub route: Vec, + #[serde(rename = "swapId")] + pub swap_id: Nat, + #[serde(rename = "tokenIn")] + pub token_in: String, + #[serde(rename = "tokenOut")] + pub token_out: String, +} + +#[derive(CandidType, Serialize, Deserialize, Clone, Debug)] +pub struct SlippageExceededDetail { + pub expected: Nat, + pub got: Nat, +} + +#[derive(CandidType, Serialize, Deserialize, Clone, Debug)] +pub struct RouteFailedDetail { + pub hop: Nat, + pub reason: String, +} + +#[derive(CandidType, Serialize, Deserialize, Clone, Debug)] +pub enum ExchangeError { + Banned, + ExchangeFrozen, + InsufficientFunds(String), + InvalidInput(String), + NotAuthorized, + OrderNotFound(String), + PoolNotFound(String), + RouteFailed(RouteFailedDetail), + SlippageExceeded(SlippageExceededDetail), + SystemError(String), + TokenNotAccepted(String), + TokenPaused(String), + TransferFailed(String), +} + +#[derive(CandidType, Serialize, Deserialize, Clone, Debug)] +pub enum SwapResult { + Ok(SwapOk), + Err(ExchangeError), +} + +#[derive(CandidType, Serialize, Deserialize, Clone, Debug)] +pub struct HopDetail { + #[serde(rename = "amountIn")] + pub amount_in: Nat, + #[serde(rename = "amountOut")] + pub amount_out: Nat, + pub fee: Nat, + #[serde(rename = "priceImpact")] + pub price_impact: f64, + #[serde(rename = "tokenIn")] + pub token_in: String, + #[serde(rename = "tokenOut")] + pub token_out: String, +} diff --git a/backend/external_canisters/taco_exchange/api/src/queries/get_expected_receive_amount_batch_multi.rs b/backend/external_canisters/taco_exchange/api/src/queries/get_expected_receive_amount_batch_multi.rs new file mode 100644 index 0000000000..034789d969 --- /dev/null +++ b/backend/external_canisters/taco_exchange/api/src/queries/get_expected_receive_amount_batch_multi.rs @@ -0,0 +1,56 @@ +use crate::HopDetail; +use candid::{CandidType, Nat}; +use serde::{Deserialize, Serialize}; + +// Candid signature (positional args): +// getExpectedReceiveAmountBatchMulti : +// (vec record { tokenSell : text; tokenBuy : text; amountSell : nat }, +// nat) +// -> (vec record { routes : vec QuoteRoute }) +// where each QuoteRoute now carries tradingFeeBps (the live ICPfee snapshot +// used by the simulation that produced this route). +pub type Args = (Vec, Nat); + +pub type Response = Vec; + +#[derive(CandidType, Serialize, Deserialize, Clone, Debug)] +pub struct Request { + #[serde(rename = "tokenSell")] + pub token_sell: String, + #[serde(rename = "tokenBuy")] + pub token_buy: String, + #[serde(rename = "amountSell")] + pub amount_sell: Nat, +} + +#[derive(CandidType, Serialize, Deserialize, Clone, Debug)] +pub struct RequestResponse { + pub routes: Vec, +} + +#[derive(CandidType, Serialize, Deserialize, Clone, Debug)] +pub struct QuoteRoute { + #[serde(rename = "expectedBuyAmount")] + pub expected_buy_amount: Nat, + pub fee: Nat, + #[serde(rename = "priceImpact")] + pub price_impact: f64, + #[serde(rename = "routeDescription")] + pub route_description: String, + #[serde(rename = "canFulfillFully")] + pub can_fulfill_fully: bool, + #[serde(rename = "potentialOrderDetails")] + pub potential_order_details: Option, + #[serde(rename = "hopDetails")] + pub hop_details: Vec, + #[serde(rename = "routeTokens")] + pub route_tokens: Vec, + #[serde(rename = "tradingFeeBps")] + pub trading_fee_bps: Nat, +} + +#[derive(CandidType, Serialize, Deserialize, Clone, Debug)] +pub struct PotentialOrderDetails { + pub amount_init: Nat, + pub amount_sell: Nat, +} diff --git a/backend/external_canisters/taco_exchange/api/src/queries/get_expected_receive_amount_batch_multi_optimal.rs b/backend/external_canisters/taco_exchange/api/src/queries/get_expected_receive_amount_batch_multi_optimal.rs new file mode 100644 index 0000000000..3d7b7147f2 --- /dev/null +++ b/backend/external_canisters/taco_exchange/api/src/queries/get_expected_receive_amount_batch_multi_optimal.rs @@ -0,0 +1,40 @@ +use crate::SwapHop; +use candid::{CandidType, Nat}; +use serde::{Deserialize, Serialize}; + +// Candid signature: +// getExpectedReceiveAmountBatchMultiOptimal : +// (tokenSell : text, tokenBuy : text, amountIn : nat) +// -> (OptimalSwapPlan) query +// +// TACO runs the BatchMulti probe grid + 2/3-leg split optimizer internally and +// returns just the chosen plan. Single-leg plans go to swapMultiHop; multi-leg +// plans go to swapSplitRoutes. +pub type Args = (String, String, Nat); +pub type Response = OptimalSwapPlan; + +#[derive(CandidType, Serialize, Deserialize, Clone, Debug)] +pub struct OptimalSwapPlan { + #[serde(rename = "expectedBuyAmount")] + pub expected_buy_amount: Nat, + pub fee: Nat, + #[serde(rename = "priceImpact")] + pub price_impact: f64, + #[serde(rename = "canFulfillFully")] + pub can_fulfill_fully: bool, + #[serde(rename = "tradingFeeBps")] + pub trading_fee_bps: Nat, + #[serde(rename = "routeDescription")] + pub route_description: String, + pub legs: Vec, +} + +#[derive(CandidType, Serialize, Deserialize, Clone, Debug)] +pub struct OptimalSwapLeg { + pub bp: Nat, + #[serde(rename = "expectedBuyAmount")] + pub expected_buy_amount: Nat, + pub route: Vec, + #[serde(rename = "routeDescription")] + pub route_description: String, +} diff --git a/backend/external_canisters/taco_exchange/api/src/queries/mod.rs b/backend/external_canisters/taco_exchange/api/src/queries/mod.rs new file mode 100644 index 0000000000..1338b1ba35 --- /dev/null +++ b/backend/external_canisters/taco_exchange/api/src/queries/mod.rs @@ -0,0 +1,2 @@ +pub mod get_expected_receive_amount_batch_multi; +pub mod get_expected_receive_amount_batch_multi_optimal; diff --git a/backend/external_canisters/taco_exchange/api/src/updates/mod.rs b/backend/external_canisters/taco_exchange/api/src/updates/mod.rs new file mode 100644 index 0000000000..9099241cac --- /dev/null +++ b/backend/external_canisters/taco_exchange/api/src/updates/mod.rs @@ -0,0 +1,2 @@ +pub mod swap_multi_hop; +pub mod swap_split_routes; diff --git a/backend/external_canisters/taco_exchange/api/src/updates/swap_multi_hop.rs b/backend/external_canisters/taco_exchange/api/src/updates/swap_multi_hop.rs new file mode 100644 index 0000000000..387a9720b3 --- /dev/null +++ b/backend/external_canisters/taco_exchange/api/src/updates/swap_multi_hop.rs @@ -0,0 +1,9 @@ +use crate::{SwapHop, SwapResult}; +use candid::Nat; + +// Candid signature is positional: +// swapMultiHop: (tokenIn: text, tokenOut: text, amountIn: nat, +// route: vec SwapHop, minAmountOut: nat, Block: nat) -> (SwapResult) +pub type Args = (String, String, Nat, Vec, Nat, Nat); + +pub type Response = SwapResult; diff --git a/backend/external_canisters/taco_exchange/api/src/updates/swap_split_routes.rs b/backend/external_canisters/taco_exchange/api/src/updates/swap_split_routes.rs new file mode 100644 index 0000000000..2c1bb981fb --- /dev/null +++ b/backend/external_canisters/taco_exchange/api/src/updates/swap_split_routes.rs @@ -0,0 +1,10 @@ +use crate::{SplitLeg, SwapResult}; +use candid::Nat; + +// Candid signature is positional: +// swapSplitRoutes : +// (tokenIn : text, tokenOut : text, splits : vec SplitLeg, +// minAmountOut : nat, Block : nat) -> (SwapResult) +pub type Args = (String, String, Vec, Nat, Nat); + +pub type Response = SwapResult; diff --git a/backend/external_canisters/taco_exchange/c2c_client/Cargo.toml b/backend/external_canisters/taco_exchange/c2c_client/Cargo.toml new file mode 100644 index 0000000000..1d637e51ba --- /dev/null +++ b/backend/external_canisters/taco_exchange/c2c_client/Cargo.toml @@ -0,0 +1,10 @@ +[package] +name = "taco_exchange_canister_c2c_client" +version.workspace = true +edition.workspace = true + +[dependencies] +candid = { workspace = true } +canister_client = { path = "../../../libraries/canister_client" } +taco_exchange_canister = { path = "../api" } +types = { path = "../../../libraries/types" } diff --git a/backend/external_canisters/taco_exchange/c2c_client/src/lib.rs b/backend/external_canisters/taco_exchange/c2c_client/src/lib.rs new file mode 100644 index 0000000000..84ce7381e0 --- /dev/null +++ b/backend/external_canisters/taco_exchange/c2c_client/src/lib.rs @@ -0,0 +1,66 @@ +use taco_exchange_canister::*; +use types::{C2CError, CanisterId}; + +// Candid uses positional args but a single return value, so the standard +// `generate_candid_c2c_call_tuple_args!` macro (which uses `decode_args`) +// doesn't fit. We pair `encode_args` with `decode_one` directly. + +pub async fn get_expected_receive_amount_batch_multi( + canister_id: CanisterId, + args: get_expected_receive_amount_batch_multi::Args, +) -> Result { + canister_client::make_c2c_call( + canister_id, + "getExpectedReceiveAmountBatchMulti", + args, + ::candid::encode_args, + |r| ::candid::decode_one(r), + None, + ) + .await +} + +pub async fn get_expected_receive_amount_batch_multi_optimal( + canister_id: CanisterId, + args: get_expected_receive_amount_batch_multi_optimal::Args, +) -> Result { + canister_client::make_c2c_call( + canister_id, + "getExpectedReceiveAmountBatchMultiOptimal", + args, + ::candid::encode_args, + |r| ::candid::decode_one(r), + None, + ) + .await +} + +pub async fn swap_multi_hop( + canister_id: CanisterId, + args: swap_multi_hop::Args, +) -> Result { + canister_client::make_c2c_call( + canister_id, + "swapMultiHop", + args, + ::candid::encode_args, + |r| ::candid::decode_one(r), + None, + ) + .await +} + +pub async fn swap_split_routes( + canister_id: CanisterId, + args: swap_split_routes::Args, +) -> Result { + canister_client::make_c2c_call( + canister_id, + "swapSplitRoutes", + args, + ::candid::encode_args, + |r| ::candid::decode_one(r), + None, + ) + .await +} diff --git a/backend/libraries/types/src/exchange_id.rs b/backend/libraries/types/src/exchange_id.rs index f717b0f066..c667942126 100644 --- a/backend/libraries/types/src/exchange_id.rs +++ b/backend/libraries/types/src/exchange_id.rs @@ -7,12 +7,14 @@ use ts_export::ts_export; #[derive(CandidType, Serialize, Deserialize, Clone, Copy, Debug, Eq, PartialEq, Hash)] pub enum ExchangeId { ICPSwap, + Taco, } impl Display for ExchangeId { fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { match self { ExchangeId::ICPSwap => f.write_str("ICPSwap"), + ExchangeId::Taco => f.write_str("Taco"), } } } diff --git a/frontend/app/src/components/home/profile/SwapCrypto.svelte b/frontend/app/src/components/home/profile/SwapCrypto.svelte index 247926d09a..a68a9ed424 100644 --- a/frontend/app/src/components/home/profile/SwapCrypto.svelte +++ b/frontend/app/src/components/home/profile/SwapCrypto.svelte @@ -184,6 +184,8 @@ switch (dex) { case "icpswap": return "ICPSwap"; + case "taco": + return "TACO"; } } diff --git a/frontend/app/src/components_mobile/home/wallet/SwapCrypto.svelte b/frontend/app/src/components_mobile/home/wallet/SwapCrypto.svelte index 6edc615f3f..bb3994cc46 100644 --- a/frontend/app/src/components_mobile/home/wallet/SwapCrypto.svelte +++ b/frontend/app/src/components_mobile/home/wallet/SwapCrypto.svelte @@ -191,8 +191,13 @@ }); } - function dexName(_dex: DexId): string { - return "ICPSwap"; + function dexName(dex: DexId): string { + switch (dex) { + case "icpswap": + return "ICPSwap"; + case "taco": + return "TACO"; + } } function loadSwaps(ledger: string) { diff --git a/frontend/openchat-agent/src/services/common/chatMappersV2.ts b/frontend/openchat-agent/src/services/common/chatMappersV2.ts index 6d0c6e21cc..20942ee4ab 100644 --- a/frontend/openchat-agent/src/services/common/chatMappersV2.ts +++ b/frontend/openchat-agent/src/services/common/chatMappersV2.ts @@ -2664,6 +2664,8 @@ export function apiDexId(dex: DexId): TExchangeId { switch (dex) { case "icpswap": return "ICPSwap"; + case "taco": + return "Taco"; default: throw new UnsupportedValueError("Unsupported dex", dex); } diff --git a/frontend/openchat-agent/src/services/dexes/index.ts b/frontend/openchat-agent/src/services/dexes/index.ts index f0978cbd85..2eb6b1453a 100644 --- a/frontend/openchat-agent/src/services/dexes/index.ts +++ b/frontend/openchat-agent/src/services/dexes/index.ts @@ -1,6 +1,7 @@ import { AnonymousIdentity, type HttpAgent, type Identity } from "@icp-sdk/core/agent"; import type { DexId, TokenSwapPool } from "openchat-shared"; import { IcpSwapIndexClient } from "./icpSwap/index/icpSwap.index.client"; +import { TacoIndexClient } from "./taco/index/taco.index.client"; const TEN_MINUTES = 10 * 60 * 1000; @@ -13,6 +14,7 @@ export class DexesAgent { this._identity = new AnonymousIdentity(); this._swapIndexClients = { icpswap: new IcpSwapIndexClient(this._identity, this.agent), + taco: new TacoIndexClient(this._identity, this.agent), }; } diff --git a/frontend/openchat-agent/src/services/dexes/taco/index/candid/can.did b/frontend/openchat-agent/src/services/dexes/taco/index/candid/can.did new file mode 100644 index 0000000000..2301278038 --- /dev/null +++ b/frontend/openchat-agent/src/services/dexes/taco/index/candid/can.did @@ -0,0 +1,17 @@ +// Trimmed candid for the TACO exchange — only the slice OpenChat queries to +// list pools. Full canister exposed at: +// https://dashboard.internetcomputer.org/canister/qioex-5iaaa-aaaan-q52ba-cai + +type AMMPool = record { + token0: text; + token1: text; + reserve0: nat; + reserve1: nat; + price0: float64; + price1: float64; + totalLiquidity: nat; +}; + +service : { + getAllAMMPools: () -> (vec AMMPool) query; +} diff --git a/frontend/openchat-agent/src/services/dexes/taco/index/candid/idl.d.ts b/frontend/openchat-agent/src/services/dexes/taco/index/candid/idl.d.ts new file mode 100644 index 0000000000..39c842e9be --- /dev/null +++ b/frontend/openchat-agent/src/services/dexes/taco/index/candid/idl.d.ts @@ -0,0 +1,8 @@ +import type { IDL } from "@icp-sdk/core/candid"; +import { GetAllAMMPoolsResponse, _SERVICE } from "./types"; +export { + GetAllAMMPoolsResponse as ApiGetAllAMMPoolsResponse, + _SERVICE as TacoExchangeIndexService, +}; + +export const idlFactory: IDL.InterfaceFactory; diff --git a/frontend/openchat-agent/src/services/dexes/taco/index/candid/idl.js b/frontend/openchat-agent/src/services/dexes/taco/index/candid/idl.js new file mode 100644 index 0000000000..47af9ecf0a --- /dev/null +++ b/frontend/openchat-agent/src/services/dexes/taco/index/candid/idl.js @@ -0,0 +1,15 @@ +export const idlFactory = ({ IDL }) => { + const AMMPool = IDL.Record({ + 'token0' : IDL.Text, + 'token1' : IDL.Text, + 'reserve0' : IDL.Nat, + 'reserve1' : IDL.Nat, + 'price0' : IDL.Float64, + 'price1' : IDL.Float64, + 'totalLiquidity' : IDL.Nat, + }); + return IDL.Service({ + 'getAllAMMPools' : IDL.Func([], [IDL.Vec(AMMPool)], ['query']), + }); +}; +export const init = ({ IDL }) => { return []; }; diff --git a/frontend/openchat-agent/src/services/dexes/taco/index/candid/types.d.ts b/frontend/openchat-agent/src/services/dexes/taco/index/candid/types.d.ts new file mode 100644 index 0000000000..5fad51b0c4 --- /dev/null +++ b/frontend/openchat-agent/src/services/dexes/taco/index/candid/types.d.ts @@ -0,0 +1,18 @@ +import type { ActorMethod } from '@icp-sdk/core/agent'; +import type { IDL } from '@icp-sdk/core/candid'; + +export interface AMMPool { + 'token0' : string, + 'token1' : string, + 'reserve0' : bigint, + 'reserve1' : bigint, + 'price0' : number, + 'price1' : number, + 'totalLiquidity' : bigint, +} +export type GetAllAMMPoolsResponse = Array; +export interface _SERVICE { + 'getAllAMMPools' : ActorMethod<[], GetAllAMMPoolsResponse>, +} +export declare const idlFactory: IDL.InterfaceFactory; +export declare const init: (args: { IDL: typeof IDL }) => IDL.Type[]; diff --git a/frontend/openchat-agent/src/services/dexes/taco/index/mappers.ts b/frontend/openchat-agent/src/services/dexes/taco/index/mappers.ts new file mode 100644 index 0000000000..545891778d --- /dev/null +++ b/frontend/openchat-agent/src/services/dexes/taco/index/mappers.ts @@ -0,0 +1,16 @@ +import type { ApiGetAllAMMPoolsResponse } from "./candid/idl"; +import type { TokenSwapPool } from "openchat-shared"; + +// TACO is a single exchange canister that internally holds many AMM pools. +// Every TokenSwapPool we emit shares the same canisterId (the exchange) — OC's +// pool registry keys by (dex, canisterId, token0, token1) so this is fine. +export const TACO_EXCHANGE_CANISTER_ID = "qioex-5iaaa-aaaan-q52ba-cai"; + +export function getAllAMMPoolsResponse(candid: ApiGetAllAMMPoolsResponse): TokenSwapPool[] { + return candid.map((p) => ({ + dex: "taco", + canisterId: TACO_EXCHANGE_CANISTER_ID, + token0: p.token0, + token1: p.token1, + })); +} diff --git a/frontend/openchat-agent/src/services/dexes/taco/index/taco.index.client.ts b/frontend/openchat-agent/src/services/dexes/taco/index/taco.index.client.ts new file mode 100644 index 0000000000..c4351c1599 --- /dev/null +++ b/frontend/openchat-agent/src/services/dexes/taco/index/taco.index.client.ts @@ -0,0 +1,26 @@ +import type { HttpAgent, Identity } from "@icp-sdk/core/agent"; +import { idlFactory, type TacoExchangeIndexService } from "./candid/idl"; +import { CandidCanisterAgent } from "../../../canisterAgent/candid"; +import type { TokenSwapPool } from "openchat-shared"; +import { getAllAMMPoolsResponse, TACO_EXCHANGE_CANISTER_ID } from "./mappers"; +import type { SwapIndexClient, SwapPoolClient } from "../../index"; +import { TacoPoolClient } from "../pool/taco.pool.client"; + +export class TacoIndexClient + extends CandidCanisterAgent + implements SwapIndexClient +{ + constructor(identity: Identity, agent: HttpAgent) { + super(identity, agent, TACO_EXCHANGE_CANISTER_ID, idlFactory, "TacoExchangeIndex"); + } + + // TACO has only one exchange canister, so the canisterId argument is + // ignored — every pool lives inside qioex-…-cai. + getPoolClient(_canisterId: string, token0: string, token1: string): SwapPoolClient { + return new TacoPoolClient(this.identity, this.agent, token0, token1); + } + + getPools(): Promise { + return this.handleQueryResponse(this.service.getAllAMMPools, getAllAMMPoolsResponse); + } +} diff --git a/frontend/openchat-agent/src/services/dexes/taco/pool/candid/can.did b/frontend/openchat-agent/src/services/dexes/taco/pool/candid/can.did new file mode 100644 index 0000000000..b120ae3519 --- /dev/null +++ b/frontend/openchat-agent/src/services/dexes/taco/pool/candid/can.did @@ -0,0 +1,30 @@ +// Trimmed candid for the TACO exchange — only the slice OpenChat queries for +// per-pool quotes. Full canister exposed at: +// https://dashboard.internetcomputer.org/canister/qioex-5iaaa-aaaan-q52ba-cai +// +// OC uses getExpectedReceiveAmountBatchMultiOptimal — TACO runs the 10-fraction +// probe grid AND the 2/3-leg split-route optimizer internally and returns the +// chosen plan. One source of truth on the canister side. + +type SwapHop = record { tokenIn : text; tokenOut : text }; + +type OptimalSwapLeg = record { + bp : nat; + expectedBuyAmount : nat; + route : vec SwapHop; + routeDescription : text; +}; + +type OptimalSwapPlan = record { + expectedBuyAmount : nat; + fee : nat; + priceImpact : float64; + canFulfillFully : bool; + tradingFeeBps : nat; + routeDescription : text; + legs : vec OptimalSwapLeg; +}; + +service : { + getExpectedReceiveAmountBatchMultiOptimal: (text, text, nat) -> (OptimalSwapPlan) query; +} diff --git a/frontend/openchat-agent/src/services/dexes/taco/pool/candid/idl.d.ts b/frontend/openchat-agent/src/services/dexes/taco/pool/candid/idl.d.ts new file mode 100644 index 0000000000..3fa9f9e175 --- /dev/null +++ b/frontend/openchat-agent/src/services/dexes/taco/pool/candid/idl.d.ts @@ -0,0 +1,8 @@ +import type { IDL } from "@icp-sdk/core/candid"; +import { OptimalSwapPlan, _SERVICE } from "./types"; +export { + OptimalSwapPlan as ApiOptimalSwapPlan, + _SERVICE as TacoExchangePoolService, +}; + +export const idlFactory: IDL.InterfaceFactory; diff --git a/frontend/openchat-agent/src/services/dexes/taco/pool/candid/idl.js b/frontend/openchat-agent/src/services/dexes/taco/pool/candid/idl.js new file mode 100644 index 0000000000..f0be22c415 --- /dev/null +++ b/frontend/openchat-agent/src/services/dexes/taco/pool/candid/idl.js @@ -0,0 +1,26 @@ +export const idlFactory = ({ IDL }) => { + const SwapHop = IDL.Record({ 'tokenIn' : IDL.Text, 'tokenOut' : IDL.Text }); + const OptimalSwapLeg = IDL.Record({ + 'bp' : IDL.Nat, + 'expectedBuyAmount' : IDL.Nat, + 'route' : IDL.Vec(SwapHop), + 'routeDescription' : IDL.Text, + }); + const OptimalSwapPlan = IDL.Record({ + 'expectedBuyAmount' : IDL.Nat, + 'fee' : IDL.Nat, + 'priceImpact' : IDL.Float64, + 'canFulfillFully' : IDL.Bool, + 'tradingFeeBps' : IDL.Nat, + 'routeDescription' : IDL.Text, + 'legs' : IDL.Vec(OptimalSwapLeg), + }); + return IDL.Service({ + 'getExpectedReceiveAmountBatchMultiOptimal' : IDL.Func( + [IDL.Text, IDL.Text, IDL.Nat], + [OptimalSwapPlan], + ['query'], + ), + }); +}; +export const init = ({ IDL }) => { return []; }; diff --git a/frontend/openchat-agent/src/services/dexes/taco/pool/candid/types.d.ts b/frontend/openchat-agent/src/services/dexes/taco/pool/candid/types.d.ts new file mode 100644 index 0000000000..fc438e69e8 --- /dev/null +++ b/frontend/openchat-agent/src/services/dexes/taco/pool/candid/types.d.ts @@ -0,0 +1,30 @@ +import type { ActorMethod } from '@icp-sdk/core/agent'; +import type { IDL } from '@icp-sdk/core/candid'; + +export interface SwapHop { + 'tokenIn' : string, + 'tokenOut' : string, +} +export interface OptimalSwapLeg { + 'bp' : bigint, + 'expectedBuyAmount' : bigint, + 'route' : Array, + 'routeDescription' : string, +} +export interface OptimalSwapPlan { + 'expectedBuyAmount' : bigint, + 'fee' : bigint, + 'priceImpact' : number, + 'canFulfillFully' : boolean, + 'tradingFeeBps' : bigint, + 'routeDescription' : string, + 'legs' : Array, +} +export interface _SERVICE { + 'getExpectedReceiveAmountBatchMultiOptimal' : ActorMethod< + [string, string, bigint], + OptimalSwapPlan + >, +} +export declare const idlFactory: IDL.InterfaceFactory; +export declare const init: (args: { IDL: typeof IDL }) => IDL.Type[]; diff --git a/frontend/openchat-agent/src/services/dexes/taco/pool/mappers.ts b/frontend/openchat-agent/src/services/dexes/taco/pool/mappers.ts new file mode 100644 index 0000000000..35f6d8dd66 --- /dev/null +++ b/frontend/openchat-agent/src/services/dexes/taco/pool/mappers.ts @@ -0,0 +1,7 @@ +import type { ApiOptimalSwapPlan } from "./candid/idl"; + +// TACO returns the chosen plan with the optimizer already applied. For OC's +// quote display we only need the headline expected output. +export function optimalQuoteResponse(candid: ApiOptimalSwapPlan): bigint { + return candid.expectedBuyAmount; +} diff --git a/frontend/openchat-agent/src/services/dexes/taco/pool/taco.pool.client.ts b/frontend/openchat-agent/src/services/dexes/taco/pool/taco.pool.client.ts new file mode 100644 index 0000000000..f2d00233c2 --- /dev/null +++ b/frontend/openchat-agent/src/services/dexes/taco/pool/taco.pool.client.ts @@ -0,0 +1,31 @@ +import type { HttpAgent, Identity } from "@icp-sdk/core/agent"; +import { idlFactory, type TacoExchangePoolService } from "./candid/idl"; +import { CandidCanisterAgent } from "../../../canisterAgent/candid"; +import { optimalQuoteResponse } from "./mappers"; +import type { SwapPoolClient } from "../../index"; +import { TACO_EXCHANGE_CANISTER_ID } from "../index/mappers"; + +export class TacoPoolClient + extends CandidCanisterAgent + implements SwapPoolClient +{ + constructor(identity: Identity, agent: HttpAgent, _token0: string, _token1: string) { + // TACO routes internally; the pool client doesn't need the token + // ordering. Constructor signature stays symmetric with ICPSwap's so + // SwapIndexClient.getPoolClient remains polymorphic. + super(identity, agent, TACO_EXCHANGE_CANISTER_ID, idlFactory, "TacoExchangePool"); + } + + quote(inputToken: string, outputToken: string, amountIn: bigint): Promise { + // Single canister call: TACO runs the BatchMulti probe grid AND the + // split-route optimizer internally and returns just the optimal output. + // No local optimizer needed — the canister is the source of truth. + if (amountIn === 0n) return Promise.resolve(0n); + const args: [string, string, bigint] = [inputToken, outputToken, amountIn]; + return this.handleQueryResponse( + () => this.service.getExpectedReceiveAmountBatchMultiOptimal(...args), + optimalQuoteResponse, + args, + ); + } +} diff --git a/frontend/openchat-agent/src/services/openchatAgent.ts b/frontend/openchat-agent/src/services/openchatAgent.ts index e61f5b51b5..19a37713fa 100644 --- a/frontend/openchat-agent/src/services/openchatAgent.ts +++ b/frontend/openchat-agent/src/services/openchatAgent.ts @@ -3500,11 +3500,25 @@ export class OpenChatAgent extends EventTarget { return Promise.reject("Cannot find a matching pool"); } - const exchangeArgs: ExchangeTokenSwapArgs = { - dex, - swapCanisterId: pool.canisterId, - zeroForOne: pool.token0 === inputTokenDetails.ledger, - }; + // TACO's checkReceive validates incoming ICRC1 deposits + // against this treasury canister, not the exchange canister + // itself. Hardcoded because every TacoIndexClient pool shares + // the same fixed treasury principal — the frontend doesn't + // derive it from the pool listing. + const TACO_TREASURY_CANISTER_ID = "qbnpl-laaaa-aaaan-q52aq-cai"; + + const exchangeArgs: ExchangeTokenSwapArgs = + dex === "taco" + ? { + dex: "taco", + swapCanisterId: pool.canisterId, + treasuryCanisterId: TACO_TREASURY_CANISTER_ID, + } + : { + dex, + swapCanisterId: pool.canisterId, + zeroForOne: pool.token0 === inputTokenDetails.ledger, + }; return this.userClient.swapTokens( swapId, diff --git a/frontend/openchat-agent/src/services/registry/mappers.ts b/frontend/openchat-agent/src/services/registry/mappers.ts index 3433bc1071..829b3de740 100644 --- a/frontend/openchat-agent/src/services/registry/mappers.ts +++ b/frontend/openchat-agent/src/services/registry/mappers.ts @@ -131,5 +131,6 @@ function nervousSystemSummary(value: RegistryNervousSystemSummary): NervousSyste function swapProvider(value: TExchangeId): DexId { if (value === "ICPSwap") return "icpswap"; + if (value === "Taco") return "taco"; throw new UnsupportedValueError("Unexpected ApiSwapProvider type received", value); } diff --git a/frontend/openchat-agent/src/services/user/mappersV2.ts b/frontend/openchat-agent/src/services/user/mappersV2.ts index 8204f45534..9d556a47a3 100644 --- a/frontend/openchat-agent/src/services/user/mappersV2.ts +++ b/frontend/openchat-agent/src/services/user/mappersV2.ts @@ -1023,16 +1023,23 @@ function resultOfResult( } export function apiExchangeArgs(args: ExchangeTokenSwapArgs): UserSwapTokensExchangeArgs { - const value = { - swap_canister_id: principalStringToBytes(args.swapCanisterId), - zero_for_one: args.zeroForOne, - }; if (args.dex === "icpswap") { return { - ICPSwap: value, + ICPSwap: { + swap_canister_id: principalStringToBytes(args.swapCanisterId), + zero_for_one: args.zeroForOne, + }, + }; + } + if (args.dex === "taco") { + return { + Taco: { + swap_canister_id: principalStringToBytes(args.swapCanisterId), + treasury_canister_id: principalStringToBytes(args.treasuryCanisterId), + }, }; } - throw new UnsupportedValueError("Unexpected dex", args.dex); + throw new UnsupportedValueError("Unexpected dex", args); } export function claimDailyChitResponse(value: UserClaimDailyChitResponse): ClaimDailyChitResponse { diff --git a/frontend/openchat-agent/src/typebox.ts b/frontend/openchat-agent/src/typebox.ts index 9f88678f9f..caeea4de5c 100644 --- a/frontend/openchat-agent/src/typebox.ts +++ b/frontend/openchat-agent/src/typebox.ts @@ -510,7 +510,7 @@ export const CommunityRole = Type.Union([ ]); export type ExchangeId = Static; -export const ExchangeId = Type.Literal("ICPSwap"); +export const ExchangeId = Type.Union([Type.Literal("ICPSwap"), Type.Literal("Taco")]); export type ProposalDecisionStatus = Static; export const ProposalDecisionStatus = Type.Union([ @@ -6641,11 +6641,22 @@ export const UserSetProfileBackgroundArgs = Type.Object({ profile_background: Type.Optional(Document), }); -export type UserSwapTokensExchangeArgs = Static; -export const UserSwapTokensExchangeArgs = Type.Object({ - ICPSwap: UserSwapTokensExchangeSwapArgs, +export type UserSwapTokensTacoArgs = Static; +export const UserSwapTokensTacoArgs = Type.Object({ + swap_canister_id: TSPrincipal, + treasury_canister_id: TSPrincipal, }); +export type UserSwapTokensExchangeArgs = Static; +export const UserSwapTokensExchangeArgs = Type.Union([ + Type.Object({ + ICPSwap: UserSwapTokensExchangeSwapArgs, + }), + Type.Object({ + Taco: UserSwapTokensTacoArgs, + }), +]); + export type UserSwapTokensArgs = Static; export const UserSwapTokensArgs = Type.Object({ swap_id: Type.BigInt(), diff --git a/frontend/openchat-shared/src/domain/dexes/index.ts b/frontend/openchat-shared/src/domain/dexes/index.ts index 7f6b653912..22da1e9f97 100644 --- a/frontend/openchat-shared/src/domain/dexes/index.ts +++ b/frontend/openchat-shared/src/domain/dexes/index.ts @@ -1,4 +1,4 @@ -export type DexId = "icpswap"; +export type DexId = "icpswap" | "taco"; export type TokenSwapPool = { dex: DexId; @@ -7,8 +7,11 @@ export type TokenSwapPool = { token1: string; }; -export type ExchangeTokenSwapArgs = { - dex: DexId; - swapCanisterId: string; - zeroForOne: boolean; -}; +// ICPSwap takes a (swap_canister_id, zero_for_one) pair because each pool is a +// distinct canister with a fixed token0/token1 ordering. TACO routes through a +// single exchange canister that does its own multi-hop / split routing, so it +// needs the exchange canister id plus the separate treasury canister id that +// receives the user's ICRC1 deposit before each swap. +export type ExchangeTokenSwapArgs = + | { dex: "icpswap"; swapCanisterId: string; zeroForOne: boolean } + | { dex: "taco"; swapCanisterId: string; treasuryCanisterId: string };