Add TACO Exchange as a swap provider#8979
Conversation
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<u64> 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
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
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.
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.
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.
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.
There was a problem hiding this comment.
Pull request overview
Adds TACO Exchange as an additional swap provider for OC user canisters by introducing a TACO-specific SwapClient implementation, wiring it through the swap flow, and adding the needed external canister API/client crates.
Changes:
- Add
ExchangeId::TacoandExchangeArgs::Taco(TacoArgs)to expose TACO as a swap provider. - Extend
SwapClient::swapwithdeposit_block_index: Option<u64>and plumb the ledger block index through the swap pipeline (TACO requires it; ICPSwap ignores it). - Introduce new external canister crates (
taco_exchange_canister,taco_exchange_canister_c2c_client) and implementTacoExchangeClientwith batch quote + split-route planning and execution.
Reviewed changes
Copilot reviewed 18 out of 19 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
| Cargo.toml | Adds TACO external canister crates to the workspace members list. |
| Cargo.lock | Adds lock entries for the new TACO canister crates and hooks them into user_canister_impl deps. |
| backend/libraries/types/src/exchange_id.rs | Adds ExchangeId::Taco and display formatting for the new provider. |
| backend/external_canisters/taco_exchange/api/Cargo.toml | New crate for TACO exchange Candid-facing type definitions. |
| backend/external_canisters/taco_exchange/api/src/lib.rs | Defines TACO swap/query result types (SwapHop, SplitLeg, SwapResult, errors, etc.). |
| backend/external_canisters/taco_exchange/api/src/queries/mod.rs | Exposes the batch quote query module. |
| backend/external_canisters/taco_exchange/api/src/queries/get_expected_receive_amount_batch_multi.rs | Adds request/response types for TACO’s batch quote API including tradingFeeBps. |
| backend/external_canisters/taco_exchange/api/src/updates/mod.rs | Exposes TACO swap update argument modules. |
| backend/external_canisters/taco_exchange/api/src/updates/swap_multi_hop.rs | Defines positional args/response type for single-route swap execution. |
| backend/external_canisters/taco_exchange/api/src/updates/swap_split_routes.rs | Defines positional args/response type for split-route swap execution. |
| backend/external_canisters/taco_exchange/c2c_client/Cargo.toml | New c2c client crate for manual encode/decode calls to TACO. |
| backend/external_canisters/taco_exchange/c2c_client/src/lib.rs | Implements c2c calls using encode_args + decode_one for TACO’s positional args / single return. |
| backend/canisters/user/api/src/updates/swap_tokens.rs | Adds ExchangeArgs::Taco and TacoArgs (swap + treasury canister IDs). |
| backend/canisters/user/impl/Cargo.toml | Adds dependencies on the new TACO external canister crates. |
| backend/canisters/user/impl/src/token_swaps/swap_client.rs | Extends SwapClient::swap signature with deposit_block_index. |
| backend/canisters/user/impl/src/token_swaps/icpswap.rs | Updates ICPSwap impl to accept (and ignore) the new swap param. |
| backend/canisters/user/impl/src/token_swaps/mod.rs | Registers the new taco module. |
| backend/canisters/user/impl/src/token_swaps/taco.rs | New TacoExchangeClient implementation: deposit account, batch quote, split optimizer, and swap execution. |
| backend/canisters/user/impl/src/updates/swap_tokens.rs | Plumbs deposit block index into swap() and adds TACO to build_swap_client. |
Comments suppressed due to low confidence (1)
backend/canisters/user/impl/src/token_swaps/taco.rs:651
map_swap_resultconvertsok.amount_outvianat_to_u128, which currently panics on out-of-range Nats. Since this value comes from an external canister, handle overflow explicitly and return an error rather than trapping the user canister.
fn map_swap_result(result: SwapResult) -> Result<SwapSuccess, String> {
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:?}")),
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
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.
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.
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.
|
Quick note on the current swap frontend: the TACO DEX doesnt have pools the way ICPSwap does. Because the DEX is a single canister, you dont need a token to have a pool with the target asset to swap between them, the only requirement is that the token is accepted within the exchange. (Feel free to PM me if you think any tokens should be added.) One thing I noticed while testing: it isnt possible to quote an amount higher than the held balance. If its the frontend that does the quote queries, would it make sense to allow arbitrary amounts there? Just so the swap functionality of OC can be fully tested. |
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.
Adds the TACO Exchange (
qioex-5iaaa-aaaan-q52ba-cai) as a swap provider for OC user canisters, alongside the existing ICPSwap integration. Implements theSwapClienttrait atbackend/canisters/user/impl/src/token_swaps/swap_client.rswith TACO-specific deposit verification, multi-route route discovery, and asymmetric split-route execution.The TACO Exchange is a hybrid orderbook + AMM (V2 constant-product + V3 concentrated liquidity) DEX run by the TACO DAO. Public docs:
https://dashboard.internetcomputer.org/canister/qioex-5iaaa-aaaan-q52ba-cai
https://forum.dfinity.org/t/taco-dao-new-exchange-taco-burn-and-nachos/68380/5
https://exchange.tacodao.com/portfolio (and then go to docs)
What this enables
OC users will be able to:
How it works
Single inter-canister call for both quote and routing decisions
TacoExchangeClient::swap(the implementation inbackend/canisters/user/impl/src/token_swaps/taco.rs) makes one call to TACO'sgetExpectedReceiveAmountBatchMulti, probing 10 fractions of the input amount ([10%, 20%, …, 100%]) with up to 5 top routes per fraction. From that single response we get:tradingFeeBps, a new field added to TACO's quote response — see TACO-side PR linked below).Route × fraction optimizer
Same algorithm the TACO frontend's
useSwapFlowcomposable runs:{ bp, route, expected_out, edge_keys }entries.bps sum to 10000 exactly, where each pair of legs has no shared pool edge (bidirectional check —{A,B}and{B,A}count as the same pool).expected_out.Asymmetric splits like 10/90, 30/70, 20/40/40 are natural outputs — not forced 50/50 or 33/33/33.
Smart pruning
To keep the search fast:
expected_outdescending.Σ group_top(bp_i) ≤ best_total.best_totalto the 0.1% acceptance threshold so the search itself enforces the criterion.Worst-case ~810 iterations; typical ~20–80. Same result as brute-force enumeration (proved by upper-bound monotonicity).
Execution
swap_multi_hop.swap_split_routeswith each leg'samountIn = total × leg.bp / 10000(last leg absorbs rounding remainder, sum equals the deposit exactly) andmin_leg_outpro-rata of the globalmin_amount_out.Trait extension
SwapClient::swapgains a new parameterdeposit_block_index: Option<u64>- required by TACO's block-verified deposit model. ICPSwap's impl ignores it (_deposit_block_index), so existing behavior is unchanged for that provider. The plumbing atbackend/canisters/user/impl/src/updates/swap_tokens.rsreads the existingtransfer_or_approvalfield that already holds the ICRC1 block index from OC'sicrc1_transfercall.Per-user-canister deposit account
deposit_account()returns{ owner: treasury_canister_id, subaccount: None }- TACO'scheckReceivevalidates blocks against its treasury canister (qbnpl-laaaa-aaaan-q52aq-cai), NOT against the exchange canister itself. Both canister IDs are passed via the newExchangeArgs::Taco { swap_canister_id, treasury_canister_id }so staging/test environments can override.Eventual-consistency note
Documented inline at
taco.rs:auto_withdrawals(around line 58): TACO delivers swap output through a 5-second internal timer rather than synchronously, so whenswap()returns and OC reportsSuccessResult, the tokens haven't physically landed in the user canister yet: they arrive ~5–10s later. No funds at risk (TACO'sBlocksDonemakes retries idempotent), but a frontend reading the wallet balance right after success will briefly see stale state. Fix would be either a pollingwithdraw()on the OC side, or a TACO-side change to make delivery synchronous for OC user canisters — both deferred.Architecture diagram
Files changed
New Rust crates (under
backend/external_canisters/taco_exchange/):api/Cargo.toml+api/src/lib.rs— types (SwapHop,SplitLeg,SwapOk,ExchangeError,SwapResult,HopDetail).api/src/queries/get_expected_receive_amount_batch_multi.rs— request/response types for the batch quote, including the newtrading_fee_bps : Natfield.api/src/updates/swap_multi_hop.rs— args for single-route execution.api/src/updates/swap_split_routes.rs— args for split-route execution.c2c_client/Cargo.toml+c2c_client/src/lib.rs— manual c2c stubs usingencode_args+decode_one(TACO uses positional Candid args with single-value returns, which the existinggenerate_candid_c2c_call_tuple_args!macro doesn't fit).Modified:
backend/canisters/user/api/src/updates/swap_tokens.rs— newExchangeArgs::Taco(TacoArgs)variant.backend/libraries/types/src/exchange_id.rs— newExchangeId::Tacovariant +Displayimpl.backend/canisters/user/impl/src/token_swaps/swap_client.rs—SwapClient::swapgainsdeposit_block_index: Option<u64>.backend/canisters/user/impl/src/token_swaps/icpswap.rs— accepts (and ignores) the new param.backend/canisters/user/impl/src/token_swaps/mod.rs— addspub mod taco;.backend/canisters/user/impl/src/token_swaps/taco.rs— new, ~400 lines. The TacoExchangeClient impl.backend/canisters/user/impl/src/updates/swap_tokens.rs— plumbs the deposit block index intoswap()and adds theExchangeArgs::Tacoarm tobuild_swap_client.backend/canisters/user/impl/Cargo.toml— adds dependencies on the new external_canisters crates.Cargo.toml— adds the two new workspace members.Verification
cargo check -p user_canister_impl→ clean (no warnings).cargo check -p taco_exchange_canister -p taco_exchange_canister_c2c_client→ clean.dfx canister --network ic call OTC_backend getExpectedReceiveAmountBatchMulti '(vec { record { tokenSell = "ryjl3-tyaaa-aaaaa-aaaba-cai"; tokenBuy = "kknbx-zyaaa-aaaaq-aae4a-cai"; amountSell = 100_000_000 } }, 2)'returnstradingFeeBps = 5 : natper route, confirming the new field is on mainnet.Decisions worth flagging for review
auto_withdrawals = true— OC's framework skips the explicitwithdraw()call. TACO auto-pushes the output via itstreasury.receiveTransferTasksqueue. Comes with the eventual-consistency caveat noted above.tradingFeeBpsflows through the quote response — the OC client reads it from the BatchMulti response rather than callinghmFee()separately. Atomically consistent (sameICPfeesnapshot used by the quote's simulation), zero extra round-trips, but requires TACO-side cooperation (already deployed).Threshold for accepting a split: 0.1% improvement over baseline single-route. Matches the TACO frontend's
useSwapFlowcomposable. Lower threshold would split more aggressively at the cost of more inter-canister-call surface; higher would split less.Two canister IDs in
TacoArgs—swap_canister_id(qioex) is where the swap calls go;treasury_canister_id(qbnpl) is where deposits must be sent (TACO'scheckReceivevalidates against the latter). Caller supplies both; defaults in the production deployment are well-known.Not changed: the OC frontend's provider picker. Adding TACO to the wallet UI is a separate frontend PR; this is backend-only.
Out of scope
withdraw()to close the eventual-consistency window.Related TACO-side changes (already deployed to IC mainnet)
The
tradingFeeBpsfield ongetExpectedReceiveAmountBatchMultiis a small addition on TACO's side. Deployed and live-verified onqioex-5iaaa-aaaan-q52ba-cai. Forward-compatible (extra Candid field; old clients ignore).