Skip to content

Add TACO Exchange as a swap provider#8979

Open
wilaq wants to merge 15 commits into
open-chat-labs:masterfrom
wilaq:taco-exchange-swap-provider
Open

Add TACO Exchange as a swap provider#8979
wilaq wants to merge 15 commits into
open-chat-labs:masterfrom
wilaq:taco-exchange-swap-provider

Conversation

@wilaq
Copy link
Copy Markdown

@wilaq wilaq commented May 12, 2026

Adds the TACO Exchange (qioex-5iaaa-aaaan-q52ba-cai) as a swap provider for OC user canisters, alongside the existing ICPSwap integration. Implements the SwapClient trait at backend/canisters/user/impl/src/token_swaps/swap_client.rs with 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:

  1. Swap any TACO-listed token for any other TACO-listed token through their user canister.
  2. Get the same multi-route output the TACO frontend produces (e.g. 30% via one route + 70% via another, when both pools are deeper than a single direct route).
  3. Benefit from TACO's hybrid orderbook+AMM matching.

How it works

Single inter-canister call for both quote and routing decisions

TacoExchangeClient::swap (the implementation in backend/canisters/user/impl/src/token_swaps/taco.rs) makes one call to TACO's getExpectedReceiveAmountBatchMulti, probing 10 fractions of the input amount ([10%, 20%, …, 100%]) with up to 5 top routes per fraction. From that single response we get:

  • The live trading-fee rate (tradingFeeBps, a new field added to TACO's quote response — see TACO-side PR linked below).
  • All route × fraction combinations needed for optimization.

Route × fraction optimizer

Same algorithm the TACO frontend's useSwapFlow composable runs:

  1. Flatten the BatchMulti grid into { bp, route, expected_out, edge_keys } entries.
  2. Establish the baseline: top route at the 100% fraction (single-route best).
  3. Enumerate 2-leg and 3-leg combinations whose 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).
  4. Pick the combination with the highest sum of expected_out.
  5. Accept the split only if it beats baseline by >0.1%, otherwise execute single-route.

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:

  • Sort each bp group by expected_out descending.
  • Pre-enumerate only the 5 viable bp pairs and 8 viable bp triples that sum to 10000.
  • Tuple upper-bound prune: skip a tuple entirely when Σ group_top(bp_i) ≤ best_total.
  • Inner-loop break when running sum ≤ best_total.
  • Pre-seed best_total to 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

  • 1-leg plan → swap_multi_hop.
  • 2-3-leg plan → swap_split_routes with each leg's amountIn = total × leg.bp / 10000 (last leg absorbs rounding remainder, sum equals the deposit exactly) and min_leg_out pro-rata of the global min_amount_out.

Trait extension

SwapClient::swap gains a new parameter deposit_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 at backend/canisters/user/impl/src/updates/swap_tokens.rs reads the existing transfer_or_approval field that already holds the ICRC1 block index from OC's icrc1_transfer call.

Per-user-canister deposit account

deposit_account() returns { owner: treasury_canister_id, subaccount: None } - TACO's checkReceive validates blocks against its treasury canister (qbnpl-laaaa-aaaan-q52aq-cai), NOT against the exchange canister itself. Both canister IDs are passed via the new ExchangeArgs::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 when swap() returns and OC reports SuccessResult, the tokens haven't physically landed in the user canister yet: they arrive ~5–10s later. No funds at risk (TACO's BlocksDone makes retries idempotent), but a frontend reading the wallet balance right after success will briefly see stale state. Fix would be either a polling withdraw() on the OC side, or a TACO-side change to make delivery synchronous for OC user canisters — both deferred.

Architecture diagram

OC user canister                        TACO Exchange (qioex-...)
       │
       │ deposit_account() ────────────► returns { owner: qbnpl-..., subaccount: None }
       │
       │ icrc1_transfer(amount → qbnpl-...) ───► ledger records block N
       │
       │ swap(amount, min_out, block=N) ───► fires:
       │
       │     getExpectedReceiveAmountBatchMulti([10%..100%], top_k=5)
       │     ◄─── [QuoteRoute { tradingFeeBps, expected_out, route, ... }]
       │
       │     build_swap_plan(batch)
       │       ↓
       │     [if best_split > baseline + 0.1%]
       │       ↓
       │     swap_split_routes(tokenIn, tokenOut, legs, min_out, block) ───► SwapResult
       │     [else]
       │       ↓
       │     swap_multi_hop(tokenIn, tokenOut, amount, route, min_out, block) ─► SwapResult
       │
       │ ◄─── SwapSuccess { amount_out, withdrawal_success: Some(true) }
       │
       │ (TACO's transferQueue timer fires ~5s later, ICRC1-transfers
       │  the output back to this user canister)

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 new trading_fee_bps : Nat field.
  • 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 using encode_args + decode_one (TACO uses positional Candid args with single-value returns, which the existing generate_candid_c2c_call_tuple_args! macro doesn't fit).

Modified:

  • backend/canisters/user/api/src/updates/swap_tokens.rs — new ExchangeArgs::Taco(TacoArgs) variant.
  • backend/libraries/types/src/exchange_id.rs — new ExchangeId::Taco variant + Display impl.
  • backend/canisters/user/impl/src/token_swaps/swap_client.rsSwapClient::swap gains deposit_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 — adds pub mod taco;.
  • backend/canisters/user/impl/src/token_swaps/taco.rsnew, ~400 lines. The TacoExchangeClient impl.
  • backend/canisters/user/impl/src/updates/swap_tokens.rs — plumbs the deposit block index into swap() and adds the ExchangeArgs::Taco arm to build_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.
  • TACO-side (live verification): 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)' returns tradingFeeBps = 5 : nat per route, confirming the new field is on mainnet.

Decisions worth flagging for review

  1. auto_withdrawals = true — OC's framework skips the explicit withdraw() call. TACO auto-pushes the output via its treasury.receiveTransferTasks queue. Comes with the eventual-consistency caveat noted above.

  2. tradingFeeBps flows through the quote response — the OC client reads it from the BatchMulti response rather than calling hmFee() separately. Atomically consistent (same ICPfee snapshot used by the quote's simulation), zero extra round-trips, but requires TACO-side cooperation (already deployed).

  3. Threshold for accepting a split: 0.1% improvement over baseline single-route. Matches the TACO frontend's useSwapFlow composable. Lower threshold would split more aggressively at the cost of more inter-canister-call surface; higher would split less.

  4. Two canister IDs in TacoArgsswap_canister_id (qioex) is where the swap calls go; treasury_canister_id (qbnpl) is where deposits must be sent (TACO's checkReceive validates against the latter). Caller supplies both; defaults in the production deployment are well-known.

  5. 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

  • Frontend changes (wallet UI / OC's exchange dropdown).
  • A polling withdraw() to close the eventual-consistency window.
  • Integration tests (not adding any; happy to add a few if the OC team prefers).

Related TACO-side changes (already deployed to IC mainnet)

The tradingFeeBps field on getExpectedReceiveAmountBatchMulti is a small addition on TACO's side. Deployed and live-verified on qioex-5iaaa-aaaan-q52ba-cai. Forward-compatible (extra Candid field; old clients ignore).

wilaq added 7 commits May 12, 2026 03:07
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.
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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::Taco and ExchangeArgs::Taco(TacoArgs) to expose TACO as a swap provider.
  • Extend SwapClient::swap with deposit_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 implement TacoExchangeClient with 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_result converts ok.amount_out via nat_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.

Comment thread backend/canisters/user/impl/src/token_swaps/taco.rs Outdated
Comment thread backend/canisters/user/impl/src/token_swaps/taco.rs Outdated
wilaq and others added 6 commits May 20, 2026 03:24
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.
@wilaq
Copy link
Copy Markdown
Author

wilaq commented May 20, 2026

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.

julianjelfs and others added 2 commits May 22, 2026 09:27
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants