From 22c896cba536b8237f13d8f26105a9e60187125f Mon Sep 17 00:00:00 2001 From: wilaq Date: Tue, 12 May 2026 03:07:00 +0200 Subject: [PATCH 01/13] Add TACO Exchange as a swap provider Adds a new SwapClient implementation backed by the TACO Exchange (qioex-5iaaa-aaaan-q52ba-cai), so user canisters can swap tokens through TACO in addition to ICPSwap. The TACO exchange is a hybrid orderbook + AMM (V2 constant-product + V3 concentrated liquidity) DEX. It verifies deposits by inspecting the ledger block of the prior transfer rather than tracking per-depositor balances internally, which required extending the SwapClient trait so swap() can receive the deposit block index. Changes: - types: add ExchangeId::Taco variant - user/api: add ExchangeArgs::Taco { swap_canister_id } - SwapClient::swap gains a Option deposit_block_index parameter; ICPSwap impl ignores it - new TacoExchangeClient impl: deposit_account returns the exchange's default account, deposit() is a no-op, swap() discovers a route via getExpectedMultiHopAmount and executes via swapMultiHop, withdraw() is a no-op (auto_withdrawals = true since TACO auto-pushes output) - new external_canisters/taco_exchange/api + c2c_client crates with positional-arg Candid bindings for the two TACO methods we call - swap_tokens.rs plumbs the block index from transfer_or_approval into the swap call and adds the Taco branch to build_swap_client --- Cargo.lock | 21 +++ Cargo.toml | 2 + .../user/api/src/updates/swap_tokens.rs | 9 ++ backend/canisters/user/impl/Cargo.toml | 2 + .../user/impl/src/token_swaps/icpswap.rs | 7 +- .../user/impl/src/token_swaps/mod.rs | 1 + .../user/impl/src/token_swaps/swap_client.rs | 7 +- .../user/impl/src/token_swaps/taco.rs | 142 ++++++++++++++++++ .../user/impl/src/updates/swap_tokens.rs | 9 +- .../taco_exchange/api/Cargo.toml | 9 ++ .../taco_exchange/api/src/lib.rs | 87 +++++++++++ .../queries/get_expected_multi_hop_amount.rs | 24 +++ .../taco_exchange/api/src/queries/mod.rs | 1 + .../taco_exchange/api/src/updates/mod.rs | 1 + .../api/src/updates/swap_multi_hop.rs | 9 ++ .../taco_exchange/c2c_client/Cargo.toml | 10 ++ .../taco_exchange/c2c_client/src/lib.rs | 36 +++++ backend/libraries/types/src/exchange_id.rs | 2 + 18 files changed, 376 insertions(+), 3 deletions(-) create mode 100644 backend/canisters/user/impl/src/token_swaps/taco.rs create mode 100644 backend/external_canisters/taco_exchange/api/Cargo.toml create mode 100644 backend/external_canisters/taco_exchange/api/src/lib.rs create mode 100644 backend/external_canisters/taco_exchange/api/src/queries/get_expected_multi_hop_amount.rs create mode 100644 backend/external_canisters/taco_exchange/api/src/queries/mod.rs create mode 100644 backend/external_canisters/taco_exchange/api/src/updates/mod.rs create mode 100644 backend/external_canisters/taco_exchange/api/src/updates/swap_multi_hop.rs create mode 100644 backend/external_canisters/taco_exchange/c2c_client/Cargo.toml create mode 100644 backend/external_canisters/taco_exchange/c2c_client/src/lib.rs diff --git a/Cargo.lock b/Cargo.lock index 1027c6a606..743f8a25ef 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..f270319393 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,12 @@ pub struct ExchangeSwapArgs { pub type ICPSwapArgs = ExchangeSwapArgs; +#[ts_export(user, swap_tokens)] +#[derive(Serialize, Deserialize, Clone, Debug)] +pub struct TacoArgs { + pub swap_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..af03432537 --- /dev/null +++ b/backend/canisters/user/impl/src/token_swaps/taco.rs @@ -0,0 +1,142 @@ +use super::swap_client::{SwapClient, SwapSuccess}; +use crate::token_swaps::nat_to_u128; +use async_trait::async_trait; +use serde::{Deserialize, Serialize}; +use taco_exchange_canister::{SwapHop, SwapResult}; +use types::icrc1::Account; +use types::{C2CError, CanisterId, TokenInfo}; + +// TACO charges its trading fee on top of the bare swap amount (per-bp of input). +// On the canister side, `checkReceive` requires: +// transferred >= amountIn * (10000 + tradingFeeBps) / 10000 + transferFee +// The current rate is 5 bps; if it changes we'll need to update this constant +// (the TACO treasury trader hardcodes the same value at +// TACO_Backend/src/swap/taco_swap.mo:403). +const TACO_TRADING_FEE_BPS: u128 = 5; + +#[derive(Serialize, Deserialize)] +pub struct TacoExchangeClient { + swap_canister_id: CanisterId, + input_token: TokenInfo, + output_token: TokenInfo, +} + +impl TacoExchangeClient { + pub fn new(swap_canister_id: CanisterId, input_token: TokenInfo, output_token: TokenInfo) -> Self { + TacoExchangeClient { + swap_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 swapMultiHop, so OC's separate withdraw step is a no-op. + true + } + + async fn deposit_account(&self) -> Result { + // TACO verifies deposits by inspecting the ledger block; the recipient + // is the exchange canister's default account. + Ok(Account { + owner: self.swap_canister_id, + subaccount: None, + }) + } + + async fn deposit(&self, amount: u128) -> Result { + // No-op for TACO: block-based verification happens inside swapMultiHop. + 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's framework passes us `amount = amount_transferred - input_token.fee`. + // Reconstruct the amount the ledger block actually records being moved. + let transferred = amount.saturating_add(self.input_token.fee); + + // Solve for the largest bare swap amount that fits within `transferred`, + // given TACO will require `amountIn * (10000 + fee_bps) / 10000 + Tfee`. + // We assume TACO's per-token transfer fee equals the ledger transfer 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 amount_in = usable.saturating_mul(10000) / (10000 + TACO_TRADING_FEE_BPS); + if amount_in == 0 { + return Ok(Err("TACO swap: deposit too small to cover trading fee".to_string())); + } + + let token_in = self.input_token.ledger.to_string(); + let token_out = self.output_token.ledger.to_string(); + + let quote = taco_exchange_canister_c2c_client::get_expected_multi_hop_amount( + self.swap_canister_id, + (token_in.clone(), token_out.clone(), amount_in.into()), + ) + .await?; + + if quote.best_route.is_empty() { + return Ok(Err(format!( + "TACO swap: no route from {} to {}", + token_in, token_out + ))); + } + + let route: Vec = quote + .best_route + .into_iter() + .map(|h| SwapHop { + token_in: h.token_in, + token_out: h.token_out, + }) + .collect(); + + let response = taco_exchange_canister_c2c_client::swap_multi_hop( + self.swap_canister_id, + ( + token_in, + token_out, + amount_in.into(), + route, + min_amount_out.into(), + block_index.into(), + ), + ) + .await?; + + match response { + SwapResult::Ok(ok) => Ok(Ok(SwapSuccess { + amount_out: nat_to_u128(ok.amount_out), + // TACO has already pushed the output back to the caller, so OC + // should consider the withdraw step complete. + withdrawal_success: Some(true), + })), + SwapResult::Err(error) => Ok(Err(format!("{error:?}"))), + } + } + + async fn withdraw(&self, _successful_swap: bool, amount: u128) -> Result { + // auto_withdrawals() == true means swap_tokens.rs skips this call, but + // we implement it as a no-op for completeness. + Ok(amount) + } +} diff --git a/backend/canisters/user/impl/src/updates/swap_tokens.rs b/backend/canisters/user/impl/src/updates/swap_tokens.rs index 3e3f4a715d..820eb67f1a 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,7 @@ fn build_swap_client(args: &Args, state: &RuntimeState) -> Box { icpswap.zero_for_one, )) } + ExchangeArgs::Taco(taco) => Box::new(TacoExchangeClient::new(taco.swap_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..31921dadf8 --- /dev/null +++ b/backend/external_canisters/taco_exchange/api/src/lib.rs @@ -0,0 +1,87 @@ +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 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_multi_hop_amount.rs b/backend/external_canisters/taco_exchange/api/src/queries/get_expected_multi_hop_amount.rs new file mode 100644 index 0000000000..cf65719a87 --- /dev/null +++ b/backend/external_canisters/taco_exchange/api/src/queries/get_expected_multi_hop_amount.rs @@ -0,0 +1,24 @@ +use crate::{HopDetail, SwapHop}; +use candid::{CandidType, Nat}; +use serde::{Deserialize, Serialize}; + +// Candid signature is positional: +// getExpectedMultiHopAmount: (tokenIn: text, tokenOut: text, amountIn: nat) -> (Response) +pub type Args = (String, String, Nat); + +#[derive(CandidType, Serialize, Deserialize, Clone, Debug)] +pub struct Response { + #[serde(rename = "bestRoute")] + pub best_route: Vec, + #[serde(rename = "expectedAmountOut")] + pub expected_amount_out: Nat, + #[serde(rename = "hopDetails")] + pub hop_details: Vec, + pub hops: Nat, + #[serde(rename = "priceImpact")] + pub price_impact: f64, + #[serde(rename = "routeTokens")] + pub route_tokens: Vec, + #[serde(rename = "totalFee")] + pub total_fee: Nat, +} 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..aa7c3a3947 --- /dev/null +++ b/backend/external_canisters/taco_exchange/api/src/queries/mod.rs @@ -0,0 +1 @@ +pub mod get_expected_multi_hop_amount; 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..68a2ab3a77 --- /dev/null +++ b/backend/external_canisters/taco_exchange/api/src/updates/mod.rs @@ -0,0 +1 @@ +pub mod swap_multi_hop; 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/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..d4c9a68410 --- /dev/null +++ b/backend/external_canisters/taco_exchange/c2c_client/src/lib.rs @@ -0,0 +1,36 @@ +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_multi_hop_amount( + canister_id: CanisterId, + args: get_expected_multi_hop_amount::Args, +) -> Result { + canister_client::make_c2c_call( + canister_id, + "getExpectedMultiHopAmount", + 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 +} 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"), } } } From fc3568ea02145b4994a5c2fe3e92896e3896127b Mon Sep 17 00:00:00 2001 From: wilaq Date: Tue, 12 May 2026 04:42:12 +0200 Subject: [PATCH 02/13] Use getExpectedReceiveAmountBatchMulti and add swap_split_routes path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the previous single-route swap_multi_hop client with a single inter-canister call to getExpectedReceiveAmountBatchMulti that drives both single-route and split-route execution, mirroring the TACO treasury trader's pattern. What changed: - api crate: - DELETED queries/get_expected_multi_hop_amount.rs (no longer used) - NEW queries/get_expected_receive_amount_batch_multi.rs with the QuoteRoute response shape including the new tradingFeeBps field surfaced by the exchange - NEW updates/swap_split_routes.rs (positional Candid args) - SplitLeg type added to lib.rs - c2c_client: swapped out get_expected_multi_hop_amount for get_expected_receive_amount_batch_multi, added swap_split_routes - TacoExchangeClient::swap rewritten: - probes 10 fractions (10%..100%) of the usable deposit, top-5 routes per fraction, in one query call - reads tradingFeeBps live from the response (replaces the previously hardcoded 5 bps constant) - greedy disjoint-pool selection over distinct routes, capped at 3 legs, verbatim port of hopsSharePool from the TACO treasury - if ≥2 disjoint routes survive, executes via swap_split_routes with equal-split amounts (remainder in the last leg) and pro-rata minLegOut; else executes via swap_multi_hop with the single route --- .../user/impl/src/token_swaps/taco.rs | 305 ++++++++++++++---- .../taco_exchange/api/src/lib.rs | 9 + .../queries/get_expected_multi_hop_amount.rs | 24 -- ...get_expected_receive_amount_batch_multi.rs | 56 ++++ .../taco_exchange/api/src/queries/mod.rs | 2 +- .../taco_exchange/api/src/updates/mod.rs | 1 + .../api/src/updates/swap_split_routes.rs | 10 + .../taco_exchange/c2c_client/src/lib.rs | 23 +- 8 files changed, 344 insertions(+), 86 deletions(-) delete mode 100644 backend/external_canisters/taco_exchange/api/src/queries/get_expected_multi_hop_amount.rs create mode 100644 backend/external_canisters/taco_exchange/api/src/queries/get_expected_receive_amount_batch_multi.rs create mode 100644 backend/external_canisters/taco_exchange/api/src/updates/swap_split_routes.rs diff --git a/backend/canisters/user/impl/src/token_swaps/taco.rs b/backend/canisters/user/impl/src/token_swaps/taco.rs index af03432537..bf06b7f1a0 100644 --- a/backend/canisters/user/impl/src/token_swaps/taco.rs +++ b/backend/canisters/user/impl/src/token_swaps/taco.rs @@ -1,18 +1,19 @@ 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::{SwapHop, SwapResult}; +use taco_exchange_canister::get_expected_receive_amount_batch_multi as batch_multi; +use taco_exchange_canister::{SplitLeg, SwapHop, SwapResult}; use types::icrc1::Account; use types::{C2CError, CanisterId, TokenInfo}; -// TACO charges its trading fee on top of the bare swap amount (per-bp of input). -// On the canister side, `checkReceive` requires: -// transferred >= amountIn * (10000 + tradingFeeBps) / 10000 + transferFee -// The current rate is 5 bps; if it changes we'll need to update this constant -// (the TACO treasury trader hardcodes the same value at -// TACO_Backend/src/swap/taco_swap.mo:403). -const TACO_TRADING_FEE_BPS: u128 = 5; +// 10-fraction probe grid (10%, 20%, ..., 100%). Mirrors the treasury trader's +// scenario builder at TACO_Backend/src/treasury/treasury.mo:10646 — top-5 routes +// per fraction, single inter-canister call. +const PROBE_FRACTIONS_BP: [u128; 10] = [1000, 2000, 3000, 4000, 5000, 6000, 7000, 8000, 9000, 10000]; +const TOP_ROUTES_PER_FRACTION: u128 = 5; +const MAX_LEGS: usize = 3; #[derive(Serialize, Deserialize)] pub struct TacoExchangeClient { @@ -38,8 +39,9 @@ impl SwapClient for TacoExchangeClient { } fn auto_withdrawals(&self) -> bool { - // TACO pushes the swap output back to the caller automatically as - // part of swapMultiHop, so OC's separate withdraw step is a no-op. + // TACO pushes the swap output back to the caller automatically as part + // of swapMultiHop / swapSplitRoutes, so OC's separate withdraw step is + // a no-op. true } @@ -53,7 +55,7 @@ impl SwapClient for TacoExchangeClient { } async fn deposit(&self, amount: u128) -> Result { - // No-op for TACO: block-based verification happens inside swapMultiHop. + // No-op for TACO: block-based verification happens inside the swap call. Ok(amount) } @@ -68,75 +70,264 @@ impl SwapClient for TacoExchangeClient { None => return Ok(Err("TACO swap requires a deposit block index".to_string())), }; - // OC's framework passes us `amount = amount_transferred - input_token.fee`. - // Reconstruct the amount the ledger block actually records being moved. + // 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); - - // Solve for the largest bare swap amount that fits within `transferred`, - // given TACO will require `amountIn * (10000 + fee_bps) / 10000 + Tfee`. - // We assume TACO's per-token transfer fee equals the ledger transfer 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 amount_in = usable.saturating_mul(10000) / (10000 + TACO_TRADING_FEE_BPS); - if amount_in == 0 { - return Ok(Err("TACO swap: deposit too small to cover trading fee".to_string())); - } let token_in = self.input_token.ledger.to_string(); let token_out = self.output_token.ledger.to_string(); - let quote = taco_exchange_canister_c2c_client::get_expected_multi_hop_amount( + // Build the 10-fraction × top-5 grid request. We probe at `usable * bp / 10000` + // (a pre-fee estimate good enough for route ranking; exact bps comes back + // in the response). + let probes: Vec = PROBE_FRACTIONS_BP + .iter() + .filter_map(|bp| { + let amt = usable.saturating_mul(*bp) / 10000; + if amt == 0 { + None + } else { + Some(batch_multi::Request { + token_sell: token_in.clone(), + token_buy: token_out.clone(), + amount_sell: amt.into(), + }) + } + }) + .collect(); + + if probes.is_empty() { + return Ok(Err("TACO swap: amount too small for any probe fraction".to_string())); + } + + let batch = taco_exchange_canister_c2c_client::get_expected_receive_amount_batch_multi( self.swap_canister_id, - (token_in.clone(), token_out.clone(), amount_in.into()), + (probes, Nat::from(TOP_ROUTES_PER_FRACTION)), ) .await?; - if quote.best_route.is_empty() { - return Ok(Err(format!( - "TACO swap: no route from {} to {}", - token_in, token_out - ))); + // Pull the live trading fee bps from any route (every route in the response + // carries the same ICPfee snapshot). + let fee_bps = match extract_trading_fee_bps(&batch) { + Some(bps) => bps, + None => return Ok(Err("TACO swap: no routes returned for any fraction".to_string())), + }; + // Defensive clamp against the canister's enforced range [1, 50] bps. + let fee_bps_clamped = fee_bps.clamp(1, 50); + + // Compute the true amount_in such that the recorded deposit covers + // amount_in * (10000 + fee_bps) / 10000 + tfee. + let amount_in_total = usable.saturating_mul(10000) / (10000 + fee_bps_clamped); + if amount_in_total == 0 { + return Ok(Err("TACO swap: deposit too small to cover trading fee".to_string())); } - let route: Vec = quote - .best_route - .into_iter() - .map(|h| SwapHop { - token_in: h.token_in, - token_out: h.token_out, - }) - .collect(); + // Greedy disjoint-pool selection (mirrors hopsSharePool at + // TACO_Backend/src/treasury/treasury.mo:10895, max 3 legs). + let selected = build_split_plan(&batch); + if selected.is_empty() { + return Ok(Err("TACO swap: no usable routes after disjoint-pool filtering".to_string())); + } - let response = taco_exchange_canister_c2c_client::swap_multi_hop( - self.swap_canister_id, - ( + if selected.len() == 1 { + execute_swap_multi_hop( + self.swap_canister_id, token_in, token_out, - amount_in.into(), - route, - min_amount_out.into(), - block_index.into(), - ), - ) - .await?; - - match response { - SwapResult::Ok(ok) => Ok(Ok(SwapSuccess { - amount_out: nat_to_u128(ok.amount_out), - // TACO has already pushed the output back to the caller, so OC - // should consider the withdraw step complete. - withdrawal_success: Some(true), - })), - SwapResult::Err(error) => Ok(Err(format!("{error:?}"))), + amount_in_total, + selected.into_iter().next().unwrap(), + min_amount_out, + block_index, + ) + .await + } else { + let legs = make_split_legs(&selected, amount_in_total, min_amount_out); + execute_swap_split_routes( + self.swap_canister_id, + token_in, + token_out, + 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, but - // we implement it as a no-op for completeness. + // auto_withdrawals() == true means swap_tokens.rs skips this call. Ok(amount) } } + +// ── helpers ───────────────────────────────────────────────────────────────── + +fn extract_trading_fee_bps(batch: &batch_multi::Response) -> Option { + batch + .iter() + .flat_map(|req| req.routes.iter()) + .next() + .map(|r| nat_to_u128(r.trading_fee_bps.clone())) +} + +// Bidirectional pool-edge overlap check — verbatim port of `hopsSharePool` at +// TACO_Backend/src/treasury/treasury.mo:10895. Returns true if any hop in `a` +// shares a pool with any hop in `b` (a pool is identified by its unordered +// token pair, so {A→B} and {B→A} are the same pool). +fn routes_share_pool_edge(a: &[SwapHop], b: &[SwapHop]) -> bool { + for ha in a { + for hb in b { + if (ha.token_in == hb.token_in && ha.token_out == hb.token_out) + || (ha.token_in == hb.token_out && ha.token_out == hb.token_in) + { + return true; + } + } + } + false +} + +// Walk all routes returned by BatchMulti (across all fractions), dedupe by route +// token path, then greedily keep routes that don't share any pool edge with an +// already-kept route. Stop at MAX_LEGS. Order follows discovery (matches +// treasury's iteration over `tacoDistinctRoutes`). +fn build_split_plan(batch: &batch_multi::Response) -> Vec> { + let mut seen_route_keys: std::collections::HashSet = std::collections::HashSet::new(); + let mut kept: Vec> = Vec::new(); + + for req in batch { + for route in &req.routes { + if route.expected_buy_amount == Nat::from(0u32) { + continue; + } + let hops: Vec = route + .hop_details + .iter() + .map(|h| SwapHop { + token_in: h.token_in.clone(), + token_out: h.token_out.clone(), + }) + .collect(); + // For direct routes the canister returns hopDetails = [] AND + // routeTokens = [tokenSell, tokenBuy]; synthesize a single hop. + let hops = if hops.is_empty() && route.route_tokens.len() == 2 { + vec![SwapHop { + token_in: route.route_tokens[0].clone(), + token_out: route.route_tokens[1].clone(), + }] + } else { + hops + }; + if hops.is_empty() { + continue; + } + let key = route.route_tokens.join("→"); + if !seen_route_keys.insert(key) { + continue; + } + if kept.iter().any(|k| routes_share_pool_edge(k, &hops)) { + continue; + } + kept.push(hops); + if kept.len() >= MAX_LEGS { + return kept; + } + } + } + + kept +} + +// Build [SplitLeg] from selected routes: equal-split with remainder in the last +// leg, pro-rata `minLegOut`. Mirrors treasury.mo:11851. +fn make_split_legs(routes: &[Vec], total: u128, min_out_total: u128) -> Vec { + let num_legs = routes.len(); + let per_leg = total / num_legs as u128; + routes + .iter() + .enumerate() + .map(|(i, route)| { + let amount_in = if i == num_legs - 1 { + total.saturating_sub(per_leg.saturating_mul((num_legs - 1) as u128)) + } else { + per_leg + }; + let min_leg_out = if total > 0 { + min_out_total.saturating_mul(amount_in) / total + } else { + 0 + }; + SplitLeg { + amount_in: amount_in.into(), + route: route.clone(), + min_leg_out: min_leg_out.into(), + } + }) + .collect() +} + +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/external_canisters/taco_exchange/api/src/lib.rs b/backend/external_canisters/taco_exchange/api/src/lib.rs index 31921dadf8..8b66eb5718 100644 --- a/backend/external_canisters/taco_exchange/api/src/lib.rs +++ b/backend/external_canisters/taco_exchange/api/src/lib.rs @@ -15,6 +15,15 @@ pub struct SwapHop { 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")] diff --git a/backend/external_canisters/taco_exchange/api/src/queries/get_expected_multi_hop_amount.rs b/backend/external_canisters/taco_exchange/api/src/queries/get_expected_multi_hop_amount.rs deleted file mode 100644 index cf65719a87..0000000000 --- a/backend/external_canisters/taco_exchange/api/src/queries/get_expected_multi_hop_amount.rs +++ /dev/null @@ -1,24 +0,0 @@ -use crate::{HopDetail, SwapHop}; -use candid::{CandidType, Nat}; -use serde::{Deserialize, Serialize}; - -// Candid signature is positional: -// getExpectedMultiHopAmount: (tokenIn: text, tokenOut: text, amountIn: nat) -> (Response) -pub type Args = (String, String, Nat); - -#[derive(CandidType, Serialize, Deserialize, Clone, Debug)] -pub struct Response { - #[serde(rename = "bestRoute")] - pub best_route: Vec, - #[serde(rename = "expectedAmountOut")] - pub expected_amount_out: Nat, - #[serde(rename = "hopDetails")] - pub hop_details: Vec, - pub hops: Nat, - #[serde(rename = "priceImpact")] - pub price_impact: f64, - #[serde(rename = "routeTokens")] - pub route_tokens: Vec, - #[serde(rename = "totalFee")] - pub total_fee: Nat, -} 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/mod.rs b/backend/external_canisters/taco_exchange/api/src/queries/mod.rs index aa7c3a3947..cfaecae476 100644 --- a/backend/external_canisters/taco_exchange/api/src/queries/mod.rs +++ b/backend/external_canisters/taco_exchange/api/src/queries/mod.rs @@ -1 +1 @@ -pub mod get_expected_multi_hop_amount; +pub mod get_expected_receive_amount_batch_multi; diff --git a/backend/external_canisters/taco_exchange/api/src/updates/mod.rs b/backend/external_canisters/taco_exchange/api/src/updates/mod.rs index 68a2ab3a77..9099241cac 100644 --- a/backend/external_canisters/taco_exchange/api/src/updates/mod.rs +++ b/backend/external_canisters/taco_exchange/api/src/updates/mod.rs @@ -1 +1,2 @@ pub mod swap_multi_hop; +pub mod swap_split_routes; 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/src/lib.rs b/backend/external_canisters/taco_exchange/c2c_client/src/lib.rs index d4c9a68410..1e5e14f750 100644 --- a/backend/external_canisters/taco_exchange/c2c_client/src/lib.rs +++ b/backend/external_canisters/taco_exchange/c2c_client/src/lib.rs @@ -5,13 +5,13 @@ use types::{C2CError, CanisterId}; // `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_multi_hop_amount( +pub async fn get_expected_receive_amount_batch_multi( canister_id: CanisterId, - args: get_expected_multi_hop_amount::Args, -) -> Result { + args: get_expected_receive_amount_batch_multi::Args, +) -> Result { canister_client::make_c2c_call( canister_id, - "getExpectedMultiHopAmount", + "getExpectedReceiveAmountBatchMulti", args, ::candid::encode_args, |r| ::candid::decode_one(r), @@ -34,3 +34,18 @@ pub async fn swap_multi_hop( ) .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 +} From 3dd3cfce7551776452a68c68b3f43ad117e7e5ae Mon Sep 17 00:00:00 2001 From: wilaq Date: Tue, 12 May 2026 05:01:57 +0200 Subject: [PATCH 03/13] =?UTF-8?q?Enumerate=20route=20=C3=97=20fraction=20c?= =?UTF-8?q?ombinations=20for=20asymmetric=20TACO=20splits?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the previous equal-split planner (which always allocated total/numLegs per leg) with the same algorithm the TACO frontend's useSwapFlow composable runs: flatten the BatchMulti grid into {fraction_bp, route, expected_out, edge_keys} entries, enumerate 2-leg and 3-leg combinations whose bp values sum exactly to 10000, reject any combo where two legs share even one normalized pool edge, and pick the combination with the highest sum of expected_out. A split is accepted only if it beats the unsplit baseline (top route at the 100% fraction) by >0.1%; otherwise we execute the single route. The single BatchMulti round-trip is unchanged. This produces asymmetric splits (e.g. 30/70, 60/40, 20/40/40) instead of the previous forced 50/50 or 33/33/33. Disjoint-edge constraint still guarantees no two legs touch the same pool, so each leg's quoted output holds at execution time. --- .../user/impl/src/token_swaps/taco.rs | 381 +++++++++++++----- 1 file changed, 274 insertions(+), 107 deletions(-) diff --git a/backend/canisters/user/impl/src/token_swaps/taco.rs b/backend/canisters/user/impl/src/token_swaps/taco.rs index bf06b7f1a0..362965f7ce 100644 --- a/backend/canisters/user/impl/src/token_swaps/taco.rs +++ b/backend/canisters/user/impl/src/token_swaps/taco.rs @@ -3,6 +3,7 @@ use crate::token_swaps::nat_to_u128; use async_trait::async_trait; use candid::Nat; use serde::{Deserialize, Serialize}; +use std::collections::HashSet; use taco_exchange_canister::get_expected_receive_amount_batch_multi as batch_multi; use taco_exchange_canister::{SplitLeg, SwapHop, SwapResult}; use types::icrc1::Account; @@ -10,10 +11,18 @@ use types::{C2CError, CanisterId, TokenInfo}; // 10-fraction probe grid (10%, 20%, ..., 100%). Mirrors the treasury trader's // scenario builder at TACO_Backend/src/treasury/treasury.mo:10646 — top-5 routes -// per fraction, single inter-canister call. -const PROBE_FRACTIONS_BP: [u128; 10] = [1000, 2000, 3000, 4000, 5000, 6000, 7000, 8000, 9000, 10000]; +// per fraction, single inter-canister call. Together with the route × fraction +// enumerator below this lets us discover asymmetric splits (e.g. 30%/70%) +// without further round-trips. +const NUM_FRACTIONS: usize = 10; +const STEP_BP: u128 = 1000; const TOP_ROUTES_PER_FRACTION: u128 = 5; const MAX_LEGS: usize = 3; +// 0.1% — must beat the unsplit baseline by this margin before we'll take on the +// extra slippage risk of a multi-leg execution. Matches the frontend's +// useSwapFlow composable. +const SPLIT_IMPROVEMENT_NUMERATOR: u128 = 1001; +const SPLIT_IMPROVEMENT_DENOMINATOR: u128 = 1000; #[derive(Serialize, Deserialize)] pub struct TacoExchangeClient { @@ -83,13 +92,14 @@ impl SwapClient for TacoExchangeClient { let token_in = self.input_token.ledger.to_string(); let token_out = self.output_token.ledger.to_string(); - // Build the 10-fraction × top-5 grid request. We probe at `usable * bp / 10000` - // (a pre-fee estimate good enough for route ranking; exact bps comes back - // in the response). - let probes: Vec = PROBE_FRACTIONS_BP - .iter() - .filter_map(|bp| { - let amt = usable.saturating_mul(*bp) / 10000; + // Build the 10-fraction × top-5 grid. Probe amounts are `usable * (i+1) / 10` + // — a pre-trading-fee estimate good enough for routing; the exact bps + // comes back inline in the response and is applied to the execution + // amount below. + let probes: Vec = (0..NUM_FRACTIONS) + .filter_map(|i| { + let bp = ((i as u128) + 1) * STEP_BP; + let amt = usable.saturating_mul(bp) / 10000; if amt == 0 { None } else { @@ -112,51 +122,51 @@ impl SwapClient for TacoExchangeClient { ) .await?; - // Pull the live trading fee bps from any route (every route in the response - // carries the same ICPfee snapshot). + // Pull the live trading fee bps from any route in the response (every + // route carries the same ICPfee snapshot). Defensively clamp to TACO's + // enforced range [1, 50]. let fee_bps = match extract_trading_fee_bps(&batch) { - Some(bps) => bps, + Some(bps) => bps.clamp(1, 50), None => return Ok(Err("TACO swap: no routes returned for any fraction".to_string())), }; - // Defensive clamp against the canister's enforced range [1, 50] bps. - let fee_bps_clamped = fee_bps.clamp(1, 50); // Compute the true amount_in such that the recorded deposit covers - // amount_in * (10000 + fee_bps) / 10000 + tfee. - let amount_in_total = usable.saturating_mul(10000) / (10000 + fee_bps_clamped); + // 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())); } - // Greedy disjoint-pool selection (mirrors hopsSharePool at - // TACO_Backend/src/treasury/treasury.mo:10895, max 3 legs). - let selected = build_split_plan(&batch); - if selected.is_empty() { - return Ok(Err("TACO swap: no usable routes after disjoint-pool filtering".to_string())); - } + // Route × fraction enumeration — same algorithm the TACO frontend's + // useSwapFlow composable runs (and the screenshot at chat shows producing + // a 30/70 split). Disjoint-pool constraint mirrors hopsSharePool from + // TACO_Backend/src/treasury/treasury.mo:10895. + let plan = build_swap_plan(&batch); - if selected.len() == 1 { - execute_swap_multi_hop( + match plan { + SwapPlan::Single(route) => execute_swap_multi_hop( self.swap_canister_id, token_in, token_out, amount_in_total, - selected.into_iter().next().unwrap(), - min_amount_out, - block_index, - ) - .await - } else { - let legs = make_split_legs(&selected, amount_in_total, min_amount_out); - execute_swap_split_routes( - self.swap_canister_id, - token_in, - token_out, - legs, + route, min_amount_out, block_index, ) - .await + .await, + SwapPlan::Multi(legs) => { + let split_legs = make_split_legs(&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 + } + SwapPlan::None => Ok(Err("TACO swap: no viable route found".to_string())), } } @@ -166,6 +176,31 @@ impl SwapClient for TacoExchangeClient { } } +// ── plan types ────────────────────────────────────────────────────────────── + +enum SwapPlan { + Single(Vec), + Multi(Vec), + None, +} + +#[derive(Clone)] +struct PlannedLeg { + /// Basis points of the total amount allocated to this leg (sums to 10000 + /// across all legs of a Multi plan). + bp: u128, + route: Vec, +} + +#[derive(Clone)] +struct QuoteEntry { + bp: u128, + route: Vec, + expected_out: u128, + route_key: String, + edge_keys: Vec, +} + // ── helpers ───────────────────────────────────────────────────────────────── fn extract_trading_fee_bps(batch: &batch_multi::Response) -> Option { @@ -176,16 +211,19 @@ fn extract_trading_fee_bps(batch: &batch_multi::Response) -> Option { .map(|r| nat_to_u128(r.trading_fee_bps.clone())) } -// Bidirectional pool-edge overlap check — verbatim port of `hopsSharePool` at -// TACO_Backend/src/treasury/treasury.mo:10895. Returns true if any hop in `a` -// shares a pool with any hop in `b` (a pool is identified by its unordered -// token pair, so {A→B} and {B→A} are the same pool). -fn routes_share_pool_edge(a: &[SwapHop], b: &[SwapHop]) -> bool { - for ha in a { - for hb in b { - if (ha.token_in == hb.token_in && ha.token_out == hb.token_out) - || (ha.token_in == hb.token_out && ha.token_out == hb.token_in) - { +fn normalize_edge(a: &str, b: &str) -> String { + if a < b { + format!("{a}|{b}") + } else { + format!("{b}|{a}") + } +} + +// Two leg's pool sets overlap iff they share any normalized edge. +fn edges_overlap(a: &[String], b: &[String]) -> bool { + for ea in a { + for eb in b { + if ea == eb { return true; } } @@ -193,83 +231,212 @@ fn routes_share_pool_edge(a: &[SwapHop], b: &[SwapHop]) -> bool { false } -// Walk all routes returned by BatchMulti (across all fractions), dedupe by route -// token path, then greedily keep routes that don't share any pool edge with an -// already-kept route. Stop at MAX_LEGS. Order follows discovery (matches -// treasury's iteration over `tacoDistinctRoutes`). -fn build_split_plan(batch: &batch_multi::Response) -> Vec> { - let mut seen_route_keys: std::collections::HashSet = std::collections::HashSet::new(); - let mut kept: Vec> = Vec::new(); +// Materialize hops for a quote entry. For direct routes the canister returns +// hopDetails = [] AND routeTokens = [tokenSell, tokenBuy]; synthesize a single +// hop from the route_tokens in that case. +fn hops_from_route(route: &batch_multi::QuoteRoute) -> Vec { + if !route.hop_details.is_empty() { + return route + .hop_details + .iter() + .map(|h| SwapHop { + token_in: h.token_in.clone(), + token_out: h.token_out.clone(), + }) + .collect(); + } + if route.route_tokens.len() == 2 { + return vec![SwapHop { + token_in: route.route_tokens[0].clone(), + token_out: route.route_tokens[1].clone(), + }]; + } + Vec::new() +} - for req in batch { +// Flatten BatchMulti into entries (one per (fraction, route)). Dedupes by +// (bp, route_key) so each fraction sees each route at most once. +fn flatten_batch(batch: &batch_multi::Response) -> Vec { + let mut out: Vec = Vec::new(); + let mut seen: HashSet<(u128, String)> = HashSet::new(); + for (i, req) in batch.iter().enumerate() { + let bp = ((i as u128) + 1) * STEP_BP; for route in &req.routes { - if route.expected_buy_amount == Nat::from(0u32) { + let expected = nat_to_u128(route.expected_buy_amount.clone()); + if expected == 0 { continue; } - let hops: Vec = route - .hop_details - .iter() - .map(|h| SwapHop { - token_in: h.token_in.clone(), - token_out: h.token_out.clone(), - }) - .collect(); - // For direct routes the canister returns hopDetails = [] AND - // routeTokens = [tokenSell, tokenBuy]; synthesize a single hop. - let hops = if hops.is_empty() && route.route_tokens.len() == 2 { - vec![SwapHop { - token_in: route.route_tokens[0].clone(), - token_out: route.route_tokens[1].clone(), - }] - } else { - hops - }; + let hops = hops_from_route(route); if hops.is_empty() { continue; } - let key = route.route_tokens.join("→"); - if !seen_route_keys.insert(key) { + let route_key = route.route_tokens.join("→"); + if !seen.insert((bp, route_key.clone())) { continue; } - if kept.iter().any(|k| routes_share_pool_edge(k, &hops)) { + let edge_keys: Vec = hops.iter().map(|h| normalize_edge(&h.token_in, &h.token_out)).collect(); + out.push(QuoteEntry { + bp, + route: hops, + expected_out: expected, + route_key, + edge_keys, + }); + } + } + out +} + +// Route × fraction optimizer. +// +// 1. Flatten the BatchMulti grid into entries `{ bp, route, expected_out, edge_keys }`. +// 2. Baseline = top expected_out among entries at bp == 10000 (unsplit best). +// 3. Enumerate 2-leg and 3-leg combinations whose `bp`s sum to 10000 exactly, +// whose routes are distinct, and whose edge_keys sets are pairwise disjoint. +// 4. Pick the combination with the largest sum of expected_out. +// 5. Accept the split only if it beats the baseline by > 0.1%; otherwise fall +// back to the baseline's single route. +// +// This matches the TACO frontend's `useSwapFlow` composable behaviour +// (`Split Route 0.4% better output` in the screenshot at chat). +fn build_swap_plan(batch: &batch_multi::Response) -> SwapPlan { + let entries = flatten_batch(batch); + if entries.is_empty() { + return SwapPlan::None; + } + + // Baseline: top route at the 100% fraction. + let baseline_entry = entries.iter().filter(|e| e.bp == 10000).max_by_key(|e| e.expected_out); + let baseline_out = baseline_entry.map(|e| e.expected_out).unwrap_or(0); + + // Search for the highest-output split plan. + let mut best_plan: Option> = None; + let mut best_total: u128 = 0; + + let n = entries.len(); + + // 2-leg combos + for i in 0..n { + for j in (i + 1)..n { + let a = &entries[i]; + let b = &entries[j]; + if a.bp + b.bp != 10000 { + continue; + } + if a.route_key == b.route_key { + continue; + } + if edges_overlap(&a.edge_keys, &b.edge_keys) { continue; } - kept.push(hops); - if kept.len() >= MAX_LEGS { - return kept; + let total = a.expected_out + b.expected_out; + if total > best_total { + best_total = total; + best_plan = Some(vec![a, b]); + } + } + } + + // 3-leg combos (TACO's swap_split_routes caps at 3 legs) + if MAX_LEGS >= 3 { + for i in 0..n { + for j in (i + 1)..n { + let a = &entries[i]; + let b = &entries[j]; + if a.bp + b.bp >= 10000 { + continue; + } + if a.route_key == b.route_key { + continue; + } + if edges_overlap(&a.edge_keys, &b.edge_keys) { + continue; + } + for k in (j + 1)..n { + let c = &entries[k]; + if a.bp + b.bp + c.bp != 10000 { + continue; + } + if c.route_key == a.route_key || c.route_key == b.route_key { + continue; + } + if edges_overlap(&a.edge_keys, &c.edge_keys) || edges_overlap(&b.edge_keys, &c.edge_keys) { + continue; + } + let total = a.expected_out + b.expected_out + c.expected_out; + if total > best_total { + best_total = total; + best_plan = Some(vec![a, b, c]); + } + } } } } - kept + // Acceptance: split must beat baseline by >0.1%, OR baseline must be unusable + // (no 100%-fraction route returned anything). + let threshold = baseline_out.saturating_mul(SPLIT_IMPROVEMENT_NUMERATOR) / SPLIT_IMPROVEMENT_DENOMINATOR; + let accept_split = match (best_plan.as_ref(), baseline_out) { + (Some(_), 0) => true, + (Some(_), _) => best_total > threshold, + (None, _) => false, + }; + + if accept_split { + let legs = best_plan + .unwrap() + .into_iter() + .map(|e| PlannedLeg { + bp: e.bp, + route: e.route.clone(), + }) + .collect(); + return SwapPlan::Multi(legs); + } + + if let Some(e) = baseline_entry { + return SwapPlan::Single(e.route.clone()); + } + + // No 100%-fraction route exists. Fall back to the highest-output entry we saw + // (best-effort) — but only as a single-route execution since we have no + // viable split. + if let Some(e) = entries.iter().max_by_key(|e| e.expected_out) { + return SwapPlan::Single(e.route.clone()); + } + + SwapPlan::None } -// Build [SplitLeg] from selected routes: equal-split with remainder in the last -// leg, pro-rata `minLegOut`. Mirrors treasury.mo:11851. -fn make_split_legs(routes: &[Vec], total: u128, min_out_total: u128) -> Vec { - let num_legs = routes.len(); - let per_leg = total / num_legs as u128; - routes - .iter() - .enumerate() - .map(|(i, route)| { - let amount_in = if i == num_legs - 1 { - total.saturating_sub(per_leg.saturating_mul((num_legs - 1) as u128)) - } else { - per_leg - }; - let min_leg_out = if total > 0 { - min_out_total.saturating_mul(amount_in) / total - } else { - 0 - }; - SplitLeg { - amount_in: amount_in.into(), - route: route.clone(), - min_leg_out: min_leg_out.into(), - } - }) - .collect() +// Build [SplitLeg] from a Multi plan: amount_in = total × bp / 10000, with the +// LAST leg carrying any rounding remainder so the sum is exactly `total`. The +// per-leg `minLegOut` is the pro-rata slice of the global min_amount_out (sums +// to min_amount_out within rounding). +fn make_split_legs(legs: &[PlannedLeg], 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 (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(leg.bp) / 10000; + let m = min_out_total.saturating_mul(leg.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( From d4e339f896ab25f1e93db6caf7bd6bee0a6d696c Mon Sep 17 00:00:00 2001 From: wilaq Date: Tue, 12 May 2026 05:10:46 +0200 Subject: [PATCH 04/13] Iterate only viable bp tuples in TACO split optimizer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the brute-force C(50,2) + C(50,3) scan over all entry pairs and triples with a direct enumeration over the 5 bp pairs and 8 bp triples that actually sum to 10000 with the 1000-bps probe grid: pairs: (1000,9000) (2000,8000) (3000,7000) (4000,6000) (5000,5000) triples: (1000,1000,8000) (1000,2000,7000) (1000,3000,6000) (1000,4000,5000) (2000,2000,6000) (2000,3000,5000) (2000,4000,4000) (3000,3000,4000) For each tuple we look up the corresponding entry groups and walk only their cross-product. When two legs share a fraction (e.g. 5000+5000 or the first two legs of 1000+1000+8000) we restrict to unordered pairs via strict index ordering so each combination is considered exactly once. Same selection logic — disjoint pool edges (bidirectional), distinct route_keys, sum of expected_out maximized, ≥0.1% improvement over the single-route baseline — but the worst-case search space drops from ~21,000 iterations to ~810. Compile-clean, no warnings. --- .../user/impl/src/token_swaps/taco.rs | 187 ++++++++++++------ 1 file changed, 123 insertions(+), 64 deletions(-) diff --git a/backend/canisters/user/impl/src/token_swaps/taco.rs b/backend/canisters/user/impl/src/token_swaps/taco.rs index 362965f7ce..bdef0729f2 100644 --- a/backend/canisters/user/impl/src/token_swaps/taco.rs +++ b/backend/canisters/user/impl/src/token_swaps/taco.rs @@ -3,7 +3,7 @@ use crate::token_swaps::nat_to_u128; use async_trait::async_trait; use candid::Nat; use serde::{Deserialize, Serialize}; -use std::collections::HashSet; +use std::collections::{HashMap, HashSet}; use taco_exchange_canister::get_expected_receive_amount_batch_multi as batch_multi; use taco_exchange_canister::{SplitLeg, SwapHop, SwapResult}; use types::icrc1::Account; @@ -287,94 +287,131 @@ fn flatten_batch(batch: &batch_multi::Response) -> Vec { out } +// Pre-enumerated bp tuples whose components are all ∈ {1000..9000} and sum to +// 10000 — the ONLY tuples that can form a full-coverage 2- or 3-leg split with +// the 10-fraction probe grid. The optimizer iterates these directly instead of +// scanning every pair / triple of entries, collapsing the search from +// C(50,2)+C(50,3) ≈ 21,000 iterations to ≈ 810. +const TWO_LEG_BP_PAIRS: &[(u128, u128)] = &[ + (1000, 9000), + (2000, 8000), + (3000, 7000), + (4000, 6000), + (5000, 5000), +]; + +const THREE_LEG_BP_TRIPLES: &[(u128, u128, u128)] = &[ + (1000, 1000, 8000), + (1000, 2000, 7000), + (1000, 3000, 6000), + (1000, 4000, 5000), + (2000, 2000, 6000), + (2000, 3000, 5000), + (2000, 4000, 4000), + (3000, 3000, 4000), +]; + // Route × fraction optimizer. // -// 1. Flatten the BatchMulti grid into entries `{ bp, route, expected_out, edge_keys }`. -// 2. Baseline = top expected_out among entries at bp == 10000 (unsplit best). -// 3. Enumerate 2-leg and 3-leg combinations whose `bp`s sum to 10000 exactly, -// whose routes are distinct, and whose edge_keys sets are pairwise disjoint. +// 1. Flatten the BatchMulti grid into entries `{ bp, route, expected_out, edge_keys }` +// and group them by bp (an entry lives in exactly one group). +// 2. Baseline = top expected_out at the 100% fraction (unsplit best). +// 3. For each precomputed bp tuple that sums to 10000, look up the matching +// entry groups and consider their cross-product, pruning combos that share +// a route_key or any normalized pool edge. // 4. Pick the combination with the largest sum of expected_out. // 5. Accept the split only if it beats the baseline by > 0.1%; otherwise fall // back to the baseline's single route. // -// This matches the TACO frontend's `useSwapFlow` composable behaviour -// (`Split Route 0.4% better output` in the screenshot at chat). +// Matches the TACO frontend's `useSwapFlow` algorithm (Split Route badge in the +// screenshot at chat). fn build_swap_plan(batch: &batch_multi::Response) -> SwapPlan { let entries = flatten_batch(batch); if entries.is_empty() { return SwapPlan::None; } - // Baseline: top route at the 100% fraction. - let baseline_entry = entries.iter().filter(|e| e.bp == 10000).max_by_key(|e| e.expected_out); - let baseline_out = baseline_entry.map(|e| e.expected_out).unwrap_or(0); + // Group entries by their bp. Each entry belongs to exactly one group. + let mut by_bp: HashMap> = HashMap::new(); + for (idx, e) in entries.iter().enumerate() { + by_bp.entry(e.bp).or_default().push(idx); + } + let empty: Vec = Vec::new(); + let group = |bp: u128| -> &Vec { by_bp.get(&bp).unwrap_or(&empty) }; - // Search for the highest-output split plan. - let mut best_plan: Option> = None; - let mut best_total: u128 = 0; + // Baseline: highest-output entry at the 100% fraction. + let baseline_idx = group(10000) + .iter() + .copied() + .max_by_key(|&i| entries[i].expected_out); + let baseline_out = baseline_idx.map(|i| entries[i].expected_out).unwrap_or(0); - let n = entries.len(); + let mut best_plan: Option> = None; + let mut best_total: u128 = 0; - // 2-leg combos - for i in 0..n { - for j in (i + 1)..n { - let a = &entries[i]; - let b = &entries[j]; - if a.bp + b.bp != 10000 { - continue; - } - if a.route_key == b.route_key { - continue; - } - if edges_overlap(&a.edge_keys, &b.edge_keys) { - continue; + // ── 2-leg search ──────────────────────────────────────────────────────── + for &(bp_a, bp_b) in TWO_LEG_BP_PAIRS { + let group_a = group(bp_a); + if bp_a == bp_b { + // Two legs from the same fraction — pick unordered pairs (xi < xj). + for (xi, &i) in group_a.iter().enumerate() { + for &j in &group_a[xi + 1..] { + try_record_pair(&entries, i, j, &mut best_total, &mut best_plan); + } } - let total = a.expected_out + b.expected_out; - if total > best_total { - best_total = total; - best_plan = Some(vec![a, b]); + } else { + let group_b = group(bp_b); + for &i in group_a { + for &j in group_b { + try_record_pair(&entries, i, j, &mut best_total, &mut best_plan); + } } } } - // 3-leg combos (TACO's swap_split_routes caps at 3 legs) + // ── 3-leg search ──────────────────────────────────────────────────────── if MAX_LEGS >= 3 { - for i in 0..n { - for j in (i + 1)..n { - let a = &entries[i]; - let b = &entries[j]; - if a.bp + b.bp >= 10000 { + for &(bp_a, bp_b, bp_c) in THREE_LEG_BP_TRIPLES { + let group_a = group(bp_a); + let group_b = group(bp_b); + let group_c = group(bp_c); + let same_ab = bp_a == bp_b; + let same_bc = bp_b == bp_c; + + for (xi, &i) in group_a.iter().enumerate() { + let b_start = if same_ab { xi + 1 } else { 0 }; + if b_start >= group_b.len() { continue; } - if a.route_key == b.route_key { - continue; - } - if edges_overlap(&a.edge_keys, &b.edge_keys) { - continue; - } - for k in (j + 1)..n { - let c = &entries[k]; - if a.bp + b.bp + c.bp != 10000 { + for (offset_j, &j) in group_b[b_start..].iter().enumerate() { + if !pair_compatible(&entries[i], &entries[j]) { continue; } - if c.route_key == a.route_key || c.route_key == b.route_key { + let xj = b_start + offset_j; + let c_start = if same_bc { xj + 1 } else { 0 }; + if c_start >= group_c.len() { continue; } - if edges_overlap(&a.edge_keys, &c.edge_keys) || edges_overlap(&b.edge_keys, &c.edge_keys) { - continue; - } - let total = a.expected_out + b.expected_out + c.expected_out; - if total > best_total { - best_total = total; - best_plan = Some(vec![a, b, c]); + for &k in &group_c[c_start..] { + if !pair_compatible(&entries[i], &entries[k]) + || !pair_compatible(&entries[j], &entries[k]) + { + continue; + } + let total = + entries[i].expected_out + entries[j].expected_out + entries[k].expected_out; + if total > best_total { + best_total = total; + best_plan = Some(vec![i, j, k]); + } } } } } } - // Acceptance: split must beat baseline by >0.1%, OR baseline must be unusable - // (no 100%-fraction route returned anything). + // Acceptance: split must beat baseline by >0.1%, or baseline must be + // unusable (no 100%-fraction route returned anything). let threshold = baseline_out.saturating_mul(SPLIT_IMPROVEMENT_NUMERATOR) / SPLIT_IMPROVEMENT_DENOMINATOR; let accept_split = match (best_plan.as_ref(), baseline_out) { (Some(_), 0) => true, @@ -386,21 +423,20 @@ fn build_swap_plan(batch: &batch_multi::Response) -> SwapPlan { let legs = best_plan .unwrap() .into_iter() - .map(|e| PlannedLeg { - bp: e.bp, - route: e.route.clone(), + .map(|idx| PlannedLeg { + bp: entries[idx].bp, + route: entries[idx].route.clone(), }) .collect(); return SwapPlan::Multi(legs); } - if let Some(e) = baseline_entry { - return SwapPlan::Single(e.route.clone()); + if let Some(i) = baseline_idx { + return SwapPlan::Single(entries[i].route.clone()); } - // No 100%-fraction route exists. Fall back to the highest-output entry we saw - // (best-effort) — but only as a single-route execution since we have no - // viable split. + // No 100%-fraction route exists. Best-effort: pick the highest-output entry + // across the whole grid and execute it as a single route. if let Some(e) = entries.iter().max_by_key(|e| e.expected_out) { return SwapPlan::Single(e.route.clone()); } @@ -408,6 +444,29 @@ fn build_swap_plan(batch: &batch_multi::Response) -> SwapPlan { SwapPlan::None } +fn pair_compatible(a: &QuoteEntry, b: &QuoteEntry) -> bool { + a.route_key != b.route_key && !edges_overlap(&a.edge_keys, &b.edge_keys) +} + +fn try_record_pair( + entries: &[QuoteEntry], + i: usize, + j: usize, + best_total: &mut u128, + best_plan: &mut Option>, +) { + let a = &entries[i]; + let b = &entries[j]; + if !pair_compatible(a, b) { + return; + } + let total = a.expected_out + b.expected_out; + if total > *best_total { + *best_total = total; + *best_plan = Some(vec![i, j]); + } +} + // Build [SplitLeg] from a Multi plan: amount_in = total × bp / 10000, with the // LAST leg carrying any rounding remainder so the sum is exactly `total`. The // per-leg `minLegOut` is the pro-rata slice of the global min_amount_out (sums From ca0140bd97ad2943ca9b2772854e70df3e41c86c Mon Sep 17 00:00:00 2001 From: wilaq Date: Tue, 12 May 2026 05:16:13 +0200 Subject: [PATCH 05/13] Prune the TACO split optimizer to skip dominated combinations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 810 is the worst-case iteration count from the previous commit, but typically much less is actually needed. Adds four orthogonal prunes: 1. Sort each bp group by expected_out descending — one-time, ~5 tiny sorts. 2. Per-tuple upper bound: skip a (bp_a, bp_b) tuple entirely when group_top(bp_a) + group_top(bp_b) ≤ best_total. Same idea for triples. 3. Inner-loop break: with groups sorted desc, once the running sum stops beating best_total, every later entry in the inner group is smaller, so break is sound. 4. Pre-seed best_total to the 0.1% threshold so every comparison during the search also enforces the acceptance criterion. Collapses the post-loop accept check to `best_plan.is_some()`. Correctness unchanged: - pair_compatible (route_key distinct + bidirectional edge_overlap) still rejects any combo where two legs share even one pool edge. - The skipped tuples / combos are provably dominated: each pruned combo has an upper bound on its total that is already ≤ best_total, so the global maximum is preserved. - Tie-breaking within a tuple changes (sort order vs entry order) but the chosen total — and the swap outcome on-chain — is identical to the brute-force version. Worst case still 810 iterations; typical case is ~20-80. --- .../user/impl/src/token_swaps/taco.rs | 175 +++++++++++------- 1 file changed, 111 insertions(+), 64 deletions(-) diff --git a/backend/canisters/user/impl/src/token_swaps/taco.rs b/backend/canisters/user/impl/src/token_swaps/taco.rs index bdef0729f2..bd8f84a226 100644 --- a/backend/canisters/user/impl/src/token_swaps/taco.rs +++ b/backend/canisters/user/impl/src/token_swaps/taco.rs @@ -313,57 +313,111 @@ const THREE_LEG_BP_TRIPLES: &[(u128, u128, u128)] = &[ // Route × fraction optimizer. // -// 1. Flatten the BatchMulti grid into entries `{ bp, route, expected_out, edge_keys }` -// and group them by bp (an entry lives in exactly one group). -// 2. Baseline = top expected_out at the 100% fraction (unsplit best). -// 3. For each precomputed bp tuple that sums to 10000, look up the matching -// entry groups and consider their cross-product, pruning combos that share -// a route_key or any normalized pool edge. -// 4. Pick the combination with the largest sum of expected_out. -// 5. Accept the split only if it beats the baseline by > 0.1%; otherwise fall -// back to the baseline's single route. +// Algorithm (matches the TACO frontend's `useSwapFlow` "Split Route" feature): // -// Matches the TACO frontend's `useSwapFlow` algorithm (Split Route badge in the -// screenshot at chat). +// 1. Flatten the BatchMulti grid into entries `{ bp, route, expected_out, edge_keys }` +// and group them by bp; sort each group by expected_out descending. +// 2. Baseline = best entry at the 100% fraction; threshold = baseline + 0.1%. +// 3. Initialize `best_total = threshold`. Any combo that displaces it is by +// definition acceptable (post-loop check becomes a simple `Some` test). +// 4. For each precomputed bp tuple that sums to 10000: +// a. Upper-bound prune: skip the tuple if `Σ group_top(bp_i) ≤ best_total`. +// b. Walk the cross-product of the matching entry groups, breaking inner +// loops as soon as the running sum can no longer beat best_total +// (sound because each group is sorted desc). +// c. Within the iteration, reject combos that share a route_key or any +// normalized pool edge. +// 5. Pick the combination with the largest sum of expected_out. +// +// Worst case is still the 5 pair tuples + 8 triple tuples × 5-route groups +// (~810 iterations); typical case prunes most of that out before any real work. fn build_swap_plan(batch: &batch_multi::Response) -> SwapPlan { let entries = flatten_batch(batch); if entries.is_empty() { return SwapPlan::None; } - // Group entries by their bp. Each entry belongs to exactly one group. + // Group entries by their bp, then sort each group by expected_out desc so + // we can early-break inner loops when the running sum stops beating + // best_total. let mut by_bp: HashMap> = HashMap::new(); for (idx, e) in entries.iter().enumerate() { by_bp.entry(e.bp).or_default().push(idx); } + for indices in by_bp.values_mut() { + indices.sort_by(|&a, &b| entries[b].expected_out.cmp(&entries[a].expected_out)); + } + let empty: Vec = Vec::new(); let group = |bp: u128| -> &Vec { by_bp.get(&bp).unwrap_or(&empty) }; + let group_top_out = |bp: u128| -> u128 { + group(bp).first().map(|&i| entries[i].expected_out).unwrap_or(0) + }; - // Baseline: highest-output entry at the 100% fraction. - let baseline_idx = group(10000) - .iter() - .copied() - .max_by_key(|&i| entries[i].expected_out); + // Baseline: top route at 100% — after sorting it's the first entry. + let baseline_idx = group(10000).first().copied(); let baseline_out = baseline_idx.map(|i| entries[i].expected_out).unwrap_or(0); + // Pre-seed best_total to the 0.1% threshold so every comparison during the + // search also enforces the acceptance criterion. When baseline_out == 0 + // (no full-amount route), this collapses to best_total = 0 and any positive + // combo wins. + let mut best_total: u128 = + baseline_out.saturating_mul(SPLIT_IMPROVEMENT_NUMERATOR) / SPLIT_IMPROVEMENT_DENOMINATOR; let mut best_plan: Option> = None; - let mut best_total: u128 = 0; // ── 2-leg search ──────────────────────────────────────────────────────── for &(bp_a, bp_b) in TWO_LEG_BP_PAIRS { + // Tuple upper-bound prune. + if group_top_out(bp_a) + group_top_out(bp_b) <= best_total { + continue; + } + let group_a = group(bp_a); if bp_a == bp_b { - // Two legs from the same fraction — pick unordered pairs (xi < xj). - for (xi, &i) in group_a.iter().enumerate() { + for xi in 0..group_a.len() { + let i = group_a[xi]; + let a_out = entries[i].expected_out; + let next_out = group_a + .get(xi + 1) + .map(|&j| entries[j].expected_out) + .unwrap_or(0); + // a_out is non-increasing in xi (sorted), and next_out ≤ a_out. + // If even (a, next) can't beat, no later xi will. + if a_out + next_out <= best_total { + break; + } for &j in &group_a[xi + 1..] { - try_record_pair(&entries, i, j, &mut best_total, &mut best_plan); + let total = a_out + entries[j].expected_out; + if total <= best_total { + break; + } + if pair_compatible(&entries[i], &entries[j]) { + best_total = total; + best_plan = Some(vec![i, j]); + } } } } else { let group_b = group(bp_b); + let max_b_out = group_b + .first() + .map(|&j| entries[j].expected_out) + .unwrap_or(0); for &i in group_a { + let a_out = entries[i].expected_out; + if a_out + max_b_out <= best_total { + break; + } for &j in group_b { - try_record_pair(&entries, i, j, &mut best_total, &mut best_plan); + let total = a_out + entries[j].expected_out; + if total <= best_total { + break; + } + if pair_compatible(&entries[i], &entries[j]) { + best_total = total; + best_plan = Some(vec![i, j]); + } } } } @@ -372,35 +426,55 @@ fn build_swap_plan(batch: &batch_multi::Response) -> SwapPlan { // ── 3-leg search ──────────────────────────────────────────────────────── if MAX_LEGS >= 3 { for &(bp_a, bp_b, bp_c) in THREE_LEG_BP_TRIPLES { + // Tuple upper-bound prune. + if group_top_out(bp_a) + group_top_out(bp_b) + group_top_out(bp_c) <= best_total { + continue; + } + let group_a = group(bp_a); let group_b = group(bp_b); let group_c = group(bp_c); let same_ab = bp_a == bp_b; let same_bc = bp_b == bp_c; - - for (xi, &i) in group_a.iter().enumerate() { + let max_c_out = group_c + .first() + .map(|&k| entries[k].expected_out) + .unwrap_or(0); + + for xi in 0..group_a.len() { + let i = group_a[xi]; + let a_out = entries[i].expected_out; let b_start = if same_ab { xi + 1 } else { 0 }; if b_start >= group_b.len() { continue; } - for (offset_j, &j) in group_b[b_start..].iter().enumerate() { + let max_b_at_start = entries[group_b[b_start]].expected_out; + if a_out + max_b_at_start + max_c_out <= best_total { + break; + } + + for offset_j in 0..(group_b.len() - b_start) { + let xj = b_start + offset_j; + let j = group_b[xj]; + let b_out = entries[j].expected_out; + if a_out + b_out + max_c_out <= best_total { + break; + } if !pair_compatible(&entries[i], &entries[j]) { continue; } - let xj = b_start + offset_j; let c_start = if same_bc { xj + 1 } else { 0 }; if c_start >= group_c.len() { continue; } for &k in &group_c[c_start..] { - if !pair_compatible(&entries[i], &entries[k]) - || !pair_compatible(&entries[j], &entries[k]) - { - continue; + let total = a_out + b_out + entries[k].expected_out; + if total <= best_total { + break; } - let total = - entries[i].expected_out + entries[j].expected_out + entries[k].expected_out; - if total > best_total { + if pair_compatible(&entries[i], &entries[k]) + && pair_compatible(&entries[j], &entries[k]) + { best_total = total; best_plan = Some(vec![i, j, k]); } @@ -410,18 +484,10 @@ fn build_swap_plan(batch: &batch_multi::Response) -> SwapPlan { } } - // Acceptance: split must beat baseline by >0.1%, or baseline must be - // unusable (no 100%-fraction route returned anything). - let threshold = baseline_out.saturating_mul(SPLIT_IMPROVEMENT_NUMERATOR) / SPLIT_IMPROVEMENT_DENOMINATOR; - let accept_split = match (best_plan.as_ref(), baseline_out) { - (Some(_), 0) => true, - (Some(_), _) => best_total > threshold, - (None, _) => false, - }; - - if accept_split { - let legs = best_plan - .unwrap() + // best_plan.is_some() ⇔ "split beats baseline by > 0.1%" because best_total + // was pre-seeded to the threshold; no separate accept check needed. + if let Some(legs_idx) = best_plan { + let legs = legs_idx .into_iter() .map(|idx| PlannedLeg { bp: entries[idx].bp, @@ -448,25 +514,6 @@ fn pair_compatible(a: &QuoteEntry, b: &QuoteEntry) -> bool { a.route_key != b.route_key && !edges_overlap(&a.edge_keys, &b.edge_keys) } -fn try_record_pair( - entries: &[QuoteEntry], - i: usize, - j: usize, - best_total: &mut u128, - best_plan: &mut Option>, -) { - let a = &entries[i]; - let b = &entries[j]; - if !pair_compatible(a, b) { - return; - } - let total = a.expected_out + b.expected_out; - if total > *best_total { - *best_total = total; - *best_plan = Some(vec![i, j]); - } -} - // Build [SplitLeg] from a Multi plan: amount_in = total × bp / 10000, with the // LAST leg carrying any rounding remainder so the sum is exactly `total`. The // per-leg `minLegOut` is the pro-rata slice of the global min_amount_out (sums From 755eac086a5a0c56938c44de6f33b4da598fac00 Mon Sep 17 00:00:00 2001 From: wilaq Date: Tue, 12 May 2026 05:23:22 +0200 Subject: [PATCH 06/13] Fix critical bug: deposit_account must return the TACO treasury canister MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TACO's checkReceive (src/exchange/main.mo line 11235) validates block recipients against `treasury_principal`, a stable var initialised to qbnpl-laaaa-aaaan-q52aq-cai — a SEPARATE canister from the exchange's own qioex-5iaaa-aaaan-q52ba-cai. The treasury trader confirms this at src/swap/taco_swap.mo:19 (EXCHANGE_TREASURY constant). Previously deposit_account() returned the exchange canister id, so OC user canisters would have transferred tokens to qioex while TACO looked for the block at qbnpl — every swap would have failed at the block check. Adds treasury_canister_id to ExchangeArgs::Taco and threads it through TacoExchangeClient::new so the frontend specifies both the exchange and its treasury canister explicitly. --- .../user/api/src/updates/swap_tokens.rs | 6 ++++++ .../user/impl/src/token_swaps/taco.rs | 18 ++++++++++++++---- .../user/impl/src/updates/swap_tokens.rs | 7 ++++++- 3 files changed, 26 insertions(+), 5 deletions(-) diff --git a/backend/canisters/user/api/src/updates/swap_tokens.rs b/backend/canisters/user/api/src/updates/swap_tokens.rs index f270319393..d1e710f4ee 100644 --- a/backend/canisters/user/api/src/updates/swap_tokens.rs +++ b/backend/canisters/user/api/src/updates/swap_tokens.rs @@ -50,7 +50,13 @@ 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)] diff --git a/backend/canisters/user/impl/src/token_swaps/taco.rs b/backend/canisters/user/impl/src/token_swaps/taco.rs index bd8f84a226..cce65f7f5d 100644 --- a/backend/canisters/user/impl/src/token_swaps/taco.rs +++ b/backend/canisters/user/impl/src/token_swaps/taco.rs @@ -27,14 +27,21 @@ const SPLIT_IMPROVEMENT_DENOMINATOR: u128 = 1000; #[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, input_token: TokenInfo, output_token: TokenInfo) -> Self { + 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, } @@ -55,10 +62,13 @@ impl SwapClient for TacoExchangeClient { } async fn deposit_account(&self) -> Result { - // TACO verifies deposits by inspecting the ledger block; the recipient - // is the exchange canister's default account. + // 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.swap_canister_id, + owner: self.treasury_canister_id, subaccount: None, }) } diff --git a/backend/canisters/user/impl/src/updates/swap_tokens.rs b/backend/canisters/user/impl/src/updates/swap_tokens.rs index 820eb67f1a..3f47dcaa22 100644 --- a/backend/canisters/user/impl/src/updates/swap_tokens.rs +++ b/backend/canisters/user/impl/src/updates/swap_tokens.rs @@ -262,7 +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, input_token, output_token)), + ExchangeArgs::Taco(taco) => Box::new(TacoExchangeClient::new( + taco.swap_canister_id, + taco.treasury_canister_id, + input_token, + output_token, + )), } } From f2c4ed8a97bb6038cce8636ca7330f01bd686cdf Mon Sep 17 00:00:00 2001 From: wilaq Date: Tue, 12 May 2026 05:30:27 +0200 Subject: [PATCH 07/13] Document the async transfer-delivery window at auto_withdrawals --- .../user/impl/src/token_swaps/taco.rs | 41 ++++++++++++++++++- 1 file changed, 39 insertions(+), 2 deletions(-) diff --git a/backend/canisters/user/impl/src/token_swaps/taco.rs b/backend/canisters/user/impl/src/token_swaps/taco.rs index cce65f7f5d..0d0e32c6e3 100644 --- a/backend/canisters/user/impl/src/token_swaps/taco.rs +++ b/backend/canisters/user/impl/src/token_swaps/taco.rs @@ -56,8 +56,45 @@ impl SwapClient for TacoExchangeClient { fn auto_withdrawals(&self) -> bool { // TACO pushes the swap output back to the caller automatically as part - // of swapMultiHop / swapSplitRoutes, so OC's separate withdraw step is - // a no-op. + // 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 may not yet be in the user canister's balance. + // They arrive a few seconds later via TACO's transfer timer. A + // frontend that polls the wallet balance right after success will + // briefly see stale state. Same for refunds on TACO-level failures + // (SlippageExceeded, etc.) — the refund queued by TACO also rides + // the 5s timer. + // + // Long-term fixes (not implemented): + // - implement `withdraw()` as a polling check that waits for the + // balance to reflect the expected amount before returning, OR + // - have TACO governance add OC user canisters to allowedCanisters + // so swap_multi_hop calls with immediate=true and drains the + // transfer queue synchronously before returning. + // + // No correctness issue: TACO's BlocksDone guard makes retries + // idempotent, and tokens always arrive eventually via the queued + // transfer. true } From 8def7748aadf038fbe0fcf982ba9852540f844db Mon Sep 17 00:00:00 2001 From: wilaq <103112897+wilaq@users.noreply.github.com> Date: Wed, 20 May 2026 03:24:38 +0200 Subject: [PATCH 08/13] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- backend/canisters/user/impl/src/token_swaps/taco.rs | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/backend/canisters/user/impl/src/token_swaps/taco.rs b/backend/canisters/user/impl/src/token_swaps/taco.rs index 0d0e32c6e3..c8e5c65e7f 100644 --- a/backend/canisters/user/impl/src/token_swaps/taco.rs +++ b/backend/canisters/user/impl/src/token_swaps/taco.rs @@ -303,11 +303,13 @@ fn hops_from_route(route: &batch_multi::QuoteRoute) -> Vec { // Flatten BatchMulti into entries (one per (fraction, route)). Dedupes by // (bp, route_key) so each fraction sees each route at most once. -fn flatten_batch(batch: &batch_multi::Response) -> Vec { +// `bps` must contain the actual basis-points for each submitted probe, in the +// same order as the batch request. This avoids mislabeling responses when +// zero-amount probes were filtered out before submission. +fn flatten_batch(batch: &batch_multi::Response, bps: &[u128]) -> Vec { let mut out: Vec = Vec::new(); let mut seen: HashSet<(u128, String)> = HashSet::new(); - for (i, req) in batch.iter().enumerate() { - let bp = ((i as u128) + 1) * STEP_BP; + for (req, bp) in batch.iter().zip(bps.iter().copied()) { for route in &req.routes { let expected = nat_to_u128(route.expected_buy_amount.clone()); if expected == 0 { From 5b5ebfc1d8a4c29127bc1df6c87b10875bf6755b Mon Sep 17 00:00:00 2001 From: wilaq Date: Wed, 20 May 2026 03:37:37 +0200 Subject: [PATCH 09/13] Complete bp-index fix: thread probe_bps through build_swap_plan --- .../user/impl/src/token_swaps/taco.rs | 28 ++++++++++++------- 1 file changed, 18 insertions(+), 10 deletions(-) diff --git a/backend/canisters/user/impl/src/token_swaps/taco.rs b/backend/canisters/user/impl/src/token_swaps/taco.rs index c8e5c65e7f..7046f4b639 100644 --- a/backend/canisters/user/impl/src/token_swaps/taco.rs +++ b/backend/canisters/user/impl/src/token_swaps/taco.rs @@ -143,21 +143,29 @@ impl SwapClient for TacoExchangeClient { // — a pre-trading-fee estimate good enough for routing; the exact bps // comes back inline in the response and is applied to the execution // amount below. - let probes: Vec = (0..NUM_FRACTIONS) + // Collect bps in parallel with probes so flatten_batch can label the + // response entries with the actual submitted bp — without this, dropping + // zero-amount probes via filter_map would shift the index and mislabel + // surviving entries (e.g. a response for the 30% probe would be tagged + // as 10% by index-derived bp). + let (probes, probe_bps): (Vec, Vec) = (0..NUM_FRACTIONS) .filter_map(|i| { let bp = ((i as u128) + 1) * STEP_BP; let amt = usable.saturating_mul(bp) / 10000; if amt == 0 { None } else { - Some(batch_multi::Request { - token_sell: token_in.clone(), - token_buy: token_out.clone(), - amount_sell: amt.into(), - }) + Some(( + batch_multi::Request { + token_sell: token_in.clone(), + token_buy: token_out.clone(), + amount_sell: amt.into(), + }, + bp, + )) } }) - .collect(); + .unzip(); if probes.is_empty() { return Ok(Err("TACO swap: amount too small for any probe fraction".to_string())); @@ -188,7 +196,7 @@ impl SwapClient for TacoExchangeClient { // useSwapFlow composable runs (and the screenshot at chat shows producing // a 30/70 split). Disjoint-pool constraint mirrors hopsSharePool from // TACO_Backend/src/treasury/treasury.mo:10895. - let plan = build_swap_plan(&batch); + let plan = build_swap_plan(&batch, &probe_bps); match plan { SwapPlan::Single(route) => execute_swap_multi_hop( @@ -380,8 +388,8 @@ const THREE_LEG_BP_TRIPLES: &[(u128, u128, u128)] = &[ // // Worst case is still the 5 pair tuples + 8 triple tuples × 5-route groups // (~810 iterations); typical case prunes most of that out before any real work. -fn build_swap_plan(batch: &batch_multi::Response) -> SwapPlan { - let entries = flatten_batch(batch); +fn build_swap_plan(batch: &batch_multi::Response, probe_bps: &[u128]) -> SwapPlan { + let entries = flatten_batch(batch, probe_bps); if entries.is_empty() { return SwapPlan::None; } From 76fd10c17bd28ae767969e4f6429ee4555168f95 Mon Sep 17 00:00:00 2001 From: wilaq Date: Wed, 20 May 2026 03:56:54 +0200 Subject: [PATCH 10/13] Add TACO frontend swap client Mirrors the ICPSwap two-class layout (SwapIndexClient + SwapPoolClient) but adapted for TACO's single-canister-many-pools model: every TokenSwapPool emitted by TacoIndexClient shares the exchange canister id, and TacoPoolClient queries getExpectedReceiveAmount directly. DexId is extended to "icpswap" | "taco", ExchangeTokenSwapArgs becomes a discriminated union so the TACO variant can carry the treasury_canister_id required by checkReceive on the backend, and apiExchangeArgs / apiDexId / swapProvider all learn the new variant. Typebox types are patched surgically to keep the diff focused on the Taco-related additions only. --- .../src/services/common/chatMappersV2.ts | 2 ++ .../src/services/dexes/index.ts | 2 ++ .../services/dexes/taco/index/candid/can.did | 17 ++++++++++ .../services/dexes/taco/index/candid/idl.d.ts | 8 +++++ .../services/dexes/taco/index/candid/idl.js | 15 +++++++++ .../dexes/taco/index/candid/types.d.ts | 18 +++++++++++ .../src/services/dexes/taco/index/mappers.ts | 16 ++++++++++ .../dexes/taco/index/taco.index.client.ts | 26 ++++++++++++++++ .../services/dexes/taco/pool/candid/can.did | 31 +++++++++++++++++++ .../services/dexes/taco/pool/candid/idl.d.ts | 8 +++++ .../services/dexes/taco/pool/candid/idl.js | 31 +++++++++++++++++++ .../dexes/taco/pool/candid/types.d.ts | 29 +++++++++++++++++ .../src/services/dexes/taco/pool/mappers.ts | 7 +++++ .../dexes/taco/pool/taco.pool.client.ts | 27 ++++++++++++++++ .../src/services/openchatAgent.ts | 24 +++++++++++--- .../src/services/registry/mappers.ts | 1 + .../src/services/user/mappersV2.ts | 19 ++++++++---- frontend/openchat-agent/src/typebox.ts | 19 +++++++++--- .../openchat-shared/src/domain/dexes/index.ts | 15 +++++---- 19 files changed, 294 insertions(+), 21 deletions(-) create mode 100644 frontend/openchat-agent/src/services/dexes/taco/index/candid/can.did create mode 100644 frontend/openchat-agent/src/services/dexes/taco/index/candid/idl.d.ts create mode 100644 frontend/openchat-agent/src/services/dexes/taco/index/candid/idl.js create mode 100644 frontend/openchat-agent/src/services/dexes/taco/index/candid/types.d.ts create mode 100644 frontend/openchat-agent/src/services/dexes/taco/index/mappers.ts create mode 100644 frontend/openchat-agent/src/services/dexes/taco/index/taco.index.client.ts create mode 100644 frontend/openchat-agent/src/services/dexes/taco/pool/candid/can.did create mode 100644 frontend/openchat-agent/src/services/dexes/taco/pool/candid/idl.d.ts create mode 100644 frontend/openchat-agent/src/services/dexes/taco/pool/candid/idl.js create mode 100644 frontend/openchat-agent/src/services/dexes/taco/pool/candid/types.d.ts create mode 100644 frontend/openchat-agent/src/services/dexes/taco/pool/mappers.ts create mode 100644 frontend/openchat-agent/src/services/dexes/taco/pool/taco.pool.client.ts 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..288df1d08c --- /dev/null +++ b/frontend/openchat-agent/src/services/dexes/taco/pool/candid/can.did @@ -0,0 +1,31 @@ +// 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 + +type HopDetail = record { + tokenIn: text; + tokenOut: text; + amountIn: nat; + amountOut: nat; + fee: nat; + priceImpact: float64; +}; + +type PotentialOrderDetails = record { + amount_init: nat; + amount_sell: nat; +}; + +type QuoteResponse = record { + expectedBuyAmount: nat; + fee: nat; + priceImpact: float64; + routeDescription: text; + canFulfillFully: bool; + potentialOrderDetails: opt PotentialOrderDetails; + hopDetails: vec HopDetail; +}; + +service : { + getExpectedReceiveAmount: (text, text, nat) -> (QuoteResponse) 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..7cfa4ab8fc --- /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 { QuoteResponse, _SERVICE } from "./types"; +export { + QuoteResponse as ApiQuoteResponse, + _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..02aa8eb98c --- /dev/null +++ b/frontend/openchat-agent/src/services/dexes/taco/pool/candid/idl.js @@ -0,0 +1,31 @@ +export const idlFactory = ({ IDL }) => { + const HopDetail = IDL.Record({ + 'tokenIn' : IDL.Text, + 'tokenOut' : IDL.Text, + 'amountIn' : IDL.Nat, + 'amountOut' : IDL.Nat, + 'fee' : IDL.Nat, + 'priceImpact' : IDL.Float64, + }); + const PotentialOrderDetails = IDL.Record({ + 'amount_init' : IDL.Nat, + 'amount_sell' : IDL.Nat, + }); + const QuoteResponse = IDL.Record({ + 'expectedBuyAmount' : IDL.Nat, + 'fee' : IDL.Nat, + 'priceImpact' : IDL.Float64, + 'routeDescription' : IDL.Text, + 'canFulfillFully' : IDL.Bool, + 'potentialOrderDetails' : IDL.Opt(PotentialOrderDetails), + 'hopDetails' : IDL.Vec(HopDetail), + }); + return IDL.Service({ + 'getExpectedReceiveAmount' : IDL.Func( + [IDL.Text, IDL.Text, IDL.Nat], + [QuoteResponse], + ['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..8ac9af4eea --- /dev/null +++ b/frontend/openchat-agent/src/services/dexes/taco/pool/candid/types.d.ts @@ -0,0 +1,29 @@ +import type { ActorMethod } from '@icp-sdk/core/agent'; +import type { IDL } from '@icp-sdk/core/candid'; + +export interface HopDetail { + 'tokenIn' : string, + 'tokenOut' : string, + 'amountIn' : bigint, + 'amountOut' : bigint, + 'fee' : bigint, + 'priceImpact' : number, +} +export interface PotentialOrderDetails { + 'amount_init' : bigint, + 'amount_sell' : bigint, +} +export interface QuoteResponse { + 'expectedBuyAmount' : bigint, + 'fee' : bigint, + 'priceImpact' : number, + 'routeDescription' : string, + 'canFulfillFully' : boolean, + 'potentialOrderDetails' : [] | [PotentialOrderDetails], + 'hopDetails' : Array, +} +export interface _SERVICE { + 'getExpectedReceiveAmount' : ActorMethod<[string, string, bigint], QuoteResponse>, +} +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..466a6ced51 --- /dev/null +++ b/frontend/openchat-agent/src/services/dexes/taco/pool/mappers.ts @@ -0,0 +1,7 @@ +import type { ApiQuoteResponse } from "./candid/idl"; + +// TACO's getExpectedReceiveAmount returns a rich quote record. OC only needs +// the headline output amount for ranking quotes. +export function quoteResponse(candid: ApiQuoteResponse): 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..65e39443c2 --- /dev/null +++ b/frontend/openchat-agent/src/services/dexes/taco/pool/taco.pool.client.ts @@ -0,0 +1,27 @@ +import type { HttpAgent, Identity } from "@icp-sdk/core/agent"; +import { idlFactory, type TacoExchangePoolService } from "./candid/idl"; +import { CandidCanisterAgent } from "../../../canisterAgent/candid"; +import { quoteResponse } 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 is kept 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 { + const args: [string, string, bigint] = [inputToken, outputToken, amountIn]; + return this.handleQueryResponse( + () => this.service.getExpectedReceiveAmount(...args), + quoteResponse, + 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 }; From 01d59e20a8b96309eadf7ce40f18f5fe3629f978 Mon Sep 17 00:00:00 2001 From: wilaq Date: Wed, 20 May 2026 04:13:28 +0200 Subject: [PATCH 11/13] Label TACO correctly in SwapCrypto UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Desktop SwapCrypto's dexName() switch only matched "icpswap" and fell through to undefined for "taco" — the TACO label in the quote selector would render blank. Mobile SwapCrypto's dexName() hardcoded "ICPSwap" for any DexId, so TACO swaps would be mislabeled as ICPSwap. Both now switch on dex with explicit "taco" → "TACO" branches. --- .../app/src/components/home/profile/SwapCrypto.svelte | 2 ++ .../src/components_mobile/home/wallet/SwapCrypto.svelte | 9 +++++++-- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/frontend/app/src/components/home/profile/SwapCrypto.svelte b/frontend/app/src/components/home/profile/SwapCrypto.svelte index 8753764922..a7c6dd99ab 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) { From 962029d0bf67ba130147d4099f2ea8dabdd79eb2 Mon Sep 17 00:00:00 2001 From: wilaq Date: Wed, 20 May 2026 14:54:01 +0200 Subject: [PATCH 12/13] Use BatchMulti in TACO frontend quotes so split routes show up The frontend was calling getExpectedReceiveAmount, which only picks the best single route. The user_canister backend runs a route x fraction optimizer that often beats it by splitting across multiple pools (the 30/70 pattern the TACO frontend shows). Result: the quote OC displayed was lower than what the swap would actually deliver. Switch TacoPoolClient.quote to call getExpectedReceiveAmountBatchMulti at the same 10-fraction x top-5 probe grid the backend uses, and port the same build_swap_plan optimizer (2-leg and 3-leg search, bp tuple pre-enumeration, sorted desc groups, upper-bound prune, inner-loop break, 0.1% threshold pre-seed, no-shared-pool-edge compatibility). Worst case is still around 810 iterations, typical case much less. Quote output now matches what the backend executor will deliver. --- .../services/dexes/taco/pool/candid/can.did | 20 +- .../services/dexes/taco/pool/candid/idl.d.ts | 6 +- .../services/dexes/taco/pool/candid/idl.js | 18 +- .../dexes/taco/pool/candid/types.d.ts | 18 +- .../src/services/dexes/taco/pool/mappers.ts | 14 +- .../src/services/dexes/taco/pool/optimizer.ts | 271 ++++++++++++++++++ .../dexes/taco/pool/taco.pool.client.ts | 36 ++- 7 files changed, 362 insertions(+), 21 deletions(-) create mode 100644 frontend/openchat-agent/src/services/dexes/taco/pool/optimizer.ts 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 index 288df1d08c..92496c71c1 100644 --- a/frontend/openchat-agent/src/services/dexes/taco/pool/candid/can.did +++ b/frontend/openchat-agent/src/services/dexes/taco/pool/candid/can.did @@ -1,6 +1,10 @@ // 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 getExpectedReceiveAmountBatchMulti (NOT the single getExpectedReceiveAmount) +// so the frontend quote reflects TACO's split-route optimizer — matching what +// the user_canister backend will actually deliver at execution time. type HopDetail = record { tokenIn: text; @@ -16,7 +20,7 @@ type PotentialOrderDetails = record { amount_sell: nat; }; -type QuoteResponse = record { +type QuoteRoute = record { expectedBuyAmount: nat; fee: nat; priceImpact: float64; @@ -24,8 +28,20 @@ type QuoteResponse = record { canFulfillFully: bool; potentialOrderDetails: opt PotentialOrderDetails; hopDetails: vec HopDetail; + routeTokens: vec text; + tradingFeeBps: nat; +}; + +type RequestResponse = record { + routes: vec QuoteRoute; +}; + +type Request = record { + tokenSell: text; + tokenBuy: text; + amountSell: nat; }; service : { - getExpectedReceiveAmount: (text, text, nat) -> (QuoteResponse) query; + getExpectedReceiveAmountBatchMulti: (vec Request, nat) -> (vec RequestResponse) 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 index 7cfa4ab8fc..9afebc510a 100644 --- 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 @@ -1,7 +1,9 @@ import type { IDL } from "@icp-sdk/core/candid"; -import { QuoteResponse, _SERVICE } from "./types"; +import { BatchMultiResponse, Request, QuoteRoute, _SERVICE } from "./types"; export { - QuoteResponse as ApiQuoteResponse, + BatchMultiResponse as ApiBatchMultiResponse, + Request as ApiBatchMultiRequest, + QuoteRoute as ApiQuoteRoute, _SERVICE as TacoExchangePoolService, }; 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 index 02aa8eb98c..28ed6da972 100644 --- a/frontend/openchat-agent/src/services/dexes/taco/pool/candid/idl.js +++ b/frontend/openchat-agent/src/services/dexes/taco/pool/candid/idl.js @@ -11,7 +11,7 @@ export const idlFactory = ({ IDL }) => { 'amount_init' : IDL.Nat, 'amount_sell' : IDL.Nat, }); - const QuoteResponse = IDL.Record({ + const QuoteRoute = IDL.Record({ 'expectedBuyAmount' : IDL.Nat, 'fee' : IDL.Nat, 'priceImpact' : IDL.Float64, @@ -19,11 +19,21 @@ export const idlFactory = ({ IDL }) => { 'canFulfillFully' : IDL.Bool, 'potentialOrderDetails' : IDL.Opt(PotentialOrderDetails), 'hopDetails' : IDL.Vec(HopDetail), + 'routeTokens' : IDL.Vec(IDL.Text), + 'tradingFeeBps' : IDL.Nat, + }); + const RequestResponse = IDL.Record({ + 'routes' : IDL.Vec(QuoteRoute), + }); + const Request = IDL.Record({ + 'tokenSell' : IDL.Text, + 'tokenBuy' : IDL.Text, + 'amountSell' : IDL.Nat, }); return IDL.Service({ - 'getExpectedReceiveAmount' : IDL.Func( - [IDL.Text, IDL.Text, IDL.Nat], - [QuoteResponse], + 'getExpectedReceiveAmountBatchMulti' : IDL.Func( + [IDL.Vec(Request), IDL.Nat], + [IDL.Vec(RequestResponse)], ['query'], ), }); 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 index 8ac9af4eea..7ee162aa19 100644 --- 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 @@ -13,7 +13,7 @@ export interface PotentialOrderDetails { 'amount_init' : bigint, 'amount_sell' : bigint, } -export interface QuoteResponse { +export interface QuoteRoute { 'expectedBuyAmount' : bigint, 'fee' : bigint, 'priceImpact' : number, @@ -21,9 +21,23 @@ export interface QuoteResponse { 'canFulfillFully' : boolean, 'potentialOrderDetails' : [] | [PotentialOrderDetails], 'hopDetails' : Array, + 'routeTokens' : Array, + 'tradingFeeBps' : bigint, } +export interface RequestResponse { + 'routes' : Array, +} +export interface Request { + 'tokenSell' : string, + 'tokenBuy' : string, + 'amountSell' : bigint, +} +export type BatchMultiResponse = Array; export interface _SERVICE { - 'getExpectedReceiveAmount' : ActorMethod<[string, string, bigint], QuoteResponse>, + 'getExpectedReceiveAmountBatchMulti' : ActorMethod< + [Array, bigint], + BatchMultiResponse + >, } 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 index 466a6ced51..cf266523a1 100644 --- a/frontend/openchat-agent/src/services/dexes/taco/pool/mappers.ts +++ b/frontend/openchat-agent/src/services/dexes/taco/pool/mappers.ts @@ -1,7 +1,11 @@ -import type { ApiQuoteResponse } from "./candid/idl"; +import type { ApiBatchMultiResponse } from "./candid/idl"; +import { buildSwapPlan } from "./optimizer"; -// TACO's getExpectedReceiveAmount returns a rich quote record. OC only needs -// the headline output amount for ranking quotes. -export function quoteResponse(candid: ApiQuoteResponse): bigint { - return candid.expectedBuyAmount; +// Run the same split-route optimizer the user_canister backend uses at +// execution time, so the displayed quote matches the actual deliverable. +export function batchMultiQuoteResponse( + candid: ApiBatchMultiResponse, + bps: bigint[], +): bigint { + return buildSwapPlan(candid, bps).expectedOut; } diff --git a/frontend/openchat-agent/src/services/dexes/taco/pool/optimizer.ts b/frontend/openchat-agent/src/services/dexes/taco/pool/optimizer.ts new file mode 100644 index 0000000000..1dd83be9a4 --- /dev/null +++ b/frontend/openchat-agent/src/services/dexes/taco/pool/optimizer.ts @@ -0,0 +1,271 @@ +// Line-by-line TypeScript port of the user_canister backend's `build_swap_plan` +// at backend/canisters/user/impl/src/token_swaps/taco.rs:391-568. The frontend +// quote runs this same optimizer over TACO's BatchMulti response so the +// displayed amount matches what the backend will deliver at execution time. +// +// All amounts are bigint (JS arbitrary-precision); Rust's u128 saturating_mul +// is unnecessary because bigint doesn't overflow. The fixed-bp tuple lists are +// pre-enumerated combinations that sum to 10000 (basis points of the swap +// input), keeping the search to ~810 worst-case iterations vs the +// C(50,2)+C(50,3) ≈ 21k it would otherwise be. + +import type { ApiBatchMultiResponse, ApiQuoteRoute } from "./candid/idl"; + +export const NUM_FRACTIONS = 10; +export const STEP_BP = 1000n; +export const TOP_ROUTES_PER_FRACTION = 5n; +export const MAX_LEGS = 3; +const SPLIT_IMPROVEMENT_NUMERATOR = 1001n; +const SPLIT_IMPROVEMENT_DENOMINATOR = 1000n; + +// (a <= b), a + b = 10000 +const TWO_LEG_BP_PAIRS: ReadonlyArray = [ + [1000n, 9000n], + [2000n, 8000n], + [3000n, 7000n], + [4000n, 6000n], + [5000n, 5000n], +]; + +// (a <= b <= c), a + b + c = 10000 +const THREE_LEG_BP_TRIPLES: ReadonlyArray = [ + [1000n, 1000n, 8000n], + [1000n, 2000n, 7000n], + [1000n, 3000n, 6000n], + [1000n, 4000n, 5000n], + [2000n, 2000n, 6000n], + [2000n, 3000n, 5000n], + [2000n, 4000n, 4000n], + [3000n, 3000n, 4000n], +]; + +export type SwapHop = { tokenIn: string; tokenOut: string }; + +export type QuoteEntry = { + bp: bigint; + route: SwapHop[]; + expectedOut: bigint; + routeKey: string; + edgeKeys: string[]; +}; + +export type SwapPlan = + | { kind: "single"; expectedOut: bigint } + | { kind: "multi"; expectedOut: bigint } + | { kind: "none"; expectedOut: bigint }; + +// Normalize a pool edge so (A,B) and (B,A) hash to the same key. +function normalizeEdge(a: string, b: string): string { + return a < b ? `${a}|${b}` : `${b}|${a}`; +} + +// Two leg edge-sets overlap iff they share any normalized edge. +function edgesOverlap(a: string[], b: string[]): boolean { + for (const ea of a) { + for (const eb of b) { + if (ea === eb) return true; + } + } + return false; +} + +// Materialize hops for a quote entry. For direct (1-hop) routes the canister +// returns hopDetails = [] AND routeTokens = [tokenSell, tokenBuy]; synthesize a +// single hop in that case so the optimizer can still compute an edge key. +export function hopsFromRoute(route: ApiQuoteRoute): SwapHop[] { + if (route.hopDetails.length > 0) { + return route.hopDetails.map((h) => ({ + tokenIn: h.tokenIn, + tokenOut: h.tokenOut, + })); + } + if (route.routeTokens.length === 2) { + return [{ tokenIn: route.routeTokens[0], tokenOut: route.routeTokens[1] }]; + } + return []; +} + +// Flatten BatchMulti into entries (one per (fraction, route)). Dedupes by +// (bp, routeKey) so each fraction sees each route at most once. `bps` must +// contain the actual basis-points for each submitted probe, in the same order +// as the batch request — otherwise dropped zero-amount probes would shift the +// index and mislabel responses (same bug Copilot caught in the backend). +export function flattenBatch(batch: ApiBatchMultiResponse, bps: bigint[]): QuoteEntry[] { + const out: QuoteEntry[] = []; + const seen = new Set(); + const n = Math.min(batch.length, bps.length); + for (let i = 0; i < n; i++) { + const req = batch[i]; + const bp = bps[i]; + for (const route of req.routes) { + const expected = route.expectedBuyAmount; + if (expected === 0n) continue; + const hops = hopsFromRoute(route); + if (hops.length === 0) continue; + const routeKey = route.routeTokens.join("→"); + const dedupKey = `${bp}|${routeKey}`; + if (seen.has(dedupKey)) continue; + seen.add(dedupKey); + const edgeKeys = hops.map((h) => normalizeEdge(h.tokenIn, h.tokenOut)); + out.push({ bp, route: hops, expectedOut: expected, routeKey, edgeKeys }); + } + } + return out; +} + +function pairCompatible(a: QuoteEntry, b: QuoteEntry): boolean { + return a.routeKey !== b.routeKey && !edgesOverlap(a.edgeKeys, b.edgeKeys); +} + +// Route × fraction optimizer. Returns the best total expected_out across all +// considered plans (single OR 2-leg OR 3-leg). Mirrors the Rust version exactly. +export function buildSwapPlan( + batch: ApiBatchMultiResponse, + probeBps: bigint[], +): SwapPlan { + const entries = flattenBatch(batch, probeBps); + if (entries.length === 0) { + return { kind: "none", expectedOut: 0n }; + } + + // Group entries by their bp, then sort each group by expectedOut desc. + const byBp = new Map(); + for (let idx = 0; idx < entries.length; idx++) { + const e = entries[idx]; + const arr = byBp.get(e.bp); + if (arr) arr.push(idx); + else byBp.set(e.bp, [idx]); + } + for (const indices of byBp.values()) { + indices.sort((a, b) => { + const da = entries[a].expectedOut; + const db = entries[b].expectedOut; + if (db > da) return 1; + if (db < da) return -1; + return 0; + }); + } + + const empty: number[] = []; + const group = (bp: bigint): number[] => byBp.get(bp) ?? empty; + const groupTopOut = (bp: bigint): bigint => { + const g = group(bp); + return g.length > 0 ? entries[g[0]].expectedOut : 0n; + }; + + // Baseline: top route at 100% — after sorting it's the first entry in the + // 10000-bp group (if any). + const baselineGroup = group(10000n); + const baselineIdx = baselineGroup.length > 0 ? baselineGroup[0] : -1; + const baselineOut = baselineIdx >= 0 ? entries[baselineIdx].expectedOut : 0n; + + // Pre-seed best_total to the 0.1% threshold so any combo that displaces it + // is by definition ≥ 0.1% better than baseline. + let bestTotal: bigint = + (baselineOut * SPLIT_IMPROVEMENT_NUMERATOR) / SPLIT_IMPROVEMENT_DENOMINATOR; + let bestPlan: number[] | null = null; + + // ── 2-leg search ──────────────────────────────────────────────────────── + for (const [bpA, bpB] of TWO_LEG_BP_PAIRS) { + // Tuple upper-bound prune. + if (groupTopOut(bpA) + groupTopOut(bpB) <= bestTotal) continue; + + const groupA = group(bpA); + if (bpA === bpB) { + for (let xi = 0; xi < groupA.length; xi++) { + const i = groupA[xi]; + const aOut = entries[i].expectedOut; + const nextOut = + xi + 1 < groupA.length ? entries[groupA[xi + 1]].expectedOut : 0n; + if (aOut + nextOut <= bestTotal) break; + for (let xj = xi + 1; xj < groupA.length; xj++) { + const j = groupA[xj]; + const total = aOut + entries[j].expectedOut; + if (total <= bestTotal) break; + if (pairCompatible(entries[i], entries[j])) { + bestTotal = total; + bestPlan = [i, j]; + } + } + } + } else { + const groupB = group(bpB); + const maxBOut = groupB.length > 0 ? entries[groupB[0]].expectedOut : 0n; + for (const i of groupA) { + const aOut = entries[i].expectedOut; + if (aOut + maxBOut <= bestTotal) break; + for (const j of groupB) { + const total = aOut + entries[j].expectedOut; + if (total <= bestTotal) break; + if (pairCompatible(entries[i], entries[j])) { + bestTotal = total; + bestPlan = [i, j]; + } + } + } + } + } + + // ── 3-leg search ──────────────────────────────────────────────────────── + if (MAX_LEGS >= 3) { + for (const [bpA, bpB, bpC] of THREE_LEG_BP_TRIPLES) { + if (groupTopOut(bpA) + groupTopOut(bpB) + groupTopOut(bpC) <= bestTotal) continue; + + const groupA = group(bpA); + const groupB = group(bpB); + const groupC = group(bpC); + const sameAB = bpA === bpB; + const sameBC = bpB === bpC; + const maxCOut = groupC.length > 0 ? entries[groupC[0]].expectedOut : 0n; + + for (let xi = 0; xi < groupA.length; xi++) { + const i = groupA[xi]; + const aOut = entries[i].expectedOut; + const bStart = sameAB ? xi + 1 : 0; + if (bStart >= groupB.length) continue; + const maxBAtStart = entries[groupB[bStart]].expectedOut; + if (aOut + maxBAtStart + maxCOut <= bestTotal) break; + + for (let xj = bStart; xj < groupB.length; xj++) { + const j = groupB[xj]; + const bOut = entries[j].expectedOut; + if (aOut + bOut + maxCOut <= bestTotal) break; + if (!pairCompatible(entries[i], entries[j])) continue; + const cStart = sameBC ? xj + 1 : 0; + if (cStart >= groupC.length) continue; + for (let xk = cStart; xk < groupC.length; xk++) { + const k = groupC[xk]; + const total = aOut + bOut + entries[k].expectedOut; + if (total <= bestTotal) break; + if ( + pairCompatible(entries[i], entries[k]) && + pairCompatible(entries[j], entries[k]) + ) { + bestTotal = total; + bestPlan = [i, j, k]; + } + } + } + } + } + } + + // best_plan != null ⇔ "split beats baseline by > 0.1%" because best_total + // was pre-seeded to the threshold. + if (bestPlan !== null) { + return { kind: "multi", expectedOut: bestTotal }; + } + + if (baselineIdx >= 0) { + return { kind: "single", expectedOut: baselineOut }; + } + + // No 100% route exists. Best-effort: pick the highest-output entry from + // any fraction as a single route. This preserves the "always returns + // something if any route exists" guarantee. + let bestEntry = entries[0]; + for (const e of entries) { + if (e.expectedOut > bestEntry.expectedOut) bestEntry = e; + } + return { kind: "single", expectedOut: bestEntry.expectedOut }; +} 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 index 65e39443c2..539c0ec534 100644 --- 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 @@ -1,27 +1,51 @@ import type { HttpAgent, Identity } from "@icp-sdk/core/agent"; import { idlFactory, type TacoExchangePoolService } from "./candid/idl"; import { CandidCanisterAgent } from "../../../canisterAgent/candid"; -import { quoteResponse } from "./mappers"; +import { batchMultiQuoteResponse } from "./mappers"; import type { SwapPoolClient } from "../../index"; import { TACO_EXCHANGE_CANISTER_ID } from "../index/mappers"; +import { NUM_FRACTIONS, STEP_BP, TOP_ROUTES_PER_FRACTION } from "./optimizer"; 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 + // TACO routes internally; the pool client doesn't need the token // ordering. Constructor signature is kept 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 { - const args: [string, string, bigint] = [inputToken, outputToken, amountIn]; + // Build the 10-fraction probe grid — same shape as the user_canister + // backend's TacoExchangeClient. Each surviving probe carries its bp + // forward in `bps` so the optimizer can label batch responses correctly + // even when filter_map drops zero-amount entries. + const probes: { tokenSell: string; tokenBuy: string; amountSell: bigint }[] = []; + const bps: bigint[] = []; + for (let i = 0; i < NUM_FRACTIONS; i++) { + const bp = (BigInt(i) + 1n) * STEP_BP; + const amt = (amountIn * bp) / 10000n; + if (amt > 0n) { + probes.push({ + tokenSell: inputToken, + tokenBuy: outputToken, + amountSell: amt, + }); + bps.push(bp); + } + } + if (probes.length === 0) return Promise.resolve(0n); + return this.handleQueryResponse( - () => this.service.getExpectedReceiveAmount(...args), - quoteResponse, - args, + () => + this.service.getExpectedReceiveAmountBatchMulti( + probes, + TOP_ROUTES_PER_FRACTION, + ), + (resp) => batchMultiQuoteResponse(resp, bps), + [probes, TOP_ROUTES_PER_FRACTION], ); } } From 29da6daea45e799ec6b6a06432599b13fa25f982 Mon Sep 17 00:00:00 2001 From: wilaq Date: Thu, 4 Jun 2026 14:22:44 +0200 Subject: [PATCH 13/13] Use getExpectedReceiveAmountBatchMultiOptimal from TACO exchange TACO now ships a single endpoint that runs the BatchMulti probe grid AND the 2/3-leg split-route optimizer internally, returning the chosen plan. OC's local optimizer (Rust build_swap_plan and TS buildSwapPlan) is no longer needed - one source of truth lives on the exchange canister. Backend: taco.rs swap() now calls get_expected_receive_amount_batch_multi_optimal, reads plan.legs (single-leg uses swap_multi_hop, multi-leg uses swap_split_routes), and uses plan.trading_fee_bps for the amount_in_total math. Drops about 300 lines (flatten_batch, build_swap_plan, pair_compatible, hops_from_route, normalize_edge, edges_overlap, extract_trading_fee_bps, QuoteEntry, PlannedLeg, SwapPlan, bp tuple constants, NUM_FRACTIONS/STEP_BP/TOP_ROUTES_PER_FRACTION/MAX_LEGS/SPLIT_IMPROVEMENT_*). make_split_legs_from_optimal replaces make_split_legs. Frontend: TacoPoolClient.quote calls getExpectedReceiveAmountBatchMultiOptimal directly; optimizer.ts is gone (271 lines removed). Candid declares only the Optimal method now. The legacy getExpectedReceiveAmountBatchMulti is still on the exchange for treasury, buyback, and the off-chain arb bot which need per-fraction outputs for cross-DEX scenarios. --- .../user/impl/src/token_swaps/taco.rs | 495 ++---------------- ...cted_receive_amount_batch_multi_optimal.rs | 40 ++ .../taco_exchange/api/src/queries/mod.rs | 1 + .../taco_exchange/c2c_client/src/lib.rs | 15 + .../services/dexes/taco/pool/candid/can.did | 53 +- .../services/dexes/taco/pool/candid/idl.d.ts | 6 +- .../services/dexes/taco/pool/candid/idl.js | 39 +- .../dexes/taco/pool/candid/types.d.ts | 37 +- .../src/services/dexes/taco/pool/mappers.ts | 14 +- .../src/services/dexes/taco/pool/optimizer.ts | 271 ---------- .../dexes/taco/pool/taco.pool.client.ts | 40 +- 11 files changed, 168 insertions(+), 843 deletions(-) create mode 100644 backend/external_canisters/taco_exchange/api/src/queries/get_expected_receive_amount_batch_multi_optimal.rs delete mode 100644 frontend/openchat-agent/src/services/dexes/taco/pool/optimizer.ts diff --git a/backend/canisters/user/impl/src/token_swaps/taco.rs b/backend/canisters/user/impl/src/token_swaps/taco.rs index 7046f4b639..ca19fe423d 100644 --- a/backend/canisters/user/impl/src/token_swaps/taco.rs +++ b/backend/canisters/user/impl/src/token_swaps/taco.rs @@ -3,27 +3,11 @@ use crate::token_swaps::nat_to_u128; use async_trait::async_trait; use candid::Nat; use serde::{Deserialize, Serialize}; -use std::collections::{HashMap, HashSet}; -use taco_exchange_canister::get_expected_receive_amount_batch_multi as batch_multi; +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}; -// 10-fraction probe grid (10%, 20%, ..., 100%). Mirrors the treasury trader's -// scenario builder at TACO_Backend/src/treasury/treasury.mo:10646 — top-5 routes -// per fraction, single inter-canister call. Together with the route × fraction -// enumerator below this lets us discover asymmetric splits (e.g. 30%/70%) -// without further round-trips. -const NUM_FRACTIONS: usize = 10; -const STEP_BP: u128 = 1000; -const TOP_ROUTES_PER_FRACTION: u128 = 5; -const MAX_LEGS: usize = 3; -// 0.1% — must beat the unsplit baseline by this margin before we'll take on the -// extra slippage risk of a multi-leg execution. Matches the frontend's -// useSwapFlow composable. -const SPLIT_IMPROVEMENT_NUMERATOR: u128 = 1001; -const SPLIT_IMPROVEMENT_DENOMINATOR: u128 = 1000; - #[derive(Serialize, Deserialize)] pub struct TacoExchangeClient { swap_canister_id: CanisterId, @@ -78,19 +62,10 @@ impl SwapClient for TacoExchangeClient { // ~5-10 seconds later when the timer fires. // // Consequence: when OC reports SuccessResult { amount_out } to the - // user, the tokens may not yet be in the user canister's balance. - // They arrive a few seconds later via TACO's transfer timer. A - // frontend that polls the wallet balance right after success will - // briefly see stale state. Same for refunds on TACO-level failures - // (SlippageExceeded, etc.) — the refund queued by TACO also rides - // the 5s timer. - // - // Long-term fixes (not implemented): - // - implement `withdraw()` as a polling check that waits for the - // balance to reflect the expected amount before returning, OR - // - have TACO governance add OC user canisters to allowedCanisters - // so swap_multi_hop calls with immediate=true and drains the - // transfer queue synchronously before returning. + // 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 @@ -139,89 +114,59 @@ impl SwapClient for TacoExchangeClient { let token_in = self.input_token.ledger.to_string(); let token_out = self.output_token.ledger.to_string(); - // Build the 10-fraction × top-5 grid. Probe amounts are `usable * (i+1) / 10` - // — a pre-trading-fee estimate good enough for routing; the exact bps - // comes back inline in the response and is applied to the execution - // amount below. - // Collect bps in parallel with probes so flatten_batch can label the - // response entries with the actual submitted bp — without this, dropping - // zero-amount probes via filter_map would shift the index and mislabel - // surviving entries (e.g. a response for the 30% probe would be tagged - // as 10% by index-derived bp). - let (probes, probe_bps): (Vec, Vec) = (0..NUM_FRACTIONS) - .filter_map(|i| { - let bp = ((i as u128) + 1) * STEP_BP; - let amt = usable.saturating_mul(bp) / 10000; - if amt == 0 { - None - } else { - Some(( - batch_multi::Request { - token_sell: token_in.clone(), - token_buy: token_out.clone(), - amount_sell: amt.into(), - }, - bp, - )) - } - }) - .unzip(); - - if probes.is_empty() { - return Ok(Err("TACO swap: amount too small for any probe fraction".to_string())); - } - - let batch = taco_exchange_canister_c2c_client::get_expected_receive_amount_batch_multi( + // 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, - (probes, Nat::from(TOP_ROUTES_PER_FRACTION)), + (token_in.clone(), token_out.clone(), Nat::from(usable)), ) .await?; - // Pull the live trading fee bps from any route in the response (every - // route carries the same ICPfee snapshot). Defensively clamp to TACO's - // enforced range [1, 50]. - let fee_bps = match extract_trading_fee_bps(&batch) { - Some(bps) => bps.clamp(1, 50), - None => return Ok(Err("TACO swap: no routes returned for any fraction".to_string())), - }; + 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. + // 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())); } - // Route × fraction enumeration — same algorithm the TACO frontend's - // useSwapFlow composable runs (and the screenshot at chat shows producing - // a 30/70 split). Disjoint-pool constraint mirrors hopsSharePool from - // TACO_Backend/src/treasury/treasury.mo:10895. - let plan = build_swap_plan(&batch, &probe_bps); - - match plan { - SwapPlan::Single(route) => execute_swap_multi_hop( + 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, - route, + leg.route.clone(), min_amount_out, block_index, ) - .await, - SwapPlan::Multi(legs) => { - let split_legs = make_split_legs(&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 - } - SwapPlan::None => Ok(Err("TACO swap: no viable route found".to_string())), + .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 } } @@ -231,364 +176,30 @@ impl SwapClient for TacoExchangeClient { } } -// ── plan types ────────────────────────────────────────────────────────────── - -enum SwapPlan { - Single(Vec), - Multi(Vec), - None, -} - -#[derive(Clone)] -struct PlannedLeg { - /// Basis points of the total amount allocated to this leg (sums to 10000 - /// across all legs of a Multi plan). - bp: u128, - route: Vec, -} - -#[derive(Clone)] -struct QuoteEntry { - bp: u128, - route: Vec, - expected_out: u128, - route_key: String, - edge_keys: Vec, -} - -// ── helpers ───────────────────────────────────────────────────────────────── - -fn extract_trading_fee_bps(batch: &batch_multi::Response) -> Option { - batch - .iter() - .flat_map(|req| req.routes.iter()) - .next() - .map(|r| nat_to_u128(r.trading_fee_bps.clone())) -} - -fn normalize_edge(a: &str, b: &str) -> String { - if a < b { - format!("{a}|{b}") - } else { - format!("{b}|{a}") - } -} - -// Two leg's pool sets overlap iff they share any normalized edge. -fn edges_overlap(a: &[String], b: &[String]) -> bool { - for ea in a { - for eb in b { - if ea == eb { - return true; - } - } - } - false -} - -// Materialize hops for a quote entry. For direct routes the canister returns -// hopDetails = [] AND routeTokens = [tokenSell, tokenBuy]; synthesize a single -// hop from the route_tokens in that case. -fn hops_from_route(route: &batch_multi::QuoteRoute) -> Vec { - if !route.hop_details.is_empty() { - return route - .hop_details - .iter() - .map(|h| SwapHop { - token_in: h.token_in.clone(), - token_out: h.token_out.clone(), - }) - .collect(); - } - if route.route_tokens.len() == 2 { - return vec![SwapHop { - token_in: route.route_tokens[0].clone(), - token_out: route.route_tokens[1].clone(), - }]; - } - Vec::new() -} - -// Flatten BatchMulti into entries (one per (fraction, route)). Dedupes by -// (bp, route_key) so each fraction sees each route at most once. -// `bps` must contain the actual basis-points for each submitted probe, in the -// same order as the batch request. This avoids mislabeling responses when -// zero-amount probes were filtered out before submission. -fn flatten_batch(batch: &batch_multi::Response, bps: &[u128]) -> Vec { - let mut out: Vec = Vec::new(); - let mut seen: HashSet<(u128, String)> = HashSet::new(); - for (req, bp) in batch.iter().zip(bps.iter().copied()) { - for route in &req.routes { - let expected = nat_to_u128(route.expected_buy_amount.clone()); - if expected == 0 { - continue; - } - let hops = hops_from_route(route); - if hops.is_empty() { - continue; - } - let route_key = route.route_tokens.join("→"); - if !seen.insert((bp, route_key.clone())) { - continue; - } - let edge_keys: Vec = hops.iter().map(|h| normalize_edge(&h.token_in, &h.token_out)).collect(); - out.push(QuoteEntry { - bp, - route: hops, - expected_out: expected, - route_key, - edge_keys, - }); - } - } - out -} - -// Pre-enumerated bp tuples whose components are all ∈ {1000..9000} and sum to -// 10000 — the ONLY tuples that can form a full-coverage 2- or 3-leg split with -// the 10-fraction probe grid. The optimizer iterates these directly instead of -// scanning every pair / triple of entries, collapsing the search from -// C(50,2)+C(50,3) ≈ 21,000 iterations to ≈ 810. -const TWO_LEG_BP_PAIRS: &[(u128, u128)] = &[ - (1000, 9000), - (2000, 8000), - (3000, 7000), - (4000, 6000), - (5000, 5000), -]; - -const THREE_LEG_BP_TRIPLES: &[(u128, u128, u128)] = &[ - (1000, 1000, 8000), - (1000, 2000, 7000), - (1000, 3000, 6000), - (1000, 4000, 5000), - (2000, 2000, 6000), - (2000, 3000, 5000), - (2000, 4000, 4000), - (3000, 3000, 4000), -]; - -// Route × fraction optimizer. -// -// Algorithm (matches the TACO frontend's `useSwapFlow` "Split Route" feature): -// -// 1. Flatten the BatchMulti grid into entries `{ bp, route, expected_out, edge_keys }` -// and group them by bp; sort each group by expected_out descending. -// 2. Baseline = best entry at the 100% fraction; threshold = baseline + 0.1%. -// 3. Initialize `best_total = threshold`. Any combo that displaces it is by -// definition acceptable (post-loop check becomes a simple `Some` test). -// 4. For each precomputed bp tuple that sums to 10000: -// a. Upper-bound prune: skip the tuple if `Σ group_top(bp_i) ≤ best_total`. -// b. Walk the cross-product of the matching entry groups, breaking inner -// loops as soon as the running sum can no longer beat best_total -// (sound because each group is sorted desc). -// c. Within the iteration, reject combos that share a route_key or any -// normalized pool edge. -// 5. Pick the combination with the largest sum of expected_out. -// -// Worst case is still the 5 pair tuples + 8 triple tuples × 5-route groups -// (~810 iterations); typical case prunes most of that out before any real work. -fn build_swap_plan(batch: &batch_multi::Response, probe_bps: &[u128]) -> SwapPlan { - let entries = flatten_batch(batch, probe_bps); - if entries.is_empty() { - return SwapPlan::None; - } - - // Group entries by their bp, then sort each group by expected_out desc so - // we can early-break inner loops when the running sum stops beating - // best_total. - let mut by_bp: HashMap> = HashMap::new(); - for (idx, e) in entries.iter().enumerate() { - by_bp.entry(e.bp).or_default().push(idx); - } - for indices in by_bp.values_mut() { - indices.sort_by(|&a, &b| entries[b].expected_out.cmp(&entries[a].expected_out)); - } - - let empty: Vec = Vec::new(); - let group = |bp: u128| -> &Vec { by_bp.get(&bp).unwrap_or(&empty) }; - let group_top_out = |bp: u128| -> u128 { - group(bp).first().map(|&i| entries[i].expected_out).unwrap_or(0) - }; - - // Baseline: top route at 100% — after sorting it's the first entry. - let baseline_idx = group(10000).first().copied(); - let baseline_out = baseline_idx.map(|i| entries[i].expected_out).unwrap_or(0); - - // Pre-seed best_total to the 0.1% threshold so every comparison during the - // search also enforces the acceptance criterion. When baseline_out == 0 - // (no full-amount route), this collapses to best_total = 0 and any positive - // combo wins. - let mut best_total: u128 = - baseline_out.saturating_mul(SPLIT_IMPROVEMENT_NUMERATOR) / SPLIT_IMPROVEMENT_DENOMINATOR; - let mut best_plan: Option> = None; - - // ── 2-leg search ──────────────────────────────────────────────────────── - for &(bp_a, bp_b) in TWO_LEG_BP_PAIRS { - // Tuple upper-bound prune. - if group_top_out(bp_a) + group_top_out(bp_b) <= best_total { - continue; - } - - let group_a = group(bp_a); - if bp_a == bp_b { - for xi in 0..group_a.len() { - let i = group_a[xi]; - let a_out = entries[i].expected_out; - let next_out = group_a - .get(xi + 1) - .map(|&j| entries[j].expected_out) - .unwrap_or(0); - // a_out is non-increasing in xi (sorted), and next_out ≤ a_out. - // If even (a, next) can't beat, no later xi will. - if a_out + next_out <= best_total { - break; - } - for &j in &group_a[xi + 1..] { - let total = a_out + entries[j].expected_out; - if total <= best_total { - break; - } - if pair_compatible(&entries[i], &entries[j]) { - best_total = total; - best_plan = Some(vec![i, j]); - } - } - } - } else { - let group_b = group(bp_b); - let max_b_out = group_b - .first() - .map(|&j| entries[j].expected_out) - .unwrap_or(0); - for &i in group_a { - let a_out = entries[i].expected_out; - if a_out + max_b_out <= best_total { - break; - } - for &j in group_b { - let total = a_out + entries[j].expected_out; - if total <= best_total { - break; - } - if pair_compatible(&entries[i], &entries[j]) { - best_total = total; - best_plan = Some(vec![i, j]); - } - } - } - } - } - - // ── 3-leg search ──────────────────────────────────────────────────────── - if MAX_LEGS >= 3 { - for &(bp_a, bp_b, bp_c) in THREE_LEG_BP_TRIPLES { - // Tuple upper-bound prune. - if group_top_out(bp_a) + group_top_out(bp_b) + group_top_out(bp_c) <= best_total { - continue; - } - - let group_a = group(bp_a); - let group_b = group(bp_b); - let group_c = group(bp_c); - let same_ab = bp_a == bp_b; - let same_bc = bp_b == bp_c; - let max_c_out = group_c - .first() - .map(|&k| entries[k].expected_out) - .unwrap_or(0); - - for xi in 0..group_a.len() { - let i = group_a[xi]; - let a_out = entries[i].expected_out; - let b_start = if same_ab { xi + 1 } else { 0 }; - if b_start >= group_b.len() { - continue; - } - let max_b_at_start = entries[group_b[b_start]].expected_out; - if a_out + max_b_at_start + max_c_out <= best_total { - break; - } - - for offset_j in 0..(group_b.len() - b_start) { - let xj = b_start + offset_j; - let j = group_b[xj]; - let b_out = entries[j].expected_out; - if a_out + b_out + max_c_out <= best_total { - break; - } - if !pair_compatible(&entries[i], &entries[j]) { - continue; - } - let c_start = if same_bc { xj + 1 } else { 0 }; - if c_start >= group_c.len() { - continue; - } - for &k in &group_c[c_start..] { - let total = a_out + b_out + entries[k].expected_out; - if total <= best_total { - break; - } - if pair_compatible(&entries[i], &entries[k]) - && pair_compatible(&entries[j], &entries[k]) - { - best_total = total; - best_plan = Some(vec![i, j, k]); - } - } - } - } - } - } - - // best_plan.is_some() ⇔ "split beats baseline by > 0.1%" because best_total - // was pre-seeded to the threshold; no separate accept check needed. - if let Some(legs_idx) = best_plan { - let legs = legs_idx - .into_iter() - .map(|idx| PlannedLeg { - bp: entries[idx].bp, - route: entries[idx].route.clone(), - }) - .collect(); - return SwapPlan::Multi(legs); - } - - if let Some(i) = baseline_idx { - return SwapPlan::Single(entries[i].route.clone()); - } - - // No 100%-fraction route exists. Best-effort: pick the highest-output entry - // across the whole grid and execute it as a single route. - if let Some(e) = entries.iter().max_by_key(|e| e.expected_out) { - return SwapPlan::Single(e.route.clone()); - } - - SwapPlan::None -} - -fn pair_compatible(a: &QuoteEntry, b: &QuoteEntry) -> bool { - a.route_key != b.route_key && !edges_overlap(&a.edge_keys, &b.edge_keys) -} - -// Build [SplitLeg] from a Multi plan: amount_in = total × bp / 10000, with the -// LAST leg carrying any rounding remainder so the sum is exactly `total`. The -// per-leg `minLegOut` is the pro-rata slice of the global min_amount_out (sums -// to min_amount_out within rounding). -fn make_split_legs(legs: &[PlannedLeg], total: u128, min_out_total: u128) -> Vec { +// 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(leg.bp) / 10000; - let m = min_out_total.saturating_mul(leg.bp) / 10000; + 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) 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 index cfaecae476..1338b1ba35 100644 --- a/backend/external_canisters/taco_exchange/api/src/queries/mod.rs +++ b/backend/external_canisters/taco_exchange/api/src/queries/mod.rs @@ -1 +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/c2c_client/src/lib.rs b/backend/external_canisters/taco_exchange/c2c_client/src/lib.rs index 1e5e14f750..84ce7381e0 100644 --- a/backend/external_canisters/taco_exchange/c2c_client/src/lib.rs +++ b/backend/external_canisters/taco_exchange/c2c_client/src/lib.rs @@ -20,6 +20,21 @@ pub async fn get_expected_receive_amount_batch_multi( .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, 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 index 92496c71c1..b120ae3519 100644 --- a/frontend/openchat-agent/src/services/dexes/taco/pool/candid/can.did +++ b/frontend/openchat-agent/src/services/dexes/taco/pool/candid/can.did @@ -2,46 +2,29 @@ // per-pool quotes. Full canister exposed at: // https://dashboard.internetcomputer.org/canister/qioex-5iaaa-aaaan-q52ba-cai // -// OC uses getExpectedReceiveAmountBatchMulti (NOT the single getExpectedReceiveAmount) -// so the frontend quote reflects TACO's split-route optimizer — matching what -// the user_canister backend will actually deliver at execution time. +// 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 HopDetail = record { - tokenIn: text; - tokenOut: text; - amountIn: nat; - amountOut: nat; - fee: nat; - priceImpact: float64; -}; - -type PotentialOrderDetails = record { - amount_init: nat; - amount_sell: nat; -}; - -type QuoteRoute = record { - expectedBuyAmount: nat; - fee: nat; - priceImpact: float64; - routeDescription: text; - canFulfillFully: bool; - potentialOrderDetails: opt PotentialOrderDetails; - hopDetails: vec HopDetail; - routeTokens: vec text; - tradingFeeBps: nat; -}; +type SwapHop = record { tokenIn : text; tokenOut : text }; -type RequestResponse = record { - routes: vec QuoteRoute; +type OptimalSwapLeg = record { + bp : nat; + expectedBuyAmount : nat; + route : vec SwapHop; + routeDescription : text; }; -type Request = record { - tokenSell: text; - tokenBuy: text; - amountSell: nat; +type OptimalSwapPlan = record { + expectedBuyAmount : nat; + fee : nat; + priceImpact : float64; + canFulfillFully : bool; + tradingFeeBps : nat; + routeDescription : text; + legs : vec OptimalSwapLeg; }; service : { - getExpectedReceiveAmountBatchMulti: (vec Request, nat) -> (vec RequestResponse) query; + 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 index 9afebc510a..3fa9f9e175 100644 --- 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 @@ -1,9 +1,7 @@ import type { IDL } from "@icp-sdk/core/candid"; -import { BatchMultiResponse, Request, QuoteRoute, _SERVICE } from "./types"; +import { OptimalSwapPlan, _SERVICE } from "./types"; export { - BatchMultiResponse as ApiBatchMultiResponse, - Request as ApiBatchMultiRequest, - QuoteRoute as ApiQuoteRoute, + OptimalSwapPlan as ApiOptimalSwapPlan, _SERVICE as TacoExchangePoolService, }; 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 index 28ed6da972..f0be22c415 100644 --- a/frontend/openchat-agent/src/services/dexes/taco/pool/candid/idl.js +++ b/frontend/openchat-agent/src/services/dexes/taco/pool/candid/idl.js @@ -1,39 +1,24 @@ export const idlFactory = ({ IDL }) => { - const HopDetail = IDL.Record({ - 'tokenIn' : IDL.Text, - 'tokenOut' : IDL.Text, - 'amountIn' : IDL.Nat, - 'amountOut' : IDL.Nat, - 'fee' : IDL.Nat, - 'priceImpact' : IDL.Float64, - }); - const PotentialOrderDetails = IDL.Record({ - 'amount_init' : IDL.Nat, - 'amount_sell' : IDL.Nat, + 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 QuoteRoute = IDL.Record({ + const OptimalSwapPlan = IDL.Record({ 'expectedBuyAmount' : IDL.Nat, 'fee' : IDL.Nat, 'priceImpact' : IDL.Float64, - 'routeDescription' : IDL.Text, 'canFulfillFully' : IDL.Bool, - 'potentialOrderDetails' : IDL.Opt(PotentialOrderDetails), - 'hopDetails' : IDL.Vec(HopDetail), - 'routeTokens' : IDL.Vec(IDL.Text), 'tradingFeeBps' : IDL.Nat, - }); - const RequestResponse = IDL.Record({ - 'routes' : IDL.Vec(QuoteRoute), - }); - const Request = IDL.Record({ - 'tokenSell' : IDL.Text, - 'tokenBuy' : IDL.Text, - 'amountSell' : IDL.Nat, + 'routeDescription' : IDL.Text, + 'legs' : IDL.Vec(OptimalSwapLeg), }); return IDL.Service({ - 'getExpectedReceiveAmountBatchMulti' : IDL.Func( - [IDL.Vec(Request), IDL.Nat], - [IDL.Vec(RequestResponse)], + 'getExpectedReceiveAmountBatchMultiOptimal' : IDL.Func( + [IDL.Text, IDL.Text, IDL.Nat], + [OptimalSwapPlan], ['query'], ), }); 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 index 7ee162aa19..fc438e69e8 100644 --- 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 @@ -1,42 +1,29 @@ import type { ActorMethod } from '@icp-sdk/core/agent'; import type { IDL } from '@icp-sdk/core/candid'; -export interface HopDetail { +export interface SwapHop { 'tokenIn' : string, 'tokenOut' : string, - 'amountIn' : bigint, - 'amountOut' : bigint, - 'fee' : bigint, - 'priceImpact' : number, } -export interface PotentialOrderDetails { - 'amount_init' : bigint, - 'amount_sell' : bigint, +export interface OptimalSwapLeg { + 'bp' : bigint, + 'expectedBuyAmount' : bigint, + 'route' : Array, + 'routeDescription' : string, } -export interface QuoteRoute { +export interface OptimalSwapPlan { 'expectedBuyAmount' : bigint, 'fee' : bigint, 'priceImpact' : number, - 'routeDescription' : string, 'canFulfillFully' : boolean, - 'potentialOrderDetails' : [] | [PotentialOrderDetails], - 'hopDetails' : Array, - 'routeTokens' : Array, 'tradingFeeBps' : bigint, + 'routeDescription' : string, + 'legs' : Array, } -export interface RequestResponse { - 'routes' : Array, -} -export interface Request { - 'tokenSell' : string, - 'tokenBuy' : string, - 'amountSell' : bigint, -} -export type BatchMultiResponse = Array; export interface _SERVICE { - 'getExpectedReceiveAmountBatchMulti' : ActorMethod< - [Array, bigint], - BatchMultiResponse + 'getExpectedReceiveAmountBatchMultiOptimal' : ActorMethod< + [string, string, bigint], + OptimalSwapPlan >, } export declare const idlFactory: IDL.InterfaceFactory; diff --git a/frontend/openchat-agent/src/services/dexes/taco/pool/mappers.ts b/frontend/openchat-agent/src/services/dexes/taco/pool/mappers.ts index cf266523a1..35f6d8dd66 100644 --- a/frontend/openchat-agent/src/services/dexes/taco/pool/mappers.ts +++ b/frontend/openchat-agent/src/services/dexes/taco/pool/mappers.ts @@ -1,11 +1,7 @@ -import type { ApiBatchMultiResponse } from "./candid/idl"; -import { buildSwapPlan } from "./optimizer"; +import type { ApiOptimalSwapPlan } from "./candid/idl"; -// Run the same split-route optimizer the user_canister backend uses at -// execution time, so the displayed quote matches the actual deliverable. -export function batchMultiQuoteResponse( - candid: ApiBatchMultiResponse, - bps: bigint[], -): bigint { - return buildSwapPlan(candid, bps).expectedOut; +// 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/optimizer.ts b/frontend/openchat-agent/src/services/dexes/taco/pool/optimizer.ts deleted file mode 100644 index 1dd83be9a4..0000000000 --- a/frontend/openchat-agent/src/services/dexes/taco/pool/optimizer.ts +++ /dev/null @@ -1,271 +0,0 @@ -// Line-by-line TypeScript port of the user_canister backend's `build_swap_plan` -// at backend/canisters/user/impl/src/token_swaps/taco.rs:391-568. The frontend -// quote runs this same optimizer over TACO's BatchMulti response so the -// displayed amount matches what the backend will deliver at execution time. -// -// All amounts are bigint (JS arbitrary-precision); Rust's u128 saturating_mul -// is unnecessary because bigint doesn't overflow. The fixed-bp tuple lists are -// pre-enumerated combinations that sum to 10000 (basis points of the swap -// input), keeping the search to ~810 worst-case iterations vs the -// C(50,2)+C(50,3) ≈ 21k it would otherwise be. - -import type { ApiBatchMultiResponse, ApiQuoteRoute } from "./candid/idl"; - -export const NUM_FRACTIONS = 10; -export const STEP_BP = 1000n; -export const TOP_ROUTES_PER_FRACTION = 5n; -export const MAX_LEGS = 3; -const SPLIT_IMPROVEMENT_NUMERATOR = 1001n; -const SPLIT_IMPROVEMENT_DENOMINATOR = 1000n; - -// (a <= b), a + b = 10000 -const TWO_LEG_BP_PAIRS: ReadonlyArray = [ - [1000n, 9000n], - [2000n, 8000n], - [3000n, 7000n], - [4000n, 6000n], - [5000n, 5000n], -]; - -// (a <= b <= c), a + b + c = 10000 -const THREE_LEG_BP_TRIPLES: ReadonlyArray = [ - [1000n, 1000n, 8000n], - [1000n, 2000n, 7000n], - [1000n, 3000n, 6000n], - [1000n, 4000n, 5000n], - [2000n, 2000n, 6000n], - [2000n, 3000n, 5000n], - [2000n, 4000n, 4000n], - [3000n, 3000n, 4000n], -]; - -export type SwapHop = { tokenIn: string; tokenOut: string }; - -export type QuoteEntry = { - bp: bigint; - route: SwapHop[]; - expectedOut: bigint; - routeKey: string; - edgeKeys: string[]; -}; - -export type SwapPlan = - | { kind: "single"; expectedOut: bigint } - | { kind: "multi"; expectedOut: bigint } - | { kind: "none"; expectedOut: bigint }; - -// Normalize a pool edge so (A,B) and (B,A) hash to the same key. -function normalizeEdge(a: string, b: string): string { - return a < b ? `${a}|${b}` : `${b}|${a}`; -} - -// Two leg edge-sets overlap iff they share any normalized edge. -function edgesOverlap(a: string[], b: string[]): boolean { - for (const ea of a) { - for (const eb of b) { - if (ea === eb) return true; - } - } - return false; -} - -// Materialize hops for a quote entry. For direct (1-hop) routes the canister -// returns hopDetails = [] AND routeTokens = [tokenSell, tokenBuy]; synthesize a -// single hop in that case so the optimizer can still compute an edge key. -export function hopsFromRoute(route: ApiQuoteRoute): SwapHop[] { - if (route.hopDetails.length > 0) { - return route.hopDetails.map((h) => ({ - tokenIn: h.tokenIn, - tokenOut: h.tokenOut, - })); - } - if (route.routeTokens.length === 2) { - return [{ tokenIn: route.routeTokens[0], tokenOut: route.routeTokens[1] }]; - } - return []; -} - -// Flatten BatchMulti into entries (one per (fraction, route)). Dedupes by -// (bp, routeKey) so each fraction sees each route at most once. `bps` must -// contain the actual basis-points for each submitted probe, in the same order -// as the batch request — otherwise dropped zero-amount probes would shift the -// index and mislabel responses (same bug Copilot caught in the backend). -export function flattenBatch(batch: ApiBatchMultiResponse, bps: bigint[]): QuoteEntry[] { - const out: QuoteEntry[] = []; - const seen = new Set(); - const n = Math.min(batch.length, bps.length); - for (let i = 0; i < n; i++) { - const req = batch[i]; - const bp = bps[i]; - for (const route of req.routes) { - const expected = route.expectedBuyAmount; - if (expected === 0n) continue; - const hops = hopsFromRoute(route); - if (hops.length === 0) continue; - const routeKey = route.routeTokens.join("→"); - const dedupKey = `${bp}|${routeKey}`; - if (seen.has(dedupKey)) continue; - seen.add(dedupKey); - const edgeKeys = hops.map((h) => normalizeEdge(h.tokenIn, h.tokenOut)); - out.push({ bp, route: hops, expectedOut: expected, routeKey, edgeKeys }); - } - } - return out; -} - -function pairCompatible(a: QuoteEntry, b: QuoteEntry): boolean { - return a.routeKey !== b.routeKey && !edgesOverlap(a.edgeKeys, b.edgeKeys); -} - -// Route × fraction optimizer. Returns the best total expected_out across all -// considered plans (single OR 2-leg OR 3-leg). Mirrors the Rust version exactly. -export function buildSwapPlan( - batch: ApiBatchMultiResponse, - probeBps: bigint[], -): SwapPlan { - const entries = flattenBatch(batch, probeBps); - if (entries.length === 0) { - return { kind: "none", expectedOut: 0n }; - } - - // Group entries by their bp, then sort each group by expectedOut desc. - const byBp = new Map(); - for (let idx = 0; idx < entries.length; idx++) { - const e = entries[idx]; - const arr = byBp.get(e.bp); - if (arr) arr.push(idx); - else byBp.set(e.bp, [idx]); - } - for (const indices of byBp.values()) { - indices.sort((a, b) => { - const da = entries[a].expectedOut; - const db = entries[b].expectedOut; - if (db > da) return 1; - if (db < da) return -1; - return 0; - }); - } - - const empty: number[] = []; - const group = (bp: bigint): number[] => byBp.get(bp) ?? empty; - const groupTopOut = (bp: bigint): bigint => { - const g = group(bp); - return g.length > 0 ? entries[g[0]].expectedOut : 0n; - }; - - // Baseline: top route at 100% — after sorting it's the first entry in the - // 10000-bp group (if any). - const baselineGroup = group(10000n); - const baselineIdx = baselineGroup.length > 0 ? baselineGroup[0] : -1; - const baselineOut = baselineIdx >= 0 ? entries[baselineIdx].expectedOut : 0n; - - // Pre-seed best_total to the 0.1% threshold so any combo that displaces it - // is by definition ≥ 0.1% better than baseline. - let bestTotal: bigint = - (baselineOut * SPLIT_IMPROVEMENT_NUMERATOR) / SPLIT_IMPROVEMENT_DENOMINATOR; - let bestPlan: number[] | null = null; - - // ── 2-leg search ──────────────────────────────────────────────────────── - for (const [bpA, bpB] of TWO_LEG_BP_PAIRS) { - // Tuple upper-bound prune. - if (groupTopOut(bpA) + groupTopOut(bpB) <= bestTotal) continue; - - const groupA = group(bpA); - if (bpA === bpB) { - for (let xi = 0; xi < groupA.length; xi++) { - const i = groupA[xi]; - const aOut = entries[i].expectedOut; - const nextOut = - xi + 1 < groupA.length ? entries[groupA[xi + 1]].expectedOut : 0n; - if (aOut + nextOut <= bestTotal) break; - for (let xj = xi + 1; xj < groupA.length; xj++) { - const j = groupA[xj]; - const total = aOut + entries[j].expectedOut; - if (total <= bestTotal) break; - if (pairCompatible(entries[i], entries[j])) { - bestTotal = total; - bestPlan = [i, j]; - } - } - } - } else { - const groupB = group(bpB); - const maxBOut = groupB.length > 0 ? entries[groupB[0]].expectedOut : 0n; - for (const i of groupA) { - const aOut = entries[i].expectedOut; - if (aOut + maxBOut <= bestTotal) break; - for (const j of groupB) { - const total = aOut + entries[j].expectedOut; - if (total <= bestTotal) break; - if (pairCompatible(entries[i], entries[j])) { - bestTotal = total; - bestPlan = [i, j]; - } - } - } - } - } - - // ── 3-leg search ──────────────────────────────────────────────────────── - if (MAX_LEGS >= 3) { - for (const [bpA, bpB, bpC] of THREE_LEG_BP_TRIPLES) { - if (groupTopOut(bpA) + groupTopOut(bpB) + groupTopOut(bpC) <= bestTotal) continue; - - const groupA = group(bpA); - const groupB = group(bpB); - const groupC = group(bpC); - const sameAB = bpA === bpB; - const sameBC = bpB === bpC; - const maxCOut = groupC.length > 0 ? entries[groupC[0]].expectedOut : 0n; - - for (let xi = 0; xi < groupA.length; xi++) { - const i = groupA[xi]; - const aOut = entries[i].expectedOut; - const bStart = sameAB ? xi + 1 : 0; - if (bStart >= groupB.length) continue; - const maxBAtStart = entries[groupB[bStart]].expectedOut; - if (aOut + maxBAtStart + maxCOut <= bestTotal) break; - - for (let xj = bStart; xj < groupB.length; xj++) { - const j = groupB[xj]; - const bOut = entries[j].expectedOut; - if (aOut + bOut + maxCOut <= bestTotal) break; - if (!pairCompatible(entries[i], entries[j])) continue; - const cStart = sameBC ? xj + 1 : 0; - if (cStart >= groupC.length) continue; - for (let xk = cStart; xk < groupC.length; xk++) { - const k = groupC[xk]; - const total = aOut + bOut + entries[k].expectedOut; - if (total <= bestTotal) break; - if ( - pairCompatible(entries[i], entries[k]) && - pairCompatible(entries[j], entries[k]) - ) { - bestTotal = total; - bestPlan = [i, j, k]; - } - } - } - } - } - } - - // best_plan != null ⇔ "split beats baseline by > 0.1%" because best_total - // was pre-seeded to the threshold. - if (bestPlan !== null) { - return { kind: "multi", expectedOut: bestTotal }; - } - - if (baselineIdx >= 0) { - return { kind: "single", expectedOut: baselineOut }; - } - - // No 100% route exists. Best-effort: pick the highest-output entry from - // any fraction as a single route. This preserves the "always returns - // something if any route exists" guarantee. - let bestEntry = entries[0]; - for (const e of entries) { - if (e.expectedOut > bestEntry.expectedOut) bestEntry = e; - } - return { kind: "single", expectedOut: bestEntry.expectedOut }; -} 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 index 539c0ec534..f2d00233c2 100644 --- 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 @@ -1,10 +1,9 @@ import type { HttpAgent, Identity } from "@icp-sdk/core/agent"; import { idlFactory, type TacoExchangePoolService } from "./candid/idl"; import { CandidCanisterAgent } from "../../../canisterAgent/candid"; -import { batchMultiQuoteResponse } from "./mappers"; +import { optimalQuoteResponse } from "./mappers"; import type { SwapPoolClient } from "../../index"; import { TACO_EXCHANGE_CANISTER_ID } from "../index/mappers"; -import { NUM_FRACTIONS, STEP_BP, TOP_ROUTES_PER_FRACTION } from "./optimizer"; export class TacoPoolClient extends CandidCanisterAgent @@ -12,40 +11,21 @@ export class TacoPoolClient { constructor(identity: Identity, agent: HttpAgent, _token0: string, _token1: string) { // TACO routes internally; the pool client doesn't need the token - // ordering. Constructor signature is kept symmetric with ICPSwap's so + // 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 { - // Build the 10-fraction probe grid — same shape as the user_canister - // backend's TacoExchangeClient. Each surviving probe carries its bp - // forward in `bps` so the optimizer can label batch responses correctly - // even when filter_map drops zero-amount entries. - const probes: { tokenSell: string; tokenBuy: string; amountSell: bigint }[] = []; - const bps: bigint[] = []; - for (let i = 0; i < NUM_FRACTIONS; i++) { - const bp = (BigInt(i) + 1n) * STEP_BP; - const amt = (amountIn * bp) / 10000n; - if (amt > 0n) { - probes.push({ - tokenSell: inputToken, - tokenBuy: outputToken, - amountSell: amt, - }); - bps.push(bp); - } - } - if (probes.length === 0) return Promise.resolve(0n); - + // 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.getExpectedReceiveAmountBatchMulti( - probes, - TOP_ROUTES_PER_FRACTION, - ), - (resp) => batchMultiQuoteResponse(resp, bps), - [probes, TOP_ROUTES_PER_FRACTION], + () => this.service.getExpectedReceiveAmountBatchMultiOptimal(...args), + optimalQuoteResponse, + args, ); } }