From 23f8671d58255700330393b8ea85eb606deb3dc8 Mon Sep 17 00:00:00 2001 From: srikanth-bitdynamics <259878899+srikanth-bitdynamics@users.noreply.github.com> Date: Thu, 13 Aug 2026 11:25:11 +0530 Subject: [PATCH] docs: restructure and clarify the documentation set Rework the docs so a reader new to the codebase can build understanding top-down: clearer hierarchy, diagrams where they explain a mechanism, short inlined Daml snippets, and cross-links from concepts to the tests that prove them. Refresh Token Standard V2 wording to reflect that it is merged and the network default. Add plain-language comments to the tests the docs link to, and colour Daml snippets with a Shiki language alias. --- docs/README.md | 8 +- docs/concepts/architecture.md | 757 +++++++---------- docs/concepts/glossary.md | 187 +++-- docs/concepts/liquidity-and-custody.md | 266 +++--- docs/concepts/lp-tokens.md | 258 +++--- docs/concepts/non-goals.md | 191 +++-- docs/concepts/overview.md | 235 +++--- docs/concepts/pricing.md | 142 ++-- docs/concepts/workflows.md | 768 +++++++----------- docs/guides/add-a-trading-pair.md | 196 +++-- docs/guides/add-lp-or-instrument.md | 324 +++++--- docs/guides/builder-guide.md | 393 ++++----- docs/guides/choice-context.md | 197 ++++- docs/guides/deployment.md | 204 +++-- docs/guides/operator-guide.md | 313 +++---- docs/guides/operator-runbook.md | 339 +++++--- docs/guides/registry-integration.md | 115 ++- docs/guides/run-on-testnet.md | 178 ++-- docs/guides/using-the-dapp.md | 320 +++++--- docs/guides/validator-test-plan.md | 58 +- docs/reference/allocation-surface.md | 170 ++-- docs/reference/ecosystem-feedback.md | 144 +++- docs/reference/http-api.md | 630 ++++++++------ docs/reference/testing.md | 346 +++++--- .../CantonDex/Tests/DvpMintBurnTests.daml | 37 + .../CantonDex/Tests/EndToEndTests.daml | 104 ++- .../Tests/PoolLiquidityRulesTests.daml | 28 + .../CantonDex/Tests/PoolRoundingTests.daml | 38 + .../Tests/PoolStateInvariantTests.daml | 28 + .../CantonDex/Tests/RfqSettlementTests.daml | 18 + website/astro.config.mjs | 5 + 31 files changed, 4102 insertions(+), 2895 deletions(-) diff --git a/docs/README.md b/docs/README.md index ea1bc8cf..3b0a11e0 100644 --- a/docs/README.md +++ b/docs/README.md @@ -12,9 +12,9 @@ For the ideas behind the design, read the **[Overview](concepts/overview.md)**. > **Standards note.** This reference implements the Canton Network Token > Standard **V2 (CIP-0112)** — the privacy/performance/accounting revision of > the base token standard (**CIP-0056**) — and uses the **CIP-0103** dApp -> standard for trader-authorized wallet submissions. V2 has merged into -> `canton-network/splice` `main` and becomes the network default from -> **mid-July 2026**; the exact vendored commit is pinned in +> standard for trader-authorized wallet submissions. V2 is merged into +> `canton-network/splice` `main` and is the network default; the exact +> vendored commit is pinned in > [`../vendor/splice/VENDOR_PIN.md`](../vendor/splice/VENDOR_PIN.md). --- @@ -59,7 +59,7 @@ lookup (reference). ### Guides — do a task | Page | Audience | Recipe | |---|---|---| -| [Builder Guide](guides/builder-guide.md) | Builder | The contract surface, off-chain layout, matcher logic, and extension patterns. | +| [Builder Guide](guides/builder-guide.md) | Builder | The contract surface, off-ledger layout, matcher logic, and extension patterns. | | [Using the dApp](guides/using-the-dapp.md) | Trader, LP, dealer | Swap, add/remove liquidity, place orders, trade an RFQ block, read the portfolio. | | [Add a Trading Pair](guides/add-a-trading-pair.md) | Operator | List a new pair (e.g. `ETH/USDT`) on a running venue. | | [Add an LP or Instrument](guides/add-lp-or-instrument.md) | Builder, operator | Mint a new asset or lifecycle-rich instrument via Token Standard V2. | diff --git a/docs/concepts/architecture.md b/docs/concepts/architecture.md index ac5d19df..bf4a4668 100644 --- a/docs/concepts/architecture.md +++ b/docs/concepts/architecture.md @@ -1,460 +1,325 @@ # Canton DEX Architecture -## Purpose - -Canton DEX is a token-standard-native reference DEX for Canton. - -It is intentionally not a generic settlement engine. [Non-goals](non-goals.md) -collects what the reference leaves out on purpose, and why. The goal is to -show builders how to build a real exchange directly on top of: - -- Token Standard V2 allocations and batch settlement -- registry-backed holdings whose registries implement the V2 holding, - allocation, and settlement APIs -- Canton privacy, routing, and atomic transaction semantics - -## Design inputs - -The architecture is based on three concrete upstream inputs. - -### 1. TradingAppV2 - -The `TradingAppV2` example establishes the basic trading pattern we want to -reuse: - -- the application owns trade state such as `OTCTrade` -- the venue or executor requests per-authorizer allocations -- allocations are grouped by admin and settled through - `SettlementFactory_SettleBatch` -- cancellation is an application concern, not a hidden wallet concern - -That example still contains V1/V2 bridging for compatibility. We can learn from -that structure without making mixed-mode support the center of this repo. - -### 2. Registry workflows - -The reference registry in this repository, and the registry workflows used in -Digital Asset examples, anchor one concrete instrument model: - -- a registrar may create an `InstrumentConfiguration`-style contract for each - supported instrument -- holder credentials govern transfer eligibility -- issuer credentials govern mint and burn eligibility -- the instrument config can carry external identifiers such as ISIN or CUSIP - -Token Standard V2 does not mandate `InstrumentConfiguration` or lifecycle -contracts (though the token-standard `metadata-v1` API does expose queryable -instrument properties). A production registry can expose the same V2 holding/allocation -interfaces with a different internal model. The DEX should therefore treat -`InstrumentId` and registry-provided choice context as the stable integration -boundary, not our reference configuration template. - -This means the DEX should treat `InstrumentId` as the join key into richer -instrument semantics instead of hardcoding asset families in the exchange. - -### 3. Token Standard V2 allocation surface - -The pool design depends on the Token Standard V2 (CIP-0112) allocation -extensions, now merged into `canton-network/splice` `main`: - -- iterated settlement -- `nextIterationFunding` -- committed allocations -- `FinalizedAllocation.extraTransferLegSides` -- settle results that return next-iteration allocation state - -Those changes make it possible to use allocations not only for trade -reservation but also for long-lived pool inventory. - -## Core decisions - -1. Token standard first - - the DEX should use V2 allocation primitives directly, not hide them behind - a generic settlement abstraction - -2. DEX contracts own market logic - - orders, matched trades, pools, and LP state belong to the application - -3. Allocations represent funds, not just approval - - bids and asks are backed by allocations - - pool inventory is represented by committed and iterated allocations - -4. Arbitrary instruments, one registry per pair - - any instrument whose registry implements the V2 holding and allocation - APIs can be traded, not just "cash vs asset" flows - - both legs of a pair share one registry `admin`. `DexPair`, `Order`, - `Pool` and `MatchedTrade` each carry a single `admin : Party`, and the - standard's `TransferLeg.instrumentId` is bare `Text`, so a leg cannot - name its own admin. Pairing instruments from two different registries is - therefore not expressible today. See - [Registry Integration](../guides/registry-integration.md#what-the-dex-does-not-assume) - -5. Instrument lifecycle stays outside DEX logic - - bonds, options, escrow obligations, margin-like positions, and LP tokens - should remain V2 holdings at the settlement boundary - - their lifecycle semantics come from registry-specific contracts, metadata, - and choice context, not from DEX templates and not from a Token Standard - lifecycle package - -6. LP token is first-class - - pool shares should be their own instrument and be holdable, transferable, - and eventually tradable - -7. Workflow-first design - - the shape of Daml choices and state transitions matters more than chasing - full feature parity with existing AMMs - -8. Executor-controlled funds need on-ledger guardrails - - once iterated or committed allocations exist, the executor can drive - their settlement path - - therefore every permitted use of those funds should be validated by Daml - contract state and choice logic, not only by an off-ledger service - -## System model - -```text -┌──────────────────────────────────────────────────────────────┐ -│ Client Layer │ -│ UI / API clients / market makers / LP operators │ -└──────────────────────────────┬───────────────────────────────┘ - │ -┌──────────────────────────────▼───────────────────────────────┐ -│ Off-Chain Service Layer │ -│ order matching · pool math · quote generation │ -│ registry lookups · lifecycle automation · observability │ -└──────────────────────────────┬───────────────────────────────┘ - │ -┌──────────────────────────────▼───────────────────────────────┐ -│ DEX Application Layer │ -│ orders · matched trades · pools · LP issuance │ -│ reserve accounting · fee accounting · cancellations │ -└──────────────────────────────┬───────────────────────────────┘ - │ -┌──────────────────────────────▼───────────────────────────────┐ -│ Token Standard / Registry Layer │ -│ HoldingV2 · AllocationV2 · AllocationRequestV2 │ -│ SettlementFactory · registry-specific metadata/context │ -└──────────────────────────────┬───────────────────────────────┘ - │ -┌──────────────────────────────▼───────────────────────────────┐ -│ Canton Layer │ -│ participants · routing · privacy · atomic execution │ -└──────────────────────────────────────────────────────────────┘ +Canton DEX keeps market logic — orders, pools, RFQ — in its own Daml contracts, +but it never moves value itself: every settlement runs through the Token +Standard V2 (CIP-0112) allocation and batch-settlement APIs, so the exchange has +no bespoke escrow and no custody path of its own. This page is the map of that +split. [Non-goals](non-goals.md) records what the reference deliberately leaves +out, and why. + +## The three layers + +```mermaid +flowchart TB + subgraph off["Off-ledger — proposes, no authority over funds"] + dapp["dApp + trader / LP wallet"] + backend["Operator backend
indexer · matcher · pricing"] + end + subgraph dex["On-ledger DEX contracts — own market logic"] + pools["Pool · PoolState · PoolSlice · PoolRules"] + orders["Order · OrderMatchExecution"] + otc["Rfq · RfqQuote · MatchedTrade"] + lp["Lp.LPTokenPolicy"] + end + subgraph spine["Token Standard V2 / CIP-0112 — owns value"] + reg["Registry.V2
Allocation · SettlementFactory"] + end + backend -. "drives DEX choices
(operator authority)" .-> dex + dapp -. "authors funding allocations
(holder authority)" .-> reg + pools & orders & otc & lp -- "SettleBatch" --> reg ``` -## Workflow-first reading - -Read this architecture through its workflows: - -- pair listing -- OTC / RFQ trade settlement -- resting order placement and matching -- pool creation -- add liquidity and remove liquidity -- pool swap -- lifecycle-driven instrument migration - -Those workflows are described in [workflows.md](./workflows.md). The contracts -should be shaped around those state transitions, not the other way around. - -## On-ledger model - -### Instrument layer - -The instrument layer defines what is being traded. Token Standard V2 gives the -DEX the cross-registry surface (`InstrumentId`, `Holding`, allocations, and -settlement). Individual registries define the richer semantics behind that -surface. - -Reference-registry concepts: - -- `InstrumentConfiguration` or an equivalent registry-specific config record -- registry-managed transfer rules and credentials -- versioned instrument semantics -- optional external identifiers such as ISIN or CUSIP - -The DEX should not understand a bond, option, or margin position by special -case. It should understand that it trades an `InstrumentId`, and the registry -should explain what that instrument means through V2 views, metadata, -disclosure, and registry-specific choice context. - -### Order and trade layer - -Orders should be represented by DEX contracts plus allocations. - -A practical model is: - -- `Order` or `Quote` stores side, pair, price, remaining quantity, and expiry -- one or more `V2.Allocation` contracts prove that the order is prefunded -- a matcher creates `MatchedTrade` or similar application state -- settlement uses the matched trade plus the referenced allocations - -Important nuance: - -- the order contract is the market object -- the allocation is the reserved-funds object - -That keeps price-time priority and cancellation in app logic while still making -fund reservation standard-native. - -### Pool layer - -The pool should also be represented by DEX contracts plus allocations. - -A practical model is: - -- `Pool` defines the pair, fee model, executor, and accounting policy -- LP deposits create or refresh pool-managed committed allocations -- pool reserves are tracked in application state for pricing purposes -- the actual locked pool funds live in committed and iterated allocations -- each swap adjusts those allocations, settles them, and rolls forward the - next-iteration allocations - -Pool inventory should be allocation-native, not a custom internal balance model -with a different settlement bridge behind it. - -### Executor-control constraint - -The V2 allocation extensions make long-lived, iterated allocations workable for orders and pools, -but it also raises a control question: - -- once funds sit in committed / iterated allocations, the executor is the party - that can drive their settlement path - -That is acceptable only if the DEX application layer constrains what those -funds may be used for. - -The intended model is: +Three bands, top to bottom: + +- **Off-ledger orchestration** has no authority over funds. The operator backend + indexes the ledger, matches orders, prices pools, and *proposes* actions. It + cannot lock a user's holdings; funds are locked only by an allocation the + holder authors under the holder's own authority. +- **On-ledger DEX contracts** own market logic — price-time priority, + cancellation, pool accounting, RFQ ranking — and drive settlement, but they + express funds only as Token Standard allocations. +- **The Token Standard V2 settlement spine** owns value. `Registry.V2` + implements the holding, allocation, and settlement interfaces; + `SettlementFactory_SettleBatch` is the one primitive that actually transfers + anything. + +The trust boundary is the pair of dashed edges crossing into the ledger: the +operator drives DEX choices under its own authority, but it can neither fund a +trade nor bypass the validation those choices perform on-ledger. That claim is +the design's core, and [the executor-control +constraint](#the-executor-control-constraint) makes it precise. + +## What settles value: the Token Standard V2 spine + +Every value movement in the DEX reduces to two things: a set of **allocations** +(funds locked by their owner, pinned to a settlement) and one +**`SettlementFactory_SettleBatch`** that atomically executes the transfer legs +those allocations authorize. + +Two properties of the V2 allocation surface — the CIP-0112 extensions, now +merged into `canton-network/splice` `main` — are load-bearing here: + +- **Allocations represent funds, not just approval.** A bid, an ask, and each + side of pool inventory are all backed by a live allocation whose holdings are + actually locked. +- **Allocations can be committed and iterated.** `committed` keeps LP liquidity + from being casually withdrawn; `nextIterationFunding` and + `FinalizedAllocation.extraTransferLegSides` let one allocation fund a + long-lived position and roll forward across many settlements. That is what + makes pool inventory *allocation-native* rather than a custom balance with an + escrow bridge behind it. + +`Registry.V2` is the reference registry implementing these interfaces for the +in-script tests, the testnet harness, and the live DEX. It is not privileged: +any registry that implements the same V2 holding/allocation/settlement APIs can +back a traded instrument, so the DEX treats `InstrumentId` and registry-supplied +choice context as the stable integration boundary, not this template. The +guarantees the DEX relies on are enforced inside `SettlementFactory_SettleBatch`: +allocation-to-leg coverage (exactly one allocation authorizes each side of each +leg) and per-instrument sender/receiver balance across the whole batch. + +## The on-ledger DEX contracts + +Read the app as four contract families, each pairing a market object with the +allocations that fund it. + +### Pools — four contracts, deliberately split + +A constant-product pool is not one contract but four, split so a swap contends +on as little state as possible: + +- `Pool` — immutable configuration: the pair, `feeBps`, and the parties. The + stable identifier everything else hangs off. +- `PoolState` — the hot singleton: aggregate `reserves`, LP supply, status. A + swap must read global reserves to price `x*y=k`, so this is the one + irreducible serialization point, kept as small as possible. +- `PoolSlice` — one committed allocation backing one side. Funds are *sharded* + across many slices, so add creates a slice (conflicting with nothing) while + swap and remove touch only the slices they source. +- `PoolRules` — the operations (`PoolRules_Swap`, `PoolRules_Pause`, + `PoolRules_ReconcileState`). Nonconsuming and operator-signed. + +```daml +template Pool with + poolId : PoolId + operator : Party + lpRegistrar : Party -- owns the LP instrument; separate from operator + admin : Party -- asset registrar for base + quote + baseInstrumentId : Text -- id under `admin`; full identity is { admin, id } + quoteInstrumentId : Text + lpInstrumentId : V2.InstrumentId + feeBps : Int -- swap fee in bps; accrues entirely to LPs + where + signatory operator + observer lpRegistrar +``` -- `Order`, `MatchedTrade`, `Pool`, and related app contracts define the exact - business state that authorizes a use of funds -- finalized allocations and `SettlementFactory_SettleBatch` are only used as - part of those app-owned workflows -- the off-chain operator proposes actions, but the ledger-visible contracts - validate the quantity, pair, expiry, side, and reserve references being used +The non-obvious part is *why the funds live off the `Pool`*. If reserves and the +committed allocations sat on one contract, every swap would rewrite the whole +thing and no two pool operations could ever run concurrently. Instead `reserves` +on `PoolState` is derived pricing state and the slices are the source of truth +for funds; `PoolRules_Swap` adjusts only the input slice and the output-side +covering prefix, with the operator's indexer supplying the ordered slice +contract ids. Keeping those two views consistent is what +[the executor-control constraint](#the-executor-control-constraint) guards. + +### Orders and matching + +An `Order` is the market object (side, pair, `limitPrice`, `remainingQty`, +expiry); a prefunded `V2.Allocation` is the reserved-funds object. Placement +locks the funding as `nextIterationFunding` with no legs; a match carries the +concrete legs through `SettleBatch` and rolls the residual budget forward; +cancel releases the allocation. + +`OrderMatchExecution_Execute` is where a resting order is defended. It fetches +both orders and refuses any fill outside their own limit prices, remaining +quantities, instruments, or bound allocations, so a buggy or malicious +off-ledger matcher cannot fill an order on terms its owner never agreed to. The +fill, the roll-forward of both remainders, and the trade record all happen in +one transaction — the settle archives the very allocations the orders are bound +to, so a partly-completed match could otherwise strand an order pointing at a +consumed allocation. + +### RFQ and OTC block trades + +`Rfq` is a trader's request to a whitelisted dealer set; each `RfqQuote` is a +dealer-signed price. `Rfq_Accept` (joint trader + operator) picks a quote, +records a `PolicyReceipt` — the ranking the operator applied over the quote set +the trader saw, replayable for audit — and creates a `MatchedTrade`. OTC and RFQ +both settle via the `TradingAppV2` pattern: request an allocation from each +authorizer, group settlement by admin, and settle with +`SettlementFactory_SettleBatch`. + +### LP token + +Pool shares are their own Token Standard instrument (`Lp.LPTokenPolicy`), minted +and burned under DEX rules by the `lpRegistrar` — a party deliberately separate +from the operator — and holdable like any other V2 instrument. Add- and +remove-liquidity are delivery-versus-payment: the deposit legs and the LP +mint/burn settle atomically, each batch under its own registry admin. + +## The executor-control constraint + +Committed and iterated allocations are what make long-lived pool and order +inventory possible — but they also hand the executor (the operator) the ability +to drive those funds' settlement path. That is safe only because every permitted +use is validated by on-ledger contract state, not by the off-ledger service. + +Concretely, `PoolState.reserves` is derived and the slices are the truth, so the +invariant **`reserves == sum of active slice amounts per side`** must hold. +Every `PoolRules` / `PoolLiquidityRules` choice that rewrites `reserves` asserts, +inside the choice, that its reserve delta equals the net slice-amount change the +same choice performs: + +```daml + outputSliceDelta = outputLeftover - outputConsumedTotal + inputSliceDelta = inputAmount -- new input slice amount - old +... +assertMsg "swap: base reserve delta must equal net base slice delta" + (newBaseReserve - state.reserves.baseAmount + == (if inputIsBase then inputSliceDelta else outputSliceDelta)) +``` -> **Further reading: decentralizing the operator.** This validation logic can -> itself be decentralized. See the +These are assertions on the choice's own arithmetic — cheap and contention-free +— so a later code change cannot silently drift reserves and slices apart. For an +on-demand global check, the nonconsuming `PoolRules_ReconcileState` fetches the +full active slice set and asserts the per-side sums equal the reserves exactly +(see [Reference](#reference-reserves-integrity-in-full)). + +One residual trust boundary remains: `PoolState` is operator-signed, so a +malicious operator could fabricate a parallel state with arbitrary reserves. +This reference assumes listing-trust in the operator; production hardening would +bind state updates to an admin-co-signed `Pool`. + +## Off-ledger services: what they may and may not do + +The operator backend (`services/operator-backend`) does the work a ledger +cannot: + +- a polling **indexer** that projects contracts into queryable state and feeds + the ordered slice/order contract ids to the rules choices; +- an **order matcher** and **pool pricing / quote generation** — the quote math + is the same function `PoolRules_Swap` re-derives on-ledger, so preview and + settlement agree; +- registry **choice-context lookup**, and transaction submission with retries + behind a small HTTP surface. + +The guardrail is structural: the backend has a fixed choice vocabulary and never +synthesizes DEX state by directly creating or archiving DEX templates — every +state change goes through a contract choice. Trader-authority writes (place +order, add liquidity, author a swap's funding allocation) have no HTTP endpoint +at all; they go through the trader's wallet. The dApp (`app/web`) is a React +frontend with a wallet-provider boundary that reads ledger state directly and +calls the backend only for one-shot orchestration. + +> **Decentralizing the operator.** The validation logic can itself be +> decentralized. See the > [BitSafe decentralization manager proposal](https://github.com/canton-foundation/canton-dev-fund/blob/main/proposals/2026-05-BitSafe-decentralization-manager.md) -> for decentralizing the execution of validation logic, the +> for decentralizing the execution of validation logic, and the > [Splice DSO automation architecture](https://docs.canton.network/sdks-tools/api-reference/splice-architecture#decentralized-transaction-validation-and-automation) -> for decentralizing the off-ledger automation a backend like this drives, and -> [`RewardAccountingV2.daml`](https://github.com/canton-network/splice/blob/main/daml/splice-amulet/daml/Splice/Amulet/RewardAccountingV2.daml) -> in Splice for efficiently batching commitments to on-ledger actions. - -This also means we should avoid designs where a routine action touches every -pool allocation at once. The implementation now follows this for all hot-path -flows: - -- one prefunded allocation per order -- a sharded set of committed reserve slices per pool side, each carrying its - own allocation CID and tracked amount -- `PoolRules_Swap` adjusts only the input-side slice and the output-side - covering prefix, settles them, and re-wraps the next-iteration allocations; - other slices are untouched -- remove-liquidity settlement (`PoolLiquidityRules_SettleRemoveLiquidity`) walks the - slice list from the front, cancels only the slices it needs to cover the - redemption, and re-allocates at most ONE boundary slice per side for the - leftover; slices beyond the boundary are untouched -- long-tail maintenance actions such as consolidation or migration stay - explicit and exceptional - -The data carrier is the standalone `PoolSlice` contract: -`{ poolId, operator, side, allocationCid, amount }` (with `operator` the -signatory). The operator indexer supplies -ordered slice contract IDs to the rules choices; the immutable `Pool` no -longer stores an unbounded slice list. -The slice's `amount` is reconciled with the underlying allocation's funding on -every choice that touches it. - -### Reserves integrity and the pool trust boundary - -`PoolState.reserves` is derived pricing state; the slices are the source of -truth for funds. The invariant `reserves == sum of active slice amounts per -side` is protected at three levels: - -- **Per-choice delta conservation, asserted on-ledger.** Every - `PoolRules` / `PoolLiquidityRules` choice that rewrites `reserves` asserts - inside the choice that its reserve delta equals the net slice-amount change - the same choice performs (created slice amounts minus consumed/drained - slice amounts). These are assertions on the choice's own arithmetic (cheap - and contention-free), so a future code change cannot silently let reserves - and slices drift apart. -- **Global equality, auditable on-ledger on demand.** The nonconsuming - `PoolRules_ReconcileState` choice takes the `PoolState` and the full list - of active `PoolSlice` contract IDs, verifies every slice belongs to the - pool, and asserts the per-side sums equal the reserves exactly. It does not - run on the hot path. Completeness of the slice list is the caller's - responsibility: an omitted slice understates the sum, so a clean reconcile - proves `reserves <= sum over all active slices`; pair the call with the - indexer's active-slice count to close that gap. -- **Residual trust boundary.** `PoolState` is operator-signed, so a malicious - operator could fabricate a parallel `PoolState` with arbitrary reserves. - Listing-trust in the operator is assumed by this reference implementation; - production hardening would bind state updates to an admin-co-signed `Pool`. - -### LP token layer - -The DEX should mint its own LP token instrument. - -Expected characteristics: - -- LP token has its own `V2.InstrumentId`; in the reference registry it also has - an `InstrumentConfiguration` -- deposit and withdraw mint and burn LP supply under DEX rules -- LP positions can be held like any other token-standard instrument -- the LP instrument definition should explain redemption policy and pool - identity - -## Token Standard usage - -### For OTC and RFQ - -The `TradingAppV2` pattern is the right starting point: - -- create matched trade state in the app -- request allocations from each authorizer -- split settlement by admin as required by the token standard -- settle with `SettlementFactory_SettleBatch` -- archive or cancel outstanding requests as part of app cleanup - -This should be the first runnable path because it proves the core settlement -story with minimal market-structure complexity. - -### For bids and asks - -Orders should be prefunded using allocations: - -- production-grade resting orders also require V2-style prefunded and - adjustable allocation semantics, because the exact matched transfer legs are - not known at order placement time - -- bid order locks the quote-side asset -- ask order locks the base-side asset -- partial fills should either shrink the order while releasing excess funding or - roll the funding forward using iterated allocation semantics once available - -This gives the order book a clean "resting order equals live reserved funds" -story. - -### For pools - -Pool funds require the V2 allocation shape. - -The design assumes: - -- allocations may be committed so LP liquidity cannot be casually withdrawn -- allocations may fund future iterations -- executors may adjust the transfer legs before each swap settlement -- settlement returns the rolled-forward allocation for the next iteration - -Without those semantics, a pool-backed design would drift back toward custom -escrow or off-ledger reserve tracking, which is exactly what this repo is trying -to avoid. - -## Admin and pairing model - -The DEX should support arbitrary trading pairs of `InstrumentId`, but -allocations still need to respect token-standard admin boundaries. +> for decentralizing the off-ledger automation a backend like this drives. + +## What proves it end to end + +- [`EndToEndTests.daml`](../../trading-tests/CantonDex/Tests/EndToEndTests.daml) + — every public workflow settles through the reference registry: pool + add-liquidity keeps reserves backed, order placement → operator bind → + trader-funded `Order_Fund`, RFQ accept produces a `MatchedTrade` with a + derived `PolicyReceipt`, `PoolRules_Swap` end to end, full OTC `MatchedTrade` + settle, and atomic order-match roll-forward with match-time limit-price + enforcement. +- [`RegistryConservationTests.daml`](../../trading-tests/CantonDex/Tests/RegistryConservationTests.daml) + — the settlement spine rejects any batch whose allocations do not cover its + legs exactly or whose per-instrument sender/receiver totals do not balance, + and proves roll-forward funding stays within real locked backing across + iterations. +- [`PoolStateInvariantTests.daml`](../../trading-tests/CantonDex/Tests/PoolStateInvariantTests.daml) + — `PoolRules_ReconcileState` holds across a full add → swap → remove + lifecycle, and catches an omitted slice, a desynced operator-fabricated + `PoolState`, or a slice from a different pool. -That implies: - -- a trade or swap may need separate allocation tracks per admin -- the app should group settlement work by admin, as in `TradingAppV2` -- pool state should store active allocation references in a way that makes - admin partitioning explicit - -## Rich asset lifecycle model - -The standard holding model remains intentionally small: - -- amount -- instrument -- owner - -Richer asset semantics should be attached by the registry that administers the -`InstrumentId`. In this reference registry that is expressed through -`InstrumentConfiguration`; other registries may use different contracts and -metadata while still implementing the same V2 holding/allocation APIs. - -Examples: - -- a bond config can carry CUSIP, maturity, coupon schedule, and callability -- an option config can carry strike, expiry, and exercise style -- an escrow obligation can point to the deal or obligation definition -- a margin-like position can point to the loan or trade configuration -- an LP token config can point to the pool and redemption semantics - -Lifecycle management is not standardized by Token Standard V2 today. Until a -registry-level lifecycle API exists, it is a registry-specific integration and -usually becomes a versioning problem: - -- create a new instrument-config version when stateful semantics change -- encode or derive the new instrument identity from that version -- use registry-specific facilities to apply lifecycle side effects such as - coupon payments, exercise outcomes, or maturity transitions - -In other words, a lifecycle-aware registry may take one instrument version in -and hand back a new version with the lifecycle side effects applied. The DEX -does not assume this is available for every registry. - -The traded asset remains a standard holding even -when its lifecycle is rich. - -## Off-chain services - -Off-chain services are still necessary, but their job is narrower than in older -generic-settlement architectures. - -They should focus on: - -- order matching and quote generation -- pool pricing and fee computation -- registry discovery and choice-context lookup -- optional registry-specific lifecycle automation for versioned instruments -- transaction submission, retries, and observability - -They should not become the main abstraction for moving value around. The token -standard remains the settlement substrate. - -## Dependency boundary - -The reference architecture has a deliberate split: - -- OTC and RFQ flows follow the `TradingAppV2` allocation-request and - per-admin `SettlementFactory_SettleBatch` pattern, against V2 allocations - only (this repo declares no V1 allocation dependency) -- pool-backed liquidity requires the V2 allocation extensions used by this - repo: committed allocations, iterated settlement, extra leg sides, and - next-iteration allocation results - -If the upstream API shape changes before landing, this repo should preserve the -same design intent even if field names or result structures move. - -## Component boundary +--- -The current implementation separates concerns by module and template: +## Reference + +### Design inputs + +Three concrete upstream inputs shaped the architecture: + +- **`TradingAppV2`** — the allocation-request / per-admin + `SettlementFactory_SettleBatch` pattern reused for OTC and RFQ. Its V1/V2 + bridging is not carried over; this repo declares no V1 allocation dependency. +- **Registry workflows** — anchor the instrument model behind `InstrumentId`: + the reference `Registry.V2` uses `InstrumentConfig`, holder/issuer + credentials, and optional external ids (ISIN/CUSIP), but a production registry + may expose the same V2 interfaces with a different internal model. +- **The V2 allocation extensions** — iterated settlement, + `nextIterationFunding`, committed allocations, and + `FinalizedAllocation.extraTransferLegSides` — which let allocations back + long-lived pool inventory, not only trade reservation. + +Two further principles run through the design: it is **workflow-first** (the +shape of choices and state transitions matters more than AMM feature parity — +see [Workflows](workflows.md)), and it trades **arbitrary `InstrumentId` pairs**, +not hardcoded "cash vs asset" families. + +### Reference: reserves integrity in full + +The `reserves == sum of active slice amounts per side` invariant is protected at +three levels: + +- **Per-choice delta conservation, asserted on-ledger.** Every choice that + rewrites `reserves` asserts its reserve delta equals the net slice-amount + change it performs (created slice amounts minus consumed/drained amounts). + Cheap and contention-free. +- **Global equality, auditable on demand.** `PoolRules_ReconcileState` takes the + `PoolState` and the full list of active `PoolSlice` ids, verifies each belongs + to the pool, and asserts the per-side sums equal the reserves exactly. It runs + off the hot path. Completeness of the slice list is the caller's + responsibility — an omitted slice understates the sum, so a clean reconcile + proves `reserves <= sum over all active slices`; pair the call with the + indexer's active-slice count to close the gap. +- **Residual trust boundary.** `PoolState` is operator-signed, so a clean + reconcile is only as trustworthy as the operator's listing. Production + hardening would co-sign state updates with an admin-signed `Pool`. + +### Reference: one registry admin per pair + +Both legs of a pair currently share one registry `admin`: `DexPair`, `Order`, +`Pool`, and `MatchedTrade` each carry a single `admin : Party`, and the +standard's `TransferLeg.instrumentId` is bare `Text`, so a leg cannot name its +own admin — the constraint lives in the app-layer templates, not the settlement +spine. [Non-goals](non-goals.md#one-registry-admin-per-pair) frames it as a +deliberate limitation; [Registry Integration](../guides/registry-integration.md#what-the-dex-does-not-assume) +sets out what lifting it would take. + +### Reference: instrument lifecycle stays outside the DEX + +The standard holding stays minimal (amount, instrument, owner). Richer semantics +— a bond's CUSIP/maturity/coupon, an option's strike/expiry, an LP token's pool +identity and redemption policy — are attached by the registry that administers +the `InstrumentId`, through V2 views, metadata, and choice context, not by DEX +templates. Token Standard V2 does not standardize lifecycle today, so until a +registry-level lifecycle API exists it is a registry-specific integration and +usually a versioning problem: mint a new instrument-config version when stateful +semantics change, and derive the new instrument identity from it. The traded +asset stays a standard holding even when its lifecycle is rich. + +### Reference: component and package boundary -- `CantonDex.Lp.*` owns the LP-token policy component. - `CantonDex.Dex.*` owns pair, order, RFQ, matched-trade, pool, and rules - workflows. -- `CantonDex.Registry.V2` is a reference registry used for tests and demos. - -The DAR implements upstream Token Standard V2 interfaces, but it does not -define custom Daml interfaces that decouple the LP-token component from the -DEX venue component. The shared boundary today is the Token Standard V2 -holding/allocation/settlement surface plus explicit template references inside -the reference app. A future package split or custom app-facing Daml interface -would be a separate architecture step, not something this reference currently -claims. - -Pair listing is similarly direct in the current reference: the operator creates -`DexPair` contracts. There is no separate `DexRules` governance contract for -pair admission yet. That keeps the reference small, while leaving room for -forks to add governance, multi-operator approval, or a decentralized rules -layer. - -## Repository shape + workflows; `CantonDex.Lp.*` owns the LP-token policy; `CantonDex.Registry.V2` + is the reference registry used for tests and demos. +- The DAR implements the upstream Token Standard V2 interfaces but does not + define custom Daml interfaces decoupling the LP component from the DEX venue; the + shared boundary today is the V2 holding/allocation/settlement surface plus + explicit template references. A package split or app-facing interface would be + a separate step. +- Pair listing is direct: the operator creates `DexPair` contracts. There is no + separate `DexRules` governance contract for pair admission yet, leaving room for forks + to add governance or a decentralized rules layer. + +### Reference: repository shape ```text canton-dex/ @@ -477,4 +342,4 @@ canton-dex/ --- -**Where to read next:** [Workflows](workflows.md) · [Liquidity & Custody](liquidity-and-custody.md) · [Glossary](glossary.md) · [All docs](../README.md) +**Where to read next:** [Workflows](workflows.md) · [Pricing](pricing.md) · [Liquidity & Custody](liquidity-and-custody.md) · [Glossary](glossary.md) · [All docs](../README.md) diff --git a/docs/concepts/glossary.md b/docs/concepts/glossary.md index 5ef3b75f..51fcf101 100644 --- a/docs/concepts/glossary.md +++ b/docs/concepts/glossary.md @@ -1,131 +1,190 @@ # Glossary -Key terms used across the Canton DEX docs and code. Standard terms link to the -canonical Canton / Token Standard sources; DEX-specific terms link to the doc -that explains them in depth. +Key terms used across the Canton DEX docs and code. Each entry is a one-line +definition; where it helps, it links to the Daml module that defines the term, +the test that exercises it, and the concept doc that covers it in depth. Source +paths are relative to the repo root (`trading/`, `trading-tests/`). ### Allocation -A Token Standard V2 contract that **locks** a holder's [holding](#holding) for a -specific settlement, so it can be settled atomically later. The DEX never moves -trader assets directly. It moves them by having the trader create allocations -and then settling a batch. See [Allocation Surface](../reference/allocation-surface.md). +A Token Standard V2 contract that locks a holder's [holding](#holding) for one +specific settlement, so the batch can settle it atomically later. The DEX never +moves trader assets directly — the trader authors allocations and the venue +settles a batch. Template +[`Allocation`](../../trading/CantonDex/Registry/V2.daml); see +[Allocation Surface](../reference/allocation-surface.md). ### AllocationFactory / `AllocationFactory_Allocate` -The registry-provided factory choice a holder exercises to turn holdings into an -[Allocation](#allocation). The holder's own authority drives it, which is why +The registry factory choice a holder exercises to turn holdings into an +[Allocation](#allocation). It runs under the holder's own authority, which is why funding an order or adding liquidity must go through the trader's wallet. +Implemented in [`Registry.V2`](../../trading/CantonDex/Registry/V2.daml). ### AllocationRequest -A V2 contract asking a party to create the allocations a settlement needs. The +A V2 contract asking a party to author the allocations a settlement needs; the party accepts by composing `AllocationFactory_Allocate` in the same submission. -The DEX uses `OrderAllocationRequest`, `LiquidityAllocationRequest`, and -`TradeAllocationRequest`. +The DEX's variants are +[`OrderAllocationRequest`](../../trading/CantonDex/Dex/Order.daml), +[`LiquidityAllocationRequest`](../../trading/CantonDex/Dex/LiquidityAllocationRequest.daml), +and [`TradeAllocationRequest`](../../trading/CantonDex/Dex/MatchedTrade.daml). ### Choice context / disclosure -The extra arguments (`ExtraArgs`) and disclosed contracts a **registry** requires -when its factory choices are exercised. The operator backend fetches these and -attaches them to each submission. See [Choice Context](../guides/choice-context.md). +The extra arguments (`ExtraArgs`) and disclosed contracts a +[registry](#registry--registrar) requires when its factory choices are +exercised. The operator backend fetches these and attaches them to each +submission. See [Choice Context](../guides/choice-context.md). ### CIP-0056 -The **Canton Network Token Standard**: the base standard (holdings, transfers, +The Canton Network Token Standard: the base standard (holdings, transfers, metadata) that CIP-0112 revises. ### CIP-0103 -The **dApp Standard**: the wallet interaction standard used for -[prepare/sign/execute](#prepare--sign--execute) interactive submission. The dApp -hands trader-authority commands to a wallet over CIP-0103. +The dApp Standard: the wallet-interaction standard behind +[prepare/sign/execute](#prepare--sign--execute). The dApp hands trader-authority +commands to a wallet over CIP-0103. ### CIP-0112 -The **Canton Network Token Standard V2**: the privacy / performance / -traditional-accounting revision of CIP-0056, adding the allocation + settlement -surface this DEX is built on. Often written "Token Standard V2" or "TSv2". +The Canton Network Token Standard V2: the privacy / performance / +traditional-accounting revision of CIP-0056 that adds the allocation + +settlement surface this DEX is built on. Often written "Token Standard V2" or +"TSv2". ### Committed allocation -An [allocation](#allocation) whose backing is committed to the pool so the pool -can settle against it repeatedly. Pool reserves are held as committed -allocations, one per [slice](#pool--poolstate--poolslice). +An [allocation](#allocation) authored with `committed = True`, so the authorizer +cannot unilaterally withdraw it and the venue can settle against it repeatedly. +Pool reserves and resting-order collateral are committed; each pool +[slice](#pool--poolstate--poolslice) wraps one. Field on `AllocationSpecification`; +see [`PoolSlice`](../../trading/CantonDex/Dex/PoolSlice.daml). ### DexPair -The listing record for a market: base + quote [instrument ids](#instrumentid), -fee model, trading mode (`TM_OrderBook`, `TM_Pool`, or `TM_Both`), and an `active` flag. +The operator's listing record for one market: base + quote +[instrument ids](#instrumentid), the fee model (maker/taker/pool bps), the +trading mode (`TM_OrderBook`, `TM_Pool`, or `TM_Both`), and an `active` flag. +Template [`DexPair`](../../trading/CantonDex/Dex/DexPair.daml). ### DvP (delivery-versus-payment) An atomic exchange where both legs settle together or not at all. Swaps, LP -add/remove, and matched trades are all DvP over a `SettleBatch`. See -[Liquidity & Custody](liquidity-and-custody.md). +add/remove, and matched trades all settle as DvP through +`SettlementFactory_SettleBatch`. See [Liquidity & Custody](liquidity-and-custody.md); +proven in +[`PoolLiquidityRulesTests`](../../trading-tests/CantonDex/Tests/PoolLiquidityRulesTests.daml) +(an add funds base+quote and mints LP tokens in one flow). ### FinalizedAllocation -The V2 settle-time structure that carries the concrete match legs +The V2 settle-time structure that carries a match's concrete leg sides (`extraTransferLegSides`) and the roll-forward funding (`nextIterationFunding`) -for [iterated settlement](#iterated-settlement). +for [iterated settlement](#iterated-settlement). Built by +[`mkFinalizedAllocation`](../../trading/CantonDex/Trading/Utils.daml) and consumed +by [`OrderMatchExecution`](../../trading/CantonDex/Dex/OrderMatchExecution.daml). ### Holding -A V2 contract representing a party's balance of an instrument. Base assets, -quote assets, and LP tokens are all holdings. +A V2 contract representing a party's balance of one instrument. Base assets, +quote assets, and LP tokens are all holdings. Template +[`Holding`](../../trading/CantonDex/Registry/V2.daml). ### InstrumentId The `{admin, id}` pair that identifies a V2 instrument. Two instruments with the -same `id` but different `admin` are **different** instruments. +same `id` but a different `admin` are different instruments. The DEX stores the +`id` component per pair/pool and pins its `admin` alongside. ### Iterated settlement Settling in steps, where each step rolls the remaining backing forward to the -next iteration via `nextIterationFunding`. Used by pool swaps and partial order -fills so a single committed allocation can back many settlements. +next iteration via `nextIterationFunding`. Pool swaps and partial order fills use +it so one [committed allocation](#committed-allocation) can back many +settlements. Enforced in +[`Registry.V2`](../../trading/CantonDex/Registry/V2.daml); proven in +[`RegistryConservationTests`](../../trading-tests/CantonDex/Tests/RegistryConservationTests.daml) +(roll-forward stays within the locked backing). ### LP token / `LPTokenPolicy` / lpRegistrar -The pool's liquidity-provider share is a V2 instrument (the **LP token**), -administered by the **lpRegistrar** and governed by the **`LPTokenPolicy`** -contract. The policy knows nothing about pools or orders. See -[LP Tokens](lp-tokens.md). +The pool's liquidity-provider share is a V2 instrument (the LP token), +administered by the lpRegistrar and governed by the +[`LPTokenPolicy`](../../trading/CantonDex/Lp/Policy.daml) contract, which tracks +only supply and knows nothing about pools or orders. See [LP Tokens](lp-tokens.md). ### MatchedTrade -The settled result of a bilateral trade (e.g. an accepted [RFQ](#rfq-request-for-quote)), carrying -an operator-signed [`PolicyReceipt`](#policyreceipt) and settled via a -per-admin `SettleBatch`. +The venue-signed trade contract [`Rfq_Accept`](#rfq-request-for-quote) emits: it +carries the transfer legs plus an optional operator-signed +[`PolicyReceipt`](#policyreceipt) and settles via a per-admin `SettleBatch`. +Template [`MatchedTrade`](../../trading/CantonDex/Dex/MatchedTrade.daml); proven +end-to-end in +[`RfqSettlementTests`](../../trading-tests/CantonDex/Tests/RfqSettlementTests.daml). ### Mint / burn account -Special Token Standard accounts with `owner = None`, used as the counterparty -for LP-token **mint** (issuance) and **burn** (redemption) legs. +Special Token Standard accounts with `owner = None`, the counterparty for +LP-token mint (issuance) and burn (redemption) legs. `Registry.V2` recognizes +exactly these two as admin-authorized mint/burn sources: + +```daml +mintAccountId = "cip-112/mint" +burnAccountId = "cip-112/burn" +... +mintAccount = HoldingV2.Account None None mintAccountId +burnAccount = HoldingV2.Account None None burnAccountId +``` + +Defined in [`Trading.Utils`](../../trading/CantonDex/Trading/Utils.daml); the +mint/burn mechanism is proven in +[`DvpMintBurnTests`](../../trading-tests/CantonDex/Tests/DvpMintBurnTests.daml). ### Operator -The venue operator: orchestrates matching, binds orders, and submits the +The venue operator: it orchestrates matching, binds orders, and submits the settlement batches it is authorized to submit. It never moves trader assets on its own. ### Over-lock -Locking **more** backing than a settlement strictly needs. Token Standard V2 -accepts `have >= needed`; the surplus is returned as unlocked change when the -batch settles. +Locking more backing than a settlement strictly needs. Token Standard V2 accepts +`have >= needed`; the surplus is returned as unlocked change when the batch +settles. Proven in +[`RegistryConservationTests`](../../trading-tests/CantonDex/Tests/RegistryConservationTests.daml) +(surplus backing returns to the authorizer). ### PolicyReceipt An operator-signed record of the ranking/whitelist policy applied to an -[RFQ](#rfq-request-for-quote), folded into `SettlementInfo.meta` so the decision is auditable. +[RFQ](#rfq-request-for-quote), folded into `SettlementInfo.meta` so the decision +travels on-ledger and stays auditable. Type +[`PolicyReceipt`](../../trading/CantonDex/Dex/PolicyReceipt.daml); proven in +[`PolicyReceiptTests`](../../trading-tests/CantonDex/Tests/PolicyReceiptTests.daml). ### Pool / PoolState / PoolSlice -The constant-product pool is split three ways: **Pool** (immutable config), -**PoolState** (the hot reserves / LP supply / status), and **PoolSlice** (one -[committed allocation](#committed-allocation) per locality unit). Slices are -operator-authored locality units, **not** per-LP entitlement. +The constant-product pool is split three ways: +[`Pool`](../../trading/CantonDex/Dex/Pool.daml) (immutable config), +[`PoolState`](../../trading/CantonDex/Dex/PoolState.daml) (the hot reserves / LP +supply / status), and [`PoolSlice`](../../trading/CantonDex/Dex/PoolSlice.daml) +(one [committed allocation](#committed-allocation) per side). Slices are +operator-authored so add/swap/remove touch only the slices they source rather +than one hot contract — they are not per-LP entitlement. Reserves↔slices +integrity is proven in +[`PoolStateInvariantTests`](../../trading-tests/CantonDex/Tests/PoolStateInvariantTests.daml). ### prepare / sign / execute -The three steps of CIP-0103 interactive submission: the dApp **prepares** a -transaction, the wallet **signs** it, and it is **executed** on the ledger. A -prepared transaction may carry only **one** top-level command. +The three steps of CIP-0103 interactive submission: the dApp prepares a +transaction, the wallet signs it, and it is executed on the ledger. A prepared +transaction may carry only one top-level command. ### Registry / Registrar The component that defines instrument semantics and supplies -[choice context](#choice-context--disclosure). It is **external** to the DEX; -this repo ships a reference registry, but Token Standard V2 does not require that -exact one. See [Registry Integration](../guides/registry-integration.md). +[choice context](#choice-context--disclosure). It is external to the DEX; this +repo ships a reference +[`Registry.V2`](../../trading/CantonDex/Registry/V2.daml), but Token Standard V2 +does not require that exact one. See +[Registry Integration](../guides/registry-integration.md). ### RFQ (request-for-quote) -The bilateral block-trade flow: a trader posts an RFQ, dealers quote, and a joint -`Rfq_Accept` emits a [MatchedTrade](#matchedtrade). See [Workflows](workflows.md). +The bilateral block-trade flow: a trader posts an `Rfq`, whitelisted dealers post +`RfqQuote`s, and a joint `Rfq_Accept` (trader + operator) emits a +[MatchedTrade](#matchedtrade). Source +[`Rfq`](../../trading/CantonDex/Dex/Rfq.daml); see [Workflows](workflows.md). ### `SettlementFactory` / `SettlementFactory_SettleBatch` -The registry-provided factory that atomically settles a batch of +The registry factory that atomically settles a batch of [allocations](#allocation), enforcing per-instrument conservation (total sent -equals total received) across the batch. +equals total received) across the batch. Implemented in +[`Registry.V2`](../../trading/CantonDex/Registry/V2.daml); conservation proven in +[`RegistryConservationTests`](../../trading-tests/CantonDex/Tests/RegistryConservationTests.daml). ### Token Standard V2 (TSv2) See [CIP-0112](#cip-0112). + +--- + +**Where to read next:** [Architecture](architecture.md) · [Workflows](workflows.md) · [Allocation Surface](../reference/allocation-surface.md) · [All docs](../README.md) diff --git a/docs/concepts/liquidity-and-custody.md b/docs/concepts/liquidity-and-custody.md index 885e6a17..27ab74c2 100644 --- a/docs/concepts/liquidity-and-custody.md +++ b/docs/concepts/liquidity-and-custody.md @@ -1,123 +1,151 @@ -# LP liquidity custody model - -This document explains how the reference pool represents LP liquidity and how -assets cross the pool boundary during add- and remove-liquidity workflows. - -## The model - -**Operator-custodied between operations, DvP at the boundary.** - -- Between operations, pool reserves are operator-authored committed - `PoolSlice` allocations. Slices are - **locality units, not LP entitlement units**: they exist so add/remove/ - swap touch only the slices they source; a slice does not "belong to" an - LP. -- An LP's entitlement is **pro-rata over the aggregate reserves** - (`lpHeld / totalLpSupply × reserves`), sourced from selected slices. - This is unchanged from the current pool math. -- The **boundary** (add / remove) is where assets cross between the LP - and the pool, so it is a **delivery-versus-payment settle batch**, - symmetric to how Swap already settles delivery to the swapper. - -Slices are not owned by individual LPs; there is no `PoolSlice.owner`. - -## Add (DvP at the boundary) - -One Daml transaction, atomic: the LP delivers base+quote into the pool -and receives LP tokens, or nothing happens. - -Because token-standard settlement is **admin-scoped** (base/quote under -`pool.admin`; the LP instrument under `pool.lpRegistrar`), this is **two -per-admin SettleBatches in the same transaction** (collapses to one only -if `pool.admin == pool.lpRegistrar`): - -- **base/quote batch** (`pool.admin`): the LP's deposit legs - (`LP → operator` base+quote) settle into **operator-authored receiver - allocations**. `nextIterationFunding = {instrument: amount}` is applied - on the `FinalizedAllocation` **at the settle step** (not pre-funded at - allocate time, which would trip the coverage check in - `Registry.V2`), matching the pool's slice roll-forward model. The - returned next-iteration allocation cids become the new - operator-authored `PoolSlice`s. -- **LP-mint batch** (`pool.lpRegistrar`): the lp-mint leg - `mintAccount lpRegistrar → LP` + the LP's receipt allocation; the LP - receives freshly-minted LP-token holdings. - -The settle choice then exercises `LPTokenPolicy_RecordMint` and rewrites -`PoolState` once with the new reserves + `totalLpSupply`. - -## Remove (DvP at the boundary, symmetric to Swap) - -Compute `baseOut/quoteOut = share × aggregate reserves` (unchanged math); -draw the covering slice prefix (`drawFromSlices`, oldest-first). Then a -two-admin settle in one transaction: - -- **base/quote batch** (`pool.admin`): legs `pool → holder` base+quote - (delivery, exactly like Swap's `pool → swapper`), settled against the - holder's pre-created receipt allocations; the sourced slices roll/drain; - the boundary slice is re-allocated (operator-authored). -- **LP-burn batch** (`pool.lpRegistrar`): the lp-burn leg - `holder → burnAccount lpRegistrar`, against the holder's burn-sender - allocation. - -Then `LPTokenPolicy_RecordBurn` + a single `PoolState` rewrite. Funds reach -the holder, not the operator's pool account. - -## Choreography & authority - -- The holder/LP pre-authors the boundary allocations via a directional - `LiquidityAllocationRequest` (implements `V2.AllocationRequest`): for - add, base+quote deposit (`pool.admin`, sender-side) + LP-token receipt - (`pool.lpRegistrar`, receiver-side); for remove, base+quote receipt - (`pool.admin`, receiver-side) + LP-token burn-sender - (`pool.lpRegistrar`, sender-side). -- The settle choices live on a **co-controlled `PoolLiquidityRules`** - contract (`{ operator, lpRegistrar }`, `signatory operator, - lpRegistrar`, choices `controller operator, lpRegistrar`), not on the - operator-only `PoolRules`, which has no `lpRegistrar` visibility. This - gives the settle the authority to drive both the operator-signed - `PoolState`/`PoolSlice` writes and the lpRegistrar-controlled - `LPTokenPolicy`/mint/burn. - -## Invariants the settle choices enforce - -- **Supply sync**: on entry assert `LPTokenPolicy.totalSupply == - PoolState.totalLpSupply`; apply the mint/burn delta; rewrite `PoolState` - once with the new supply + reserves. Both trackers move in lockstep and - any pre-existing divergence surfaces loudly. -- **Stale-quote rejection**: `Request*` records only a short deadline - (`settleAt`) alongside the expected allocations; the operator - re-supplies its off-ledger quote (`knownTotalLpSupply` and slippage - bounds such as `minLpTokens` / `minBaseOut` / `minQuoteOut`) as - `Settle*` arguments. `Settle*` asserts `knownTotalLpSupply == - PoolState.totalLpSupply` (plus the min bounds) against current - `PoolState` and aborts a - stale request. - -## What does not change - -- Pricing / share math (`x*y=k` on aggregate reserves; pro-rata shares). -- `PoolSlice` shape (still operator-authored, no `owner`). -- `PoolRules_Swap` (already settles delivery to the swapper). -- The existing pricing and reserve model. Only the liquidity entrypoints - changed: add/remove now run exclusively through the DvP request/settle - flow. - -## Registry prerequisite - -The LP-token mint/burn legs use the special `mintAccount`/`burnAccount` -(`owner = None`). `Registry.V2` must support them at all three sites that -currently assume a real owner: the `Allocation` signatory, settlement -crediting, and the allocate factory. `RealRegistry` already supports -these semantics. - -One more registry-dependent bound: pool slices are **long-lived committed -allocations**, and some registries cap allocation lifetime: Amulet enforces -`tokenStandardMaxTTL` (default **90 days**) from Splice 0.6.11. Against such a -registry the operator must roll slices into fresh allocations before the cap -expires; see -[Registry Integration](../guides/registry-integration.md#allocation-lifetime-caps). +# Liquidity and custody + +A pool keeps its liquidity in two forms: a `Decimal` reserve figure it prices +against, and the actual assets, which sit in committed allocations the operator +holds on the pool's behalf. This page explains where the value physically +lives, the invariant that ties the two forms together, and why every flow moves +them as one. + +## Reserves are accounting; slices are custody + +`PoolState.reserves` is a `PoolReserves { baseAmount, quoteAmount }` — two +`Decimal`s. It is *derived* state: the pool keeps it only so a constant-product +swap can price against the global totals in a single transaction. It custodies +nothing. + +The assets live on `PoolSlice` contracts — one committed allocation each, one +side (base or quote) each. A slice pairs a committed `V2.Allocation`, which +locks real holdings, with a cached `amount`: + +```daml +template PoolSlice with + poolId : PoolId + operator : Party + side : Side + -- ^ Which pool leg (base or quote) this slice funds. + allocationCid : ContractId V2.Allocation + -- ^ The committed allocation holding this slice's funds. + amount : Decimal + -- ^ Cached funded amount; reconciled against the allocation by the + -- choice that writes the slice. + where + signatory operator +``` + +Two things here are specific to this design: + +- **Slices are locality units, not LP shares.** A slice does not belong to an + LP — there is no `PoolSlice.owner`. The `operator` is the sole signatory, and + the operator-backend indexer tracks slices by `poolId` and hands the relevant + `ContractId`s to each choice. Add creates a *new* slice, which conflicts with + nothing; remove and swap touch only the slices they source. So no single hot + contract serializes every pool operation — only the small `PoolState` does. +- **`amount` is a cache, not the source of truth.** The committed allocation + holds the funds; `amount` is stored alongside so the rules choices can walk + slices without a `fetch` per slice, and it is reconciled against the + allocation by the choice that writes the slice. That the committed allocation + really locks holdings worth its funding is proved in + [`RegistryConservationTests.daml`](../../trading-tests/CantonDex/Tests/RegistryConservationTests.daml) + (a settle can never move more than the locked backing; a roll-forward carries + real locked holdings worth its funding; surplus returns to the authorizer + unlocked). + +## The invariant: reserves equal the sum of slice funding + +Per instrument, the reserve equals the sum of the active slices' amounts on +that side: the sum of active base `PoolSlice.amount` equals +`reserves.baseAmount`, and likewise for quote. The slices are authoritative; the +reserve is their aggregate. Two on-ledger mechanisms keep the two from drifting: + +- **Per-choice delta conservation.** Every `PoolRules` / `PoolLiquidityRules` + choice that rewrites `reserves` asserts, in that same choice, that its reserve + delta equals the net slice-amount change it just performed — for example + `PoolLiquidityRules_SettleAddLiquidity` asserts `"add: base reserve delta must + equal created base slice amount"`. A code change cannot silently move one + without the other. +- **Global reconciliation.** The nonconsuming `PoolRules_ReconcileState` choice + re-derives both side totals from the full active slice set and asserts each + equals the recorded reserve (`baseTotal == state.reserves.baseAmount`). It + writes no state, so an operator or auditor can run it against a live pool + without contending with swaps. Completeness of the slice list is the caller's + responsibility — pair the call with the indexer's active-slice count. + +Both are exercised by +[`PoolStateInvariantTests.daml`](../../trading-tests/CantonDex/Tests/PoolStateInvariantTests.daml): +reconcile stays clean across a full add → swap → remove lifecycle at every +stage, and fails on an omitted slice, a slice from another pool, or a +fabricated, desynced `PoolState`. + +```mermaid +flowchart TB + subgraph PS["PoolState — pricing figure"] + R["reserves.baseAmount
reserves.quoteAmount"] + end + subgraph SL["PoolSlices — where the value lives (signatory: operator)"] + BS["base slice(s)
amount + allocationCid"] + QS["quote slice(s)
amount + allocationCid"] + end + BH[("committed V2.Allocation
locked base holdings")] + QH[("committed V2.Allocation
locked quote holdings")] + R -.->|"= sum of base slice amounts"| BS + R -.->|"= sum of quote slice amounts"| QS + BS --> BH + QS --> QH +``` + +## Every flow moves holdings and reserves together + +Because the assets are real committed allocations, moving them is settlement, +not bookkeeping. Add, remove, and swap each run as a delivery-versus-payment +`SettlementFactory_SettleBatch` and rewrite `PoolState` once, inside one Daml +transaction — so holdings and reserves change co-atomically, or nothing changes. + +- **Add.** The LP's base and quote deposits settle into operator-authored + receiver allocations, which roll forward (via `nextIterationFunding`) into the + two new slices; the registrar mints LP tokens to the LP; `PoolState` is + rewritten once with the new reserves and supply. Base/quote settle under + `pool.admin` and the LP mint under `pool.lpRegistrar`, so this is two + per-admin batches in the same transaction. +- **Remove** is symmetric to swap: the sourced slices deliver base and quote to + the holder (exactly as `PoolRules_Swap` delivers to the swapper), each fully + drawn slice drains, the boundary slice re-wraps its leftover, the holder's LP + tokens burn, and `PoolState` drops by the same amounts. +- **Swap** pays the input into one side's slice and delivers the output from the + other, updating both reserves in the same choice. + +[`PoolLiquidityRulesTests.daml`](../../trading-tests/CantonDex/Tests/PoolLiquidityRulesTests.daml) +drives these end to end against the reference registry: an add funds base+quote +and mints the LP holding in one flow; a remove delivers base+quote to the +*holder* (not the operator) and burns the LP tokens; a stale supply quote aborts +the settle. + +The mint/burn legs and the co-controlled `PoolLiquidityRules` contract are what +give a settle the authority to touch both the operator-signed slice state and +the registrar-controlled LP tokens at once; that machinery is covered in +[LP Tokens](lp-tokens.md). --- +### Reference / details + +- **Residual trust boundary.** `PoolState` is operator-signed, so a malicious + operator could fabricate a parallel state contract that overstates reserves. + `PoolRules_ReconcileState` catches that against the real slices (the desync + case above), but listing-trust in the operator is assumed; production + hardening would bind state updates to an admin-co-signed `Pool`. +- **Slices are long-lived, and some registries cap that.** The committed + allocations backing slices persist between operations. A registry may bound + allocation lifetime — Amulet enforces `tokenStandardMaxTTL` (default + **90 days**) from Splice 0.6.11 — so against such a registry the operator must + roll slices into fresh allocations before the cap expires. See + [Registry Integration](../guides/registry-integration.md#allocation-lifetime-caps). +- **Mint/burn accounts.** The LP-token legs use the special + `mintAccount`/`burnAccount` (`owner = None`); the registry must support them + at the `Allocation` signatory, settlement crediting, and allocate-factory + sites. `Registry.V2` already does. +- **Off-ratio adds don't inflate reserves.** LP tokens are minted against the + limiting side, so only the ratio-matched part of a deposit enters the slices; + the unmatched excess is refunded to the provider in the same batch and never + reaches `reserves`. + **Where to read next:** [LP Tokens](lp-tokens.md) · [Pricing](pricing.md) · [Registry Integration](../guides/registry-integration.md) · [All docs](../README.md) diff --git a/docs/concepts/lp-tokens.md b/docs/concepts/lp-tokens.md index 9e765290..e4308c65 100644 --- a/docs/concepts/lp-tokens.md +++ b/docs/concepts/lp-tokens.md @@ -1,105 +1,163 @@ -# LP token versioning strategy +# LP tokens + +An LP token is an ordinary V2 holding whose mint and burn ride the *same* +atomic settlement that moves the underlying base and quote. There is no +separate issuance lifecycle — no settle, no mint. One fungible instrument per +pool, and its value is realised only by redeeming it. + +## One fungible instrument per pool + +Each pool has exactly one LP instrument, fixed at pool creation as +`Pool.lpInstrumentId : V2.InstrumentId` (with `admin = lpRegistrar`). Its +identity and circulating supply live in a small `LPTokenPolicy` that knows +nothing about the pool — no base, no quote, no reserves, just the instrument id, +a `totalSupply`, and an `active` flag. + +The token is **unversioned**: the `instrumentId` never carries a version suffix +or per-iteration discriminator, so two `BTC-USDC-LP` holdings of the same amount +are interchangeable — no rebase, no per-version balance map. It MUST NOT be +derived from the pool's contract id, settlement iteration, or status, all of +which change over a pool's life and would re-version the LP behind holders' +backs. (Why this matters for wallets and downstream dApps is in +[Reference](#reference-versioning-and-upgrades).) + +## Mint and burn are a sibling of the settlement + +Adding and removing liquidity settle through `PoolLiquidityRules` — +`PoolLiquidityRules_SettleAddLiquidity` and +`PoolLiquidityRules_SettleRemoveLiquidity`. Each choice is one atomic +transaction that runs *two* `SettlementFactory_SettleBatch` calls under +different authorities (a split-admin DvP): + +- the base/quote batch under `pool.admin`, moving the real assets into or out of + the operator-custodied reserves; +- the LP mint/burn batch under `pool.lpRegistrar`, creating or destroying the LP + holding. + +A mint and a burn are just transfer legs to and from two reserved accounts whose +`owner` is `None`. `Registry.V2` recognises exactly these as admin-authorised +issuance sources (`mintAccount` id `"cip-112/mint"`, `burnAccount` id +`"cip-112/burn"`), so a leg *from* `mintAccount` is an issuance and a leg *to* +`burnAccount` is a redemption — [`lpMintLeg` / `lpBurnLeg`](../../trading/CantonDex/Lp/Instrument.daml): + +```daml +lpMintLeg _lpRegistrar recipient lpInstrumentId amount = V2.TransferLeg with + transferLegId = "lp-mint" + sender = Utils.mintAccount + receiver = recipient + amount + instrumentId = lpInstrumentId + meta = emptyMetadata + +lpBurnLeg _lpRegistrar holder lpInstrumentId amount = V2.TransferLeg with + transferLegId = "lp-burn" + sender = holder + receiver = Utils.burnAccount + amount + instrumentId = lpInstrumentId + meta = emptyMetadata +``` + +Two things about this are non-obvious: + +- **No settle, no mint.** The mint leg lives in the *same choice* as the deposit + legs. Both batches are all-or-nothing together: you cannot receive LP tokens + without your base+quote landing in the reserves, and you cannot pull assets out + without your LP holding burning. The mint is a sibling of the delivery, not a + standalone issuance step that could run on its own. +- **The mint amount is bounded, not trusted.** The LP receipt carries the + operator's off-ledger quote, but the settle recomputes the fair entitlement + on-ledger — `sqrt(base·quote)` at first funding, else pro-rata — and rejects a + receipt claiming more than that beyond a `1e-6` dust tolerance. The registrar + signs the mint; `PoolLiquidityRules` bounds it. + +```mermaid +flowchart TB + subgraph add["Add — SettleAddLiquidity (one atomic transaction)"] + direction LR + A1["LP"] -->|"base + quote in"| AR[("pool reserves")] + AM(["mintAccount
owner = None"]) -->|"LP-mint leg"| A1 + end + subgraph rem["Remove — SettleRemoveLiquidity (one atomic transaction)"] + direction LR + RR[("pool reserves")] -->|"pro-rata base + quote out"| R1["holder"] + R1 -->|"LP-burn leg"| RB(["burnAccount
owner = None"]) + end +``` + +Supply is tracked in two places and kept in lockstep: `LPTokenPolicy.totalSupply` +and `PoolState.totalLpSupply`. Each settle asserts they already agree, then +`LPTokenPolicy_RecordMint` / `LPTokenPolicy_RecordBurn` moves the policy's total +while the same choice rewrites `PoolState` with the matching delta. + +## Value is realised by redemption + +The LP token never pays a coupon and never rebases. Swap fees stay in the pool's +reserves (see [Pricing](pricing.md)), so the reserves a fixed LP balance can +claim grow as the pool trades. Value comes out only on +`PoolLiquidityRules_SettleRemoveLiquidity`, which burns the holder's LP and pays +the current pro-rata share of reserves: + +``` +share = lpTokensToRedeem / knownTotalLpSupply -- floored +baseOut = reserves.baseAmount · share -- floored +quoteOut = reserves.quoteAmount · share -- floored +``` + +Because the reserves include accrued fees, redeeming a share returns more base +and quote than backed it at deposit time — that surplus *is* the LP return. +Rounding is one-directional (`floorDiv`/`floorMul`), so the pool never pays out +more than the exact share and `x·y = k` stays non-decreasing. A pool that never +traded returns exactly what went in; there is no off-ledger event a holder must +crystallise first. -## Decision - -**Canton DEX LP tokens are unversioned.** The LP token `instrumentId` for a -given pool is derived from the pool's pair (e.g., `BTC-USDC-LP`) and does -not include a version suffix or per-iteration discriminator. - -## Rationale - -The reference DEX prioritises: - -1. **Fungibility** — all LP holders for a pool hold the same instrument and - can transfer between each other freely. Two `BTC-USDC-LP` holdings of - amount X are interchangeable; no rebase, no migration. -2. **Composability** — other dApps (lending markets, vaults, structured - product builders) can treat an LP holding as collateral by checking a - single `instrumentId`. They don't need to track per-version balance maps. -3. **UX simplicity** — the wallet shows one LP balance per pool, not a - timeline of versioned slivers. - -## Implications for the pool contract - -The pool contract template carries `lpInstrumentId : V2.InstrumentId` as a -static field fixed at pool creation. It MUST NOT be derived from the pool's -contract id, the current settlement iteration, or the pool's status. Those -all change over a pool's life and would re-version the LP behind users' backs. - -Concretely: - -- `PoolLiquidityRules_SettleAddLiquidity` mints `lpInstrumentId` tokens at the pool's - current proportional ratio. The minted holdings use the same `instrumentId` - as every prior LP minted from this pool. -- `PoolLiquidityRules_SettleRemoveLiquidity` burns `lpInstrumentId` tokens. The pool - does not care which iteration created them. -- The `LPTokenPolicy` registrar must accept any holding of `lpInstrumentId` - for burn: there is no version check. - -## Settlement iterations - -The V2 allocation API introduces `nextIterationAllocationCid` so the pool can hand pool- -side allocations forward across settlement batches without users re-allocating. -This is an allocation lifecycle concern, not an instrument-versioning -concern. The LP holdings users hold are unaffected by allocation iteration. - -## Fee and rule changes - -If the admin changes the pool's fee model, the LP instrument stays the same. -Existing LP holders share in future fees at the new rate. If the change is -non-trivial and existing LPs should be honoured at old rates, the operator -must spin up a new pool with a new pair of `lpInstrumentId`. That -is a deliberate migration, not an incidental rebase. - -## Emergency upgrades - -If the LP token policy itself needs to be replaced (security fix, choice -signature change), the upgrade path is a Canton package upgrade: same -`instrumentId`, new package hash. Holders are unaffected. - -## Why one LP instrument per pool - -- The LP registrar/policy component is the issuer of the LP token for the - pool. Fee accrual is reserve growth on the underlying assets, not a separate - coupon event that needs to be crystallized into a new instrument version. -- There is no off-ledger lifecycle event that LP holders need to settle - out before continuing to trade. A pool's fee revenue accumulates in its - reserves; redeem-by-burn always pays out the current ratio. - -## Why this is separate from registry primitive versioning - -Some lifecycle-aware registry-side instruments may version when their issuer -makes a breaking change, e.g., a coupon payment at epoch N that needs to be -paid into v_N holdings before they roll forward to v_{N+1}. That is a -fundamentally different shape of problem from the LP token: - -- **Registry primitives** have an external issuer who occasionally needs - to crystallize an off-ledger event onto the on-chain instrument. One - registry-specific pattern is upgrade-on-use inside the transfer/allocation - factories, plus a force-upgrade choice for passive holders who never touch - their balance. -- **LP tokens** have the pool's LP registrar/policy as the issuer. The - reference LP token has no off-ledger event to crystallize against a passive - holder's balance. So upgrade-on-use plus force-upgrade does not apply in this - reference: the LP token stays at one stable `instrumentId` for the - life of the pool. - -That means a wallet, lending market, or vault that consumes registry -primitives needs to handle upgrade-on-use behavior; the same wallet -consuming LP tokens does not. The two surfaces look fungible-equivalent -externally but have different upgrade semantics. - -See [CIP-0112](https://github.com/global-synchronizer-foundation/cips/blob/main/cip-0112/cip-0112.md) -for the canonical V1→V2 compatibility framing: V1 instruments continue -to exist alongside V2 implementations rather than being bulk-migrated. - -## See also +--- -- [Registry Integration](../guides/registry-integration.md) — for how the - LP registry config is registered at pool creation in the reference registry, - and for the registry-specific force-upgrade pattern some assets may exercise. -- [Workflows](workflows.md) — for the add/remove-liquidity flow. +## Reference: versioning and upgrades + +The single-instrument choice buys three things a versioned LP would cost: +**fungibility** (all holders of a pool hold one instrument and transfer freely), +**composability** (a lending market or vault treats an LP holding as collateral +by checking one `instrumentId`, with no per-version balance map), and **UX +simplicity** (one LP balance per pool, not a timeline of versioned slivers). + +- **Settlement iterations are not versions.** The V2 allocation API uses + `nextIterationAllocationCid` so the pool hands pool-side allocations forward + across settlement batches without users re-allocating. That is an allocation + lifecycle concern; the LP holdings users hold are unaffected. +- **Fee or rule changes keep the instrument.** Existing LPs simply share future + fees at the new rate. Honouring old LPs at old rates means the operator spins + up a *new* pool with a new `lpInstrumentId` — a deliberate migration, never an + incidental rebase. +- **Policy upgrades are package upgrades.** Replacing the LP policy itself + (security fix, choice-signature change) is a Canton package upgrade: same + `instrumentId`, new package hash; holders are unaffected. +- **Contrast with registry primitives.** A lifecycle-aware registry instrument + may version when its external issuer must crystallise an off-ledger event + (e.g. a coupon at epoch N paid into `v_N` before rolling to `v_{N+1}`), which + needs upgrade-on-use plus a force-upgrade choice for passive holders. The LP + token has no such event — its issuer is the pool's `lpRegistrar` and fee + accrual is just reserve growth — so it stays at one stable `instrumentId` for + the life of the pool. A wallet consuming registry primitives must handle + upgrade-on-use; the same wallet consuming LP tokens does not. See + [CIP-0112](https://github.com/global-synchronizer-foundation/cips/blob/main/cip-0112/cip-0112.md) + for the V1→V2 compatibility framing. + +## Tests + +- [`DvpMintBurnTests.daml`](../../trading-tests/CantonDex/Tests/DvpMintBurnTests.daml) + — `testDvpMintThenBurn` proves the mint/burn *mechanism* in isolation: a mint + leg from `mintAccount` credits the recipient, a burn leg to `burnAccount` + debits the holder and leaves nothing behind (the owner-`None` account is never + credited). +- [`PoolLiquidityRulesTests.daml`](../../trading-tests/CantonDex/Tests/PoolLiquidityRulesTests.daml) + — the same mint/burn as the sibling batch of a real add/remove: + `testDvpAddLiquidity` (deposit + LP mint settle atomically), + `testDvpRemoveDeliversToHolder` (reserves pay the holder, LP burns), + `testAddRejectsOverMint` (a receipt above the on-ledger fair share is + rejected), and `testSettleRequiresCoControl` (the settle needs both `operator` + and `lpRegistrar`). --- -**Where to read next:** [Liquidity & Custody](liquidity-and-custody.md) · [Add an LP or Instrument](../guides/add-lp-or-instrument.md) · [All docs](../README.md) +**Where to read next:** [Liquidity & Custody](liquidity-and-custody.md) · [Pricing](pricing.md) · [Add an LP or Instrument](../guides/add-lp-or-instrument.md) · [All docs](../README.md) diff --git a/docs/concepts/non-goals.md b/docs/concepts/non-goals.md index dcdd4bb7..7e7114f5 100644 --- a/docs/concepts/non-goals.md +++ b/docs/concepts/non-goals.md @@ -1,106 +1,161 @@ # What this reference does not include This is a reference implementation, not a product. Several things a production -DEX would carry are left out on purpose, either because they are an operator's -choice rather than a settlement-pattern concern, or because including them would -obscure the one thing the reference exists to show: that spot trading on Canton -can be built entirely on Token Standard V2 allocations, with the ledger enforcing -custody and conservation. - -Each item below is a deliberate choice. Where a boundary is visible -in the code, the module is named. +DEX would carry are left out on purpose — either because they are an operator's +deployment choice rather than a settlement-pattern concern, or because including +them would obscure the one thing the reference exists to show: that spot trading +on Canton can be built entirely on Token Standard V2 (CIP-0112) allocations, with +the ledger enforcing custody and conservation. + +Each item below is a deliberate choice, not an unfinished edge. Where a boundary +is visible in the code, the section names the template or choice that draws it, +and points at the guide or contract where the excluded work would live. + +## What is out of scope, and where it belongs + +| Excluded | Why it is out of scope | Where it belongs | +|---|---|---| +| A generic settlement engine | The templates encode one DEX's rules — constant-product pricing, price-time order priority, RFQ ranking — not a parameterisable framework | A fork that reuses the allocate-then-`SettleBatch` pattern for its own flows | +| Cross-registry pairs (two admins) | App-layer templates key each pair on a single `admin : Party`; the settlement spine already allows more | A scoped schema change — [registry integration](../guides/registry-integration.md#known-limitation-one-registry-admin-per-pair) | +| A production matching engine | The batch matcher shows only the Canton-specific part: a fill re-checked and settled atomically on-ledger | A fork's off-ledger matcher (pro-rata, iceberg, continuous auction) | +| A rich instrument lifecycle | Token Standard V2 standardizes the holding, not lifecycle; the DEX needs only a holding | The registry that administers the `InstrumentId` — [add an instrument](../guides/add-lp-or-instrument.md) | +| A privileged reference registry | `Registry.V2` is a convenience so the DEX runs standalone, not the mechanism value settles through | Any conforming TSv2 registry (Amulet, or another) | +| Self-custody onboarding | The hosted relay is a testnet convenience while DA Utilities lacks TSv2 support | The user's own wallet (PartyLayer / dapp-sdk), once the validator supports V2 | +| Operational hardening | HA, secrets management, and a rate-limited gateway are an operator's deployment decisions | Whoever runs an instance — [operator runbook](../guides/operator-runbook.md) | +| Production off-ledger services | The on-ledger contracts are the specification; the backend and indexer are one implementation of the surface around them | The integrator's own service — [architecture](architecture.md#off-ledger-services-what-they-may-and-may-not-do) | ## Not a generic settlement engine -The Daml models a DEX: pools, orders, RFQ. It is not a configurable settlement +The Daml models a DEX — pools, orders, RFQ. It is not a configurable settlement framework that a caller parameterises into arbitrary flows. The settlement -pattern (allocate, then settle a batch atomically through the registry's -`SettlementFactory`) is meant to be read and reused, but the templates encode -the DEX's own rules (constant-product pricing, price-time order priority, -best-execution RFQ ranking) rather than exposing a general engine. -See [architecture.md](architecture.md). +pattern — allocate, then settle a batch atomically through the registry's +`SettlementFactory_SettleBatch` — is meant to be read and reused, but the +templates encode the DEX's own rules: constant-product pricing, price-time order +priority, best-execution RFQ ranking. Lifting that pattern into a general engine +is a fork's job, not a configuration flag. See [architecture.md](architecture.md). ## One registry admin per pair A trading pair carries a single `admin : Party` covering both its base and quote -instruments (`trading/CantonDex/Dex/Order.daml`, `Pool.daml`). Under Token -Standard V2 an instrument is identified by `(admin, id)`, so this reference cannot -list a pair whose two assets come from different registries — for example Canton -Coin quoted against a third-party stablecoin. The settlement layer itself does -not require this (a single Daml transaction can settle one batch per admin, and -this repository already does so on the LP path); the limitation is in the app-layer -templates. Lifting it is a scoped design change, written up separately. +instruments (`trading/CantonDex/Dex/Order.daml`, `Pool.daml`): + +```daml +template Order with + ... + admin : Party + -- ^ Registry admin for the base + quote instruments. + baseInstrumentId : Text + -- ^ `id` component of the base instrument, under `admin`. + quoteInstrumentId : Text + -- ^ `id` component of the quote instrument, under `admin`. +``` + +Under Token Standard V2 an instrument is identified by `(admin, id)`, so this +reference cannot list a pair whose two assets come from different registries — +Canton Coin quoted against a third-party stablecoin, for instance. The limitation +is app-layer, not settlement-layer: a single Daml transaction can settle one batch +per admin, and the LP path already does exactly that, calling +`SettlementFactory_SettleBatch` once for the base/quote admin and once for the LP +registrar. Lifting it is a scoped schema change — a second-admin field on the four +pair-keyed templates and one allocation specification per `(authorizer, admin)`, +written up in +[registry-integration.md](../guides/registry-integration.md#known-limitation-one-registry-admin-per-pair). ## Not a production matching engine Order matching is a batch process the operator runs (`runMatching` in `services/operator-backend/src/order/index.ts`), not a continuous in-ledger matching loop. It clears crossing orders best-price-then-time, settles each match -atomically, and applies simple self-trade prevention (a party's own orders are -not paired). It does not implement pro-rata allocation, iceberg or hidden orders, -matching priority tiers, or a continuous auction. A production venue would layer -those on; the reference shows that the settlement of a match is atomic and -allocation-backed, the part specific to Canton. +atomically through the on-ledger `OrderMatchExecution_Execute` choice, and applies +simple self-trade prevention — a party's own bid and ask are never paired +(`services/operator-backend/src/order/matching.ts`). It does not implement +pro-rata allocation, iceberg or hidden orders, matching priority tiers, or a +continuous auction; a production venue would layer those on off-ledger. + +What the reference does show is the part specific to Canton: a match settles +atomically against both traders' funding allocations, and +`OrderMatchExecution_Execute` re-checks the fill against both orders' own limit +prices, quantities, instruments, and bound allocations — so a buggy or malicious +off-ledger matcher cannot settle a fill the traders never agreed to. Proven by +[EndToEndTests.daml](../../trading-tests/CantonDex/Tests/EndToEndTests.daml): +`testMatchedTradeFullSettle` (two trader allocations settle in one operator batch) +and `testOrderMatchEnforcesLimitPrice` (`OrderMatchExecution_Execute` refuses a +fill outside either order's limit price). ## A minimal instrument model -The vendored standard holding model is kept intentionally small. The reference -issues exactly one lifecycle-bearing instrument, the LP token, as a -token-standard instrument with its own registrar and DvP mint/burn -(`trading/CantonDex/Lp/`). Token Standard V2 does not mandate -`InstrumentConfiguration` or a rich lifecycle, and the reference does not assume -one exists for every registry. A guide for issuing a lifecycle-richer instrument -is included ([../guides/add-lp-or-instrument.md](../guides/add-lp-or-instrument.md)), -but the reference itself stays at the minimum the DEX needs. +The standard holding model is kept intentionally small. The reference issues +exactly one lifecycle-bearing instrument — the LP token — as a token-standard +instrument with its own registrar and DvP mint/burn +(`trading/CantonDex/Lp/Instrument.daml`). Token Standard V2 standardizes the +holding, not lifecycle: it does not mandate `InstrumentConfiguration` or a rich +lifecycle, and the reference does not assume one exists for every registry. +Anything richer — a bond's maturity and coupon, a vested or dividend-paying token +— is attached by the registry that administers the `InstrumentId`, not by DEX +templates. A guide for issuing a lifecycle-richer instrument is included +([add-lp-or-instrument.md](../guides/add-lp-or-instrument.md)); the DEX itself +stays at the minimum it needs. ## The reference registry is one option, not the mechanism -`CantonDex.Registry.V2` is a self-contained reference registry so the DEX can be -run end to end without depending on an external one. It is not the settlement -mechanism. The dApp and operator reach any conforming TSv2 registry through its -factories, choice context and disclosure. The reference does not assume its own -registry is present, and does not require every registry to expose the same -conveniences (`architecture.md`, "Dependency Boundary"). On the public testnet -the pair's assets happen to be issued by this registry; the flows are written to -work against Amulet or any other conforming registry. +`CantonDex.Registry.V2` is a self-contained reference registry so the DEX can run +end to end without depending on an external one. It is not the settlement +mechanism, and it is not privileged. The dApp and operator reach any conforming +TSv2 registry through its factories, choice context, and disclosure; the reference +does not assume its own registry is present, nor that every registry exposes the +same conveniences. [architecture.md](architecture.md#what-settles-value-the-token-standard-v2-spine) +and [registry-integration.md](../guides/registry-integration.md) set out exactly +what a registry must provide. On the public testnet the pair's assets happen to be +issued by this registry, but the flows are written to work against Amulet or any +other conforming registry just as well. ## The hosted testnet is a demo surface, not a wallet The public deployment lets a visitor with no wallet trade, by minting a hosted -demo party and relaying its signatures through a fixed, allowlisted set of -choices under per-IP and daily caps. This is explicitly a testnet convenience, -not self-custody: the two connect options are marked **DEV**. A real user brings -their own wallet (PartyLayer or the dapp-sdk) and signs for themselves; the hosted -relay exists so the milestone flows can be exercised from a browser without one. -The hosted onboarding routes and their caps are documented in -[../guides/operator-runbook.md](../guides/operator-runbook.md). +demo party and relaying its signatures through a fixed, allowlisted set of choices +under per-IP and daily caps. This is explicitly a testnet convenience, not +self-custody: the walletless connect options are marked **DEV** and are never +preselected in a testnet or production build +([using-the-dapp.md](../guides/using-the-dapp.md#connecting-a-wallet)). A real user +brings their own wallet (PartyLayer or the dapp-sdk) and signs for themselves; the +hosted relay exists only so the milestone flows can be exercised from a browser +without one. The whole `/v1/testnet/*` relay surface and the faucet's per-IP party +quota, and why each was added, are documented in +[ecosystem-feedback.md](../reference/ecosystem-feedback.md). **Current deployment status.** On the public testnet at `testnet-dex.bitdynamics.cc`, every tester is onboarded as a hosted party on the -operator's (BitDynamics) validator, and every traded asset (`dBTC`, `dUSD`, and -the pool's LP token) is issued locally by the deployment's own Token Standard V2 -registry. This is a bridge: external participants cannot yet bring their own -Token Standard V2 party and assets because the general-purpose validator and -wallet tooling (DA Utilities) does not yet support Token Standard V2. When that -support ships, users connect their own participant's party and trade their own V2 -assets through PartyLayer or the dapp-sdk, and the hosted onboarding is retired. -The code path for that is already the intended one. The hosted relay is the only -piece specific to this interim. +operator's (BitDynamics) validator, and every traded asset (`dBTC`, `dUSD`, and the +pool's LP token) is issued locally by the deployment's own Token Standard V2 +registry. This is a bridge: external participants cannot yet bring their own Token +Standard V2 party and assets because the general-purpose validator and wallet +tooling (DA Utilities) does not yet support Token Standard V2. When that support +ships, users connect their own participant's party and trade their own V2 assets +through PartyLayer or the dapp-sdk, and the hosted onboarding is retired. The code +path for that is already the intended one; the hosted relay is the only piece +specific to this interim. ## Operational hardening is out of scope -The reference includes an operator runbook covering deployment, recovery and -observability, but it is not a hardened production service. There is no HA, no -rate-limited public gateway beyond the testnet caps, no secrets-management -integration, and the operator's authority is a single party. These are an -operator's deployment decisions, deliberately left to whoever runs an instance -rather than baked into the reference. +The reference includes an operator runbook covering deployment, recovery, and +observability ([operator-runbook.md](../guides/operator-runbook.md)), but it is not +a hardened production service. There is no HA, no rate-limited public gateway +beyond the testnet caps, no secrets-management integration, and the operator's +authority is a single party. These are an operator's deployment decisions, +deliberately left to whoever runs an instance rather than baked into the reference +— the runbook's own [out-of-scope +list](../guides/operator-runbook.md#out-of-scope-for-this-document) draws the same +line. -## Off-chain services are illustrative +## Off-ledger services are illustrative The operator backend and indexer are a working reference, not a prescription. The indexer is a single-writer SQLite projection sized for a testnet; the backend is one Node process. They show what an integrator needs to read and relay, not the -only way to build it. The on-ledger contracts are the specification; the -off-chain services are one implementation of the surface around them -(`architecture.md`, "Off-Chain Services"). +only way to build it. The on-ledger contracts are the specification; the off-ledger +services are one implementation of the surface around them +([architecture.md](architecture.md#off-ledger-services-what-they-may-and-may-not-do)). + +--- + +**Where to read next:** [Architecture](architecture.md) · [Workflows](workflows.md) · [Registry Integration](../guides/registry-integration.md) · [All docs](../README.md) diff --git a/docs/concepts/overview.md b/docs/concepts/overview.md index d2ac120a..fc983b36 100644 --- a/docs/concepts/overview.md +++ b/docs/concepts/overview.md @@ -1,107 +1,146 @@ # Overview -Canton DEX is a runnable **reference implementation** of exchange workflows on -the Canton Network. It shows how market state, wallet-authorized funding, -registry-defined holdings, Token Standard V2 allocations, and atomic settlement -batches fit together in one application, as real Daml workflows and a working -dApp, not just diagrams. - -If you want to run it, go to **[Getting Started](../getting-started.md)**. This -page explains why it is shaped the way it is. - -## What it demonstrates - -- **Token-standard-native funds movement.** Value moves through V2 holdings, - allocations, allocation requests, and settlement factories, not a custom - off-ledger balance model. -- **A strict authority boundary.** The operator can only submit the commands it - is authorized to submit; every movement of trader assets is authorized by - the trader's own wallet. -- **Concrete settlement patterns.** RFQs, matched trades, prefunded orders, - swaps, and LP add/remove are each implemented as delivery-versus-payment - settlement over V2 allocations. - -> **This is a reference implementation, not an audited production exchange.** -> It is built for learning, evaluation, demos, and forks. Production adopters -> should do their own security review, operational hardening, compliance work, -> and version-compatibility checks. - -## The standards it builds on - -| Standard | What it is | Role here | -|---|---|---| -| **CIP-0056** — Canton Network Token Standard | The base token standard (holdings, transfers, metadata). | The foundation the V2 revision extends. | -| **CIP-0112** — Token Standard **V2** | The privacy / performance / traditional-accounting revision, adding the allocation + settlement surface. | Every asset (base, quote, and LP token) is a V2 instrument; funds move via V2 allocations and settlement factories. | -| **CIP-0103** — dApp Standard | The wallet interaction standard (prepare → sign → execute interactive submission). | The dApp hands trader-authority commands to a wallet over CIP-0103 rather than submitting them itself. | +This is your first stop. It says what Canton DEX is, shows the whole system on +one diagram, and points you at the doc that answers your next question. + +## What Canton DEX is + +Canton DEX is a runnable **Token Standard V2 (CIP-0112) reference exchange** for +the Canton Network. It offers four ways to trade — AMM pools, a prefunded order +book, request-for-quote, and bilateral OTC blocks — and every one of them moves +funds the same way: the holder locks a holding into a V2 **allocation**, and the +matched legs settle atomically as a delivery-versus-payment batch through a +registry's `SettlementFactory_SettleBatch`. There is no custom off-ledger balance +model and no house wallet; the DEX contracts own market logic, and the token +standard owns custody and settlement. The repo ships the Daml package, an +operator backend, a React dApp with a CIP-0103 wallet boundary, tests, and +runbooks, and runs end-to-end locally with no Canton participant. + +## System at a glance + +Two backends sit between the trader and the ledger, split by **who is allowed to +sign what**. The operator backend submits only operator-authority commands +(listing, matching, settling). Anything that moves a trader's assets is signed by +the trader's own wallet over CIP-0103. Both submit into one Daml package, +`canton-dex-trading`, whose trading surfaces settle through a Token Standard V2 +registry. + +```mermaid +flowchart TB + Trader["Trader
React dApp — app/web"] + Wallet["Wallet
CIP-0103 (external)"] + Operator["Operator backend
services/operator-backend
HTTP API · matcher · indexer"] + + subgraph Ledger["Canton ledger — canton-dex-trading package"] + direction TB + Surfaces["Four trading surfaces
AMM pools · Order book · RFQ · OTC"] + Registry["Token Standard V2 registry
Holding · Allocation · SettlementFactory"] + Surfaces -->|"SettlementFactory_SettleBatch"| Registry + end + + Trader -->|"reads + operator APIs"| Operator + Trader -->|"signs trader-authority commands"| Wallet + Operator -->|"operator-authority submissions"| Ledger + Wallet -->|"trader-authority submissions"| Ledger +``` -Token Standard V2 has **merged into `canton-network/splice` `main`** and -becomes the network default from **mid-July 2026**. This repo vendors the V2 -sources at a pinned commit. See -[`../../vendor/splice/VENDOR_PIN.md`](../../vendor/splice/VENDOR_PIN.md) and the -[Allocation Surface](../reference/allocation-surface.md) reference for the exact -surface it relies on. +- **dApp** (`app/web/`) — the trader-facing screens and the wallet-provider + boundary (Mock, CIP-0103 SDK, WalletConnect, PartyLayer, and more). +- **Operator backend** (`services/operator-backend/`) — HTTP API, JSON Ledger + API driver, the reference matcher, indexer, idempotency, and recovery. Ships an + in-memory dev ledger so the stack runs without Canton. +- **`canton-dex-trading` package** (`trading/`) — the DEX templates, the LP-token + component, and a reference V2 registry. +- **Registry** — external by contract. The reference ships one, but V2 does not + require this exact registry; the DEX integrates against `InstrumentId` and + registry-provided choice context, not a specific config template. -## The trust model +## The four trading surfaces -The single most important design idea is who is allowed to move what. Four -authorities, each with a distinct responsibility: +Each surface posts its own market object, but they converge on the same +allocate-then-settle-a-batch pattern: -| Authority | Owns | Example | +| Surface | Market object → settlement | Price source | |---|---|---| -| **DEX contracts** | Market state and workflow validation. | An `Order` records price/size and enforces matching rules. | -| **Token Standard contracts** | Asset reservation and settlement. | An `Allocation` locks a holding; `SettlementFactory_SettleBatch` settles atomically. | -| **Registry** | Instrument semantics and choice context. | The registry says what a holding is and supplies the context a settlement needs. | -| **Wallet + operator** | Submission authority. | The **wallet** submits trader-authority commands (funding, allocation creation); the **operator** submits only the commands it is authorized to (binding orders, matching, settling batches). | - -The operator backend **never** moves a trader's assets on its own. When a -trader funds an order, adds liquidity, or authorizes a swap, that command is -composed by the dApp and signed by the trader's wallet over CIP-0103. The -operator orchestrates and settles only what it is authorized to submit. - -See [Architecture](architecture.md) for the component boundaries and -[Workflows](workflows.md) for how each flow choreographs these authorities. - -## The components - -```text -┌────────────────────────────────────────────────────────────┐ -│ React dApp (app/web) │ -│ Trade · Pools · Orders · RFQ · Portfolio · Admin │ -│ ── wallet boundary (CIP-0103): trader-authority commands ──│ -└───────────────┬─────────────────────────┬──────────────────┘ - │ reads + operator APIs │ trader-signed submissions - ▼ ▼ -┌───────────────────────────────┐ ┌──────────────────────────┐ -│ Operator backend │ │ Wallet │ -│ (services/operator-backend) │ │ (external, CIP-0103) │ -│ HTTP API · matcher · indexer │ │ │ -│ · idempotency · recovery │ │ │ -└───────────────┬───────────────┘ └────────────┬─────────────┘ - │ operator-authority │ trader-authority - ▼ JSON Ledger API submissions ▼ -┌────────────────────────────────────────────────────────────┐ -│ Canton ledger (trading/ — canton-dex-trading Daml package)│ -│ DEX app: DexPair, Order, Rfq, MatchedTrade, Pool, PoolState│ -│ LP component: LPTokenPolicy │ -│ Token Standard V2 + reference registry: Holding, Allocation│ -│ AllocationRequest, SettlementFactory │ -└────────────────────────────────────────────────────────────┘ +| **AMM pool** | `Pool` / `PoolState` / `PoolSlice` → `PoolRules_Swap` | constant-product curve, computed on-ledger | +| **Order book** | `OrderFundingRequest` → `Order` → `OrderMatchExecution` | the trader's `limitPrice` | +| **RFQ** | `Rfq` / `RfqQuote` → `Rfq_Accept` → `MatchedTrade` | the dealer's quoted price | +| **OTC** | `MatchedTrade` → `MatchedTrade_Settle` | leg amounts both sides pre-agreed | + +Settlement is **grouped by registry admin** and executed in one transaction, so a +trade either clears every leg or none. `MatchedTrade_Settle` shows the shape: + +```daml +results <- forA (Map.toList batchesByAdmin) $ \(batchAdmin, batch) -> do + ... + result <- exercise batch.factoryCid V2.SettlementFactory_SettleBatch with + settlement + transferLegs = batchLegs + allocations = batch.allocations + actors = [venue] + extraArgs = batch.extraArgs + pure (batchAdmin, result) ``` -- **[`trading/`](../../trading/)** — the `canton-dex-trading` Daml package: DEX - templates, the LP-token component, and a reference V2 registry. -- **[`services/operator-backend/`](../../services/operator-backend/)** — the - operator HTTP API, JSON Ledger API driver, matcher, indexer, idempotency, and - recovery. Ships an in-memory dev ledger so the stack runs with no Canton. -- **[`app/web/`](../../app/web/)** — the React dApp and its wallet-provider - boundary (Mock, CIP-0103 SDK, WalletConnect, PartyLayer, and more). -- **The registry is external.** The reference ships one, but Token Standard V2 - does not require this exact registry — see - [Registry Integration](../guides/registry-integration.md). - -## Where to go next - -- **Run it:** [Getting Started](../getting-started.md) -- **Read the design:** [Architecture](architecture.md) → [Workflows](workflows.md) -- **Build on it:** [Builder Guide](../guides/builder-guide.md) -- **Look up a term:** [Glossary](glossary.md) +## The authority boundary + +The one idea to carry into every other doc: **the operator never moves a +trader's assets.** When a trader funds an order, adds liquidity, or authorizes a +swap, the dApp composes that command and the trader's **wallet** signs it over +CIP-0103. The operator orchestrates and settles only what it is authorized to +submit. That boundary is why funding and liquidity always route through a wallet, +and it is enforced by the token standard's own authorization rules, not by the +backend. [Architecture](architecture.md) draws the component boundaries; +[Workflows](workflows.md) shows how each flow choreographs them. + +## How to read these docs + +Read top to bottom for the design, or jump to the row that matches your question. + +| Doc | What you'll learn | +|---|---| +| **Overview** (this page) | What the DEX is, the system shape, and the authority boundary. | +| [Architecture](architecture.md) | The layered system model, component boundaries, and the executor-authority constraint that keeps operator-held funds governed on-ledger. | +| [Workflows](workflows.md) | How each surface choreographs allocate-then-settle — the actors, contracts, and state transitions, workflow-first. | +| [Pricing](pricing.md) | Where every executable price comes from (pool curve, limit price, quote) and why there is no oracle. | +| [LP Tokens](lp-tokens.md) | Why each pool's LP share is a single, unversioned V2 instrument. | +| [Liquidity & Custody](liquidity-and-custody.md) | How the pool custodies reserves as committed slices and crosses the LP boundary via DvP. | +| [Glossary](glossary.md) | The vocabulary: allocation, commitment, iterated settlement, DvP, slice, registrar. | +| [Non-goals](non-goals.md) | What the reference leaves out on purpose, and why. | + +> **This is a reference implementation, not an audited production exchange.** It +> is built for learning, evaluation, demos, and forks. Production adopters should +> do their own security review, operational hardening, compliance work, and +> version-compatibility checks. + +Each claim above is exercised end-to-end by a Daml Script test against the +reference registry: + +- **AMM pool** — [`testPoolSwapEndToEnd`](../../trading-tests/CantonDex/Tests/EndToEndTests.daml) + drives a swap through `PoolRules_Swap` and the registry. +- **Order book** — [`testOrderFundingFlow`](../../trading-tests/CantonDex/Tests/EndToEndTests.daml) + proves the trader-signed → operator-bound → trader-accepted funding path. +- **RFQ** — [`testRfqAcceptProducesMatchedTradeWithReceipt`](../../trading-tests/CantonDex/Tests/EndToEndTests.daml) + shows an accepted quote yielding a `MatchedTrade` plus a `PolicyReceipt`. +- **OTC** — [`testMatchedTradeFullSettle`](../../trading-tests/CantonDex/Tests/EndToEndTests.daml) + settles a matched trade as per-admin DvP batches in one transaction. + +--- + +### Reference: standards and versioning + +Canton DEX builds on three CIPs. **CIP-0056** is the base Canton Network Token +Standard (holdings, transfers, metadata). **CIP-0112** is Token Standard **V2**, +the privacy / performance / traditional-accounting revision that adds the +allocation and settlement surface — every asset here (base, quote, and the LP +token) is a V2 instrument. **CIP-0103** is the dApp standard the wallet boundary +uses for trader-authorized submissions. + +Token Standard V2 is **merged into `canton-network/splice` `main`** and is the +network default. This repo vendors the V2 sources so builds are reproducible; the +exact commit is pinned in +[`../../vendor/splice/VENDOR_PIN.md`](../../vendor/splice/VENDOR_PIN.md), and the +[Allocation Surface](../reference/allocation-surface.md) reference records the +committed-allocation and iterated-settlement semantics the pool depends on. + +**Where to read next:** [Getting Started](../getting-started.md) · [Architecture](architecture.md) · [Workflows](workflows.md) · [All docs](../README.md) diff --git a/docs/concepts/pricing.md b/docs/concepts/pricing.md index aa1b6a41..37f9b94f 100644 --- a/docs/concepts/pricing.md +++ b/docs/concepts/pricing.md @@ -1,73 +1,93 @@ -# Pricing and oracle sources +# Pricing and price sources -## No on-chain price oracle +This DEX has no price oracle. Every price it can execute is set inside the +system, so no external feed can move funds on the ledger. -There is no on-chain price oracle in this DEX. Every executable -price comes from one of four endogenous sources, and the codebase -contains no integration with Chainlink, Pyth, an attested feed, a TWAP -window, or any external pricing service. +## The four price surfaces -## Where each price comes from +A price here is always one of four things, and each is signed by whoever is +accountable for it: -| Surface | Price source | Authority | +| Surface | Price source | Signed by | |---|---|---| -| AMM `Pool` (`PoolRules_Swap`) | Constant-product formula over on-chain reserves: `out = reserveOut * (in * (1 - fee)) / (reserveIn + in * (1 - fee))` | Pool reserves are signed by the `operator`; the swap re-validates against `minOutputAmount`, so the swapper sets their own price floor | -| `Order` book | Trader's `limitPrice` on the `OrderFundingRequest` | Trader-signed | -| `Rfq` / `RfqQuote` (Workflow 2) | Dealer-quoted `price` on each `RfqQuote`; the operator's `applyPolicy` ranks but never alters the quoted price | Dealer-signed (quote), trader+operator-signed (accept) | -| `MatchedTrade` (OTC) | Pre-agreed leg amounts in `transferLegs` | Bilateral, signed by both authorizers | +| AMM pool (`PoolRules_Swap`) | the constant-product curve over the pool's reserves | operator (reserves); the taker sets a `minOutputAmount` floor | +| Order book (`OrderFundingRequest`) | the trader's `limitPrice` | the trader | +| RFQ (`RfqQuote`) | the dealer's quoted `price`; the operator ranks quotes but never rewrites one | dealer (quote); trader + operator (accept) | +| OTC (`MatchedTrade`) | the leg amounts both sides pre-agreed | both authorizers | -The quote endpoint mirrors the `constantProductOut` helper over current -reserves; it does not consult any external feed. The operator -backend's `policy/index.ts rankQuotes` ranks dealer quotes but never -substitutes a price. +Only the first is a price the *system* computes; the rest are prices someone +posted. The rest of this page covers the AMM. + +## How the pool prices a swap + +The pool is a constant-product AMM: it holds a reserve of each asset and prices +every swap off the invariant `x · y = k`. A swap pays `Δin` into one reserve and +takes the `Δout` from the other that keeps the product from decreasing; the +marginal price is the reserve ratio, and each trade moves that ratio against the +taker (price impact). + +Two things about this implementation matter more than the curve itself: + +- **The fee is retained in the pool.** It is charged on the input + (`Δin · (1 − fee)` drives the curve) while the full `Δin` still lands in the + reserve, so `k` is strictly non-decreasing across a swap. That surplus is what + accrues to liquidity providers. +- **The output is re-derived on the ledger, not quoted by the operator.** + `PoolRules_Swap` computes `Δout` from the current reserves inside the choice and + settles the taker against that value, so the operator cannot quote one number + and settle another. The dApp's quote endpoint runs the same function + off-ledger, so preview and settlement agree to the last digit. + +The computation is one helper, +[`constantProductOut`](../../trading/CantonDex/Dex/PoolModel.daml): + +```daml +constantProductOut reserveIn reserveOut feeBps inputAmount = + let amountInAfterFee = + floorDiv (floorMul inputAmount (intToDecimal (10000 - feeBps))) 10000.0 + in floorDiv (floorMul amountInAfterFee reserveOut) (reserveIn + amountInAfterFee) +``` + +```mermaid +flowchart LR + In["Δin (input)"] -->|"fee retained:
Δin · (1 − fee)"| C{{"x · y = k"}} + RI[("reserveIn")] --> C + RO[("reserveOut")] --> C + C -->|"Δout"| Out["Δout (output)"] + C -.->|"reserves move +Δin / −Δout"| P[("new Pool")] +``` + +**Rounding is one-directional.** `floorMul` and `floorDiv` round `Δout` down, so +the pool never pays more than the exact amount and `k` stays non-decreasing even +after scale-10 `Decimal` rounding. Verified in +[`PoolRoundingTests.daml`](../../trading-tests/CantonDex/Tests/PoolRoundingTests.daml) +(a swap never overpays; `k` never decreases) and +[`PoolStateInvariantTests.daml`](../../trading-tests/CantonDex/Tests/PoolStateInvariantTests.daml) +(reserves stay consistent across a swap). ## Practical consequences -- Pool prices follow reserves. A pool with stale or thin liquidity - will quote stale prices. There is no oracle-backed "fair value" - protection; arbitrageurs are the only mechanism that pulls pool - prices toward broader-market prices. -- Order-book prices are whatever traders post. There is no spread - policy or reference-price guard on the operator side beyond the - fee-model the pair config encodes. -- RFQ prices are whatever dealers quote. The - [PolicyReceipt](../../trading/CantonDex/Dex/PolicyReceipt.daml) records - which quote ranked where under which policy version, but does not - certify that the chosen price is "good", only that the policy was - applied honestly. -- The fiat estimates the dApp shows next to instrument balances are - **live, pool-derived values**, sourced from the operator backend's - `/v1/prices` endpoint via the `useAssetPricesUsd` hook - ([usePrices.ts](../../app/web/src/hooks/usePrices.ts)). Each quote - resolves from pool mid-price (constant-product), then a configured - static `PRICES` feed, and falls back to "—" when no source has a - price. They are advisory display estimates, deliberately not used for - any executable decision. - -## Oracle attachment points - -If a future tranche introduces an oracle, the natural attachment -points are: - -1. Slippage / circuit-breaker on `PoolRules_Swap`. Add an - `oracleAttestation` argument with a signed price + timestamp; the - choice asserts the realized swap price stays within a band of the - attested price. The signer would be a separate `oracleAuthority` - party in the choice context (production registries already follow - this pattern for credential checks, see - [registry-integration.md](../guides/registry-integration.md)). -2. TWAP for compliance reporting. A separate `PoolPriceObservation` - template the operator creates after each `PoolRules_Swap`, sampled by an - off-chain ingestor. Pure observability, no consensus role. -3. Fiat-display reference. The dApp's `assets.ts` could call out - to a public price API at the edge. This is presentation-only and - does not need to be on-ledger. - -None of the above are implemented. The current design intentionally -keeps pricing endogenous so that a malicious external feed cannot move -on-ledger funds, at the cost of the protections an oracle would -provide. +- Pool prices track reserves. A thin or stale pool quotes a stale price, and + only arbitrage pulls it back; there is no oracle "fair value" guard. +- Order and RFQ prices are whatever was posted. For RFQ, the + [`PolicyReceipt`](../../trading/CantonDex/Dex/PolicyReceipt.daml) records which + quote won under which policy version — evidence the ranking was applied + honestly, not that the price was good. + +## Fiat estimates in the dApp + +The dollar figures next to balances are advisory: pool mid-price, falling back +to a static feed and then to "—", served from `/v1/prices`. Display only; never +an input to settlement. --- -**Where to read next:** [Architecture](architecture.md) · [Registry Integration](../guides/registry-integration.md) · [All docs](../README.md) +### Reference: where an oracle would attach + +Pricing is endogenous by design, so a compromised feed cannot move funds. If a +later version added an oracle, the attachment points would be a slippage band on +`PoolRules_Swap` (a signed price + timestamp from a separate `oracleAuthority`), a +`PoolPriceObservation` template for TWAP reporting, or an edge-side price API for +fiat display. None are implemented. + +**Where to read next:** [Architecture](architecture.md) · [Workflows](workflows.md) · [Registry integration](../guides/registry-integration.md) diff --git a/docs/concepts/workflows.md b/docs/concepts/workflows.md index 38e467b4..d3381156 100644 --- a/docs/concepts/workflows.md +++ b/docs/concepts/workflows.md @@ -1,150 +1,65 @@ # Canton DEX workflow design -## Why workflow first - -The hard part of this reference DEX is not matching Uniswap feature-for-feature. -The hard part is getting the Daml workflows right so that: - -- the app contracts own market structure -- token-standard contracts own reservation and settlement -- registry contracts own asset semantics -- the production instance is operationally believable - -That means we should design workflows first and let features fall out of those -workflows. - -## We do not need full Uniswap parity - -A production-shaped reference DEX does not need every Uniswap V2 or V3 feature. - -It does need: - -- single-pool or single-hop swaps -- add liquidity and remove liquidity -- LP token mint and burn -- slippage bounds -- fees and fee accrual -- order or RFQ settlement that proves the V2 allocation story -- cancellation and expiry flows -- operational controls, observability, and failure handling - -It does not need on day one: - -- concentrated liquidity -- ticks and tick crossing -- NFT positions -- permissionless pool factory -- multi-hop routing -- flash swaps -- advanced oracle and TWAP machinery - -Those are worthwhile later features, but they are not required to validate the -core Canton-native design. - -The chosen workflows cover the dominant reference-DEX shapes without claiming -full market parity: pair listing, single-hop constant-product swaps, LP -add/remove, prefunded orders, OTC/RFQ, cancellation, and operator recovery. If -this document makes volume-coverage claims in the future, they should be backed -by current market data rather than asserted qualitatively. - -## Workflow design principles - -1. One workflow, one business object - - orders, trades, pools, and LP issuance each get their own app contract - -2. Allocations represent funds - - not just abstract approvals - -3. Settlement is explicit - - the DEX should create matched trade or swap state before calling settlement - -4. Cancellation is a first-class workflow - - no hidden cleanup assumptions - -5. Registry lifecycle stays outside market logic - - the DEX trades `InstrumentId`; the registry explains what that means - through V2 views, metadata, and any registry-specific context/contracts - -6. Executor-controlled funds must be usage-constrained on ledger - - if committed or iterated allocations put funds under executor-driven - settlement control, the app contracts must validate every permitted use - - off-chain services may choose when to exercise a workflow, but not - redefine what the funds may be used for - -7. Keep hot-path transactions shard-local - - avoid workflow shapes that require touching every pool reserve allocation - for ordinary swaps or redemptions - - prefer order-local or reserve-slice-local transactions, with - consolidation handled as an explicit maintenance path - -## Actors - -- `Trader` -- `LiquidityProvider` -- `DexOperator` -- `Matcher` -- `PoolOperator` -- `Registrar` - -For a reference deployment, `Matcher` and `PoolOperator` may both be operated -by the `DexOperator`, but the workflows should keep their responsibilities -separate. - -## Core on-ledger contracts - -- `DexPair` -- `MatchedTrade` -- `TradeAllocationRequest` -- `Rfq` -- `RfqQuote` -- `OrderFundingRequest` -- `Order` -- `OrderAllocationRequest` -- `Pool` -- `PoolState` -- `PoolSlice` -- `PoolRules` -- `PoolLiquidityRules` -- `LiquidityAllocationRequest` -- `LPTokenPolicy` - -Together these contracts separate market state, pool accounting, LP-token -policy, and token-standard allocation requests. - -This is a template/module boundary, not a custom Daml-interface boundary. The -DAR implements upstream Token Standard V2 interfaces, but it does not define a -separate app-facing interface that decouples an LP-token registry package from a -venue package. - -## Dependency split - -There are two distinct workflow families. - -### Bilateral settlement workflows - -- OTC and RFQ trade request -- matched trade settlement -- trade cancellation - -### Pool and prefunded-order workflows - -- resting orders backed by prefunded allocations -- pool reserves represented by committed allocations -- repeated swaps via iterated settlement -- reserve roll-forward using `FinalizedAllocation.nextIterationFunding` - -This split matters because the bilateral path and the pool path share the same -token-standard settlement primitives while preserving different application -state and cancellation rules. - -## Sequence diagrams - -These three flows show how the authorities — the trader's wallet, the dApp, the -operator, and the ledger's Token Standard contracts — choreograph a settlement. -Every trader-authority step goes through the wallet over CIP-0103; the operator -submits only what it is authorized to. - -### Pool swap (Workflow 9) +Every settling workflow in this DEX is one app choice that builds the transfer +legs and calls a single Token Standard settlement. The app contracts own market +structure; the Token Standard owns the funds; the registry owns what an +instrument means. The hard part was never Uniswap parity — it was getting those +Daml workflows right. + +## Two workflow families + +Every flow settles through the same primitive — `SettlementFactory_SettleBatch` +over Token Standard allocations — but they split into two families that keep +different application state and cancellation rules. + +- **Bilateral settlement** — OTC and RFQ block trades. A `MatchedTrade` names + two pre-agreed legs; each side authors a one-shot allocation; the operator + batches them by registry admin and settles. +- **Pool and prefunded orders** — swaps, add/remove liquidity, and resting + orders. Funds sit under *committed*, *iterated* allocations that the settle + rolls forward via `FinalizedAllocation.nextIterationFunding`, so the same + reserve or order can settle repeatedly without re-authoring. + +The rest of this page walks the four workflows that carry the design: swap, +add/remove liquidity, the order lifecycle, and RFQ. Secondary flows (pair +listing, pool creation, asset lifecycle) and the design principles are in +[Reference](#reference). + +## Actors and core contracts + +| Actor | Role | +|---|---| +| `Trader` / `LiquidityProvider` | authors allocations from their own wallet over CIP-0103 | +| `DexOperator` | drives the settling choices; also acts as `Matcher` and `PoolOperator` in a reference deployment | +| `Registrar` / `lpRegistrar` | signs mint/burn of the LP instrument | + +The market objects (`DexPair`, `Order`, `MatchedTrade`, `Rfq`, `RfqQuote`) stay +separate from the pool-accounting objects (`Pool`, `PoolState`, `PoolSlice`) and +the LP-token policy (`LPTokenPolicy`). This is a template boundary, not a custom +Daml-interface boundary: the DAR implements upstream Token Standard V2 +interfaces but defines no app-facing interface of its own. + +## The settlement shape every workflow shares + +Two mechanics recur below and are worth stating once, because they are the +non-obvious part: + +- **Prefunded, iterated allocations.** A pool reserve slice and a resting order + are authored with `nextIterationFunding = Some ...` and no transfer legs. The + settling choice supplies the real legs as `extraTransferLegSides` on a + `FinalizedAllocation`, and the registry rolls the residual budget into a fresh + allocation the choice binds back onto the slice or order. A one-shot + allocation (`nextIterationFunding = None`) could not do this. +- **Per-admin batches.** Legs are grouped by the registry `admin` that governs + the instrument. Liquidity settles as *split-admin* DvP: base and quote under + `pool.admin`, the LP mint/burn under `pool.lpRegistrar` — two + `SettleBatch`es in one transaction, each carrying its own registry choice + context. + +## Swap against a pool + +**Intent:** a trader swaps one pool asset for the other at the constant-product +price, atomically against the pool's reserves. ```mermaid sequenceDiagram @@ -153,17 +68,50 @@ sequenceDiagram participant O as Operator backend participant L as Ledger (Token Standard + Pool) D->>O: POST /v1/pools/swap/request - O-->>D: allocation spec + choice context - T->>L: AllocationFactory_Allocate (lock input holding) + O->>L: PoolRules_RequestSwap + L-->>O: allocation spec + settlement descriptor + O-->>D: spec + T->>L: AllocationFactory_Allocate (prefund the input) Note over T,L: trader-signed via wallet (CIP-0103) D->>O: POST /v1/pools/swap (allocation cid) O->>L: PoolRules_Swap -> SettlementFactory_SettleBatch - Note over O,L: pool + trader allocations settle atomically (DvP) - L-->>O: settled, pool state rolled forward - O-->>D: swap result + Note over O,L: swapper + input slice + output slices settle atomically (DvP) + L-->>O: settled, PoolState rolled forward +``` + +`PoolRules_Swap` prices `amountOut` from the current reserves *inside the +choice* (`constantProductOut`, see [Pricing](pricing.md)), enforces the taker's +`minOutputAmount` floor, then settles the swapper against the pool in one batch. +The input reserve slice rolls forward grown by the full input; the output side +is drained from an ordered slice prefix so a routine swap never touches every +reserve slice. + +```daml +let swapperFinalized = Utils.mkFinalizedAllocation swapperAllocationCid + (Utils.legsToSides swapperAccount (swapInLeg :: outDel.legs)) None + inputFinalized = Utils.mkFinalizedAllocation inputSlice.allocationCid + (Utils.legsToSides poolAccount [swapInLeg]) + (Some (TextMap.fromList [(inputInstrumentId, inputSlice.amount + inputAmount)])) + +settleResult <- exercise factoryCid V2.SettlementFactory_SettleBatch with + settlement + transferLegs = swapInLeg :: outDel.legs + allocations = swapperFinalized :: inputFinalized :: outDel.sliceFinalizeds + actors = [operator] + extraArgs ``` -### Add liquidity — delivery-versus-payment (Workflow 7) +Proven in +[`EndToEndTests.daml`](../../trading-tests/CantonDex/Tests/EndToEndTests.daml) — +`testPoolSwapEndToEnd` (reserves move, the consumed input slice is replaced by +its next-iteration slice, sibling slices stay untouched) and +`testPoolSwapViaRequestSwap` (the spec `PoolRules_RequestSwap` emits settles +end to end). + +## Add and remove liquidity + +**Intent:** fund the pool and mint LP shares, or burn LP shares and return the +provider's proportional reserves — each in one atomic settlement. ```mermaid sequenceDiagram @@ -172,18 +120,103 @@ sequenceDiagram participant O as Operator + lpRegistrar participant L as Ledger D->>O: POST /v1/pools/add-liquidity/request - O->>L: create LiquidityAllocationRequest - O-->>D: request + specs (base, quote, LP receipt) + factories + O->>L: PoolLiquidityRules_RequestAddLiquidity + O-->>D: request + specs (base, quote, LP receipt) LP->>L: 3x AllocationFactory_Allocate (base, quote, LP receipt) Note over LP,L: LP-signed via wallet D->>O: POST /v1/pools/add-liquidity/settle (allocation cids) O->>L: PoolLiquidityRules_SettleAddLiquidity - Note over O,L: two per-admin SettleBatches — base/quote under pool admin,
LP mint under lpRegistrar - L-->>O: funds in pool, LP tokens minted to LP, PoolState rewritten - O-->>D: settled + Note over O,L: base/quote batch under pool.admin,
LP mint batch under pool.lpRegistrar + L-->>O: funds in pool, LP tokens minted, PoolState rewritten +``` + +`PoolLiquidityRules_SettleAddLiquidity` runs the split-admin DvP: the LP's +committed deposits and LP-mint receipt settle together, the operator's receiver +allocations roll forward into the two new `PoolSlice`s, and the registrar mints +LP tokens to the provider. Only the ratio-matched part of an off-ratio deposit +enters the reserves; the excess is refunded in the same batch, so it never buys +LP tokens. + +```daml +-- base/quote batch (pool.admin): deposits in, operator receivers roll +-- forward with nextIterationFunding on the finalized step. +bqResult <- exercise baseQuoteSettleCid V2.SettlementFactory_SettleBatch with + settlement + transferLegs = [baseDepositLeg, quoteDepositLeg] ++ baseRefundLegs ++ quoteRefundLegs + allocations = ... + actors = [operator] + extraArgs = poolAdminExtraArgs +... +-- LP-mint batch (pool.lpRegistrar). +_ <- exercise lpSettleCid V2.SettlementFactory_SettleBatch with + settlement + transferLegs = [lpMintLeg] + allocations = [Utils.finalAllocation regMint, Utils.finalAllocation lpReceiptCid] + actors = [operator] + extraArgs = lpRegistrarExtraArgs ``` -### RFQ accept (Workflow 2) +Remove is the mirror: `PoolLiquidityRules_SettleRemoveLiquidity` draws the +pro-rata payout across an ordered slice prefix (full slices drain, only the +boundary slice is re-wrapped for its leftover), delivers base and quote to the +holder, and burns the LP tokens under `pool.lpRegistrar`. + +Proven in +[`PoolLiquidityRulesTests.daml`](../../trading-tests/CantonDex/Tests/PoolLiquidityRulesTests.daml) — +`testDvpAddLiquidity` (LP funds base+quote and receives real LP holdings in one +flow), `testDvpAddOffRatioRefundsExcess` (the unmatched leg is refunded, not +donated), `testDvpRemoveDeliversToHolder` (base+quote go to the holder, LP burns), +and `testDvpMultiSliceRemove` (a redemption draws across multiple slices). + +## Order lifecycle + +**Intent:** rest a prefunded bid or ask, then convert two crossing orders into +one settled trade without either side trusting the matcher. + +```mermaid +flowchart LR + P["Order (Pending)"] -->|Order_Fund| F["Order (Funded)"] + F -->|OrderMatchExecution_Execute| S{{"SettleBatch
+ roll orders forward"}} + S -->|partial fill| PF["Order (PartiallyFilled)"] + S -->|full fill| X["archived + SettledTrade"] + PF -->|OrderMatchExecution_Execute| S + F -->|Order_Cancel| C["cancelled, allocation released"] + PF -->|Order_Cancel| C +``` + +A resting order is an authorization for a future match whose exact legs are not +yet known — a prefunded, iterated allocation, not a one-shot one. +`OrderMatchExecution_Execute` fetches both orders and refuses any fill their own +terms do not permit, so a buggy or malicious matcher cannot cross a resting +order outside its limit price or for instruments it never agreed to. + +```daml +assertMsg "fill price must be positive" (match.fillPrice > 0.0) +assertMsg "fill price above bid limit" + (match.fillPrice <= buyOrder.limitPrice) +assertMsg "fill price below ask limit" + (match.fillPrice >= sellOrder.limitPrice) +``` + +The same transaction settles the batch and rolls both orders forward: a fully +filled order is archived, a partial fill is recreated bound to the allocation +the settle minted, and a `SettledTrade` records the fill. Doing this in one +choice is load-bearing — the settle archives the allocations the orders point +at, so an order left behind by a two-step flow would be uncancellable and +unfillable. `Order_Cancel` is the single operator-controlled path for +trader-requested cancels and post-expiry cleanup. + +Proven in +[`EndToEndTests.daml`](../../trading-tests/CantonDex/Tests/EndToEndTests.daml) — +`testOrderMatchEnforcesLimitPrice` (a fill outside `[ask, bid]` is rejected) and +`testOrderMatchRollsOrdersForwardAtomically` (both orders roll onto the minted +allocations and the trade is recorded, in one transaction). + +## RFQ and OTC block trades + +**Intent:** let a trader request quotes from a whitelisted dealer set, accept +one, and settle the bilateral trade — with an audit trail of how the quote was +ranked. ```mermaid sequenceDiagram @@ -191,352 +224,103 @@ sequenceDiagram actor Dl as Dealer (wallet) participant O as Operator participant L as Ledger - T->>O: POST /v1/rfq (create RFQ) + T->>O: POST /v1/rfq (create Rfq) Dl->>O: post RfqQuote T->>O: POST /v1/rfq/accept - O->>L: Rfq_Accept (operator + trader) -> MatchedTrade + PolicyReceipt + O->>L: Rfq_Accept (trader + operator) -> MatchedTrade + PolicyReceipt T->>L: author allocation Dl->>L: author allocation O->>L: MatchedTrade_Settle -> SettlementFactory_SettleBatch (per admin) - L-->>O: settled, trade recorded (private to counterparties) + L-->>O: settled, trade private to counterparties ``` -## Workflow 1: Pair listing - -Purpose: -- define that the DEX supports trading a given base and quote `InstrumentId` - -Inputs: - -- base instrument id -- quote instrument id -- fee model -- allowed trading mode - - RFQ only - - order book - - pool - -On-ledger flow: - -1. `DexOperator` creates `DexPair` -2. `DexPair` records the supported instruments and execution policy -3. off-chain services subscribe to the pair for matching or pool operations - -Current governance boundary: - -- `DexPair`, `Pool` and `PoolState` are all directly operator-created in - this reference. -- There is no separate `DexRules` contract for pair admission yet. -- A production fork can add a rules/governance layer if pair listing needs - multi-party approval, package-level decoupling, or decentralized operation. - -Why it matters: - -- it makes pair support explicit -- it is the right place to gate experimental pool support or lifecycle-rich - assets - -## Workflow 2: OTC / RFQ trade - -Purpose: -- prove the baseline token-standard-native trade flow - -Primary contracts: - -- `MatchedTrade` -- `TradeAllocationRequest` - -On-ledger flow: - -1. traders negotiate off-chain -2. `DexOperator` creates `MatchedTrade` -3. `MatchedTrade_RequestAllocations` creates one allocation request per - authorizer, following the `TradingAppV2` pattern -4. traders accept their allocation requests -5. `DexOperator` groups allocations by admin -6. `MatchedTrade_Settle` calls `SettlementFactory_SettleBatch` -7. settlement archives requests and finalizes the trade state - -Failure and unwind flow: - -1. if allocations are not accepted in time, the trade expires -2. `MatchedTrade_Cancel` archives outstanding requests -3. any live allocations are cancelled and funds are released - -Why it comes first: - -- this is the cleanest reference workflow available today -- it teaches the core DvP pattern without depending on pool semantics - -## Workflow 3: Resting order placement - -Purpose: -- represent a bid or ask as DEX state backed by reserved funds - -Primary contracts: - -- `Order` -- allocation contract referenced by the order - -Reason: - -- a resting order is an authorization for a future match whose exact transfer - legs are not yet known -- that is a much better fit for prefunded, adjustable allocations than for the - one-shot bilateral allocation shape - -On-ledger flow: - -1. trader submits order parameters - - pair - - side - - limit price - - quantity - - expiry -2. DEX requests or validates a prefunding allocation for the order -3. trader accepts the allocation -4. `DexOperator` creates `Order` pointing at the live allocation reference -5. order becomes matchable only once funding is confirmed - -Required invariants: - -- live order implies live allocation -- allocation funding must cover remaining order quantity -- order expiry must bound allocation usability - -## Workflow 4: Order match and settlement - -Purpose: -- convert two resting orders into one settled trade - -Primary contracts: - -- `Order` -- `MatchedTrade` - -On-ledger flow: - -1. `Matcher` chooses compatible orders -2. `DexOperator` creates `MatchedTrade` -3. the buy and sell allocations are adjusted with the concrete transfer legs -4. adjusted allocations are settled atomically -5. returned next-iteration allocation references are stored back on any - partially filled orders -6. fully filled orders are archived -7. partially filled orders remain open with reduced remaining quantity - -Failure and unwind flow: - -1. if adjustment fails, the match is rejected before settlement -2. if settlement fails, orders remain unchanged -3. if one order expires mid-flight, the DEX cancels the match attempt - -## Workflow 5: Order cancel or expiry - -Purpose: -- release funds and remove dead liquidity - -On-ledger flow: - -1. `DexOperator` exercises `Order_Cancel` (the single operator-controlled - cancellation path; it covers both trader-requested cancels relayed via - the operator API and post-expiry cleanup) -2. the referenced allocation is cancelled -3. the order is archived -4. any residual state is recorded for auditability - -Important policy choice: - -- trader-requested cancel should be honoured before match -- the operator should sweep orders past their expiry time; for RFQs the - operator-controlled `Rfq_Expire` choice enforces the deadline on-ledger - -## Workflow 6: Pool creation - -Purpose: -- define a pool and its LP token - -Primary contracts: - -- `Pool` -- `LPTokenPolicy` - -On-ledger flow: - -1. `DexOperator` creates `Pool` for a `DexPair` -2. `DexOperator` or `Registrar` creates the LP token instrument definition - required by the chosen registry; in the reference registry this is an - `InstrumentConfiguration` -3. `Pool` stores fee policy, invariant type, and active reserve references -4. the pool starts in `Unfunded` state until first liquidity arrives - -Recommended first invariant: - -- constant product - -Why: - -- it is enough for a credible reference implementation -- it keeps the workflow challenge in Daml rather than concentrated-liquidity - math - -## Workflow 7: Add liquidity - -Purpose: -- fund the pool and mint LP shares - -Primary contracts: - -- `Pool` -- `PoolLiquidityRules` -- `LiquidityAllocationRequest` - -On-ledger flow: - -1. operator creates a `LiquidityAllocationRequest` for the deposit amounts and - minimum LP shares (the add-liquidity request step) -2. the trader's wallet authors the base-deposit, quote-deposit, and LP-receipt - allocations via `AllocationFactory_Allocate` -3. operator and `lpRegistrar` settle with `PoolLiquidityRules_SettleAddLiquidity`: - funds enter the pool, reserve state is updated, pool-managed committed - allocations are refreshed, and LP tokens are minted to the provider, - atomically in one settlement - -Important note: - -- LP deposits do not need concentrated-liquidity position NFTs -- fungible LP shares are enough for the first production-shaped reference - -## Workflow 8: Remove liquidity - -Purpose: -- burn LP shares and return the provider's proportional reserves - -On-ledger flow: - -1. operator creates a `LiquidityAllocationRequest` for the LP amount to redeem - and minimum asset outputs (the remove-liquidity request step) -2. the wallet authors the holder's base-receipt and quote-receipt allocations - plus the LP burn-sender allocation via `AllocationFactory_Allocate` -3. operator and `lpRegistrar` settle with `PoolLiquidityRules_SettleRemoveLiquidity`: - base and quote are delivered to the holder, the LP tokens burn to the burn - account, pool reserve allocations are adjusted down, and reserve references - are rolled forward, atomically in one settlement - -Required invariants: - -- no over-redemption -- reserve updates and LP burn must stay atomic -- routine withdrawals must not touch every reserve allocation. `Pool` holds a - list of `PoolSlice` per side and removal settlement walks slices from - the front. Only slices needed to cover the redemption are cancelled; the - boundary slice (if any) is re-allocated for its leftover; all slices beyond - the boundary are untouched. Operator pays for at most ONE re-allocation per - side, never one per existing slice - -## Workflow 9: Pool swap - -Purpose: -- execute a trader swap against the pool - -Primary contracts: - -- `Pool` -- `PoolRules` -- `PoolSlice` - -On-ledger flow: - -1. trader submits swap parameters - - asset in - - amount in or amount out target - - slippage bound - - deadline -2. DEX computes quote from the current reserve state -3. DEX requests or validates trader funding allocation -4. DEX adjusts the pool reserve allocations for the exact swap legs -5. DEX settles trader and pool allocations atomically -6. returned next-iteration pool allocation references are stored on the pool -7. fees are reflected in reserve accounting -8. swap action is archived with result metadata - -Executor-control note: - -- because the executor can drive iterated settlement on the pool allocations, - the pool contract state must fully determine which reserve slice is being - consumed, the permitted transfer legs, and the resulting reserve update -- this is why reserve references belong on ledger and why swap settlement - should touch only the specific reserve slices participating in the trade - -Failure and unwind flow: - -1. if slippage bound is violated, no settlement happens -2. if trader funding disappears, swap action expires or is cancelled -3. if pool reserve references are stale, the operator must refresh state before - retrying - -## Workflow 10: Asset lifecycle interaction - -Purpose: -- let lifecycle-rich instruments trade without making the DEX own their - lifecycle semantics - -Token Standard V2 does not standardize lifecycle transitions for bonds, -options, escrow obligations, or similar assets. This workflow describes how a -DEX can stay compatible with registries that implement those behaviours -themselves. - -On-ledger flow: - -1. registrar or lifecycle service applies a registry-specific lifecycle - transition - - coupon event - - maturity event - - exercise event -2. a new instrument version or registry metadata record becomes the tradable - reference -3. the DEX updates pair or pool eligibility rules if needed -4. old orders or pools can be paused, migrated, or settled out according to - policy +`Rfq_Accept` is jointly controlled by `trader, operator`: the trader consumes +the `Rfq` and every quote, the operator signs the resulting `MatchedTrade`. It +ranks the considered quotes, records the winner and its rank in a +[`PolicyReceipt`](../../trading/CantonDex/Dex/PolicyReceipt.daml) (evidence the +published policy was applied, not that the price was good), and copies the RFQ's +`expiresAt` onto the trade's `settlementDeadline`. + +```daml +tradeCid <- create MT.MatchedTrade with + venue = operator + admin + transferLegs = legs + settlementDeadline = Some expiresAt + policyReceipt = Some receipt +``` -Important boundary: +That deadline coupling is a real hazard: `Allocation_Settle` aborts once the +deadline passes, and because `Rfq_Accept` is consuming there is nothing left to +retry with, so an RFQ that expires between accept and settle strands both +sides' funds until someone cancels — which is why the backend clamps a +requested expiry to a floor. -- the DEX should not calculate coupons or option exercise -- it should only respond to registry-published tradable instrument versions or - metadata updates - -## Implemented reference scope +Proven in +[`RfqSettlementTests.daml`](../../trading-tests/CantonDex/Tests/RfqSettlementTests.daml), +which runs against real `Registry.V2` holdings — +`testRfqBuySettlesAgainstRealHoldings` (balances and the rank-1 receipt are +exactly as expected, no locks stranded) and +`testExpiryBetweenAcceptAndSettleBlocksTheSettle` (past the inherited deadline +the settle fails and the funds stay locked). -The reference implementation covers: - -1. pair listing -2. OTC / RFQ trade settlement -3. constant-product pools -4. add liquidity -5. remove liquidity -6. single-hop swaps with slippage bounds -7. LP token issuance -8. cancellation, expiry, and operator observability - -It deliberately defers: - -1. concentrated liquidity -2. multi-hop routing -3. permissionless pool creation -4. advanced oracle surfaces -5. NFT-style LP positions - -## Contract boundary summary - -Keep the market objects (`DexPair`, `Order`, `MatchedTrade`, `Rfq`) separate -from the pool accounting objects (`Pool`, `PoolState`, `PoolSlice`) and the -LP-token policy (`LPTokenPolicy`). The shared boundary is the token-standard -allocation and settlement surface, not a custom internal escrow system. +--- -The current reference stops at that shared Token Standard boundary. It does not -yet introduce custom Daml interfaces or a separate `DexRules` contract to -govern pair creation. +## Reference + +### Secondary workflows + +- **Pair listing.** `DexOperator` creates a `DexPair` recording the base/quote + `InstrumentId`s, fee model, and trading mode (RFQ, order book, or pool). There + is no separate `DexRules` admission contract yet; a production fork can add one + if listing needs multi-party approval. +- **Pool creation.** `DexOperator` creates a `Pool` for a `DexPair` and the LP + instrument definition (an `InstrumentConfiguration` in the reference + registry). The pool starts `Unfunded` with a constant-product invariant until + the first add-liquidity settles. +- **Direct creation.** `DexPair`, `Pool`, and `PoolState` are all directly + operator-created; no rules contract mediates their creation. +- **Asset lifecycle.** Token Standard V2 does not standardize coupon, maturity, + or exercise transitions. The DEX only responds to registry-published tradable + instrument versions; it never calculates lifecycle events itself. + +### Design principles + +1. One workflow, one business object — orders, trades, pools, and LP issuance + each get their own app contract. +2. Allocations represent funds, not abstract approvals. +3. Settlement is explicit — the app creates matched trade/swap state before + calling settlement. +4. Cancellation is a first-class workflow, with no hidden cleanup. +5. Registry lifecycle stays outside market logic — the DEX trades + `InstrumentId`; the registry explains what it means through V2 views. +6. Executor-controlled funds are usage-constrained on-ledger. Because the + executor can drive iterated settlement on committed pool allocations, the + `Pool`/`PoolSlice` state must fully determine which reserve slice is + consumed, the permitted legs, and the resulting reserve update. Off-ledger + services choose *when* to settle, never *what* the funds may be used for. +7. Keep hot-path transactions shard-local — an ordinary swap or redemption + touches only the slices it draws from, with consolidation as an explicit + maintenance path. `PoolRules_ReconcileState` is the off-hot-path audit anchor + that asserts the per-side slice sums equal the recorded reserves. + +### Implemented scope + +Covered: pair listing; OTC/RFQ settlement; constant-product pools; add and +remove liquidity; single-hop swaps with slippage bounds; LP token issuance; +cancellation, expiry, and operator observability. + +Deferred: concentrated liquidity and ticks; multi-hop routing; permissionless +pool creation; NFT-style LP positions; advanced oracle/TWAP surfaces. These are +later features, not requirements for validating the Canton-native design. + +### Contract boundary + +The shared boundary between the market objects and the pool/LP objects is the +Token Standard allocation-and-settlement surface, not a custom internal escrow +system. The reference stops at that boundary: it introduces no custom Daml +interfaces and no separate `DexRules` governance contract. --- -**Where to read next:** [Architecture](architecture.md) · [Builder Guide](../guides/builder-guide.md) · [Allocation Surface](../reference/allocation-surface.md) · [All docs](../README.md) +**Where to read next:** [Architecture](architecture.md) · [Pricing](pricing.md) · [Builder Guide](../guides/builder-guide.md) · [Allocation Surface](../reference/allocation-surface.md) · [All docs](../README.md) diff --git a/docs/guides/add-a-trading-pair.md b/docs/guides/add-a-trading-pair.md index ea967cd1..f3ce958c 100644 --- a/docs/guides/add-a-trading-pair.md +++ b/docs/guides/add-a-trading-pair.md @@ -1,29 +1,53 @@ # Adding a new trading pair -Recipe for listing a new pair (say `ETH/USDT`) on a running -Canton DEX deployment. Assumes the operator backend is already wired -to a participant and the base + quote assets already have registries that -produce Token Standard V2 holdings, allocation factories, and settlement -factories. +Listing a pair (say `ETH/USDT`) is one operator-signed contract, `DexPair`. That +contract is a venue record — trading mode, fee schedule, active flag — and nothing +more. It does **not** by itself make the market tradable: order-book mode needs the +registry's Token Standard V2 factories behind each asset, and pool mode needs a pool +that a first liquidity provider has funded. This recipe creates the listing, then +does whichever of those the pair requires. + +It assumes the operator backend is already wired to a participant, and that the base +and quote assets already have registries producing V2 holdings, allocation factories, +and settlement factories. If either asset lacks a V2-compatible registry, do that +first — see [`add-lp-or-instrument.md`](add-lp-or-instrument.md) — because pair +creation will succeed but trades will not flow. + +## What a listing is, and what it isn't + +`DexPair` is signed by the operator and observed by the pair's registry `admin` (plus +any `publicReaders`): + +```daml +signatory operator +observer admin :: optional [] identity publicReaders +``` + +So the operator owns the listing, the admin can see it, and traders see it only if +you add them as public readers. The listing carries the trading mode and fee model; +tradability comes from elsewhere: -If the base or quote asset does not yet have a V2-compatible registry, -do that first: see -[`add-lp-or-instrument.md`](add-lp-or-instrument.md). +```mermaid +flowchart TB + L["DexPair — operator-signed listing
tradingMode · feeModel · active"] + L -->|"TM_OrderBook / TM_Both"| OB["Order book: tradable once the
registry publishes V2 allocation +
settlement factories for both assets"] + L -->|"TM_Pool / TM_Both"| P["Pool: tradable once createPool + the
first-LP DvP move it
PS_Unfunded → PS_Active"] +``` ## Inputs you need | Input | Where it comes from | |---|---| -| `baseInstrumentId : Text` | the `id` component of the base asset's V2 `InstrumentId` in this reference API | +| `baseInstrumentId : Text` | the `id` component of the base asset's V2 `InstrumentId`; full identity is `{ admin, id }` | | `quoteInstrumentId : Text` | same, for the quote asset | -| `admin : Party` | the registry admin for the pair in this reference implementation | +| `admin : Party` | the registry admin for the base + quote instruments | | `tradingMode : "TM_OrderBook" \| "TM_Pool" \| "TM_Both"` | which surfaces are enabled | -| `feeModel : { makerFeeBps, takerFeeBps, poolFeeBps }` | fee schedule | -| `publicReaders : [Party]` (Optional) | parties that should observe the pair contract | +| `feeModel : { makerFeeBps, takerFeeBps, poolFeeBps }` | fee schedule, in basis points | +| `publicReaders : [Party]` (Optional) | parties that should observe the listing | -## Step 1. Create the `DexPair` +## Step 1 — List the pair (`DexPair`) -Operator-signed. Submitted by the operator backend: +Operator-signed, submitted by the operator backend: ```bash curl -X POST http://localhost:8080/v1/admin/pairs \ @@ -38,42 +62,31 @@ curl -X POST http://localhost:8080/v1/admin/pairs \ }' ``` -This routes to `AdminService.createPair` in -`services/operator-backend/src/admin/index.ts`, which submits -`CreateCommand` for `CantonDex.Dex.DexPair:DexPair`. - -What you get back: `{ pairCid: ContractId }`. Note it. - -## Step 2. If `TM_Pool` or `TM_Both`: create the LP token policy - -```bash -# (currently no admin endpoint; submit via the operator-backend in code) -``` +The route calls `AdminService.createPair` in +[`services/operator-backend/src/admin/index.ts`](../../services/operator-backend/src/admin/index.ts), +which submits one `create` for `CantonDex.Dex.DexPair:DexPair` as the operator: ```ts -// From operator-backend or a script: -await ledger.submit({ - actAs: [lpRegistrar], - commandId: `lp-policy-eth-usdt`, +this.ledger.submit>({ + actAs: [this.operatorParty], + commandId: `pair-create:${input.baseInstrumentId}:${input.quoteInstrumentId}`, command: { - kind: 'create', - templateId: 'CantonDex.Lp.Policy:LPTokenPolicy', - argument: { - lpRegistrar, - operator, - lpInstrumentId: { admin: lpRegistrar, id: 'ETH-USDT-LP' }, - totalSupply: '0.0', - active: true, - }, + kind: "create", + templateId: "CantonDex.Dex.DexPair:DexPair", + argument: { operator: this.operatorParty, admin: input.admin, /* … */ + active: input.active ?? true, publicReaders: null, /* … */ }, }, }); ``` -The current LP policy is the LP-token component only: it owns the full -`V2.InstrumentId` and circulating supply, and it does not reference the -pool, base instrument, quote instrument, or order venue. +Response: `{ pairCid: ContractId }`. Note it. If `tradingMode` is +`TM_OrderBook`, the listing is complete — traders can now post V2-allocation-backed +orders, provided the registries publish the required factories. + +## Step 2 — For pool mode, create the pool -## Step 3. Create the `Pool` +`TM_Pool` and `TM_Both` need a pool. One admin call provisions everything the pool +needs: ```bash curl -X POST http://localhost:8080/v1/admin/pools \ @@ -88,42 +101,47 @@ curl -X POST http://localhost:8080/v1/admin/pools \ }' ``` -Pool starts in `PS_Unfunded`. No reserves until the first LP completes the -same add-liquidity DvP flow used for later funding. +`AdminService.createPool` creates, in one flow: the immutable `Pool` config, its +`PoolState` in `PS_Unfunded`, the per-venue `PoolRules`, the co-signed +`PoolLiquidityRules` (operator + lpRegistrar), and the matching +`CantonDex.Lp.Policy:LPTokenPolicy`. There is **no** separate LP-policy step — +creating the pool creates the policy. Response: `{ poolCid: ContractId }`. -## Step 4. Optional: seed the first LP +Because the LP policy is `signatory lpRegistrar` and `PoolLiquidityRules` is signed by +both parties, the backend must be authorized to submit as **both** the operator and +the `lpRegistrar`. The pool starts with no reserves and is not tradable; the first LP +funds it in Step 3. -The first LP needs to: +The pool's executable swap fee is the pool's own `feeBps` set here — the pair's +`feeModel.poolFeeBps` is a listing-level record, not the number the curve charges. -1. Hold V2 base and quote holdings of the amounts they want to deposit. -2. Call `POST /v1/pools/add-liquidity/request`. -3. Have the wallet author the three requested allocations via - `AllocationFactory_Allocate`: - - base deposit - - quote deposit - - LP receipt -4. Call `POST /v1/pools/add-liquidity/settle`. The operator and - `lpRegistrar` co-settle the request via `PoolLiquidityRules_SettleAddLiquidity`, - which seeds the first pool slices, transitions the pool to `PS_Active`, - and mints `sqrt(baseAmount * quoteAmount)` LP tokens atomically. +## Step 3 — Seed the first liquidity (pool mode) -## Step 5. Surface in the dApp +The first LP moves the pool from `PS_Unfunded` to `PS_Active` through the same +add-liquidity DvP used for every later deposit: -The dApp's `/v1/pairs` endpoint will return the new pair automatically -on the next backend tick. The Pools page will show the new pool once -seeded. +1. Hold V2 base and quote holdings of the amounts to deposit. +2. `POST /v1/pools/add-liquidity/request` — returns the request plus the allocation + specs and factory contract ids the wallet needs. +3. The wallet authors the three requested allocations with `AllocationFactory_Allocate`: + base deposit, quote deposit, and the LP receipt. +4. `POST /v1/pools/add-liquidity/settle` — operator and `lpRegistrar` co-settle via + `PoolLiquidityRules_SettleAddLiquidity`, which seeds the first pool slices, + transitions the pool to `PS_Active`, and mints `sqrt(baseAmount * quoteAmount)` LP + tokens atomically. -If you want the pair to appear on the trader's Trade page, make sure -`active = true` and `tradingMode` is `TM_OrderBook` or `TM_Both`. +## Step 4 — Surface and verify -## Step 6. Verify +The dApp's `/v1/pairs` returns the new pair on the next backend tick; the Pools page +shows the pool once it is seeded. For the pair to appear on the trader's Trade page, +`active` must be `true` and `tradingMode` must be `TM_OrderBook` or `TM_Both`. ```bash curl -s http://localhost:8080/v1/pairs | jq '.[] | select(.baseInstrumentId=="ETH")' curl -s http://localhost:8080/v1/pools | jq '.[] | select(.baseInstrumentId=="ETH")' ``` -After the first seed: +After the first seed, liquidity and swap events show up on `/v1/swaps`: ```bash curl -s 'http://localhost:8080/v1/swaps?pair=ETH/USDT&limit=10' @@ -133,20 +151,40 @@ curl -s 'http://localhost:8080/v1/swaps?pair=ETH/USDT&limit=10' | Symptom | Cause | |---|---| -| `/v1/pools/add-liquidity/request` or `/settle` fails with an allocation mismatch | The wallet-authored allocation triple does not match the request's expected specs; recreate the request and re-author the allocations from that payload. | -| `/v1/pools/add-liquidity/settle` fails with a quote/supply guard | The pool moved or the request expired before settle; recreate the request and have the wallet re-author fresh allocations. | -| `DexPair` created but doesn't show in `/v1/pairs` | Operator backend wasn't observing the new contract; check the backend's `operator` party matches the pair's `operator` signatory. | -| Pool created but `/v1/pools` is empty | Pool is operator + lpRegistrar observed only. The backend observes as `operator`, but if you used a different signing party the read won't see it. | -| Trades fail even though `DexPair` exists | The pair metadata is only a venue listing. The relevant registries still need to publish V2 holdings, allocation factories, settlement factories, and any choice context required for the instruments. | - -## When not to do this - -- If you're listing many pairs programmatically: write a one-shot - script that builds all the commands in one batch, not curl loops. -- If the base or quote does not yet have a V2-compatible registry: stop and - register or integrate it first. Pair creation may succeed, but trades will - not flow because wallets and the operator cannot create or settle the - required V2 holdings and allocations. +| `/v1/admin/pools` fails with an authorization error | The backend can only act as the operator. Pool creation submits the co-signed `PoolLiquidityRules` and the `lpRegistrar`-signed `LPTokenPolicy`, so the backend must be authorized to act as both the operator and the `lpRegistrar`. | +| `DexPair` created but absent from `/v1/pairs` | The backend isn't observing the new contract; check the backend's `operator` party matches the pair's `operator` signatory. | +| Pool created but `/v1/pools` is empty | `Pool` is observed only by operator + lpRegistrar. The backend reads as `operator`; a different signing party won't be seen. | +| `/v1/pools/add-liquidity/request` or `/settle` fails with an allocation mismatch | The wallet-authored allocation triple doesn't match the request's expected specs; recreate the request and re-author from that payload. | +| `/settle` fails with a quote/supply guard | The pool moved or the request expired before settle; recreate the request and re-author fresh allocations. | +| Trades fail even though `DexPair` exists | The listing is only a venue record. The registries still need to publish V2 holdings, allocation factories, settlement factories, and any required choice context for the instruments. | + +## Reference: post-listing lifecycle choices + +After listing, the operator adjusts the pair through `DexPair`'s own choices — all +`controller operator` — each fronted by an admin route: + +| Choice | Route | +|---|---| +| `DexPair_UpdateFeeModel` | `POST /v1/admin/pairs/{pairCid}/fee-model` | +| `DexPair_SetActive` | `POST /v1/admin/pairs/{pairCid}/active` | +| `DexPair_UpdateTradingMode` | `POST /v1/admin/pairs/{pairCid}/trading-mode` | +| `DexPair_UpdatePublicReaders` | (no admin route; submit via the backend) | + +Listing many pairs at once is a script, not a curl loop: build every `create` command +in one batch keyed off your asset list, and never list a pair whose base or quote +lacks a V2 registry — the listing will exist, but no wallet can create or settle the +holdings and allocations a trade requires. + +### What proves this + +- [`builder-guide.md#a-pair-and-instrument-listing`](builder-guide.md#a-pair-and-instrument-listing) + — the pair-and-instrument-listing workflow family and its contract surface. +- [`PoolLiquidityRulesTests.daml`](../../trading-tests/CantonDex/Tests/PoolLiquidityRulesTests.daml) + (`testDvpAddLiquidity`) — proves the first seed moves the pool `PS_Unfunded → PS_Active` + and mints `sqrt(baseAmount * quoteAmount)` LP tokens. +- [`InstrumentTests.daml`](../../trading-tests/CantonDex/Tests/InstrumentTests.daml) + (`testInstrumentConfigCreate`) — proves the reference registry's per-instrument + configuration that a pool-mode pair's assets rely on. --- diff --git a/docs/guides/add-lp-or-instrument.md b/docs/guides/add-lp-or-instrument.md index 946953f4..f01665df 100644 --- a/docs/guides/add-lp-or-instrument.md +++ b/docs/guides/add-lp-or-instrument.md @@ -1,57 +1,97 @@ # Issuing a new LP token or lifecycle-rich instrument -How to mint a new asset on Canton DEX using Token Standard V2 (CIP-0112) -surfaces. It covers the simple case (a fungible LP token) and lifecycle-rich -assets (vested, dividend-paying, restricted) implemented through -registry-specific contracts. - -## What "lifecycle-rich" means here - -Token Standard V2 standardizes the holding/allocation/settlement surface. It -does not standardize an instrument-configuration or lifecycle package. In this -repo, the reference `Registry.V2` adds an `InstrumentConfig` contract -that can encode: - -- supply caps (`supplyCap`; `InstrumentConfig_BumpSupply` enforces them) -- issuer credential requirements (`issuerRequirements : [CredentialRequirement]`): - only holders who present the right credentials can be minted to -- decimals for display -- transfer constraints (via the chosen `TransferFactory` implementation) -- allocation constraints (via the `AllocationFactory`) -- upgrade hooks for migrating to a future version of the instrument - -Those are reference-registry features, not Token Standard requirements. A -different registry may publish different metadata and choice context while -still implementing the same V2 `Holding`, `AllocationFactory`, and -`SettlementFactory` interfaces. A lifecycle-rich instrument in this repo -combines a per-instrument config with optional issuer-signed -`Credential` contracts that the recipient must present at mint time. - -## Case A. Vanilla LP token (the common case) - -This is what the add-liquidity DvP flow already does. Nothing extra to write. -The LP token: - -- has `instrumentId = "--LP"` -- has `admin = lpRegistrar` -- is created when `PoolLiquidityRules_SettleAddLiquidity` settles the LP receipt - against the registry-side mint allocation -- is a real `Registry.V2.Holding`: fungible with other V2 holdings, - usable as input to `V2.TransferInstruction`, lockable into a - `V2.Allocation` (so LP tokens can themselves back orders or pools) - -If you want supply caps on the LP token in the reference registry, create an -instrument config with `supplyCap = Some 10_000_000.0`. The -`LPTokenPolicy_RecordMint` choice will respect it once the reference config -check is plumbed through (today it is policy-side bookkeeping only). - -## Case B. Issuing a fresh base or quote instrument +Most assets on this DEX need no new Daml. You register an instrument in the +reference registry and mint it; the DEX treats the resulting `instrumentId` as +opaque. This page gives five escalating recipes — from the LP token you already +get for free, up to a custom vesting wrapper — and marks clearly where you leave +the reference registry and start writing your own templates. + +## Two layers you build on + +Token Standard V2 (CIP-0112) standardizes the *holding, allocation, and +settlement* surface — how value is held, locked, and moved atomically. It does +**not** standardize instrument configuration or lifecycle. Those live in the +registry that administers the `instrumentId` — here, `CantonDex.Registry.V2`. A +different registry can publish different config and credentials and still +implement the same V2 `Holding`, `AllocationFactory`, and `SettlementFactory` +interfaces. + +So a "lifecycle-rich" instrument in this repo is a per-instrument +[`InstrumentConfig`](../../trading/CantonDex/Registry/V2.daml) plus optional +issuer-signed `Credential`s the minter must present. `InstrumentConfig` can encode: + +- **supply caps** (`supplyCap`; enforced by `InstrumentConfig_BumpSupply`) +- **issuance credentials** (`issuerRequirements : [CredentialRequirement]`) — who is allowed to mint +- **holder credentials** (`holderRequirements`) — recorded for downstream policy +- **decimals** for display precision +- **external ids** (`isin`, `cusip`) + +Transfer and allocation constraints come from the `TransferFactory` / +`AllocationFactory` implementation, not from the config. All of this is +reference-registry behavior, not a Token Standard requirement. + +## Pick your recipe + +| You want to issue | Recipe | New Daml? | +|---|---|---| +| An LP token for a pool | [A](#a-vanilla-lp-token--already-built) | none — the add-liquidity DvP mints it | +| A plain fungible base/quote asset | [B](#b-a-fresh-base-or-quote-instrument) | none — register + `Registry_Mint` | +| A whitelisted / accredited-only asset | [C](#c-gated-issuance-credential-required) | none — attach credential requirements | +| A token that unlocks over time | [D](#d-vested-lp-custom-lifecycle) | a wrapper template in your fork | +| A token that pays dividends | [E](#e-dividend-paying-instrument) | a distribution script or claim template | + +## A. Vanilla LP token — already built + +The add-liquidity DvP flow already mints an LP token; there is nothing extra to +write. The token has `instrumentId = "--LP"`, `admin = lpRegistrar`, +and is a real V2 `Holding` — fungible with other V2 holdings, usable as +`TransferInstruction` input, and lockable into an `Allocation`, so LP tokens can +themselves back orders or pools. + +Mint and burn ride the V2 allocation surface as ordinary transfer legs to and +from two reserved accounts whose `owner` is `None`. An account with no owner is +never credited on settlement, so it is a sink for burns and a source for mints: + +```mermaid +flowchart LR + MA(["mintAccount
owner = None"]) -->|"lp-mint leg"| R["recipient
credited fresh LP"] + H["holder
locks LP"] -->|"lp-burn leg"| BA(["burnAccount
owner = None"]) +``` + +The legs are pure constructors in +[`Lp/Instrument.daml`](../../trading/CantonDex/Lp/Instrument.daml): + +```daml +lpMintLeg _lpRegistrar recipient lpInstrumentId amount = V2.TransferLeg with + transferLegId = "lp-mint" + sender = Utils.mintAccount + receiver = recipient + ... +lpBurnLeg _lpRegistrar holder lpInstrumentId amount = V2.TransferLeg with + transferLegId = "lp-burn" + sender = holder + receiver = Utils.burnAccount + ... +``` + +`PoolLiquidityRules_SettleAddLiquidity` settles the mint leg and bumps the +[`LPTokenPolicy`](../../trading/CantonDex/Lp/Policy.daml) supply; +`PoolLiquidityRules_SettleRemoveLiquidity` settles the burn leg and draws it +back down. The policy tracks circulating LP supply on its own; it is +bookkeeping, not a cap. If you need a hard cap on LP supply, register an +`InstrumentConfig` with `supplyCap = Some 10_000_000.0` — but note the LP mint +path drives `LPTokenPolicy_RecordMint`, not `InstrumentConfig_BumpSupply`, so +that cap is not enforced on the LP token today. + +## B. A fresh base or quote instrument + +Register the instrument, then mint to a holder. Both are choices on the +[`Registry`](../../trading/CantonDex/Registry/V2.daml) template: ```ts // 1. Register the instrument const configCid = await ledger.submit({ actAs: [admin], - commandId: `register-${instrumentId}`, command: { kind: 'exercise', templateId: 'CantonDex.Registry.V2:Registry', @@ -60,64 +100,83 @@ const configCid = await ledger.submit({ argument: { instrumentId: 'USDC', decimals: 6, - supplyCap: null, // unbounded + supplyCap: null, // unbounded holderRequirements: [], - issuerRequirements: [], // open issuance + issuerRequirements: [], // open issuance isin: null, cusip: null, }, }, }); -// 2. Mint to a holder (controller: admin, owner; needs both in actAs) +// 2. Mint to a holder (controller is admin + owner; both go in actAs) await ledger.submit({ actAs: [admin, alice], - commandId: `mint-${alice}-USDC-100000`, command: { kind: 'exercise', templateId: 'CantonDex.Registry.V2:Registry', contractId: registryCid, choice: 'Registry_Mint', - argument: { - configCid, - owner: alice, - amount: '100000.0', - issuerClaims: [], // no credential reqs - }, + argument: { configCid, owner: alice, amount: '100000.0', issuerClaims: [] }, }, }); ``` -The `admin, owner` joint authority is by V2 design: receivers must -consent to receive a token. The operator-backend cannot mint to -`alice` without `alice`'s wallet co-signing. In a real deployment -this lands as a CIP-0103 prepare/execute round-trip through the -trader's wallet. +The `admin, owner` joint authority is by V2 design: a receiver must consent to +receive a token, so the operator backend cannot mint to `alice` without her +wallet co-signing. In a real deployment this is a CIP-0103 prepare/execute +round-trip through the trader's wallet. On-ledger, `Registry_Mint` bumps supply +and creates the holding: + +```daml +nonconsuming choice Registry_Mint : ContractId Holding + with configCid; owner; amount; issuerClaims + controller admin, owner + do + config <- fetch configCid + ... + exercise configCid InstrumentConfig_BumpSupply with delta = amount + create Holding with admin; owner; instrumentId = config.instrumentId; amount; locked = False +``` + +`InstrumentConfig_BumpSupply` is consuming, so each mint archives the config and +creates its successor with the new `circulatingSupply` — **re-read the config cid +before each mint** rather than caching it. It is also where the cap bites: + +```daml +forA_ supplyCap $ \cap -> + assertMsg ("mint exceeds supply cap " <> show cap) (next <= cap) +``` + +## C. Gated issuance (credential-required) + +For a security token or whitelisted-investor asset, attach an issuer credential +requirement. `Registry_Mint` checks `issuerRequirements` against the **minting +admin** — this is the credential the admin must hold to be allowed to issue: -## Case C. Gated issuance (credential-required) +```daml +let credsOk = + null config.issuerRequirements || + verifyCredentials admin config.issuerRequirements issuerClaims +assertMsg "issuer credentials not satisfied for mint" credsOk +``` -For a security token or whitelisted-investor LP: +`verifyCredentials` matches each requirement on `issuer`, `property`, `value`, +and `holder == admin`, so `issuerClaims` carries `Credential` **records** (those +four fields), not contract ids: ```ts -// 1. Issuer signs a Credential template. Registry_Mint checks -// `issuerRequirements` against the ISSUING party (the admin), so this is the -// credential the admin has to hold to be allowed to mint the instrument. +// 1. Credential issuer signs a Credential naming the admin as holder const credCid = await ledger.submit({ actAs: [credentialIssuer], - commandId: `cred-${admin}-accredited`, command: { kind: 'create', templateId: 'CantonDex.Registry.V2:Credential', - argument: { - issuer: credentialIssuer, - holder: admin, - property: 'accredited-investor', - value: 'true', - }, + argument: { issuer: credentialIssuer, holder: admin, property: 'accredited-investor', value: 'true' }, }, }); -// 2. Register the instrument with the credential requirement +// 2. Register with the requirement const configCid = await ledger.submit({ actAs: [admin], command: { @@ -137,9 +196,7 @@ const configCid = await ledger.submit({ }, }); -// 3. Mint, supplying the credential. `issuerClaims` takes Credential RECORDS, -// not contract ids: verifyCredentials matches on issuer/holder/property/ -// value, so pass the same four fields the contract above carries. +// 3. Mint, supplying the matching claim record await ledger.submit({ actAs: [admin, alice], command: { @@ -151,94 +208,91 @@ await ledger.submit({ configCid, owner: alice, amount: '100.0', - issuerClaims: [ - { - issuer: credentialIssuer, - holder: admin, - property: 'accredited-investor', - value: 'true', - }, - ], + issuerClaims: [{ issuer: credentialIssuer, holder: admin, property: 'accredited-investor', value: 'true' }], }, }, }); ``` -If the claim is missing, wrong-issuer, or held by someone other than the -minting admin, `verifyCredentials` in `Registry.V2` rejects the mint. -`holderRequirements` is recorded on the config for downstream policy; the -mint itself checks `issuerRequirements` only. +If the claim is missing, wrong-issuer, or held by anyone other than the minting +admin, the mint rejects. `holderRequirements` is recorded on the config for +downstream policy; the mint itself checks `issuerRequirements` only. -Every mint consumes the `InstrumentConfig` it is given (`Registry_Mint` -exercises the consuming `InstrumentConfig_BumpSupply`) and creates a -replacement, so re-read the config cid before each mint rather than caching -it. +> The reference `verifyCredentials` accepts party-issued claims at face value — +> production **must** replace it with a real credential lookup. See the module +> note in [`Instrument/Credentials.daml`](../../trading/CantonDex/Instrument/Credentials.daml). -## Case D. Vested LP (custom lifecycle) +## D. Vested LP (custom lifecycle) -Token Standard V2 does not have first-class vesting. The recommended pattern is -a custom template that owns or controls a V2 holding: +Token Standard V2 has no first-class vesting. The pattern is a custom template +that owns or gates a V2 holding until a cliff passes. The following is an +**illustrative example — it is not in the repo**; write it in your fork: ```daml +-- EXAMPLE (not shipped): a wrapper that gates transfer until a cliff. template VestedLP with holder : Party admin : Party - underlying : ContractId V2.Holding -- the actual LP holding (locked) + underlying : ContractId V2.Holding -- the LP holding, held locked cliffAt : Time - fullyVestedAt : Time where signatory admin, holder - choice VestedLP_Claim : ContractId V2.Holding controller holder do now <- getTime assertMsg "not yet cliff" (now >= cliffAt) - -- Transfer the underlying to the holder via TransferInstruction - ... + ... -- release the underlying via TransferInstruction ``` -The `underlying` holding stays locked (admin-controlled) until -`VestedLP_Claim` releases it. This composes with the rest of the -reference: the vested LP can still appear in `/v1/holdings` because -it's still a V2.Holding under the covers; the wrapper just gates -transfer. - -## Case E. Dividend-paying instrument - -Two patterns: +The `underlying` holding stays locked until `VestedLP_Claim` releases it. It is +still a V2 `Holding` under the wrapper, so it can appear in balance reads; the +wrapper only gates the transfer. -1. **Periodic distribution by the admin**: admin runs a script that - queries all current holders (`V2.Holding` ACS filtered by - `instrumentId`) and creates corresponding USDC `TransferInstruction` - contracts pro-rata. Simple, off-chain logic. +## E. Dividend-paying instrument -2. **Pull-based via a `DividendClaim` template**: admin posts a - per-period dividend rate; each holder exercises `Claim` to mint - their share. Cheaper for admin, more contracts. +Neither pattern ships in the reference; both are straightforward in a fork: -The reference doesn't ship either; build them in your fork. +1. **Admin-pushed distribution** — a script queries current holders (the + `Holding` ACS filtered by `instrumentId`) and creates pro-rata payout + `TransferInstruction`s. Simple, off-ledger logic; cost falls on the admin. +2. **Pull-based claim** — the admin posts a per-period rate and each holder + exercises a `Claim` choice for their share. Cheaper for the admin, more + contracts on-ledger. -## What you cannot do without extending the reference +## Reference — what needs a registry extension -- Native rebasing tokens: V2 holdings have a fixed `amount`; - rebases require ACS rewrites which the standard doesn't support - natively. Use a wrapper that exposes a rebasing view. -- Token-bound permissions that do not fit the reference registry's credential - model: more complex predicates (e.g. "holder must be in jurisdiction X but - not Y") require a custom registry or custom `TransferFactory`. -- Multi-asset baskets in a single holding: V2 holdings are - single-instrument. Baskets are a wrapper template. +- **Native rebasing.** V2 holdings have a fixed `amount`; rebases would mean ACS + rewrites the standard does not support. Expose a rebasing *view* over a wrapper + instead. +- **Predicates the credential model can't express** (e.g. "holder in + jurisdiction X but not Y") need a custom registry or a custom `TransferFactory`. +- **Multi-asset baskets in one holding.** V2 holdings are single-instrument; a + basket is a wrapper template. ## Where to look in this repo -- `trading/CantonDex/Registry/V2.daml`: reference registry implementing the - Token Standard V2 interfaces used by the DEX -- `trading/CantonDex/Instrument/Credentials.daml`: credential primitive -- `trading/CantonDex/Lp/Policy.daml`: LP-token policy component - driving V2 mints/burns -- `trading-tests/CantonDex/Tests/InstrumentTests.daml`: registration, - mint, transfer, burn flows in Daml Script +- [`Registry/V2.daml`](../../trading/CantonDex/Registry/V2.daml) — the reference + registry: `InstrumentConfig`, `Registry_RegisterInstrument`, `Registry_Mint`, + and the V2 interface instances. *Proves the register-then-mint path in B and C.* +- [`Lp/Instrument.daml`](../../trading/CantonDex/Lp/Instrument.daml) — the LP + mint/burn legs and allocation specs. *Proves how the LP token in A rides the V2 + allocation surface (mint = leg to `mintAccount`, burn = leg from it).* +- [`Lp/Policy.daml`](../../trading/CantonDex/Lp/Policy.daml) — `LPTokenPolicy`, + the LP supply component. *Proves LP supply tracking is separate from + `InstrumentConfig` caps.* +- [`Instrument/Credentials.daml`](../../trading/CantonDex/Instrument/Credentials.daml) + — the credential primitive and `verifyCredentials`. *Proves the C gate — and + flags that the check is a placeholder.* +- [`DvpMintBurnTests.daml`](../../trading-tests/CantonDex/Tests/DvpMintBurnTests.daml) + — `testDvpMintThenBurn` mints 100 LP to Alice then burns it. *Proves a mint + credits the receiver and a burn leaves nothing behind, not even a stray locked + holding.* `testHarnessDoesNotGateMintAuthorization` documents that the shipped + test registry does not enforce mint authorization — production registries do. +- [`InstrumentTests.daml`](../../trading-tests/CantonDex/Tests/InstrumentTests.daml) + — config, credential-gated mint, and burn over the request-workflow templates. + *Proves open vs. gated issuance: `testMintGatedIssuance` rejects a mint whose + credential is absent.* --- diff --git a/docs/guides/builder-guide.md b/docs/guides/builder-guide.md index 47c1b590..b34e25cb 100644 --- a/docs/guides/builder-guide.md +++ b/docs/guides/builder-guide.md @@ -1,90 +1,216 @@ # Builder guide -For engineers who want to read and extend this reference. Read -after [Getting Started](../getting-started.md) (which gets the stack running) -and the [Overview](../concepts/overview.md) + [Architecture](../concepts/architecture.md) +How to read and extend this reference. Start after +[Getting Started](../getting-started.md) (which runs the stack) and the +[Overview](../concepts/overview.md) and [Architecture](../concepts/architecture.md) (which explain the design). -## In scope +## Three layers, one boundary + +Every extension lives in one of three layers, and most extensions succeed or fail +on whether they respect the boundary between them. + +```mermaid +flowchart TB + subgraph DEX["DEX contracts — market structure"] + P["Pool / PoolRules"] + O["Order / OrderMatchExecution"] + RQ["Rfq / MatchedTrade"] + end + subgraph TS["Token Standard V2 — reservation and settlement"] + SB["AllocationFactory · SettlementFactory_SettleBatch"] + end + subgraph REG["Registry — asset semantics"] + H["Holding · Instrument · choice context"] + end + DEX -->|"builds legs, drives"| TS + TS -->|"moves value through"| REG +``` + +- **DEX contracts own market structure**: orders, pools, LP issuance, RFQ, trades. +- **Token Standard contracts own reservation and settlement**: a trade is a set of + committed allocations settled by one `SettlementFactory_SettleBatch`. +- **Registry contracts own asset semantics**: what a holding is, who may hold it, + and the choice context a settlement needs. + +A DEX choice never moves a holding itself. It builds the transfer legs and asks the +settlement factory to move them, under authority the holder already signed. Any +change that blurs these layers shows up later as duplicated state or authority +confusion; keep them separate and most extensions stay local. + +## What this reference is A runnable Canton DEX that: -- uses Token Standard V2 (CIP-0112) for every asset: base, quote, and LP are - contracts implementing `V2.Holding`. -- uses iterated allocations from Token Standard V2 (CIP-0112), now merged into - `canton-network/splice` `main`, so pool reserves and resting orders can be - adjusted in place without re-funding round trips. -- ships an on-chain operator policy receipt (`PolicyReceipt`) for RFQ accepts, - so dealer ranking is replayable after the fact. -- can be deployed to a Canton testnet participant with the included tooling (`scripts/deploy-testnet.sh`); see [Run on a Testnet](run-on-testnet.md) to point the operator backend and dApp at your own participant. - -## Out of scope - -No central limit-order-book matcher, no production order routing, no oracle -integration, no custody, and no compliance/KYC layer. Those belong in forks or -deployment-specific services. - -## Workflow families - -The four workflow families below are the ones the Daml test suite exercises. -Each corresponds to tests under `trading-tests/CantonDex/Tests/`. Read them in -this order to understand the venue end-to-end. - -### A. Pair / instrument listing -- `trading/CantonDex/Dex/DexPair.daml`: listing record with base + quote - instrument id, fee model, trading mode (`OrderBook`, `Pool`, `Both`), and an - `active` flag. -- `trading/CantonDex/Instrument/InstrumentConfiguration.daml`: the reference - registry's instrument config (holder/issuer credential requirements, optional - ISIN/CUSIP). This is not a Token Standard V2 template; other registries can - use different config contracts. -- Test: `InstrumentTests.daml::testInstrumentConfigCreate`. - -### B. Matched-trade OTC / RFQ settlement (TradingAppV2 pattern) -- `trading/CantonDex/Dex/MatchedTrade.daml`: a V2 adaptation of TradingAppV2. - `MatchedTrade_RequestAllocations` creates one request per authorizer; - `MatchedTrade_Settle` groups by admin and calls `SettlementFactory_SettleBatch`; - `MatchedTrade_Cancel` mirrors the cleanup. -- `trading/CantonDex/Dex/Rfq.daml` + `PolicyReceipt.daml` hold the bilateral - block-trade flow: trader RFQ, dealer quotes, joint `Rfq_Accept` emitting a - `MatchedTrade` that carries an operator-signed `PolicyReceipt` folded into - `SettlementInfo.meta`. -- Tests: `EndToEndTests.daml::testMatchedTradeFullSettle`, - `testRfqAcceptProducesMatchedTradeWithReceipt`, - `testTradeAllocationRequestAccept`. -- Upstream reference: the vendored - `vendor/splice/token-standard/examples/splice-token-test-trading-app-v2/`. +- represents every asset (base, quote, and LP) as a Token Standard V2 (CIP-0112) + `V2.Holding`; +- uses iterated allocations, so pool reserves and resting orders adjust in place + without a re-funding round trip; +- records an operator `PolicyReceipt` on every RFQ accept, so dealer ranking is + replayable after the fact; +- deploys to a Canton testnet participant with the included tooling + (`scripts/deploy-testnet.sh`; see [Run on a testnet](run-on-testnet.md)). + +It deliberately leaves out a production limit-order-book matcher, order routing, +oracle integration, custody, and a compliance/KYC layer. Those belong in forks or +deployment-specific services, not the shared templates. See +[Non-goals](../concepts/non-goals.md). + +## The four workflow families + +The Daml test suite exercises four families. Reading them in order is the fastest +way to understand the venue; each lists its contracts, its entry choice, and the +test that proves it. + +### A. Pair and instrument listing +Register a tradable pair, and for pool mode its instruments. + +- `Dex/DexPair.daml` — the listing: base + quote instrument ids, fee model, trading + mode (`OrderBook` / `Pool` / `Both`), and an `active` flag. +- `Instrument/InstrumentConfiguration.daml` — the reference registry's per-instrument + config (credential requirements, optional ISIN/CUSIP). Registry-specific, not a + Token Standard template. +- Proven by + [`InstrumentTests.daml`](../../trading-tests/CantonDex/Tests/InstrumentTests.daml) + (`testInstrumentConfigCreate`). + +### B. OTC and RFQ settlement +A bilateral block trade settles as one atomic batch. + +- `Dex/MatchedTrade.daml` — `MatchedTrade_RequestAllocations` (one request per + authorizer), `MatchedTrade_Settle` (groups legs by registry admin, calls + `SettlementFactory_SettleBatch`), `MatchedTrade_Cancel`. +- `Dex/Rfq.daml` + `PolicyReceipt.daml` — trader RFQ, dealer quotes, then a joint + `Rfq_Accept` that emits a `MatchedTrade` carrying an operator-signed + `PolicyReceipt` in `SettlementInfo.meta`. +- Proven by + [`EndToEndTests.daml`](../../trading-tests/CantonDex/Tests/EndToEndTests.daml) + (`testMatchedTradeFullSettle`, `testRfqAcceptProducesMatchedTradeWithReceipt`). ### C. Resting orders backed by a V2 allocation -- `trading/CantonDex/Dex/OrderFundingRequest.daml`: trader-signed intent. -- `trading/CantonDex/Dex/Order.daml`: the operator-bound `Order` plus its - `OrderAllocationRequest`. Funding requires the trader to author the allocation - via `AllocationFactory_Allocate`, so the trader's own authority moves the - holding. The operator cannot move trader holdings on its own. -- `trading/CantonDex/Dex/OrderMatchExecution.daml`: the prefunded-trade pattern: - concrete match legs are supplied as `FinalizedAllocation.extraTransferLegSides` - at batch-settlement time; next-iteration cids roll forward onto partial fills. -- Tests: `EndToEndTests.daml::testOrderFundingFlow`, - `testFinalizedAllocationFundingConservation`. - -### D. Constant-product pool with committed allocations -- `trading/CantonDex/Dex/Pool.daml` + `PoolState.daml` + `PoolSlice.daml` make up - the split pool: immutable config, the hot reserves/supply/status state, and one - committed allocation per slice (each slice is its own contract, passed by - cid). -- `trading/CantonDex/Dex/PoolRules.daml` holds the swap-side choices: - `PoolRules_RequestSwap`, `PoolRules_Swap`, `PoolRules_Pause`, `PoolRules_Resume`. -- `trading/CantonDex/Dex/PoolLiquidityRules.daml` + `LiquidityAllocationRequest.daml` - hold the delivery-versus-payment add/remove path: `_RequestAddLiquidity` / - `_SettleAddLiquidity` and `_RequestRemoveLiquidity` / `_SettleRemoveLiquidity`, - co-controlled by `operator` + `lpRegistrar`. -- `trading/CantonDex/Lp/Policy.daml` + `Instrument.daml`: the LP-token component. - `LPTokenPolicy` is owned by `lpRegistrar`, keyed by a `V2.InstrumentId`, and - knows nothing about pools or orders. -- Tests: `EndToEndTests.daml::testPoolFullLifecycle`, `testPoolSwapEndToEnd`; - `PoolLiquidityRulesTests.daml` (DvP add, remove-to-holder, boundary slice). - -## Contract surface +A limit order rests in the book, funded by the trader's own locked allocation. + +- `Dex/OrderFundingRequest.daml` — the trader-signed intent. +- `Dex/Order.daml` — the operator-bound `Order` and its `OrderAllocationRequest`. The + trader authors the allocation with `AllocationFactory_Allocate`, so their own + authority locks the holding; the operator cannot move it. +- `Dex/OrderMatchExecution.daml` — the atomic match (see the matcher section below). +- Proven by + [`EndToEndTests.daml`](../../trading-tests/CantonDex/Tests/EndToEndTests.daml) + (`testOrderFundingFlow`, `testFinalizedAllocationFundingConservation`). + +### D. Constant-product pool +An AMM whose reserves are committed allocations. + +- `Dex/Pool.daml` + `PoolState.daml` + `PoolSlice.daml` — immutable config, the hot + reserves/supply/status, and one committed allocation per slice (each slice is its + own contract, passed by cid). +- `Dex/PoolRules.daml` — `PoolRules_RequestSwap`, `PoolRules_Swap`, `PoolRules_Pause`, + `PoolRules_Resume`. +- `Dex/PoolLiquidityRules.daml` + `LiquidityAllocationRequest.daml` — the DvP + add/remove path (`_RequestAddLiquidity` / `_SettleAddLiquidity` and the remove + pair), co-signed by `operator` and `lpRegistrar`. +- `Lp/Policy.daml` + `Lp/Instrument.daml` — the LP token, owned by `lpRegistrar`, + keyed by a `V2.InstrumentId`, and unaware of pools or orders. +- Proven by + [`EndToEndTests.daml`](../../trading-tests/CantonDex/Tests/EndToEndTests.daml) + (`testPoolFullLifecycle`, `testPoolSwapEndToEnd`) and + [`PoolLiquidityRulesTests.daml`](../../trading-tests/CantonDex/Tests/PoolLiquidityRulesTests.daml). + +## The off-ledger matcher: where a fork does most of its work + +The on-ledger `OrderMatchExecution` template settles two opposing allocations +atomically. Everything above it — finding opposing orders and choosing the fill +quantity and price — is operator code, so a fork can rewrite matching without +touching a Daml template. + +The operator scans active `Order`s (`/v1/orders`), pairs compatible ones (same pair, +opposite side, `bid.limitPrice >= ask.limitPrice`), sets the fill quantity to +`min(remaining)` and a policy fill price, then creates and exercises the match in one +submission: + +```daml +choice OrderMatchExecution_Execute : OrderMatch_ExecuteResult + with + factoryCid : ContractId V2.SettlementFactory + extraArgs : ExtraArgs -- registry choice context for the batch + controller operator + do + ... -- finalize both allocations with the concrete match legs, + -- SettleBatch, roll each order onto its next-iteration + -- allocation, and write a SettledTrade +``` + +Using one `createAndExercise` keeps funds and orders moving together: the settle +archives both allocations, so an order left pointing at a spent one could neither be +filled nor cancelled. The split is deliberate — matchers change often, settlement +primitives do not. + +## Wallet integration + +The dApp never signs as the trader. Trader-authority writes (placing an order, +authoring add/remove-liquidity or swap allocations with `AllocationFactory_Allocate`) +go through the connected wallet over the CIP-0103 dApp standard +(prepare → sign → execute): the operator backend builds the unsigned command tree, +the wallet signs and submits. RFQ accept is the one exception here — trader and +operator co-sign via `POST /v1/rfq/accept`; a production deployment would route the +trader's authority through the wallet too. + +Read endpoints (`/v1/pools`, `/v1/trades`, …) are operator-observed and served from +the backend's indexer cache. Keep trader-authority writes on the wallet path; the +operator backend should only orchestrate and settle what it is authorized to submit. + +CIP-0103 prepares one top-level command per transaction, so each flow is a single +Daml command. Where a flow needs several allocations at once, the DEX uses the token +standard's batching utility (`Splice.Util.Token.Wallet.BatchingUtilityV2`, vendored +under `vendor/splice/daml/splice-util-token-standard-wallet/`): the wallet +`createAndExercise`s `ExecuteBatch`, which authors every named allocation in one +transaction. Deploy that DAR alongside the DEX DAR. + +## Extending the reference + +| Goal | How | +|---|---| +| Add a trading pair (BTC/EUR, ETH/USDT, …) | Create a `DexPair`; add a `Pool` for pool mode. See [Add a trading pair](add-a-trading-pair.md). | +| Issue a new LP token or lifecycle-rich instrument (vested, dividend-bearing) | See [Add an LP or instrument](add-lp-or-instrument.md). | +| Use a different registry | Swap `CantonDex.Testing.MockRegistry` for the real registry's `AllocationFactory` + `SettlementFactory`. See [Registry integration](registry-integration.md). | +| Add a pricing curve (StableSwap, weighted) | Fork the `Pool` template; the slice model is curve-agnostic. See `examples/stable-pool/`. | +| Add a fee policy | Extend `Pool.feeBps` / `DexPair.feeModel` and the `constantProductOut` quote math. | +| Add an RFQ policy (oracle-weighted, multi-tier) | `Rfq.applyPolicy` holds the sort chain; bump `policyVersion`/`policyHash` and mirror it in `app/web/src/services/rfq-policy.ts`. | +| Point at a different participant | Set `CANTON_LEDGER_URL`, `CANTON_LEDGER_TOKEN`, `CANTON_SYNCHRONIZER`. See [Run on a testnet](run-on-testnet.md). | + +Whatever you change, keep the layer boundary above intact: DEX contracts own market +structure, Token Standard contracts own reservation and settlement, registry +contracts own asset semantics. + +## Upgrade discipline + +Keep the templates as small as possible; do not carry compatibility choices "just in +case". If an adopter needs to preserve Daml smart-upgrade lineage, follow the +participant's upload-check rules: new fields `Optional` and at the end of the record, +choices kept rather than removed, input/result field types stable, no field +reordering. To break compatibility on purpose, rename the package and treat it as a +fresh lineage. + +## Testing + +```bash +cd trading-tests && dpm test # in-script Daml suites +``` + +Expected counts are in [Getting Started](../getting-started.md). Testnet smoke test: + +```bash +node --import tsx scripts/testnet-v2registry-trade.ts # real V2-standard trade +``` + +Keep deployment-specific responsibilities outside the reference core — custody, +KYC/compliance, oracle selection, production routing, market surveillance — so the +shared templates stay small. + +--- + +### Reference: contract surface ``` DexPair pair listing, fee model, optional public observers @@ -100,13 +226,13 @@ OrderMatchExecution operator-driven match of two opposing allocations MatchedTrade bilateral block-trade carrier, optional PolicyReceipt TradeAllocationRequest per-authorizer allocation request for a matched trade Rfq / RfqQuote trader's request for quotes; dealer's quote -PolicyReceipt on-chain record of operator ranking policy at accept time +PolicyReceipt on-ledger record of the operator ranking policy at accept time Registry.V2.* reference registry implementing Token Standard V2 interfaces ``` The Daml package is `canton-dex-trading` (current version `v0.1.4`). -## Off-chain layout +### Reference: off-ledger layout ``` services/operator-backend/ @@ -124,109 +250,8 @@ app/web/ wallet/ wallet providers (CIP-0103 SDK, WalletConnect, mock, ...) ``` -## Off-chain matcher - -The on-chain `OrderMatchExecution` template adjusts and settles two opposing -allocations atomically. Orchestration (finding opposing orders, computing fill -quantity, picking fill price) lives in operator code: - -1. Operator scans active `Order` contracts via `/v1/orders`. -2. Pairs compatible orders: same `(baseInstrumentId, quoteInstrumentId)`, - opposite `side`, `bid.limitPrice >= ask.limitPrice`. -3. Fill quantity = `min(bid.remainingQty, ask.remainingQty)`. -4. Fill price by operator policy (typically maker-priority or midpoint). -5. Creates `OrderMatchExecution` referencing both allocations and the fill numbers. -6. Exercises `OrderMatchExecution_Execute`, which finalizes each allocation with - the concrete match leg-sides, calls `SettlementFactory_SettleBatch` on the - finalized batch, rolls each order onto the next-iteration allocation the - batch minted for its residual budget, and writes a `SettledTrade` record. - -Steps 5 and 6 are one `createAndExercise` submission, so the funds and the -orders they back always move together: the settle archives both allocations, -and an order left pointing at one could neither be cancelled nor filled again. - -The split is intentional: matchers change often, settlement primitives do not. A -fork can rewrite the matcher without touching any Daml template. - -## Wallet integration - -The dApp does not sign as the trader. Trader-authority writes (place order, -add/remove-liquidity allocations via `AllocationFactory_Allocate`, swap allocation -creation) go through the connected wallet over the CIP-0103 dApp standard -(prepare → sign → execute). The operator backend produces unsigned command -trees; the wallet signs and submits. RFQ accepts are the one exception in this -reference: trader + operator co-sign via `POST /v1/rfq/accept` (a production -deployment would route the trader's authority through the wallet as well). - -Read endpoints (`/v1/pools`, `/v1/trades`, etc.) are operator-observed and served -from the backend's indexer cache. Keep trader-authority writes in the wallet path; -the operator backend should only orchestrate and settle flows it is authorized to -submit. - -**Why single-command flows.** CIP-0103 interactive submission prepares one -top-level command per transaction, and the Splice Amulet Wallet UI only -batches multiple requested allocations for Amulet allocations. The DEX -therefore uses the token standard's batching utility, -`Splice.Util.Token.Wallet.BatchingUtilityV2` (Splice 0.6.11, vendored under -`vendor/splice/daml/splice-util-token-standard-wallet/` and built by the -vendored-DAR script): the wallet `createAndExercise`s `ExecuteBatch`, which -accepts the request and authors every allocation it names in a single Daml -transaction, threading each account's holdings between the calls. Deploy the -`splice-util-token-standard-wallet` DAR alongside the DEX DAR. - -## Extending the reference - -| Goal | How | -|---|---| -| Add a new trading pair (BTC/EUR, ETH/USDT, …) | Create a `DexPair`; add a `Pool` if the pair runs pool-mode. See [Add a Trading Pair](add-a-trading-pair.md). | -| Issue a new LP token or lifecycle-rich instrument (vested, dividend-bearing) | See [Add an LP or Instrument](add-lp-or-instrument.md). | -| Use a different registry | Replace `CantonDex.Testing.MockRegistry` with the real registry's `AllocationFactory` + `SettlementFactory`. See [Registry Integration](registry-integration.md). | -| Add a different pricing curve (StableSwap, weighted) | Fork the `Pool` template; the slice model is curve-agnostic. See `examples/stable-pool/`. | -| Add a fee policy | Extend `Pool.feeBps` / `DexPair.feeModel` and the `constantProductOut` quote math. | -| Add a different RFQ policy (oracle-weighted, multi-tier) | `Rfq.applyPolicy` holds the sort chain; bump `policyVersion`/`policyHash` and mirror in `app/web/src/services/rfq-policy.ts`. | -| Talk to a different participant | Set `CANTON_LEDGER_URL`, `CANTON_LEDGER_TOKEN`, `CANTON_SYNCHRONIZER`. See [Run on a Testnet](run-on-testnet.md). | - -**The boundary that must not move:** - -- DEX contracts own market structure (orders, trades, pools, LP issuance). -- Token Standard contracts own reservation and settlement. -- Registry contracts own asset semantics. The reference registry exposes - `InstrumentConfiguration`, but the DEX boundary is the V2 holding / allocation / - settlement surface plus registry-supplied choice context. - -Any change that blurs these will surface as duplicated state or authority -confusion in the workflows. - -## Upgrade discipline - -Keep the reference templates as small as possible; do not carry compatibility -choices "just in case". They add noise for readers. If an adopter deploys a -package and needs to preserve Daml smart-upgrade lineage, follow the participant's -upload-check rules: new fields `Optional` and at the end of the record, choices -kept (not removed), input/result field types stable, no field reordering. If you -intentionally break compatibility, rename the package and treat it as a fresh -lineage. - -## Testing - -```bash -cd trading-tests && dpm test # in-script Daml suites -``` - -Expected counts are listed in [Getting Started](../getting-started.md). Testnet -smoke: - -```bash -node --import tsx scripts/testnet-v2registry-trade.ts # real V2-standard trade -``` - -Keep deployment-specific responsibilities outside the reference core: custody, -KYC/compliance, oracle selection, production routing policy, and market -surveillance should be implemented by the adopter, not hardcoded into the shared -templates. - ## Where to read next -- **Reference:** [HTTP API](../reference/http-api.md) · [Allocation Surface](../reference/allocation-surface.md) -- **Deeper design:** [Workflows](../concepts/workflows.md) · [Liquidity & Custody](../concepts/liquidity-and-custody.md) -- **Recipes:** [Add a Trading Pair](add-a-trading-pair.md) · [Add an LP or Instrument](add-lp-or-instrument.md) +- **Reference:** [HTTP API](../reference/http-api.md) · [Allocation surface](../reference/allocation-surface.md) +- **Deeper design:** [Workflows](../concepts/workflows.md) · [Liquidity and custody](../concepts/liquidity-and-custody.md) +- **Recipes:** [Add a trading pair](add-a-trading-pair.md) · [Add an LP or instrument](add-lp-or-instrument.md) diff --git a/docs/guides/choice-context.md b/docs/guides/choice-context.md index a2fed920..fd750832 100644 --- a/docs/guides/choice-context.md +++ b/docs/guides/choice-context.md @@ -1,12 +1,116 @@ # Choice context and disclosure retrieval -Defines what the DEX operator backend must attach to each transaction it -submits, in terms of registry-supplied disclosed contracts and choice-context -fields. This is the reference registry-client integration contract, not a -Token Standard V2 endpoint specification. It mirrors the Registry Utility guide's -"Note: Before the command is submitted by the UI, an API call is being -made (in the background) to an endpoint to retrieve required additional -choice context (including disclosure)..." pattern. +The operator submits every transaction under its own party. But the holdings a +settlement archives are signed `signatory admin, owner` — a registry admin the +operator never sees — and the Token Standard V2 factory choices take a context +argument the operator cannot compute for itself. So each registry-touching +submission carries two riders sourced from the asset registry: a **choice +context** threaded into the choice's `extraArgs.context`, and a set of +**disclosed contracts** threaded into the ledger submission's +`disclosedContracts`. One module — the operator backend's +[`registry-client`](../../services/registry-client/src/index.ts) — is the single +place both come from, so cache invalidation stays correct. + +This is the reference registry-client integration contract, not a Token Standard +V2 endpoint specification. It mirrors the Registry Utility guide's "Note: Before +the command is submitted by the UI, an API call is being made (in the +background) to an endpoint to retrieve required additional choice context +(including disclosure)..." pattern. + +## The two riders + +| Rider | Threaded into | Why the operator needs it | +|---|---|---| +| **Choice context** (`context.values`) | `choiceArgument.extraArgs.context` | The registry computes it (disclosed config, featured-app rights, rate limits). Self-registries return it empty, but the choice's `ExtraArgs` shape still requires the field. | +| **Disclosed contracts** | submission `disclosedContracts` | The factory contracts, registry config, and admin-signed holdings the choice fetches are invisible to the operator's party. Disclosure hands the participant the created-event blobs it needs to validate them without `readAs`. | + +```mermaid +flowchart LR + subgraph reg["Asset registry — off-ledger HTTP"] + E1["/registry/factories/:admin"] + E2["/registry/choice-context/:admin"] + end + subgraph rc["registry-client — TTL caches"] + F["getFactories
→ { factoryCid, disclosure }"] + C["getChoiceContext
→ { context, disclosure }"] + end + A["operator submission:
extraArgs.context +
[...factories.disclosure, ...ctx.disclosure]"] + L["JSON Ledger API
extraArgs + disclosedContracts"] + X["on-ledger factory choice
Allocate / SettleBatch"] + E1 --> F --> A + E2 --> C --> A + A --> L --> X +``` + +## Where the riders are assembled + +One helper turns the registry's `ChoiceContextRef` into the `extraArgs` shape the +choices take — [`fetchChoiceContext`](../../services/operator-backend/src/ledger/choice-context.ts), +shared by the pool, order, and matched-trade services: + +```typescript +export async function fetchChoiceContext( + registry: RegistryClient, + admin: Party, +): Promise { + const ctx = await registry.getChoiceContext(admin); + return { + extraArgs: { context: ctx.context, meta: { values: {} } }, + disclosure: ctx.disclosure, + }; +} +``` + +At each submit site, the factory disclosure and the choice-context disclosure are +merged into one array and the context is passed through as `extraArgs`. From the +pool swap ([`pool/index.ts`](../../services/operator-backend/src/pool/index.ts), +`PoolRules_Swap`): + +```typescript +const factories = await this.registry.getFactories(pool.admin); +const ctx = await this.choiceContext(pool.admin); +// ... +this.ledger.submit({ + actAs: [this.operatorParty], + readAs: input.swapperAccount.owner ? [input.swapperAccount.owner] : [], + disclosure: [...factories.disclosure, ...ctx.disclosure], + command: { + kind: "exercise", + choice: "PoolRules_Swap", + argument: { /* ... */ extraArgs: ctx.extraArgs }, + }, +}); +``` + +The submitter's last step drops that `disclosure` verbatim into the JSON Ledger +API command ([`ledger/json-api.ts`](../../services/operator-backend/src/ledger/json-api.ts)): + +```typescript +disclosedContracts: req.disclosure ?? [], +``` + +Each disclosed contract carries a base64 `createdEventBlob` — Canton's +disclosed-contract field — threaded through unchanged; the operator never +inspects or rewrites it. + +For a **cross-registry** trade, the merge is per admin: the operator groups legs +by their instrument's admin, fetches each admin's factories and context +separately, and concatenates the disclosure so every batch carries only its own +registry's contracts (see [`matched-trade/index.ts`](../../services/operator-backend/src/matched-trade/index.ts), +`MatchedTrade_Settle`). On-ledger the context rides all the way down: the +registry's `SettlementFactory_SettleBatch` forwards `arg.extraArgs` into each +`Allocation_Settle` it exercises (see +[`Registry/V2.daml`](../../trading/CantonDex/Registry/V2.daml), +`settlementFactory_settleBatchImpl`). + +**Proven by:** +[`registry-client.test.ts`](../../services/operator-backend/test/registry-client.test.ts) +— `getChoiceContext` fetches, caches (one HTTP call for two reads), and falls +back to empty context + no disclosure on a 404; +[`matched-trade.test.ts`](../../services/operator-backend/test/matched-trade.test.ts) +— a two-admin settle threads each admin's `extraArgs.context` into its own +`SettlementBatchV2` and emits the disclosure in +`[factory-A, context-A, factory-B, context-B]` order. ## Endpoints the operator queries @@ -26,11 +130,48 @@ the operator backend can produce the disclosed contracts and choice context required by the registry's Token Standard V2 choices. The operator-backend's `registry-client` module is the single integration point. -## Choice-context-bearing arguments +## Disclosure retrieval and caching -Each registry-touching choice the DEX exercises has a context -shape the operator must satisfy. Listed here as `(choice, required -context)` pairs. +The `registry-client` owns five caches, keyed and refreshed as follows: + +1. Registry config CIDs and their explicit-disclosure payloads, keyed by + `InstrumentId`, when the registry provides config contracts. +2. Allocation/Settlement factory CIDs (plus disclosure) per admin. Stale on admin + re-publish, when the registry archives + recreates. +3. Choice-context refs per admin, honouring `choiceContextTtlMs`. +4. Reference-registry `TransferPreapproval` CIDs, keyed by `(receiver, admin)`, + when the registry supports preapproval contracts. +5. Credentials or equivalent authorization evidence, keyed by `holder` — a short + TTL (`credentialsTtlMs`, default 60 s) because credentials can be revoked. + +Only the credentials and choice-context caches carry a TTL; the config, factory, +and preapproval caches hold their entries until they are flushed. The client +exposes `invalidateAll()` for a full flush after a known archive or re-publish. +There is no registry-side event stream driving invalidation, so a co-hosted +registry (or an explicit flush) is what keeps a non-TTL cache honest. + +Registry responses are never trusted via a bare cast: `fetchJson` runs each +payload through a shape validator and raises `RegistryError("malformed", ...)` on +a mismatch (see [`registry-client/src/validate.ts`](../../services/registry-client/src/validate.ts)). + +## Failure modes the backend must handle + +| Failure | Recovery | +|---|---| +| Stale registry config/disclosure CID (archived since cache fetch) | Refetch and retry once | +| Missing credential for required claim | Surface to the wallet UI; operator does not synthesize credentials | +| Factory CID stale | Refetch from `factories/:admin`; backoff on repeated failures | +| Preapproval revoked between fetch and submit | Fall back to offer/accept flow | +| Settlement batch rejected by factory | Cancel the trade, surface to operator monitoring | + +The `registry-client` module raises a typed `RegistryError` — with a `kind` +(`config-not-found`, `factory-stale`, `auth`, `transport`, `malformed`, …) and a +`retryable` flag — so the calling code path can recover correctly. + +## Reference: choice-context-bearing arguments + +Each registry-touching choice the DEX exercises has a context shape the operator +must satisfy. Listed here as `(choice, required context)` pairs. ### Allocation creation @@ -133,40 +274,6 @@ Required inputs: - `configCid : ContractId InstrumentConfiguration` — reference-registry config. - `senderClaims : [Credential]` — sender's holder claims. -## Disclosure retrieval pattern - -The operator backend caches: - -1. Registry config CIDs and their associated explicit-disclosure payloads, - keyed by `InstrumentId`, when the registry provides config contracts. - Refreshed on archive events. -2. Allocation/Settlement factory CIDs per admin. Refreshed on admin - re-publish (the registry archives + recreates). -3. Reference-registry `TransferPreapproval` CIDs, keyed by `(receiver, admin)`, - when the registry supports preapproval contracts. -4. Credentials or equivalent authorization evidence, keyed by - `holder` — short TTL because credentials can be revoked. - -The cache keys are hashed. Entries expire by TTL (`credentialsTtlMs` -for credentials, `choiceContextTtlMs` for choice contexts, with -per-cache defaults); the client also exposes `invalidate()` / -`invalidateAll()` for manual eviction after a known archive or -re-publish. There is no registry-side event stream driving -invalidation. - -## Failure modes the backend must handle - -| Failure | Recovery | -|---|---| -| Stale registry config/disclosure CID (archived since cache fetch) | Refetch and retry once | -| Missing credential for required claim | Surface to the wallet UI; operator does not synthesize credentials | -| Factory CID stale | Refetch from `factories/:admin`; backoff on repeated failures | -| Preapproval revoked between fetch and submit | Fall back to offer/accept flow | -| Settlement batch rejected by factory | Cancel the trade, surface to operator monitoring | - -The operator backend's `registry-client` module provides typed -errors for each so the calling code path can recover correctly. - --- **Where to read next:** [Registry Integration](registry-integration.md) · [Allocation Surface](../reference/allocation-surface.md) · [All docs](../README.md) diff --git a/docs/guides/deployment.md b/docs/guides/deployment.md index d48e57aa..6dd6a1bd 100644 --- a/docs/guides/deployment.md +++ b/docs/guides/deployment.md @@ -1,70 +1,130 @@ # Deployment guide -How to deploy the Canton DEX reference implementation. Three deployment -paths are supported: **Docker Compose**, **local dev** (in-memory ledger), -and **direct testnet** (real Canton participant). +Three ways to run the reference DEX, ordered by how much Canton you bring. +**Local dev** needs no participant at all; **Docker Compose** packages the whole +edge — backend plus nginx — in front of a remote Canton participant; **direct +testnet** runs that same backend under your own process supervisor. Pick one. -## 1. Local dev (no Canton required) +Two invariants hold across all three: only the operator backend holds +`CANTON_LEDGER_TOKEN` and submits with operator authority, and it never signs as +a trader — add/remove liquidity, swaps, and order funding are authored by a +wallet. See the [wallet boundary](run-on-testnet.md#wallet-boundary). -For UI development. Uses the `InMemoryLedger` and seeds a BTC/USDC pair -and pool. +## 1. Local dev (no Canton) + +For UI work. `npm run dev` boots the backend on an +[`InMemoryLedger`](../../services/operator-backend/src/ledger/in-memory.ts) and +seeds a BTC/USDC pair, a funded pool, and a demo trader — no participant, no +token. ```bash # backend cd services/operator-backend npm install -npm run dev # listens on :8080 +npm run dev # in-memory ledger, listens on :8080 # frontend (separate terminal) cd app/web npm install -cp .env.example .env.local # set VITE_API_BASE=http://localhost:8080 -npm run dev # listens on :5173 +cp .env.example .env.local # VITE_API_BASE defaults to http://localhost:8080 +npm run dev # Vite dev server on :5173 ``` -No Canton participant needed. All writes go through stub choice handlers -in `services/operator-backend/src/dev-server.ts`. +Read paths work immediately. State-changing routes are auth-gated and return +`401` in the demo unless you set `DEX_DEV_OPEN=1`. The full local walkthrough — +write-gate flags, wallet options, and the test suites — is in +[Local Setup & Testing](../getting-started.md); this page covers the real-Canton +paths. ## 2. Docker Compose -For deployments against a remote Canton testnet/MainNet, packaged for -ops. Brings up: +The packaged edge, for running against a remote Canton testnet or MainNet. Two +containers come up: + +- **backend** — [`Dockerfile.backend`](../../Dockerfile.backend) runs + [`testnet-server.ts`](../../services/operator-backend/src/testnet-server.ts) on + `:8080`, persisting the indexer DB to the `backend-data` volume. +- **frontend** — nginx on `:80` serves the Vite build and reverse-proxies + `/v1/*` to the backend, per [`nginx.conf`](../../nginx.conf). + +```mermaid +flowchart LR + B["Browser (dApp)"] -->|"HTTP :80"| N["frontend
nginx :80"] + N -->|"serves Vite build"| B + N -->|"/v1/* → proxy"| A["backend
testnet-server.ts :8080"] + A -->|"SQLite"| V[("backend-data
volume")] + A -->|"JSON Ledger API
(operator authority)"| P[("Canton participant
CANTON_LEDGER_URL")] +``` -- `backend` (operator-backend, port 8080) running `testnet-server.ts`. -- `frontend` (nginx, port 80) serving the Vite build, proxying `/v1/*` - to the backend. +nginx is the only ingress. Operator-API traffic takes the path above; trader +wallet calls reach Canton directly from the browser and do not pass through the +backend. ```bash cp services/operator-backend/.env.example .env -# Edit .env with CANTON_LEDGER_URL, CANTON_LEDGER_TOKEN, party ids, etc. +# Edit .env: CANTON_LEDGER_URL, CANTON_LEDGER_TOKEN, party ids, synchronizer, +# package id — see Environment variables below. + +docker compose build +docker compose up -d +``` + +Compose reads the repo-root `.env` for **both** the backend environment and the +frontend `VITE_*` build args (baked at build time — rebuild the frontend to +change them). See [`docker-compose.yml`](../../docker-compose.yml) for the exact +wiring. Persistent state lives in the `backend-data` volume (the SQLite indexer +DB). To wipe and restart fresh: -docker-compose build -docker-compose up -d +```bash +docker compose down -v && docker compose up -d ``` -Persistent state lives in the `backend-data` volume (SQLite indexer DB). -To wipe and restart fresh: `docker-compose down -v && docker-compose up -d`. +## 3. Testnet deployment (no containers) + +Run the same backend directly and manage the Node process yourself (systemd, +pm2, fly.io, …). Two ways in. + +### Automated: `deploy-testnet.sh` + +[`scripts/deploy-testnet.sh`](../../scripts/deploy-testnet.sh) drives the full +first-time sequence against a participant: build DARs → upload → allocate the +operator / lpRegistrar / admin / demo-trader parties → run the registry +bootstrap → seed a BTC/USDC pair → health-check. + +```bash +export CANTON_LEDGER_URL=... +export CANTON_LEDGER_TOKEN=... +export CANTON_OPERATOR=... +export CANTON_LP_REGISTRAR=... +export CANTON_ADMIN=... +export OPERATOR_ADMIN_TOKEN=... # for the seed step + +bash scripts/deploy-testnet.sh +``` -## 3. Testnet deployment +Each stage is skippable once done: `DEPLOY_SKIP_BUILD=1`, `DEPLOY_SKIP_UPLOAD=1`, +`DEPLOY_SKIP_PARTIES=1`, `DEPLOY_SKIP_SEED=1`. Party allocation is idempotent, so +re-runs are safe. The script does not start the backend — do that separately. -Direct deployment without containers. Same path as docker-compose's -backend service but you manage the Node process yourself (systemd, pm2, -fly.io, etc.). +### Manual: run the backend ```bash cd services/operator-backend npm install export CANTON_LEDGER_URL=... export CANTON_LEDGER_TOKEN=... -# ... (see .env.example for the full list) -npm start +# ... (see Environment variables below) +npm start # runs testnet-server.ts ``` +The full walkthrough — smoke checks, package-hash alignment, and the PartyLayer +live probe — is in [Run on a Testnet](run-on-testnet.md). + ### One-time bootstrap -Before the operator backend can serve trades, the registry must have -the right contracts on-ledger. Run the bootstrap script once per -ledger: +Before the backend can serve trades, the registry must have the right contracts +on-ledger. `deploy-testnet.sh` runs this for you; run it standalone with +[`scripts/bootstrap-registry.ts`](../../scripts/bootstrap-registry.ts): ```bash export CANTON_LEDGER_URL=... @@ -76,54 +136,78 @@ node --import tsx scripts/bootstrap-registry.ts ``` The script is idempotent: running it twice is a no-op. See -[Registry Integration](registry-integration.md) for what -contracts are created and why. +[Registry Integration](registry-integration.md) for what contracts are created +and why. Among them is a `Registry.V2` under the **lpRegistrar**. That one is not -optional: the pool's LP token is issued by this repository, and its -allocation specs name the lpRegistrar as admin, which `Registry.V2` asserts -against its own. Without it, add- and remove-liquidity cannot allocate, -whatever the pool trades. +optional: the pool's LP token is issued by this repository, and its allocation +specs name the lpRegistrar as admin, which `Registry.V2` asserts against its own. +Without it, add- and remove-liquidity cannot allocate, whatever the pool trades. A second registry, under `CANTON_ADMIN`, is created only if you add a -`registryV2` block to `scripts/bootstrap-registry.json`. That one is for -instruments a deployment mints itself; a deployment whose users bring their -own Token Standard V2 assets does not need it. - -`CANTON_ALLOC_FACTORY_CID` and `CANTON_SETTLE_FACTORY_CID` are a -single-registry stopgap (see `FixedRegistry` in -`services/operator-backend/src/testnet-server.ts`), standing in for the -per-admin registry lookup the design calls for. In a deployment serving -foreign tokens, each admin's factory cid comes from that admin's own -registry API, not from these variables. +`registryV2` block to +[`scripts/bootstrap-registry.json`](../../scripts/bootstrap-registry.json) (the +committed config has none). That one is for instruments a deployment mints +itself; a deployment whose users bring their own Token Standard V2 assets does +not need it. + +`CANTON_ALLOC_FACTORY_CID` and `CANTON_SETTLE_FACTORY_CID` are a single-registry +stopgap — the `FixedRegistry` in +[`testnet-server.ts`](../../services/operator-backend/src/testnet-server.ts) +returns them for every admin, standing in for the per-admin registry lookup the +design calls for. Unset, they default to `PENDING_*` placeholders. In a +deployment serving foreign tokens, each admin's factory cid comes from that +admin's own registry API, not from these variables. ## Environment variables -See `services/operator-backend/.env.example` and `app/web/.env.example` -for the canonical list. Required for production: +[`services/operator-backend/.env.example`](../../services/operator-backend/.env.example) +and [`app/web/.env.example`](../../app/web/.env.example) are the canonical lists +(including the wallet-provider flags). The backend variables that matter for a +real deployment: + +**Required** — the backend exits at boot if any is missing: | Var | Purpose | |-----|---------| | `CANTON_LEDGER_URL` | JSON Ledger API base URL | -| `CANTON_LEDGER_TOKEN` | Bearer JWT for the participant | +| `CANTON_LEDGER_TOKEN` | Bearer JWT for the participant (operator authority) | | `CANTON_OPERATOR` | Operator party id | | `CANTON_LP_REGISTRAR` | LP registrar party id | | `CANTON_ADMIN` | Asset admin party id | -| `CANTON_ALLOC_FACTORY_CID` | AllocationFactory contract id | -| `CANTON_SETTLE_FACTORY_CID` | SettlementFactory contract id | -| `OPERATOR_ADMIN_TOKEN` | Admin auth token for `/v1/admin/*` | -| `ALLOWED_ORIGINS` | CSV of CORS origins to allow | + +**Defaulted / optional:** + +| Var | Default | Purpose | +|-----|---------|---------| +| `CANTON_SYNCHRONIZER` | — | Synchronizer id for command submission | +| `CANTON_DEX_PACKAGE_ID` | — | Package hash prefix for template ids | +| `CANTON_ALLOC_FACTORY_CID` | `PENDING_ALLOC_FACTORY` | `FixedRegistry` AllocationFactory cid | +| `CANTON_SETTLE_FACTORY_CID` | `PENDING_SETTLE_FACTORY` | `FixedRegistry` SettlementFactory cid | +| `CANTON_USER_ID` | `ledger-api-user` | JSON Ledger API user id | +| `CANTON_NETWORK` | `canton:devnet` | Display label for the network | +| `PORT` | `8080` | HTTP server port | +| `DB_PATH` | `./data/operator.db` | SQLite indexer DB path (`/app/data/operator.db` in the container) | +| `INDEXER_INTERVAL_MS` | `5000` | Indexer polling interval | +| `OPERATOR_ADMIN_TOKEN` | — | Bearer token for `/v1/admin/*`; unset leaves admin routes unprotected | +| `ALLOWED_ORIGINS` | — | CSV of CORS origins; unset allows all | + +**Frontend build args** (baked into the static build; see +[`docker-compose.yml`](../../docker-compose.yml) `args:`): `VITE_API_BASE`, +`VITE_CANTON_NETWORK_ID`, `VITE_CANTON_LEDGER_URL`, `VITE_WC_PROJECT_ID`. ## Production checklist - [ ] `OPERATOR_ADMIN_TOKEN` set to a strong random value -- [ ] `ALLOWED_ORIGINS` narrowed to your dApp host (not `*`) -- [ ] SQLite DB path on persistent volume (`DB_PATH=/var/lib/dex/operator.db`) -- [ ] Process supervisor configured to restart on crash (systemd / pm2 / docker restart) -- [ ] Reverse proxy in front of `:8080` terminating TLS -- [ ] Backups configured for the indexer DB (it carries trade history and idempotency keys) -- [ ] Bootstrap script run once per ledger -- [ ] Monitoring: scrape logs from stdout/stderr; alert on `level: error` lines +- [ ] `ALLOWED_ORIGINS` narrowed to your dApp host (not unset / `*`) +- [ ] `CANTON_DEX_PACKAGE_ID` and `CANTON_SYNCHRONIZER` pinned to the vetted values +- [ ] `CANTON_ALLOC_FACTORY_CID` / `CANTON_SETTLE_FACTORY_CID` set to real cids (not the `PENDING_*` defaults) +- [ ] Indexer DB on a persistent volume (`backend-data` under Compose; `DB_PATH=/var/lib/dex/operator.db` bare) +- [ ] Process supervisor restarts on crash (systemd / pm2 / `restart: unless-stopped`) +- [ ] TLS terminated at your ingress in front of `:80` (Compose) or `:8080` (bare) +- [ ] Backups for the indexer DB (it carries trade history and idempotency keys) +- [ ] Registry bootstrap run once per ledger +- [ ] Monitoring: scrape stdout/stderr; alert on `level: error` lines --- diff --git a/docs/guides/operator-guide.md b/docs/guides/operator-guide.md index b3a6ad27..6a3d9035 100644 --- a/docs/guides/operator-guide.md +++ b/docs/guides/operator-guide.md @@ -1,33 +1,40 @@ # Operator Guide -How the DEX operator (admin) deploys, configures, and runs the venue. +The operator owns the trading venue: it lists pairs, creates pools, runs the +matching engine, and keeps the backend healthy. This guide covers the two +things an operator does — **stand the venue up once**, then **run it day to +day**. It stops where the deep incident work begins; that lives in the +[Operator Runbook](operator-runbook.md), which this guide hands off to +whenever recovery gets involved. -The operator is the party that owns the trading venue. It sets up pairs -and pools, observes settlement events, runs the matching engine, -collects fees, and recovers from incidents. - -This guide is the operational counterpart to the user-facing -[`using-the-dapp.md`](using-the-dapp.md). For the design rationale behind these -flows, see [`../concepts/workflows.md`](../concepts/workflows.md). +For the design rationale behind these flows, see +[`../concepts/workflows.md`](../concepts/workflows.md); for the trader-facing +side, [`using-the-dapp.md`](using-the-dapp.md). --- ## Operator identity -The operator is a single Daml party. In the reference deployment: +The operator is a single Daml party, but the reference deployment splits venue, +LP custody, and asset governance across three parties so those +responsibilities can be handed to different custodians later: - `CANTON_OPERATOR` — DEX market venue party. Signatory on `DexPair`, `Pool`, `Order`. Observer on `Holding` (so the indexer can read). -- `CANTON_LP_REGISTRAR` — separate party that holds the - `LPTokenPolicy` and accepts LP mint/burn. Logically distinct so the - operator can hand off LP custody to a regulated custodian later. -- `CANTON_ADMIN` — asset admin / registrar. Owns - the registry-side definition for the underlying instruments. In the - reference registry this is `InstrumentConfiguration`; Token Standard V2 does - not require that exact template. - -In production these are typically three different parties for -separation of concerns. For local dev they can be the same party. +- `CANTON_LP_REGISTRAR` — holds the `LPTokenPolicy` and accepts LP + mint/burn. Logically distinct so the operator can hand off LP custody to a + regulated custodian later. +- `CANTON_ADMIN` — asset admin / registrar. Owns the registry-side definition + for the underlying instruments. In the reference registry this is + `InstrumentConfiguration`; Token Standard V2 does not require that exact + template. + +In production these are typically three different parties for separation of +concerns. For local dev they can be the same party — see the +[single-operator dev shortcut](operator-runbook.md#single-operator-dev-shortcut) +in the runbook. The runbook's +[roles and party model](operator-runbook.md#roles-and-party-model) table maps +each party to the contracts it signs. --- @@ -42,7 +49,7 @@ bash scripts/build-trading-surface.sh Outputs `.daml/dist/canton-dex-*.dar`. -### 2. Upload DARs + allocate parties + bootstrap registry +### 2. Upload DARs, allocate parties, bootstrap the registry ```bash export CANTON_LEDGER_URL=https://your-participant:7575 @@ -54,17 +61,14 @@ export CANTON_ADMIN=admin::1220::... ./scripts/deploy-testnet.sh ``` -This script is idempotent. It uploads DARs, allocates the parties if -they don't exist, runs `bootstrap-registry.ts` to create -reference-registry `InstrumentConfiguration` contracts for BTC / USDC / ETH and -the LP instruments, and (if `OPERATOR_ADMIN_TOKEN` is set) seeds an initial -BTC/USDC pair. +The script is idempotent. It uploads DARs, allocates the parties if they don't +exist, runs `bootstrap-registry.ts` to create reference-registry +`InstrumentConfiguration` contracts for BTC / USDC / ETH and the LP +instruments, and (if `OPERATOR_ADMIN_TOKEN` is set) seeds an initial BTC/USDC +pair. -Skip flags for re-runs: -- `DEPLOY_SKIP_BUILD=1` -- `DEPLOY_SKIP_UPLOAD=1` -- `DEPLOY_SKIP_PARTIES=1` -- `DEPLOY_SKIP_SEED=1` +Skip flags for re-runs: `DEPLOY_SKIP_BUILD=1`, `DEPLOY_SKIP_UPLOAD=1`, +`DEPLOY_SKIP_PARTIES=1`, `DEPLOY_SKIP_SEED=1`. ### 3. Start the operator backend @@ -72,17 +76,27 @@ Skip flags for re-runs: cd services/operator-backend cp .env.example .env # Fill in: CANTON_LEDGER_URL, CANTON_LEDGER_TOKEN, party ids, -# OPERATOR_ADMIN_TOKEN, ALLOWED_ORIGINS, DB_PATH +# OPERATOR_ADMIN_TOKEN, DEX_OPERATOR_API_TOKEN, +# ALLOWED_ORIGINS, DB_PATH npm install npm start ``` -Production checklist: -- `OPERATOR_ADMIN_TOKEN` set to a strong random value (gates - `/v1/admin/*` writes). -- `ALLOWED_ORIGINS` narrowed to your dApp host (not `*`). -- `DB_PATH` on persistent storage (the indexer carries trade history - and idempotency keys). +**Production checklist:** + +- **Both write tokens set to strong random values.** The HTTP surface has two + fail-closed bearer gates ([`src/http/auth.ts`](../../services/operator-backend/src/http/auth.ts)): + `OPERATOR_ADMIN_TOKEN` gates writes to `/v1/admin/*` (pair and pool + administration), and `DEX_OPERATOR_API_TOKEN` gates every other + state-changing route — swaps, liquidity settles, order fund/bind/cancel, + RFQ, matched-trade settle, and the matching pass. With either token unset, + its routes reject writes with 401 (there is no open default on the testnet + server; `DEX_DEV_OPEN=1` bypasses the operator gate on the in-memory dev + server only). +- `ALLOWED_ORIGINS` narrowed to your dApp host (not `*`). CORS + default-denies when it is unset. +- `DB_PATH` on persistent storage (the indexer carries trade history and + idempotency keys). - TLS termination by a reverse proxy in front of `:8080`. - Logs scraped from stdout / stderr (JSON, one event per line). @@ -95,25 +109,30 @@ curl -fsS http://localhost:8080/v1/context curl -fsS http://localhost:8080/v1/pools ``` -See [`validator-test-plan.md`](validator-test-plan.md) -for the full live-validation checklist (10 phases, all the way through -wallet flows and resilience tests). +Reads (`/v1/status`, `/v1/context`, `/v1/pools`, `/v1/pairs`) are ungated. See +[`validator-test-plan.md`](validator-test-plan.md) for the full live-validation +checklist (10 phases, through wallet flows and resilience tests). --- ## Day-to-day operations -### Create a new trading pair +Every write below is issued either from the **Admin** page or against the HTTP +API. Admin routes carry `OPERATOR_ADMIN_TOKEN`; the matching pass carries +`DEX_OPERATOR_API_TOKEN`. The reference implementations for the admin routes +live in [`services/operator-backend/src/admin/index.ts`](../../services/operator-backend/src/admin/index.ts) — +each HTTP route maps 1:1 to a method there and to one Daml choice. -In the **Admin** page → **Pairs** section → **+ Add pair**. +### Create a trading pair -Or via the HTTP API directly: +**Admin** page → **Pairs** → **+ Add pair**, or: ```bash curl -X POST http://localhost:8080/v1/admin/pairs \ -H "Authorization: Bearer $OPERATOR_ADMIN_TOKEN" \ -H "Content-Type: application/json" \ -d '{ + "admin":"admin::1220::...", "baseInstrumentId":"BTC", "quoteInstrumentId":"USDC", "feeModel":{"makerFeeBps":10,"takerFeeBps":30,"poolFeeBps":30}, @@ -121,14 +140,37 @@ curl -X POST http://localhost:8080/v1/admin/pairs \ }' ``` -Trading mode is one of `TM_OrderBook`, `TM_Pool`, `TM_Both`. The fee model -ships maker / taker / pool fees in basis points. +The body is the `CreatePairInput` shape from `admin/index.ts`. Trading mode and +the fee model are the two knobs that define a pair: -Once created, the pair appears in `GET /v1/pairs`. Pause / resume / update -fee model from the Admin UI; the underlying choice exercises are -`DexPair_SetActive`, `DexPair_UpdateFeeModel`, `DexPair_UpdateTradingMode`. +```ts +export type TradingMode = "TM_OrderBook" | "TM_Pool" | "TM_Both"; + +export interface FeeModel { + makerFeeBps: number; + takerFeeBps: number; + poolFeeBps: number; +} +``` -### Create a new pool +`active` defaults to `true`. Once created, the pair appears in +`GET /v1/pairs`. + +### Update a pair + +Pause / resume and re-tune a pair from the Admin UI, or via the cid-suffixed +admin routes. Each maps to one choice on `DexPair`: + +| Action | Route | Choice | +|---|---|---| +| Pause / resume trading | `POST /v1/admin/pairs/:cid/active` | `DexPair_SetActive { newActive }` | +| Change fees | `POST /v1/admin/pairs/:cid/fee-model` | `DexPair_UpdateFeeModel { newFeeModel }` | +| Change order-book / pool mode | `POST /v1/admin/pairs/:cid/trading-mode` | `DexPair_UpdateTradingMode { newTradingMode }` | + +Pausing toggles the `active` flag without archiving the pair record, so a +paused pair keeps its history and fee policy and can be resumed in place. + +### Create a pool Admin → **Pool operations** → **+ Create pool**, or: @@ -137,6 +179,8 @@ curl -X POST http://localhost:8080/v1/admin/pools \ -H "Authorization: Bearer $OPERATOR_ADMIN_TOKEN" \ -H "Content-Type: application/json" \ -d '{ + "lpRegistrar":"lp::1220::...", + "admin":"admin::1220::...", "baseInstrumentId":"BTC", "quoteInstrumentId":"USDC", "lpInstrumentId":"BTC-USDC-LP", @@ -144,60 +188,62 @@ curl -X POST http://localhost:8080/v1/admin/pools \ }' ``` -The new pool starts in `PS_Unfunded`. First trader to add liquidity -completes the add-liquidity request/allocate/settle flow, which mints the initial LP supply at -`sqrt(baseAmount * quoteAmount)`. +`createPool` creates four contracts in one flow: the immutable `Pool`, the hot +`PoolState` in `PS_Unfunded`, the per-venue `PoolRules` / +co-controlled `PoolLiquidityRules` (created once and reused across pools), and +the matching `LPTokenPolicy` signed by `lpRegistrar`. The pool starts empty; +the first LP completes the same add-liquidity request/allocate/settle DvP flow +as every later LP, and that settle mints the initial LP supply at +`sqrt(baseAmount * quoteAmount)` and transitions the state to `PS_Active`. -### Order matching +### Run a matching pass -The reference matching engine is a pure-function price-time-priority -matcher (`services/operator-backend/src/order/matching.ts`). To run a -matching pass: +The reference matcher is a pure price-time-priority function in +[`services/operator-backend/src/order/matching.ts`](../../services/operator-backend/src/order/matching.ts). +A pass settles each crossing pair atomically as it finds it: ```bash curl -X POST http://localhost:8080/v1/orders/match \ + -H "Authorization: Bearer $DEX_OPERATOR_API_TOKEN" \ -H "Content-Type: application/json" \ -d '{"base":"BTC","quote":"USDC"}' ``` -Each crossing pair is settled atomically as it is found: one -`OrderMatchExecution_Execute` re-checks the fill against both orders' -own terms, runs the settle batch that consumes both funding -allocations, rolls each order onto the allocation that batch minted, -and records the fill as a `SettledTrade` for `GET /v1/trades`. The -response lists the matches, each with the order its remainder rolled -forward to (`null` when that side filled completely). +Each match runs one `OrderMatchExecution_Execute`, which re-checks the fill +against both orders' own terms, runs the settle batch that consumes both +funding allocations, rolls each order onto the allocation that batch minted, +and records the fill as a `SettledTrade` for `GET /v1/trades`. The response +carries `{ matches, settled, failed }`; because runMatching catches per match, +one bad pair cannot stop the rest, so the status is **200** when all settled, +**207** when some failed, **502** when every one did. -A fill whose spend exhausts the side's committed budget closes that -order out even when quantity remains: the residual quantity has no -collateral behind it and no later fill could back it. +A fill whose spend exhausts the side's committed budget closes that order out +even when quantity remains: the residual has no collateral behind it and no +later fill could back it. Production deployments run matching on a tick (every +1–5 seconds) plus on order-placement events. -Production deployments typically run matching on a tick (every 1-5 -seconds) plus on order-placement events. +*Proven by* [`test/matching.test.ts`](../../services/operator-backend/test/matching.test.ts): +the matcher clears at the resting side's limit, never crosses a party against +its own order, fills older orders first at equal price, and skips expired or +unfunded orders. ### Stale RFQ cleanup -`RfqService.sweepExpired(now)` cancels RFQs whose `expiresAt` has -passed. A periodic task (cron or systemd timer) should call the -backend maintenance entrypoint hourly from an authenticated operator -environment. - -### Fee accrual + revenue - -Admin → **Fee accrual** shows per-pool 24h volume and fees. The entire -swap fee (`feeBps` on each pool) accrues to LPs via the constant-product -(`x*y=k`) invariant; there is no operator fee split in this reference -implementation. - -### Pause / resume +`RfqService.sweepExpired(now)` cancels RFQs whose `expiresAt` has passed. Run +it on a schedule (cron or systemd timer) from an authenticated operator +environment. The runbook's +[stale RFQs and quotes](operator-runbook.md#stale-rfqs-and-quotes) section +covers the choice-level behavior — an RFQ past `expiresAt` is already inert, +because `Rfq_Accept` asserts `currentTime < expiresAt`. -Pair-level pause: Admin → **Pairs** → **Pause**. Underlying: -`DexPair_SetActive { newActive = False }`. +### Fees and revenue -Pool-level pause: the operator stops accepting new operator-driven -writes against the pool while leaving the contract in place. To make a -pool fully read-only, archive it and re-create when the venue is ready -to resume. +Admin → **Fee accrual** shows per-pool 24h volume and fees. The entire swap fee +(`feeBps` on each pool) accrues to LPs through the constant-product (`x·y=k`) +invariant — the fee is retained in the reserve, so `k` is non-decreasing across +a swap. There is no operator fee split in this reference implementation; see +[`../concepts/pricing.md`](../concepts/pricing.md#how-the-pool-prices-a-swap) +for how the pool prices and where the fee lands. --- @@ -205,11 +251,9 @@ to resume. ### Logs -Operator backend emits structured JSON, one event per line. Required -fields: `ts`, `level`, `msg`. Errors go to stderr; everything else to -stdout. Scrape both. +The backend emits structured JSON, one event per line. Required fields: `ts`, +`level`, `msg`. Errors go to stderr; everything else to stdout. Scrape both. -Example: ``` {"ts":"2026-05-17T14:18:23Z","level":"info","msg":"request completed", "component":"http","requestId":"...","method":"POST","path":"/v1/swaps/quote", @@ -218,50 +262,48 @@ Example: ### Status endpoint -`GET /v1/status` returns network label, current ledger slot, and sync -state. Wire this to your uptime monitor with a 5-second poll. +`GET /v1/status` returns network label, current ledger slot, and sync state. +Wire it to your uptime monitor with a 5-second poll. ### Indexer health -The SQLite indexer is a single file at `$DB_PATH`. Back it up on a -schedule (it carries idempotency keys + trade history). Check its -mtime if you suspect the indexer has stalled. +The SQLite indexer is a single file at `$DB_PATH` (default +`./data/operator.db`). It reconciles from the current ACS on every tick, so a +missed tick doesn't corrupt state, but it carries the only copy of trade +history older than the ACS-archive cutoff plus the idempotency keys. Back it up +on a schedule and check its mtime if you suspect the indexer has stalled. Tune +the cadence with `INDEXER_INTERVAL_MS` (default 5s). ---- - -## Incident response - -### Operator backend crashes mid-submission - -The `IdempotentLedger` records `commandId` before submitting and the -result after. On restart, in-flight commands are de-duplicated — a -re-submitted `commandId` returns the cached result instead of -double-spending. Crashes during a multi-step operator flow are safe to -retry. - -### Pool DvP recovery: slice CIDs rolled forward without an observed event - -The pool's slice CIDs may have rolled forward on-ledger without the -operator backend observing the event. Check the participant's ACS for -the pool's latest contract id, update `Pool#xxx` references, and -resume. - -### Stale idempotency keys +The runbook's [observability](operator-runbook.md#observability) section maps +each audit question ("why did this RFQ accept go to this dealer?", "where did +this pool's reserves come from?") to the on-ledger fact that answers it. -`IdempotentLedger.sweep()` runs hourly to drop keys older than 24h. -Manual sweep: run the operator backend's maintenance command from an -authenticated operational environment. - -### Recovering a forgotten admin token +--- -The token is configured via the `OPERATOR_ADMIN_TOKEN` env var. If -lost, set a new one and restart the operator backend. Existing admin -writes that haven't settled won't be replayable (different token -hash). Re-submit them via the new token. +## When something breaks + +These are the quick operator actions. For the full playbook — participant +outages, upgrade-lineage breaks, LP-supply drift, the failure-mode table — go +to the runbook's [recovery procedures](operator-runbook.md#recovery-procedures). + +- **Backend crashed mid-submission.** Safe to retry. `IdempotentLedger` + records `commandId` before submitting and the result after, so a re-submitted + command returns the cached result instead of double-spending. On restart the + indexer reconciles from the live ACS — no replay needed. +- **A DvP liquidity or swap receipt came back with only an `updateId`.** The + operator can recover the created allocation cids from the update tree: + `POST /v1/pools/recover-dvp-allocations` with the `updateId`. This is the + operator-discovery path for wallet flows that returned before the backend + observed the allocations. +- **Stale idempotency keys.** `IdempotentLedger.sweep()` drops keys older than + the 24h TTL; run it hourly from an authenticated operational environment. +- **Forgotten admin token.** Set a new `OPERATOR_ADMIN_TOKEN` (or + `DEX_OPERATOR_API_TOKEN`) and restart. In-flight admin writes that hadn't + settled aren't replayable under the new token — re-submit them. --- -## Roles and responsibilities +## Roles at the UI | Role | What they do | UI surface | |---|---|---| @@ -272,20 +314,11 @@ hash). Re-submit them via the new token. | **Asset admin** | Govern instruments, accept mint/burn | Out-of-band | | **LP registrar** | Accept LP mint/burn | Out-of-band | -The reference dApp's frontend serves **Trader**, **LP**, and **Operator** -roles directly. **Dealer** and registrar workflows are scripted / -operator-tooled. - ---- - -## See also - -- [`../concepts/architecture.md`](../concepts/architecture.md) — design rationale -- [`registry-integration.md`](registry-integration.md) -- [`operator-runbook.md`](operator-runbook.md) — incident playbook -- [`../reference/http-api.md`](../reference/http-api.md) — every HTTP endpoint -- [`validator-test-plan.md`](validator-test-plan.md) +The reference dApp serves **Trader**, **LP**, and **Operator** roles directly. +**Dealer** and registrar workflows are scripted / operator-tooled. For the +on-ledger ownership behind these roles — which party signs which contract — see +the runbook's [roles and party model](operator-runbook.md#roles-and-party-model). --- -**Where to read next:** [Operator Runbook](operator-runbook.md) · [Deployment](deployment.md) · [Run on a Testnet](run-on-testnet.md) · [All docs](../README.md) +**Where to read next:** [Operator Runbook](operator-runbook.md) · [Deployment](deployment.md) · [Run on a Testnet](run-on-testnet.md) · [HTTP API](../reference/http-api.md) · [All docs](../README.md) diff --git a/docs/guides/operator-runbook.md b/docs/guides/operator-runbook.md index 3096425d..0964a9f9 100644 --- a/docs/guides/operator-runbook.md +++ b/docs/guides/operator-runbook.md @@ -1,10 +1,15 @@ # Operator Runbook -Deployment, recovery, and observability guidance for the operator roles -defined by the reference DEX. It describes the contract surface and the -off-chain responsibilities that follow from it. Specific cluster topology -(cantond / participants / synchronizer config) belongs in your Canton -operational documentation, not here. +How to deploy, observe, and recover the off-ledger operator services that run +the reference DEX. The through-line for every recovery decision below: **the +ledger is the source of truth.** Every fact an operator needs to explain a +trade or rebuild a service lives on-ledger, replicated by the synchronizer; +the operator backend's own SQLite is a projection over it. That single +property is why most recovery here is "rebuild a cache", not "restore a +database". + +Specific cluster topology (cantond / participants / synchronizer config) is a +Canton operational concern, not a DEX one — see [Out of scope](#out-of-scope-for-this-document). ## Roles and party model @@ -20,7 +25,9 @@ operator dev instance but should not be the production posture. | `trader` / `lp` | `OrderFundingRequest`, `Rfq`, and the deposit/receipt/burn allocations they author against a `LiquidityAllocationRequest` | Their own intents and allocation accepts | The traffic-cost split (called out in module headers) follows the role -ownership: each role pays for the transactions it submits. +ownership: each role pays for the transactions it submits. The party wiring is +read from env at boot (`CANTON_OPERATOR`, `CANTON_LP_REGISTRAR`, `CANTON_ADMIN`); +see [`.env.example`](../../services/operator-backend/.env.example). ## Deployment checklist @@ -54,29 +61,33 @@ In rough order of dependency: are active, traders may submit `OrderFundingRequest`, liquidity adds/removes via the DvP `/request` flow, `Rfq`, etc. -The dev / testnet path in `trading-tests/CantonDex/Tests/EndToEndTests.daml` -walks every step above against the mock registry. Treat it as the canonical -bring-up script. +The dev / testnet path in +[`trading-tests/CantonDex/Tests/EndToEndTests.daml`](../../trading-tests/CantonDex/Tests/EndToEndTests.daml) +walks every step above against the mock registry — treat it as the canonical +bring-up script (it proves the full deploy sequence settles end to end). -## Recovery and operator-driven cleanup +## Operator-driven cleanup (on-ledger) Iterated allocations put settlement authority in the executor's hands, so the -DEX application layer must constrain every permitted use. The recovery -choices below are app-owned cleanup hooks; an operator service drives them -on a schedule. +DEX application layer must constrain every permitted use. The choices below are +app-owned cleanup hooks on the ledger; an operator service drives them on a +schedule. None of them fabricate state — each is a real contract choice, so +the cleanup surface is auditable in one place. ### Stale or expired orders -- `Order_Cancel` (operator-driven): cancels the bound allocation via - `Allocation_Cancel`, releasing the trader's locked holdings back to their - authorizer account. The operator's sweep uses it both for orders past - `expiry` (checked off-ledger when scheduling the cancel) and for +- `Order_Cancel` (operator-driven, + [`Order.daml`](../../trading/CantonDex/Dex/Order.daml)): cancels the bound + allocation via `Allocation_Cancel`, releasing the trader's locked holdings + back to their authorizer account. The operator's sweep uses it both for + orders past `expiry` (checked off-ledger when scheduling the cancel) and for operator-initiated takedowns (compliance, fat-finger cancels, pair de-listing). ### Stale RFQs and quotes -- An RFQ past `expiresAt` is inert: `Rfq_Accept` asserts +- An RFQ past `expiresAt` is inert: + [`Rfq_Accept`](../../trading/CantonDex/Dex/Rfq.daml) asserts `currentTime < expiresAt`, so nothing can settle against it. Quote contracts stay until their own `expiresAt`; the operator sweep exercises `RfqQuote_Withdraw` (dealer-driven) or lets quotes age out. @@ -85,27 +96,32 @@ on a schedule. ### Stuck matched trades -- `MatchedTrade_Cancel` (venue-driven): archives outstanding - `TradeAllocationRequest` contracts and exercises `Allocation_Cancel` on - any allocations that have already been created. Use when one leg's - authorizer rejects or times out before settlement. +- `MatchedTrade_Cancel` (venue-driven, + [`MatchedTrade.daml`](../../trading/CantonDex/Dex/MatchedTrade.daml)): + archives outstanding `TradeAllocationRequest` contracts and exercises + `Allocation_Cancel` on any allocations that have already been created. Use + when one leg's authorizer rejects or times out before settlement. ### Pool maintenance -- `PoolRules_Pause` (operator): halts new swaps and liquidity actions while - leaving reserve allocations in place. Useful for upgrades and incident - response. +- `PoolRules_Pause` (operator, + [`PoolRules.daml`](../../trading/CantonDex/Dex/PoolRules.daml)): halts new + swaps and liquidity actions while leaving reserve allocations in place. + Useful for upgrades and incident response. - `PoolRules_Resume` (operator): exits Paused back to Active. - Remove-liquidity is slice-local: the `PoolLiquidityRules_SettleRemoveLiquidity` settle sources a routine withdrawal from at most one boundary re-allocation per side. The architecture and workflows docs describe the - invariant; the liquidity rules tests exercise the boundary case. + invariant; + [`PoolLiquidityRulesTests.daml`](../../trading-tests/CantonDex/Tests/PoolLiquidityRulesTests.daml) + exercises the multi-slice boundary case (`testDvpMultiSliceRemove`). ### LP supply reconciliation -- `PoolState_RecordLPSupply` (lpRegistrar): pushes the registrar-owned LP - supply ledger back into the pool's pricing state. Run after each - mint/burn accept so add-liquidity quoting stays accurate. +- `PoolState_RecordLPSupply` (lpRegistrar, + [`PoolState.daml`](../../trading/CantonDex/Dex/PoolState.daml)): pushes the + registrar-owned LP supply ledger back into the pool's pricing state. Run + after each mint/burn accept so add-liquidity quoting stays accurate. ## Observability @@ -122,7 +138,7 @@ operators do not need a parallel database to explain a trade. | Why is this `PoolRules_Swap` failing slippage? | Call the quote endpoint before swap; the on-ledger choice re-validates against current reserves and `minOutputAmount` | | Did this LP mint actually run? | `PoolLiquidityRules_SettleAddLiquidity` mints against the LP receipt allocation and records the resulting supply on `LPTokenPolicy` | -Off-chain telemetry the operator should also collect: +Off-ledger telemetry the operator should also collect: - **Latency** per workflow (`OrderFundingRequest_Bind` → `Order_Fund`, `Rfq_Accept` → `MatchedTrade_Settle`, `PoolRules_Swap` end-to-end). @@ -134,105 +150,203 @@ Off-chain telemetry the operator should also collect: `LiquidityAllocationRequest`, and `MintRequest` records have been open without a downstream accept. -### Indexer-backed endpoints (v0.1.0+) +Every HTTP request carries an `X-Request-Id` (echoed back and stamped on each +log line) and emits a structured, one-JSON-object-per-line record via +[`lib/logger.ts`](../../services/operator-backend/src/lib/logger.ts) — set +`LOG_LEVEL` to tune verbosity. Errors and warnings go to stderr, everything +else to stdout. + +### Indexer-backed endpoints -The operator backend ships with a polling indexer that projects ledger -state into a local SQLite database (`data/operator.db` by default). -Surfaces: +The operator backend ships with a polling indexer +([`indexer/index.ts`](../../services/operator-backend/src/indexer/index.ts)) +that projects ledger state into a local SQLite database (`data/operator.db` by +default). These endpoints exist only when the server was started with a `db` +handle; without one they return `503 indexer disabled`. | Endpoint | Returns | |---|---| -| `GET /v1/trades?trader=&pair=&limit=` | Matched-trade history including archived contracts | -| `GET /v1/swaps?pair=&limit=` | Per-swap base/quote deltas + price after | +| `GET /v1/trades?trader=&pair=&limit=` | Matched-trade history including archived contracts (unscoped view is admin-only) | +| `GET /v1/swaps?pair=&kind=&limit=` | Per-rotation base/quote deltas + price after; `kind` ∈ `swap` (default) / `add_liquidity` / `remove_liquidity` / `state_change` | | `GET /v1/rfq/history?trader=&limit=` | RFQ lifecycle events (open / accepted / closed) | +| `GET /v1/price-history?pair=&hours=` · `GET /v1/stats/24h?pair=` | Price points and derived 24h volume / change from the `swaps` table | | `GET /v1/admin/config` | Operator KV (read open by default) | | `PUT /v1/admin/config` (Bearer auth) | Set a KV key | -The indexer is single-flight and tolerant of restarts: it reconciles -from the current ACS on every tick, so a missed tick doesn't corrupt -state. Set `INDEXER_INTERVAL_MS` to tune polling cadence (default 5s). +A `swaps` row is not necessarily a swap: five different choices rotate a +`PoolState`. The indexer polls the ACS and never sees a choice name, so it uses +`totalLpSupply` as the discriminator (only an add or a remove moves it), in +exact scaled-integer arithmetic so an LP mint that float subtraction would +collapse to zero is never misclassified as a swap. Proven in +[`indexer-pool-kind.test.ts`](../../services/operator-backend/test/indexer-pool-kind.test.ts) +("sees an LP mint that float subtraction would lose entirely") and +[`indexer-projection-exactness.test.ts`](../../services/operator-backend/test/indexer-projection-exactness.test.ts) +(the served magnitudes are the stored strings, digit for digit). + +The indexer is single-flight and tolerant of restarts. Its own header states +the guarantee: + +```ts +// Crash safety: state is reconciled from current ACS on every tick, +// so a crash just means a missed poll, not a corrupt DB. +``` + +Set `INDEXER_INTERVAL_MS` to tune polling cadence (default 5s). Read scoping is +enforced at the route: an unfiltered `/v1/trades` or `/v1/rfq/history` sweep +names both counterparties, so it requires the admin token — proven in +[`read-exposure.test.ts`](../../services/operator-backend/test/read-exposure.test.ts) +(refuses an unscoped read without the admin token). ### Idempotency cache -Every command submission is keyed by `commandId` and stored in -`command_submissions(commandId PK, submittedAt, status, resultJson)`. -A retry with the same `commandId`: -- returns the cached result if status='ok' -- rejects if status='pending' and submittedAt < 60s ago -- overwrites if stale-pending or 'error' - -The cache survives operator restarts and is the recommended defence -against double-fire across crash/replay boundaries. Sweep the table -once an hour to discard rows older than the 24h TTL. +Every command submission is wrapped by `IdempotentLedger` +([`indexer/idempotency.ts`](../../services/operator-backend/src/indexer/idempotency.ts)), +keyed by `commandId` and stored in: + +```sql +CREATE TABLE IF NOT EXISTS command_submissions ( + commandId TEXT PRIMARY KEY, + submittedAt INTEGER NOT NULL, + completedAt INTEGER, + status TEXT NOT NULL, -- 'pending' | 'ok' | 'error' + resultJson TEXT +); +``` + +A retry with the same `commandId` returns the cached result if `status='ok'`, +rejects if it is still `pending` and younger than `PENDING_STALE_MS` (60s), and +overwrites if the row is stale-pending or `error`. A same-`commandId` submit +carrying *different* args is a replay conflict and is rejected rather than +served a stale result — proven in +[`idempotency.test.ts`](../../services/operator-backend/test/idempotency.test.ts) +("rejects a replay: same commandId, different args"). The cache survives +operator restarts and is the recommended defence against double-fire across +crash/replay boundaries. `testnet-server` sweeps rows past the 24h TTL once an +hour. ## Recovery procedures -The most likely operational pains for a single-operator deployment: - -### Operator backend crash / restart - -1. SQLite WAL is durable; the indexer state survives. -2. On reboot, `Indexer.start()` reconciles from current ACS (no replay - needed). Anything new shows up on the next tick. -3. The idempotency cache prevents the dApp's retry-on-restart from - double-submitting commands that completed pre-crash. -4. If `data/operator.db` is corrupted, delete it: the next tick - rebuilds from the live ledger. Cost: trade history older than the - ACS-archive cutoff is gone (since it lives only in the indexer DB). - -### Participant / synchronizer outage - -1. The JSON LAPI returns 5xx; the indexer logs the error and tries - again next tick. -2. Operator-driven writes (pair create, pool init, settle) fail with - `transport` errors; the idempotency cache marks them 'error'. -3. When the participant recovers, retry from the dApp. - -### Smart-upgrade lineage break (lost upgrade compatibility) - -Symptom: `NOT_VALID_UPGRADE_PACKAGE` on DAR upload. - -Either: -- Revert the offending change (add removed choices back as deprecated - stubs, make new fields Optional, move new fields to the end of the - record). -- Rename the package (e.g. `canton-dex-trading` to `canton-dex`). All - existing contracts from the old name remain queryable but cannot be - upgraded. - -See the "Upgrade discipline" section of `docs/guides/builder-guide.md` for smart-upgrade lineage guidance. - -### LP supply drift - -`LPTokenPolicy.totalSupply` and `PoolState.totalLpSupply` are kept in -lock-step: the DvP liquidity settles -(`PoolLiquidityRules_SettleAddLiquidity`/`_SettleRemoveLiquidity`) rewrite both -inline and assert they match on entry. If they diverge, the settle's -supply-sync guard aborts. Recovery: query the policy supply and re-run +Recovery starts from one distinction: what is authoritative versus what is a +rebuildable projection. The on-ledger ACS is authoritative and replicated by +the synchronizer. The operator backend's `operator.db` holds only projections +of it — with one exception, `operator_kv`, which carries runtime knobs (dealer +whitelist, RFQ policy) that were never written on-ledger and therefore cannot +be rebuilt from it. + +```mermaid +flowchart LR + ACS[("On-ledger ACS
source of truth,
synchronizer-replicated")] + subgraph db["operator.db · local SQLite (WAL)"] + PROJ["projections:
trades · swaps
rfq_history · pool_states"] + IDEM["command_submissions
(idempotency cache)"] + KV["operator_kv
dealer whitelist · RFQ policy"] + end + ACS -->|"indexer reconciles
every tick"| PROJ + PROJ -.->|"rebuildable on delete"| ACS + IDEM -.->|"rebuildable"| ACS + KV ==>|"off-ledger only —
the one thing to back up"| BK[["operator backup"]] +``` + +Ledger errors are classified once, in the JSON-API driver's `errorFor` +([`json-api.ts`](../../services/operator-backend/src/ledger/json-api.ts)), into +the `LedgerErrorKind` the rest of the backend reacts to. Only `contention` is +retryable: + +```ts +if (lower.includes("contention") || lower.includes("inconsistent")) { + kind = "contention"; + retryable = true; +} else if (lower.includes("authoriz") || res.status === 401 || res.status === 403) { + kind = "authorization"; +} else if (res.status === 400) { + kind = "validation"; +} +``` + +Every operator write runs inside `retryOnContention` +([`submit-with-retry.ts`](../../services/operator-backend/src/ledger/submit-with-retry.ts)), +which retries only that class, with exponential backoff, up to five attempts: + +```ts +if (e instanceof LedgerError && e.kind === "contention") { + await sleep(delay); + delay = Math.min(maxDelay, Math.floor(delay * 2)); + continue; +} +throw e; +``` + +### Failure modes and recovery + +Operational (infrastructure- and process-level) failures. For contract-choice +rejections a trader or LP hits, see [Contract-level rejections](#contract-level-rejections). + +| Symptom | Likely cause | Action | +| --- | --- | --- | +| Operator backend crashed / was restarted | Process died; WAL keeps `operator.db` intact | None required. `Indexer.start()` reconciles from the current ACS on the first tick; the idempotency cache absorbs the dApp's retry-on-restart. | +| Indexer endpoints stall; logs show `[indexer] tick failed` | Participant / JSON LAPI unreachable | Transient: the tick retries next interval — no corruption. Persistent: check the participant and `CANTON_LEDGER_URL` / token. | +| Operator writes fail with a `transport` `LedgerError` | Participant or synchronizer outage | The idempotency row is marked `error`; retry from the dApp once the participant recovers. | +| A write fails with a `contention` error after retrying | Two commands raced the same input UTXO and the five backoff attempts were exhausted | Resubmit; the on-ledger choice is safe to re-run once the contending commit lands. | +| `operator.db` corrupted / unreadable | Disk fault or partial write | Delete it and restart; the next tick rebuilds projections from the ACS. See [Reference](#reference-multi-step-procedures) — this also drops `operator_kv`. | +| `NOT_VALID_UPGRADE_PACKAGE` on DAR upload | Smart-upgrade lineage broken | Revert the incompatible change or rename the package. See [Reference](#reference-multi-step-procedures). | +| A liquidity settle aborts on its supply-sync guard | `LPTokenPolicy.totalSupply` drifted from `PoolState.totalLpSupply` | Re-run `PoolState_RecordLPSupply` with `newSupply = policy.totalSupply`. See [Reference](#reference-multi-step-procedures). | +| Trader reports a missing holding | V2 holding not visible to the party's query | Replay the registry's `Registry_RegisterInstrument` / `Registry_Mint` events for that party via the `EventLog` interface. | + +### Reference: multi-step procedures + +The table cells above are one-liners for the failures that resolve in a step or +two. These three need more. + +**`operator.db` corruption — rebuild from the ledger.** SQLite runs in WAL mode +([`db.ts`](../../services/operator-backend/src/indexer/db.ts)), so an ordinary +crash leaves the file intact and nothing is needed. If the file is genuinely +corrupt, delete it and restart: the indexer reconciles projections from the +live ACS on the next tick. Two things do *not* come back — trade history older +than the ACS-archive cutoff (it lived only in the indexer DB) and, because it +lives in the same file, `operator_kv`. Restore `operator_kv` from backup after +the rebuild (see [Backup](#backup)). Schema migrations are append-only and +tolerant of a hand-repaired database, proven in +[`indexer-migrations.test.ts`](../../services/operator-backend/test/indexer-migrations.test.ts) +("a hand-repaired database can still advance"). + +**Smart-upgrade lineage break.** Symptom: `NOT_VALID_UPGRADE_PACKAGE` on DAR +upload. Either: + +- Revert the offending change — add removed choices back as deprecated stubs, + make new fields `Optional`, move new fields to the end of the record. +- Rename the package (e.g. `canton-dex-trading` → `canton-dex`). All existing + contracts from the old name remain queryable but cannot be upgraded. + +See [Upgrade discipline](builder-guide.md#upgrade-discipline) for the lineage +rules and the CI gate that catches a break before upload. + +**LP supply drift.** `LPTokenPolicy.totalSupply` and `PoolState.totalLpSupply` +are kept in lock-step: the DvP liquidity settles +(`PoolLiquidityRules_SettleAddLiquidity` / `_SettleRemoveLiquidity`) rewrite +both inline and assert they match on entry, so a divergence aborts the settle +rather than corrupting reserves. Recovery: query the policy supply and re-run `PoolState_RecordLPSupply` with `newSupply = policy.totalSupply`. -### Lost trader holdings - -V2 holdings are admin+owner signed. If a trader claims a missing -holding, check the registry's `Registry_RegisterInstrument` / -`Registry_Mint` events for that party. The `splice-api-token-transfer-events-v2` -package exposes an `EventLog` interface for replayable audit. - ## Backup The on-ledger state is the source of truth and is replicated by the -synchronizer. The operator backend's local SQLite is rebuildable from -the ledger and does not need to be backed up for correctness; back -it up only if you care about historical query performance during -rebuild. Operator config in the `operator_kv` table is worth backing -up. It carries dealer whitelist, RFQ policy parameters, and similar -runtime knobs that are not encoded on-ledger. +synchronizer. `operator.db` is rebuildable from the ledger and does not need to +be backed up for correctness; back it up only if you care about historical +query performance during a rebuild. The one thing worth backing up is the +`operator_kv` table — it carries the dealer whitelist, RFQ policy parameters, +and similar runtime knobs that are not encoded on-ledger and cannot be +reconstructed from the ACS. + +## Contract-level rejections -## Failure modes and remediation +Rejections a trader, LP, or dealer hits at a contract choice — business-logic +guards, not infrastructure faults. Each surfaces to the caller as the assert +message shown; the operator's job is to route the fix, not to override the +guard. | Symptom | Likely cause | Remediation | | --------------------------------------------------------- | -------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------- | -| `FinalizedAllocation extra leg-sides exceed funding budget` | Operator tried to settle more than the authorizer pre-committed | Re-quote: the swap or match math drifted from the budget. Fix off-chain quoting state | +| `FinalizedAllocation extra leg-sides exceed funding budget` | Operator tried to settle more than the authorizer pre-committed | Re-quote: the swap or match math drifted from the budget. Fix off-ledger quoting state | | `Pool has no base slices` / `... no quote slices` | Pool drained to empty by Remove without entering Unfunded state | Inspect the slice list and reserves; if mismatched, escalate (the contract should prevent this) | | `LP tokens below minimum` | LP's `minLpTokens` slippage bound too tight | LP resubmits with a looser bound or smaller deposit | | `Output below slippage minimum` | Reserve drift between quote time and submit | Trader resubmits with a looser bound, or operator routes through a different pool | @@ -242,10 +356,15 @@ runtime knobs that are not encoded on-ledger. ## Single-operator dev shortcut For local exploration, collapse `operator` / `lpRegistrar` / `admin` into one -party. Tests under `trading-tests/` show the multi-party shape, but the same -contracts compile and run with one party signing everything. Production -should keep the parties distinct so audit-trail and key-management -responsibilities stay decoupled. +party, and run the dev server with `DEX_DEV_OPEN=1` so the operator-token gate +is bypassed (in-memory dev only). Tests under `trading-tests/` show the +multi-party shape, but the same contracts compile and run with one party +signing everything. Production should keep the parties distinct so audit-trail +and key-management responsibilities stay decoupled, and must set +`DEX_OPERATOR_API_TOKEN` / `OPERATOR_ADMIN_TOKEN` — both gates fail closed +otherwise, proven in +[`auth.test.ts`](../../services/operator-backend/test/auth.test.ts) +("fails closed when no token and no devOpen"). ## Out of scope for this document diff --git a/docs/guides/registry-integration.md b/docs/guides/registry-integration.md index 92fa5768..746d37b3 100644 --- a/docs/guides/registry-integration.md +++ b/docs/guides/registry-integration.md @@ -6,9 +6,73 @@ instrument-configuration or lifecycle template. This document therefore separates hard V2 interface requirements from the reference registry's optional configuration model in `trading/CantonDex/Instrument/`. +## The registry boundary + +The DEX touches a registry through exactly four surfaces. Everything else about +your asset — issuance policy, precision, lifecycle, credential rules — stays +behind that line, and the DEX never reaches across it. + +```mermaid +flowchart LR + subgraph DEX["DEX (this repo)"] + W["Trader wallet"] + OB["Operator backend"] + end + subgraph REG["Asset registry (yours)"] + AF["AllocationFactory"] + SF["SettlementFactory"] + H[("Holding")] + CC(["Choice-context endpoint
(off-ledger)"]) + end + W -->|"AllocationFactory_Allocate
locks holdings into an Allocation"| AF + OB -->|"SettlementFactory_SettleBatch
atomic net settlement"| SF + W -.->|"observe / select"| H + AF --> H + SF --> H + OB -.->|"fetch disclosures"| CC + CC -.->|"extraArgs"| SF +``` + +Solid arrows are on-ledger interface choices; dashed arrows are off-ledger +reads. The two choices are the whole on-ledger contract the DEX depends on: + +```daml +-- AllocationInstructionV2.daml -- the trader locks funds under their own authority +nonconsuming choice AllocationFactory_Allocate : AllocationInstructionResult + with + settlement : SettlementInfo + allocation : AllocationSpecification + requestedAt : Time + inputHoldingCids : [ContractId Holding] + extraArgs : ExtraArgs + actors : [Party] + ... + +-- AllocationV2.daml -- the operator settles a batch of allocations atomically +nonconsuming choice SettlementFactory_SettleBatch : SettlementFactory_SettleBatchResult + with + settlement : SettlementInfo + transferLegs : [TransferLeg] + allocations : [FinalizedAllocation] + actors : [Party] + extraArgs : ExtraArgs + ... +``` + +The trader exercises `AllocationFactory_Allocate` under their own authority to +lock holdings into a `V2.Allocation`; the operator exercises +`SettlementFactory_SettleBatch` to move the net amounts atomically. `extraArgs` +on both choices is where the registry's [choice context](choice-context.md) — +disclosed config and credential contracts — rides along. The DEX only ever +*reads* a holding through the `V2.Holding` interface (`account`, `instrumentId`, +`amount`, `lock`); the registry alone mints, locks, splits, and merges it. For +the exact allocation-surface fields the DEX sets and reads on these choices, see +[Allocation Surface](../reference/allocation-surface.md). + ## What the registry guarantees -For every instrument the DEX trades, the registry must provide: +Those surfaces rest on a small set of guarantees. For every instrument the DEX +trades, the registry must provide: 1. **A stable `InstrumentId` and admin.** The DEX keys orders, pools, RFQs, and matched trades by `InstrumentId`. In the reference registry this information @@ -64,6 +128,17 @@ surface is the choice-context endpoint the backend's registry-client consumes implement the standard OpenAPI so V2-compliant wallets and apps can discover factories and context without bespoke integration. +The DEX's own flows are exercised against a standard-shaped registry, not only +its reference one. `testMatchedTradeViaTokenStandardRegistry` in +[`TokenStandardHarnessTests.daml`](../../trading-tests/CantonDex/Tests/TokenStandardHarnessTests.daml) +drives the matched-trade flow through the upstream `RegistryApiV2` +factory-discovery path — proving the DEX composes over a standard registry, not +a bespoke one. `testRealRegistryDvpAddSettles` and `testRealRegistryDvpSwapSettles` +in [`RealRegistryDvpTests.daml`](../../trading-tests/CantonDex/Tests/RealRegistryDvpTests.daml) +settle add-liquidity and swap DvPs against a genuinely context-requiring +registry, and `testRealRegistryDvpRejectsMissingContext` proves the settle +aborts when that registry's disclosed context is dropped. + ## Mint / Burn / Transfer prerequisites For trader-facing flows (mint, burn, hold, transfer), the reference registry @@ -116,11 +191,28 @@ spend funds the authorizer never granted has to submit an invalid Daml transaction, which the engine rejects regardless of operator intent. -The mock at [MockRegistry.daml](../../trading/CantonDex/Testing/MockRegistry.daml) -implements these conservation rules and is covered by the iterated -settlement tests in -[EndToEndTests.daml](../../trading-tests/CantonDex/Tests/EndToEndTests.daml). -Production registries are expected to do at least the same. +The reference registry +[`Registry.V2`](../../trading/CantonDex/Registry/V2.daml) enforces all five in +Daml, inside `allocation_settleImpl` and `settlementFactory_settleBatchImpl`. +[`RegistryConservationTests.daml`](../../trading-tests/CantonDex/Tests/RegistryConservationTests.daml) +proves them against that production code: + +- `testExtraLegBeyondBackingRejected` — executor-supplied extra leg-sides + cannot draw more than the allocation's locked backing. +- `testNextFundingBeyondBackingRejected` — `sent + nextIterationFunding` is + bounded by that backing. +- `testRollForwardCarriesLockedBacking` / `testSecondIterationCannotExceedFunding` + — each roll-forward is backed by freshly locked holdings worth its funding, so + a follow-on iteration can spend only what was reserved (the double-spend guard). +- `testBatchRejectsMissingAuthorization` / `testBatchRejectsSuperfluousAuthorization` + / `testBatchRejectsUnbalancedReceiverLeg` — the batch settles every leg-side + with exactly one allocation and balances per instrument. + +The testing-only +[`MockRegistry.daml`](../../trading/CantonDex/Testing/MockRegistry.daml) +deliberately skips these checks: it tracks no holdings and exists to exercise +flows that *compose* the V2 calls, not the authorization model. Production +registries are expected to enforce at least what `Registry.V2` does. ## Choice-context retrieval the DEX needs @@ -160,7 +252,7 @@ What this means in practice for a DEX integrator: - **Issuers should do this sparingly.** Force-upgrades cost the issuer traffic and may disrupt active trades that touch a holding mid-upgrade. - Issuers will batch them and choose moments when on-chain activity is + Issuers will batch them and choose moments when on-ledger activity is low. ### DEX exposure model @@ -229,7 +321,14 @@ Pairing instruments from two different registries needs a second admin field on those four templates and one specification per `(authorizer, admin)`. That is a schema change, not a configuration option. -## What the DEX does NOT assume +The per-admin batching this describes is load-bearing and tested: +`testMatchedTradeSettlesPerAdminLegSubsets` in +[`RealRegistryDvpTests.daml`](../../trading-tests/CantonDex/Tests/RealRegistryDvpTests.daml) +settles a trade's legs across two real registries in one transaction when they +are partitioned by admin, and proves a batch handed the full leg list — or no +legs — is rejected rather than settled. + +## What the DEX does not assume - It does not assume any particular registry implementation. Any registry implementing the V2 holding and allocation APIs works; nothing depends on diff --git a/docs/guides/run-on-testnet.md b/docs/guides/run-on-testnet.md index 4358cc27..cb7e11cd 100644 --- a/docs/guides/run-on-testnet.md +++ b/docs/guides/run-on-testnet.md @@ -1,25 +1,36 @@ # Run against a Canton testnet -This guide shows how to point the operator backend and web app at a Canton -participant that already has the required DEX and token-standard packages -uploaded and vetted. - -Use your own participant URL, synchronizer id, party ids, package id, and JWT. -Do not commit tokens, concrete party ids, or validator-specific package hashes. +The DEX runs as two long-lived processes against a Canton participant: the +**operator backend** (operator-authority commands, ledger reads, the indexer) +and the **web app** (reads plus wallet-authority commands). This guide points +both at a participant that already has the DEX and Token Standard V2 packages +uploaded and vetted, and its parties allocated. The one-time build, upload, +party allocation, registry bootstrap, and pair/pool seeding are automated by +[`scripts/deploy-testnet.sh`](../../scripts/deploy-testnet.sh) — run that first, +or perform its steps by hand, then use this guide to bring up and verify the two +processes. + +One invariant throughout: tokens, concrete party ids, and validator-specific +package hashes live in the environment, never in the repo. ## Prerequisites -- A Canton participant JSON Ledger API URL. -- A JWT that can `actAs` the operator party and any bootstrap parties used by - the commands you submit. -- Uploaded and vetted DARs for: - - `canton-dex-trading` - - the Token Standard V2 packages under `vendor/splice/token-standard` +- A Canton participant JSON Ledger API URL and a JWT that can `actAs` the + operator party and any bootstrap parties used by the commands you submit. +- Uploaded and vetted DARs for `canton-dex-trading` (built from `trading/`) and + the Token Standard V2 packages under `vendor/splice/token-standard`. - Operator, LP registrar, and asset-admin parties allocated on the participant. -- Registry factory contracts for the asset admins the DEX will touch. +- The `lpRegistrar`'s `Registry.V2` and the asset admins' registry factory + contracts created — the registry bootstrap in + [`scripts/bootstrap-registry.ts`](../../scripts/bootstrap-registry.ts) does + this; without the LP registry no pool can allocate a liquidity move. ## Start the operator backend +The backend runs `src/testnet-server.ts`. It requires five variables and reads +the rest with defaults. Pass the token through the environment; the process +reads it and does not write it to disk. + ```bash cd services/operator-backend @@ -31,15 +42,32 @@ CANTON_LP_REGISTRAR="" \ CANTON_ADMIN="" \ CANTON_NETWORK="canton:testnet" \ CANTON_SYNCHRONIZER="" \ -CANTON_DEX_PACKAGE_ID="" \ +CANTON_DEX_PACKAGE_ID="#canton-dex-trading" \ PORT=8080 \ npm run testnet ``` -The backend reads the token from the environment and does not write it to disk. +| Variable | Required | Purpose | +|---|---|---| +| `CANTON_LEDGER_URL` | yes | JSON Ledger API base URL of the participant. | +| `CANTON_LEDGER_TOKEN` | yes | Bearer JWT that can `actAs` the operator party. | +| `CANTON_OPERATOR` | yes | Operator (venue) party id. | +| `CANTON_LP_REGISTRAR` | yes | LP registrar party id. | +| `CANTON_ADMIN` | yes | Asset-admin party id. | +| `CANTON_SYNCHRONIZER` | recommended | Synchronizer id, e.g. `global-domain::1220...`. `submit-and-wait` requires it on a shared synchronizer. | +| `CANTON_DEX_PACKAGE_ID` | recommended | Template-id prefix. A concrete package hash, or `#canton-dex-trading` to resolve by package name. | +| `CANTON_NETWORK` | optional | Display label surfaced by `/v1/status` (default `canton:devnet`). | +| `CANTON_ALLOC_FACTORY_CID`, `CANTON_SETTLE_FACTORY_CID` | optional | Registry factory CIDs from the bootstrap; set them before the allocation/settlement flows (add/remove liquidity, swaps, order funding) can run. See [Deployment](deployment.md#environment-variables). | + +The exact variable contract is the header of +[`testnet-server.ts`](../../services/operator-backend/src/testnet-server.ts); +the full list with defaults is +[`services/operator-backend/.env.example`](../../services/operator-backend/.env.example). ## Start the web app +The dApp reads its network and backend base URL at build time. + ```bash cd app/web @@ -52,17 +80,54 @@ npm run preview ``` Open . The header should show the configured network and -the backend status should report `synced: true`. +the backend status should report `synced: true`. The full frontend variable list +is [`app/web/.env.example`](../../app/web/.env.example). + +## Smoke checks + +```bash +curl -s http://localhost:8080/v1/status | python3 -m json.tool +curl -s http://localhost:8080/v1/context | python3 -m json.tool +curl -s http://localhost:8080/v1/pairs | python3 -m json.tool +curl -s http://localhost:8080/v1/pools | python3 -m json.tool +``` + +Expected: + +- `/v1/status` returns the configured network and a live slot. +- `/v1/context` returns operator/admin/LP registrar parties and factory CIDs. +- `/v1/pairs` and `/v1/pools` return the on-ledger contracts visible to the + operator party. + +## Bootstrap a pair and pool + +Use the admin endpoints in [operator-guide.md](operator-guide.md): + +- `POST /v1/admin/pairs` +- `POST /v1/admin/pools` + +New pools start in `PS_Unfunded`. The first LP funds the pool through the same +add-liquidity request/allocate/settle flow used for later deposits. + +## Wallet boundary + +Operator-authority calls go through the backend. Trader-authority calls — such +as authoring allocations for add/remove liquidity, swaps, and order funding — +must go through a wallet or another user-authorized submitter. The backend must +not sign as traders. + +--- + +## Reference -## PartyLayer wallet live probe +### PartyLayer wallet live probe PartyLayer support is integrated into the main web app; no separate probe app is needed. Use this checklist when validating a submit-capable wallet adapter against a live Canton network. -### Enable the connector - -Set the PartyLayer env vars before building or previewing the frontend: +**Enable the connector.** Set the PartyLayer env vars before building or +previewing the frontend: ```bash cd app/web @@ -77,15 +142,15 @@ npm run build npm run preview ``` -If you are validating a specific adapter, set `VITE_PARTYLAYER_WALLET_IDS` -to just that adapter id. Optional registry overrides are documented in -`app/web/.env.example`. +To validate a specific adapter, set `VITE_PARTYLAYER_WALLET_IDS` to just that +adapter id. Optional registry overrides are documented in +[`app/web/.env.example`](../../app/web/.env.example). -### Validate the flow +**Validate the flow.** -1. Open the app, click **Connect Wallet**, and select **PartyLayer**. - Approve the connection in the wallet and confirm the connected party is the - party that owns the test holdings. +1. Open the app, click **Connect Wallet**, and select **PartyLayer**. Approve + the connection in the wallet and confirm the connected party is the party + that owns the test holdings. 2. Confirm holdings load in **Portfolio**. The PartyLayer provider reads holdings through its `ledgerApi` bridge for the connected party. 3. Run a small trader-authority action, such as: @@ -99,9 +164,7 @@ to just that adapter id. Optional registry overrides are documented in 5. Confirm the operator settle step completes and the app refreshes holdings, pool reserves, orders, or activity from the backend/indexer. -### What to record - -For each wallet adapter tested, record: +**What to record.** For each wallet adapter tested: - adapter id and network - connected party @@ -111,44 +174,18 @@ For each wallet adapter tested, record: - final on-ledger result: swap settled, LP add/remove settled, or order funded If discovery fails, capture the operator backend error and the transaction-tree -lookup response. The usual causes are missing operator visibility on the -created contracts, a wallet receipt without `updateId`, or a party mismatch -between the connected wallet and the holdings being spent. +lookup response. The usual causes are missing operator visibility on the created +contracts, a wallet receipt without `updateId`, or a party mismatch between the +connected wallet and the holdings being spent. -## Smoke checks +### Package hash alignment -```bash -curl -s http://localhost:8080/v1/status | python3 -m json.tool -curl -s http://localhost:8080/v1/context | python3 -m json.tool -curl -s http://localhost:8080/v1/pairs | python3 -m json.tool -curl -s http://localhost:8080/v1/pools | python3 -m json.tool -``` - -Expected: +If DAR upload or vetting fails with package-version/hash errors, confirm that all +local DARs were built against the same upstream Token Standard package hashes +already accepted by the target network. Rebuild the dependent packages against +the vetted upstream DARs, then rebuild `trading` and `trading-tests`. -- `/v1/status` returns the configured network and a live slot. -- `/v1/context` returns operator/admin/LP registrar parties and factory CIDs. -- `/v1/pairs` and `/v1/pools` return the on-ledger contracts visible to the - operator party. - -## Bootstrap a pair and pool - -Use the admin endpoints in [operator-guide.md](operator-guide.md): - -- `POST /v1/admin/pairs` -- `POST /v1/admin/pools` - -New pools start in `PS_Unfunded`. The first LP funds the pool through the same -add-liquidity request/allocate/settle flow used for later deposits. - -## Package hash alignment - -If DAR upload or vetting fails with package-version/hash errors, confirm that -all local DARs were built against the same upstream Token Standard package -hashes already accepted by the target network. Rebuild the dependent packages -against the vetted upstream DARs, then rebuild `trading` and `trading-tests`. - -## Running against Amulet assets +### Running against Amulet assets If a pair uses Amulet (CC) as an asset, note the Splice 0.6.11+ requirements: @@ -161,15 +198,8 @@ If a pair uses Amulet (CC) as an asset, note the Splice 0.6.11+ requirements: [Registry Integration](registry-integration.md#allocation-lifetime-caps). - Known upstream limitation: the **Splice Amulet Wallet UI** can only create multiple requested allocations in a single transaction for *Amulet* - allocations. The DEX sidesteps this for its own flows by having one Daml - choice author all allocations of a request in a single command. - -## Wallet boundary - -Operator-authority calls go through the backend. Trader-authority calls, such as -authoring allocations for add/remove liquidity, swaps, and order funding, must -go through a wallet or another user-authorized submitter. The backend should not -sign as traders. + allocations. The DEX sidesteps this for its own flows by having one Daml choice + author all allocations of a request in a single command. --- diff --git a/docs/guides/using-the-dapp.md b/docs/guides/using-the-dapp.md index 0353dc6f..00a2cb72 100644 --- a/docs/guides/using-the-dapp.md +++ b/docs/guides/using-the-dapp.md @@ -1,22 +1,23 @@ # User guide -How traders, LPs, and RFQ counterparties use the Canton DEX. +How traders, LPs, and RFQ counterparties use the Canton DEX. Every action +below is task-oriented: connect once, then swap, provide liquidity, place an +order, or trade an RFQ block. The one rule that shapes the whole surface — the +dApp never signs as you — is explained in +[How a trade is authorised](#how-a-trade-is-authorised). -Audience: someone who already has a Canton party id (or is willing to -use the mock wallet locally) and wants to swap, add liquidity, place an -order, or trade an RFQ block. +Audience: someone who already has a Canton party id (or is willing to use the +mock wallet locally) and wants to trade. --- ## Connecting a wallet -The Connect Wallet button in the top bar opens a provider menu. There is -no built-in default in production or testnet builds: if you have -configured PartyLayer (`VITE_ENABLE_PARTYLAYER=1`), WalletConnect -(`VITE_WC_PROJECT_ID`), or the SDK wallet (`VITE_ENABLE_SDK=1`), the -first configured one (in that order) is preselected; otherwise you pick a -provider explicitly. The Token Standard V2 relay is preselected only in -local dev builds, never in production or testnet. +The Connect Wallet button in the top bar opens the wallet picker. It +auto-detects the wallets available in this deployment — a dapp-sdk gateway, +injected/announced browser wallets, PartyLayer's catalog — and lists the +remaining providers below them, then routes your choice to its owning provider. +There is no built-in default in production or testnet builds. | Provider | When to use | Required env | |---|---|---| @@ -25,8 +26,9 @@ local dev builds, never in production or testnet. | **Direct Canton** | Advanced testnet sessions with a bearer token | `VITE_CANTON_LEDGER_URL`, `VITE_CANTON_AUTH_TOKEN` | | **Mock Wallet** | Local dev only — DEV builds only | none | -Once connected, your party id appears in the top bar. The wallet -provider persists across reloads (session is stored in `localStorage`). +Once connected, your party id appears in the top bar. The provider persists +across reloads (the session is stored in `localStorage`), and clicking the +connected pill disconnects. On the public testnet at `testnet-dex.bitdynamics.cc`, testers are onboarded as hosted parties on the operator's (BitDynamics) validator, and the traded assets @@ -37,60 +39,74 @@ and V2 assets. See [Non-goals](../concepts/non-goals.md#the-hosted-testnet-is-a- --- -## Swap (Trade page) +## How a trade is authorised + +Read this once and the rest of the guide follows. **The dApp holds no keys and +invents nothing.** Every trader-authority action is the same three-step +handshake: the dApp asks the operator for a Daml-built spec, your wallet signs +exactly that spec (locking the funds it names), and the operator settles against +it. The wallet carries *your* authority; the operator carries *its own*. + +```mermaid +sequenceDiagram + actor W as Your wallet + participant D as dApp + participant O as Operator backend + Note over W,O: Wallet holds your keys and signs trader-authority allocations.
The dApp holds none; the operator orchestrates settlement. + D->>O: 1. Ask for a Daml-built spec (e.g. PoolRules_RequestSwap) + O-->>D: allocationSpec + settlement + disclosed factory context + D->>W: 2. Hand off the intent (e.g. request-swap) + W->>W: Sign AllocationFactory_Allocate + W-->>D: Prefunded trader Allocation (or updateId) + D->>O: 3. Settle + O->>O: Exercise PoolRules_Swap / SettleBatch (operator authority) + O-->>D: Atomic settlement + Note over W,O: You receive the output; holdings and pool reserves refresh. +``` -Use this when you want to swap two assets at the pool mid-price plus -fee. Goes through the constant-product pool. +Because the settle step re-derives its own numbers on the ledger, the operator +cannot quote you one price and settle another. The exact template and choice +names behind each action are in +[Reference: what the wallet signs](#reference-what-the-wallet-signs-and-what-settles). -``` -You ──┐ pool roll-forward - │ 1. lock allocation ▲ - │ (input) │ next allocation - ▼ │ - ┌───────────────────────────────────────────────────┴───┐ - │ AllocationFactory_Allocate (your authority) │ - │ ↓ │ - │ PoolRules_Swap (operator) │ - │ ↓ │ - │ SettlementFactory_SettleBatch (atomic) │ - │ ↓ │ - │ You receive the output instrument │ - └───────────────────────────────────────────────────────┘ -``` +--- -**UI walkthrough**: +## Swap (Trade page) + +Swap two assets at the pool mid-price plus fee, through the constant-product +pool. Use this when you want immediate execution at the pool's current rate. 1. Open **Trade** → pick the input + output asset. -2. Enter an amount. The output, rate, fee, price impact, and minimum - received update live. +2. Enter an amount. The output, rate, fee, price impact, and minimum received + update live. 3. Set slippage tolerance via the ⚙ settings button (default 0.5 %). 4. Click **Review Swap** → confirm the on-ledger sequence. 5. Click **Approve & Submit**. The dApp has already asked the operator for a - Daml-built swap allocation spec (`PoolRules_RequestSwap`); your wallet - signs the matching `AllocationFactory_Allocate` with the registry's choice - context. -6. A toast banner shows each on-ledger phase as it completes. When the - final phase ("Pool roll-forward") goes green, your holdings and the - pool reserves refresh automatically. + Daml-built swap allocation spec (`PoolRules_RequestSwap`); your wallet signs + the matching `AllocationFactory_Allocate`, locking the input. The operator + then settles with `PoolRules_Swap`. +6. A toast banner shows each on-ledger phase as it completes. When the final + phase ("Pool roll-forward") goes green, your holdings and the pool reserves + refresh automatically. **Failure modes you might hit**: - *"Connect wallet to swap"* → use the top-bar Connect button first. -- *"Insufficient balance"* → your unlocked holdings of the input - instrument are below the amount entered. -- *Toast stuck at phase 2 with a red dot* → the operator rejected the - swap (price impact > slippage, factory mismatch, etc.). Check the - error message in the toast. +- *"Insufficient balance"* → your unlocked holdings of the input instrument are + below the amount entered. +- *Toast stuck at phase 2 with a red dot* → the operator rejected the swap + (price impact > slippage, factory mismatch, etc.). Check the toast message. --- ## Add liquidity (Pools page) -Use this to provide both sides of a pool and earn LP tokens. +Provide both sides of a pool at its current ratio and earn LP tokens that accrue +a share of swap fees. 1. Open **Pools** → click a pool → enter the base amount. -2. The quote amount auto-fills at the current pool ratio. The card - shows your expected LP tokens and post-add pool share %. +2. The quote amount auto-fills at the current pool ratio. The card shows your + expected LP tokens and post-add pool share %. 3. Click **Add liquidity**. The operator opens the request (`POST /v1/pools/add-liquidity/request`), creating a `LiquidityAllocationRequest`. @@ -100,94 +116,96 @@ Use this to provide both sides of a pool and earn LP tokens. (`POST /v1/pools/add-liquidity/settle`, `PoolLiquidityRules_SettleAddLiquidity`): your funds enter the pool and LP tokens are minted to you, atomically. -6. Your LP balance appears under "Your LP position" once settled. +6. Your LP balance appears under **Your LP position** once settled. -LP tokens are **unversioned**: any holder of `BTC-USDC-LP` holds the -same instrument regardless of when they minted. See -[`../concepts/lp-tokens.md`](../concepts/lp-tokens.md) for why. +LP tokens are **unversioned**: any holder of `BTC-USDC-LP` holds the same +instrument regardless of when they minted. See +[LP tokens](../concepts/lp-tokens.md) for why. --- ## Remove liquidity (Pools page) -A DvP flow because the LP holding lives in the registry, not the DEX: - -1. Operator step (driven by the UI): `POST /v1/pools/remove-liquidity/request` - creates a `LiquidityAllocationRequest`. -2. Wallet step: your wallet authors the base-receipt, quote-receipt, - and LP burn-sender allocations via `AllocationFactory_Allocate`. -3. Settle step: `POST /v1/pools/remove-liquidity/settle` - (`PoolLiquidityRules_SettleRemoveLiquidity`, co-signed by the operator and - lpRegistrar) delivers base + quote to you and burns the LP tokens to - the burn account, atomically. - -**UI walkthrough**: +A delivery-versus-payment flow, because the LP holding lives in the registry, +not the DEX: the underlying and the LP burn move in a single atomic settlement. 1. Pool detail → scroll to **Your LP position**. -2. Use the 25 / 50 / 75 / 100 % buttons or the slider to pick how much - to redeem. The card shows what you'll receive. -3. Click **Remove liquidity** → toast walks the request, allocation, - and settle steps. +2. Use the 25 / 50 / 75 / 100 % buttons or the slider to pick how much to + redeem. The card shows what you'll receive, with a slippage floor. +3. Click **Remove liquidity**. The toast walks three steps: + - **Request** — the operator creates a `LiquidityAllocationRequest` + (`POST /v1/pools/remove-liquidity/request`). + - **Allocate** — your wallet authors the base-receipt, quote-receipt, and LP + burn-sender allocations via `AllocationFactory_Allocate`. + - **Settle** — `PoolLiquidityRules_SettleRemoveLiquidity` (co-signed by the + operator and lpRegistrar) delivers base + quote to you and burns the LP + tokens, atomically. --- ## Place an order (Orders page) -Limit orders for traders who want execution at a price, not a pool -mid. Uses prefunded `Order` allocations. +Limit orders for traders who want execution at a chosen price, not the pool mid. +Collateral is locked up front in a prefunded `Order` allocation. 1. Open **Orders** → pick BUY or SELL. -2. Set the limit price and amount. (The order is placed with no expiry.) -3. Click **Place Order**. Your wallet signs an `OrderFundingRequest`; - the operator binds + funds it on-ledger. -4. Toast walks: submitted → bound → funded → in book. -5. Your open orders appear under **My open orders**. Click ✕ to - cancel. Cancel releases the funding allocation back to available - balance. - -The order book on the left shows depth aggregated across all parties -(but not which counterparty holds which order). Status colours: -green = funded, amber = partially filled. +2. Set the limit price and amount. Orders are placed with no expiry. +3. Click **Place order**. This takes **two wallet approvals**: the first creates + the order's funding request (`OrderFundingRequest`), which the operator binds + into a live `Order`; the second locks the collateral that funds it. +4. The toast walks four phases: **Submitted → Bound → Funded → Open** (in book). +5. Your open orders appear under **My open orders**. Click ✕ to cancel; cancel + releases the funding allocation back to available balance. + +If the second approval fails, the order is *bound but unfunded* — the dApp names +the stuck order and best-effort cancels it, so no collateral is stranded. + +The order book on the left shows depth aggregated across all parties (but not +which counterparty holds which order). Status colours: green = funded, +amber = partially filled. --- ## Trade an RFQ block (RFQ page) -Bilateral block trades. You publish a request, whitelisted dealers -quote, you accept the best one, and the trade settles as a -MatchedTrade visible only to you and the accepted dealer. +Bilateral block trades. You publish a request, whitelisted dealers quote, you +accept one, and the trade settles as a `MatchedTrade` visible only to you and the +accepted dealer. 1. Open **RFQ** → click **+ New RFQ**. -2. Pick pair, side, size, expiry window. Select dealers from the - whitelist on the right. -3. Send. Dealers receive your RFQ off-ledger and post quotes - on-ledger; quotes stream into the expanded row in real time. -4. Inspect the **Operator policy** modal to see how quotes are ranked - (tier → price → posting time → tiebreaker). -5. Click **Accept** on the dealer you want. The operator + you - co-sign `Rfq_Accept`, the trade settles, and a `PolicyReceipt` is - produced as proof of the ranking applied. -6. The receipt appears as a clickable pill in your **Portfolio → - Activity** feed. Click it to see the full attestation: which - policy version, which rank, how many quotes were considered. - -Settled RFQs move to the **Settled** tab. Expired (no accept, or no +2. Pick pair, side, size, and validity window. Select dealers from the whitelist + on the right. +3. Send. Dealers receive your RFQ off-ledger and post quotes on-ledger; quotes + stream into the expanded row in real time. +4. Keep the default **Operator policy** ranking, or re-sort with the Best price / + Earliest / Trusted only buttons. Under policy `v2.0` the ranking chain is + **trusted tier first → later expiry first → earlier posting time first → + dealer id** as the tiebreaker — price is *not* part of the policy chain; you + choose from the policy-ranked candidates. The policy modal shows the exact + ranking that was applied. +5. Click **Accept** on the dealer you want. The operator and you co-sign + `Rfq_Accept`, the trade settles, and a `PolicyReceipt` is produced as proof of + the ranking applied. +6. The receipt appears as a clickable pill in your **Portfolio → Activity** feed. + Click it to see the full attestation: which policy version, which rank, how + many quotes were considered. + +Settled RFQs move to the **Settled** tab; those that expire with no accept (or no quotes) move to **Expired**. --- ## Portfolio (Portfolio page) -Snapshot of everything visible to your party: +A snapshot of everything visible to your party: -- **Holdings** — every instrument you hold, with available / locked. - Locked = currently backing an open order, swap, or RFQ allocation. -- **LP positions** — shown separately with pool-share % and underlying - value. -- **Allocation breakdown** — what's locking your funds, with the - Allocation CID + type (prefunded / committed). -- **Activity** — every settled action with timestamp, type, on-ledger - Trade CID, and (for RFQs) a clickable policy receipt pill. +- **Holdings** — every instrument you hold, with available / locked. Locked = + currently backing an open order, swap, or RFQ allocation. +- **LP positions** — shown separately with pool-share % and underlying value. +- **Allocation breakdown** — what's locking your funds, with the Allocation CID + + type (prefunded / committed). +- **Activity** — settled actions with timestamp, type, on-ledger Trade CID, and + (for RFQs) a clickable policy-receipt pill. Use the filter buttons (All / Swaps / Orders / LP) to narrow the feed. @@ -195,35 +213,85 @@ Use the filter buttons (All / Swaps / Orders / LP) to narrow the feed. ## Credential warnings -Some instruments require the holder to present credentials (e.g., a -KYC tier-1 claim). If your party doesn't hold the required credential, -the UI shows a yellow warning banner before you can trade. Contact the -relevant credential issuer to obtain the claim, then refresh. +Some instruments require the holder to present credentials (for example, a +KYC tier-1 claim). If your party doesn't hold the required credential, the UI +shows a yellow **Missing credentials** banner before you can trade, naming the +issuer and the exact `property=value` claim you need. Contact the credential +issuer to obtain the claim, then refresh. -This enforcement is on-ledger: the registry rejects mint/burn/transfer -that fails the credential check, regardless of what the dApp shows. +This enforcement is on-ledger: the registry rejects mint/burn/transfer that fails +the credential check, regardless of what the dApp shows. --- -## What the wallet actually signs +## Reference: what the wallet signs, and what settles -The dApp never signs as your party. Every trader-authority action -above goes through your wallet provider: +Every trader-authority write is the handshake from +[How a trade is authorised](#how-a-trade-is-authorised): the operator builds a +spec, your wallet authors it, the operator settles. The wallet provider knows +the disclosed factory CIDs, the package hash, and the holding CIDs to lock; the +dApp passes only the intent verb. | UI action | Wallet intent | On-ledger result | |---|---|---| -| Swap | `request-swap` | Prefunded input `Allocation`, then `PoolRules_Swap` | -| Add liquidity | `add-liquidity` | Base-deposit + quote-deposit + LP-receipt `Allocation`s (settled by `PoolLiquidityRules_SettleAddLiquidity`) | -| Remove liquidity | `remove-liquidity` | Base-receipt + quote-receipt + LP burn-sender `Allocation`s (settled by `PoolLiquidityRules_SettleRemoveLiquidity`) | -| Place order | `place-order` | `OrderFundingRequest` | -| Accept RFQ | `accept-rfq` | Joint `Rfq_Accept` exercise | +| Swap | `request-swap` | Prefunded input `Allocation`, then [`PoolRules_Swap`](../../trading/CantonDex/Dex/PoolRules.daml) | +| Add liquidity | `add-liquidity` | Base-deposit + quote-deposit + LP-receipt `Allocation`s, settled by [`PoolLiquidityRules_SettleAddLiquidity`](../../trading/CantonDex/Dex/PoolLiquidityRules.daml) | +| Remove liquidity | `remove-liquidity` | Base-receipt + quote-receipt + LP burn-sender `Allocation`s, settled by [`PoolLiquidityRules_SettleRemoveLiquidity`](../../trading/CantonDex/Dex/PoolLiquidityRules.daml) | +| Place order | `place-order` + `accept-allocation-request` | [`OrderFundingRequest`](../../trading/CantonDex/Dex/OrderFundingRequest.daml) → funded [`Order`](../../trading/CantonDex/Dex/Order.daml) | +| Accept RFQ | `accept-rfq` | Joint [`Rfq_Accept`](../../trading/CantonDex/Dex/Rfq.daml) exercise → [`MatchedTrade`](../../trading/CantonDex/Dex/MatchedTrade.daml) | | Post RFQ quote (dealer) | `post-rfq-quote` | `RfqQuote` create | -The wallet provider knows the disclosed factory CIDs, the package hash, -and the holding CIDs to lock; the dApp passes only the intent verb. -Trader-authority writes go through the connected wallet; operator-authority -settlement steps go through the operator backend. +The split that makes the "operator can't rewrite your price" guarantee is one +pair of choices: the request choice builds a spec and creates nothing, and the +settle choice consumes the wallet-authored allocation directly. From +[`PoolRules.daml`](../../trading/CantonDex/Dex/PoolRules.daml): + +```daml +nonconsuming choice PoolRules_RequestSwap : PoolRules_RequestSwapResult + with + poolCid : ContractId Pool + swapper : Party + inputInstrumentId : Text + inputAmount : Decimal + ... +nonconsuming choice PoolRules_Swap : PoolRules_SwapResult + ... +``` + +What your wallet actually signs is a single `AllocationFactory_Allocate` +exercise that locks the named holdings +([`commands.ts`](../../app/web/src/wallet/commands.ts)): + +```ts +choice: "AllocationFactory_Allocate", +choiceArgument: { + settlement, + allocation: spec, + requestedAt, + inputHoldingCids, + actors: [party], + extraArgs, +}, +``` + +**Proven on-ledger** (each line links the choice and the test that pins it): + +- A swap re-derives its output from live reserves inside `PoolRules_Swap`, and + the pool's recorded reserves always equal the real slices — + [`PoolStateInvariantTests.daml`](../../trading-tests/CantonDex/Tests/PoolStateInvariantTests.daml). +- Add / remove liquidity move funds and mint / burn LP tokens in one atomic, + co-controlled (operator + lpRegistrar) settlement — + [`PoolLiquidityRulesTests.daml`](../../trading-tests/CantonDex/Tests/PoolLiquidityRulesTests.daml). +- An accepted RFQ moves each side's real funds and lands a `MatchedTrade` for the + trader and dealer only — + [`RfqSettlementTests.daml`](../../trading-tests/CantonDex/Tests/RfqSettlementTests.daml). +- The `PolicyReceipt` records the ranking honestly, and a trade signed by anyone + but the venue is rejected — + [`PolicyReceiptTests.daml`](../../trading-tests/CantonDex/Tests/PolicyReceiptTests.daml). + +For how each of the four price surfaces is set and signed, see +[Pricing and price sources](../concepts/pricing.md). --- -**Where to read next:** [Getting Started](../getting-started.md) · [Overview](../concepts/overview.md) · [All docs](../README.md) +**Where to read next:** [Getting Started](../getting-started.md) · [Overview](../concepts/overview.md) · [Pricing](../concepts/pricing.md) · [All docs](../README.md) diff --git a/docs/guides/validator-test-plan.md b/docs/guides/validator-test-plan.md index 091800a5..03f67e08 100644 --- a/docs/guides/validator-test-plan.md +++ b/docs/guides/validator-test-plan.md @@ -1,7 +1,11 @@ # Canton Testnet Validator — Live Test Plan -End-to-end test plan for validating the Canton DEX reference -implementation against a live Canton testnet validator. +The checklist that signs off a Canton DEX deployment against a live testnet +validator. Work it top to bottom: an offline pre-flight first, then eleven +numbered phases — from DAR upload through Docker Compose — each a set of +checkboxes you tick against a real participant. Where a phase has a headless +script that proves the same thing without a browser, it is linked inline; run it +to corroborate the manual check, not to replace the sign-off. ## Goals @@ -21,11 +25,35 @@ implementation against a live Canton testnet validator. `https://canton-testnet.example.com:7575`). - A bearer JWT issued for `ledger-api-user` with rights to act-as the operator, lpRegistrar, admin, and demo trader parties. -- The synchronizer id (e.g., `global-domain::1220...`). +- The synchronizer id (e.g., `global-domain::1220...`), exported as + `CANTON_SYNCHRONIZER`. - Docker / Docker Compose installed on the test runner host. -- `daml` CLI installed (SDK 3.4.11). +- `dpm` installed — it resolves the pinned SDK 3.5.2 automatically (see + [Local Setup](../getting-started.md#prerequisites)). - All env vars in `services/operator-backend/.env.example` populated. +## Pre-flight (offline) + +Before pointing anything at the validator, prove the build and the API surface +on your own machine — no Canton required. Both scripts exit non-zero on the +first failure, so they gate cleanly. + +```bash +bash scripts/run-local-daml-tests.sh # dpm build + the Daml suites +bash scripts/e2e-smoke.sh # boots the dev backend, curls every endpoint +``` + +- [`run-local-daml-tests.sh`](../../scripts/run-local-daml-tests.sh) — builds + `canton-dex-trading` and runs the `trading-tests` and `stable-pool` suites. + Proves the DAR you are about to upload compiles and its conservation and + invariant tests hold. +- [`e2e-smoke.sh`](../../scripts/e2e-smoke.sh) — starts the backend on an + in-memory ledger and curls the read endpoints, a swap quote, the order book, + the price feed, and the admin auth gate, printing `==> All smoke checks + passed`. Proves the HTTP surface answers and that `POST /v1/admin/pairs` is + refused without a bearer token — the same shapes Phases 1–8 exercise against + the validator. + ## Phase 0 — Build & upload DARs ```bash @@ -39,7 +67,7 @@ export CANTON_ADMIN=... ``` Expected: -- `daml build` succeeds; `.daml/dist/canton-dex-*.dar` exists. +- `dpm build` succeeds; `trading/.daml/dist/canton-dex-trading-0.1.4.dar` exists. - DARs upload to participant (HTTP 200 from `/v2/packages`). - Parties allocated (or pre-existing). - `scripts/bootstrap-registry.ts` reports each instrument and LP @@ -122,6 +150,24 @@ Requires `OPERATOR_ADMIN_TOKEN`. ## Phase 5 — Trader flows +Three scripts drive these flows against a live participant without a browser +wallet — run them to corroborate the manual checks below, each proving one seam: + +- [`localnet-dvp-e2e.ts`](../../scripts/localnet-dvp-e2e.ts) + (`npm run localnet:dvp-e2e --prefix services/operator-backend`) — stands in + for the trader's CIP-0103 wallet, authoring the three allocations for each + DvP and settling. Proves the operator two-call add → swap → remove round-trip + (§5.3–5.5) and asserts the on-ledger reserves and LP supply. +- [`seed-testnet-pool.ts`](../../scripts/seed-testnet-pool.ts) + (`npm run testnet:seed-pool --prefix services/operator-backend`) — mints, adds + liquidity, and swaps against an *existing* live pool. Proves a swap moved the + reserves by exactly the constant-product amount and that `x·y` did not + decrease (§5.3). +- [`testnet-v2registry-trade.ts`](../../scripts/testnet-v2registry-trade.ts) — + posts a `MatchedTrade`, runs the V2 allocation accept on both sides, and + settles via `SettleBatch`. Proves matched-trade settlement through the + registry acting as allocation + settlement factory (§5.2). + ### 5.1 Place order - [ ] Submit a buy order for BTC/USDC at limit price < current ask - [ ] Wallet intent translates to OrderFundingRequest creation @@ -217,4 +263,4 @@ real Canton testnet validator. --- -**Where to read next:** [Run on a Testnet](run-on-testnet.md) · [Operator Runbook](operator-runbook.md) · [All docs](../README.md) +**Where to read next:** [Run on a Testnet](run-on-testnet.md) · [Operator Runbook](operator-runbook.md) · [Testing reference](../reference/testing.md) · [All docs](../README.md) diff --git a/docs/reference/allocation-surface.md b/docs/reference/allocation-surface.md index dbc6c291..b07adc9f 100644 --- a/docs/reference/allocation-surface.md +++ b/docs/reference/allocation-surface.md @@ -1,58 +1,65 @@ # Token Standard V2 allocation surface -This document records the specific Token Standard **V2 (CIP-0112)** allocation- -surface features this DEX relies on (committed allocations and iterated -settlement), together with the exact DEX consumers, reconstructed from the -actual vendored interface so readers can audit the dependency directly. - -Token Standard V2 has merged into `canton-network/splice` `main` and becomes the -network default from mid-July 2026; this repo vendors the V2 sources at the -commit pinned in -[`../../vendor/splice/VENDOR_PIN.md`](../../vendor/splice/VENDOR_PIN.md). - -For the architectural rationale (why the DEX leans on these extensions for pool -inventory, not just trade reservation), see +Token Standard **V2 (CIP-0112)** is merged into `canton-network/splice` `main` +and is the network default. This document is the factual, file-anchored +reference for the specific V2 allocation-surface features the DEX consumes — +committed allocations and iterated settlement — and the exact DEX code that +consumes each one. + +The DEX uses allocations for two jobs. The obvious one is reserving funds for a +single trade. The load-bearing one is holding **long-lived, iterated pool +inventory**: a bid, an ask, and each side of pool liquidity are backed by an +allocation that stays live and rolls forward across many settlements. That +second job is what pulls in the committed-allocation and iterated-settlement +parts of the standard catalogued below. + +For the architectural rationale (why the pool leans on these features rather +than a custom balance with an escrow bridge behind it), see [`../concepts/architecture.md`](../concepts/architecture.md), section -"3. Token Standard V2 allocation surface". This document is the -factual, file-anchored reference; the architecture doc is the design context. +["What settles value: the Token Standard V2 spine"](../concepts/architecture.md#what-settles-value-the-token-standard-v2-spine). +That page is the design context; this page is the field-by-field reference. ## Source of truth -- Vendored interface: +- Standard interface (vendored): [`vendor/splice/token-standard/splice-api-token-allocation-v2/daml/Splice/Api/Token/AllocationV2.daml`](../../vendor/splice/token-standard/splice-api-token-allocation-v2/daml/Splice/Api/Token/AllocationV2.daml) - Vendor pin (upstream repo, branch, commit): [`../../vendor/splice/VENDOR_PIN.md`](../../vendor/splice/VENDOR_PIN.md) - DEX consumers: - [`trading/CantonDex/Trading/Utils.daml`](../../trading/CantonDex/Trading/Utils.daml) — funding arithmetic, leg→leg-side projection, allocation/spec builders. - Together with the registry below it consumes the full vendored surface, so - the build fails fast if a vendored package drifts. + Together with the registry below it exercises every field listed here, so + the build fails fast if a re-pin changes the surface. - [`trading/CantonDex/Registry/V2.daml`](../../trading/CantonDex/Registry/V2.daml) - — the registry that implements `AllocationFactory` / `Allocation` / + — the reference registry implementing `AllocationFactory` / `Allocation` / `SettlementFactory`. -> The build targets Token Standard V2 at the commit pinned above. See the pin -> file and the README's "Token Standard V2" section for the vendoring details. - -## Why the pool leans on these features - -The pool design uses allocations not only as one-shot trade reservations but -also as long-lived, iterated pool inventory. That requires the iterated- -settlement and committed-allocation semantics that Token Standard V2 (CIP-0112) -provides. The sections below are the specific surface elements the DEX consumes. - ## Surface features -The following fields/behaviours are the Token Standard V2 allocation-surface -elements the DEX consumes directly. +The following fields and behaviours are the Token Standard V2 allocation-surface +elements the DEX consumes directly. Each is defined in `AllocationV2.daml`; the +"DEX usage" notes point at the code that reads or sets it. ### `committed` — on `AllocationSpecification` -Defined in `AllocationV2.daml` on `AllocationSpecification` -(`committed : Bool`). When `True`, the authorizer cannot withdraw the -allocation until the settlement deadline passes (or the executors -settle/cancel, or the admin expires it). This lets pool liquidity sit in -an allocation that an LP cannot casually pull back. +`committed : Bool` on `AllocationSpecification`. When `True`, the authorizer +cannot withdraw the allocation until the settlement deadline passes (or the +executors settle/cancel it, or the admin expires it). This lets pool liquidity +sit in an allocation that an LP cannot casually pull back: + +```daml +committed : Bool + -- ^ Whether the authorizer commits to the allocation until either + -- - the executors settle allocation, + ... + -- - the admin expires the allocation. + -- If set to `True`, then the authorizer cannot withdraw the allocation + -- until the settlement deadline. +``` + +The matching enforcement is on `Allocation_Withdraw`: "For committed allocations +(i.e., `committed` set to `True`), this choice can only be exercised once the +settlement deadline has passed." DEX usage: @@ -64,16 +71,16 @@ DEX usage: `nextIterationFunding : Optional (TextMap.TextMap Decimal)`, keyed by instrument id with positive amounts. Setting it to `None` disables iterated settlement (the -allocation can settle exactly once with its specified legs). An empty map -enables iterated settlement with no reserved funding. It appears in three -places on the vendored surface: +allocation settles exactly once, with its specified legs). An empty map enables +iterated settlement with no reserved funding. It appears in three places on the +surface: - `AllocationSpecification.nextIterationFunding` — funds reserved at allocation creation for the next iteration. - `FinalizedAllocation.nextIterationFunding` — the funding to reserve for the next iteration at settlement time. -- `Allocation_Settle.nextIterationFunding` — same, on the settle choice; - `None` here signals that no further iterations follow. +- `Allocation_Settle.nextIterationFunding` — same, on the settle choice; `None` + here signals that no further iterations follow. DEX usage: @@ -81,26 +88,34 @@ DEX usage: `Utils.normalizeFunding` compute the per-instrument funding map the authorizer must cover. - `Utils.mkIteratedAllocationSpecification` / - `mkPrefundedAllocationSpecification` set it on the spec. + `Utils.mkPrefundedAllocationSpecification` set it on the spec. - `Registry.V2.allocationFactory_allocateImpl` validates that the locked input holdings cover the sender-side legs **plus** `nextIterationFunding` - (`required = sideRequired ∪ funding`). + (`required = Utils.textMapUnionWith (+) sideRequired funding`). - `Registry.V2.allocation_settleImpl` rolls `arg.nextIterationFunding` forward into a fresh allocation with `numIterations + 1`. ### `nextIterationAllocationCid` — via `AllocationResult_Settled` -On the released surface a settle result does not carry a forward pointer to a -next-iteration allocation. The vendored surface's -`AllocationResult_Output = AllocationResult_Settled with nextIterationAllocationCid : Optional (ContractId Allocation)` -returns the allocation created for the next iteration (or `None` when fully -settled). +A settle result carries a forward pointer to the allocation created for the next +iteration: + +```daml +| AllocationResult_Settled + with + nextIterationAllocationCid : Optional (ContractId Allocation) + -- ^ The new allocation created for the next settlement iteration, if any. +``` + +It is `None` when the allocation is fully settled. (Historically, an earlier V2 +release exposed no such forward pointer; the merged standard includes it.) DEX usage: -- `Registry.V2.allocation_settleImpl` populates - `AllocationResult_Settled next`, where `next` is the freshly created - next-iteration allocation when `nextIterationFunding` is set. +- `Registry.V2.allocation_settleImpl` returns + `AllocationResult_Settled nextCid`, where `nextCid` is the freshly created + next-iteration allocation when `nextIterationFunding` is set, and `None` + otherwise. - `Utils.nextIterationAllocationCids` reads these back out of a `SettlementFactory_SettleBatchResult` (order-preserving; `Some` when the allocation rolled forward, `None` when fully settled). Partial fills rely on @@ -110,52 +125,53 @@ DEX usage: `FinalizedAllocation.extraTransferLegSides : [TransferLegSide]` lets executors supply the concrete transfer leg sides to authorize in this settlement -iteration, on top of the legs fixed at allocation creation. They MUST be empty -unless the authorizer enabled iterated settlement. The matching -`Allocation_Settle.extraTransferLegSides` choice argument carries them into the -settle path. +iteration, on top of the legs fixed at allocation creation. Per the standard, +they "MUST be empty unless iterated settlement was enabled by the allocation's +authorizer." The matching `Allocation_Settle.extraTransferLegSides` choice +argument carries them into the settle path. DEX usage: -- `Utils.mkFinalizedAllocation` - builds a `FinalizedAllocation` carrying extra leg sides + optional funding; - `Utils.finalAllocation` is the settle-as-is form (no extra legs, no next - iteration). -- `OrderMatchExecution` supplies concrete match legs as - `extraTransferLegSides` at batch-settlement time (see the prefunded-order tour - in [`../guides/builder-guide.md`](../guides/builder-guide.md) and +- `Utils.mkFinalizedAllocation` builds a `FinalizedAllocation` carrying extra + leg sides + optional funding; `Utils.finalAllocation` is the settle-as-is form + (no extra legs, no next iteration). +- `OrderMatchExecution` supplies concrete match legs as `extraTransferLegSides` + at batch-settlement time (see the prefunded-order tour in + [`../guides/builder-guide.md`](../guides/builder-guide.md) and `trading/CantonDex/Dex/OrderMatchExecution.daml`). - `Registry.V2.allocation_settleImpl` appends `arg.extraTransferLegSides` to the - spec's fixed `transferLegSides` (`allSides = spec.transferLegSides ++ arg.extraTransferLegSides`) - and credits receiver-side holdings for the authorizer. + spec's fixed `transferLegSides` + (`allSides = spec.transferLegSides ++ arg.extraTransferLegSides`) and credits + receiver-side holdings for the authorizer. - `Registry.V2.settlementFactory_settleBatchImpl` threads each `FinalizedAllocation`'s `extraTransferLegSides` and `nextIterationFunding` into the per-allocation `Allocation_Settle`. -### Retirement of `Allocation_Adjust` +### No `Allocation_Adjust` choice -The vendored `AllocationV2.daml` `Allocation` interface exposes exactly three -state-changing choices: `Allocation_Settle`, `Allocation_Cancel`, and -`Allocation_Withdraw`. There is no `Allocation_Adjust` choice on the -V2 surface. Earlier/alternative designs adjusted an allocation's -authorized amounts in place via a dedicated choice; on this surface that role is +The `Allocation` interface exposes exactly three state-changing choices — +`Allocation_Settle`, `Allocation_Cancel`, and `Allocation_Withdraw`. There is no +`Allocation_Adjust`. Where an alternative design might adjust an allocation's +authorized amounts in place via a dedicated choice, on this surface that role is subsumed by iterated settlement: `Allocation_Settle` carries `extraTransferLegSides` and `nextIterationFunding` and emits a next-iteration allocation via `nextIterationAllocationCid`, so the funding "adjustment" happens as part of settle rather than as a separate choice. -This is why the conservation test was renamed: the former -`testAllocationAdjustConservation` is succeeded by +This is why the DEX's conservation test is named for the settle path: `testFinalizedAllocationFundingConservation` in -[`trading-tests/CantonDex/Tests/EndToEndTests.daml`](../../trading-tests/CantonDex/Tests/EndToEndTests.daml). +[`trading-tests/CantonDex/Tests/EndToEndTests.daml`](../../trading-tests/CantonDex/Tests/EndToEndTests.daml) +checks that funding is conserved across the finalize-and-settle flow. ## Vendoring -These semantics are part of Token Standard V2 (CIP-0112), now merged into -`canton-network/splice` `main`. This repo vendors the V2 sources at a pinned -commit and re-pins as the surface evolves upstream; the pin in -[`../../vendor/splice/VENDOR_PIN.md`](../../vendor/splice/VENDOR_PIN.md) is the -authoritative record of exactly what the build targets. +This repo vendors the Token Standard V2 sources under +[`vendor/splice/`](../../vendor/splice/). They track the standard as merged into +`canton-network/splice` `main`; the vendoring exists to pin an exact commit for +reproducible builds, not because the DEX depends on anything non-standard. The +pin in [`../../vendor/splice/VENDOR_PIN.md`](../../vendor/splice/VENDOR_PIN.md) +is the authoritative record of exactly which commit the build compiles against, +and is re-pinned as the standard's sources advance upstream. --- diff --git a/docs/reference/ecosystem-feedback.md b/docs/reference/ecosystem-feedback.md index 0242fa70..44035b86 100644 --- a/docs/reference/ecosystem-feedback.md +++ b/docs/reference/ecosystem-feedback.md @@ -48,45 +48,109 @@ external builder would hit. ## Findings and resulting changes Every finding from the six rounds was addressed. They fall into a few themes. - -Amounts must be served at ledger precision as -exact decimal strings, not re-floated. Fixes: the fills feed no longer routes -deltas through `parseFloat().toFixed` (F13); `/v1/swaps` serves the exact strings -rather than re-floating them (F20); `/v1/instruments` reports each instrument's -`decimals`, so a client can learn scale from the API; pre-fix rows were backfilled -rather than left wrong (F21). - -External clients depend on the read API being -uniform. Fixes: the status wire value stopped shipping a `PS_`-prefixed enum the -dApp silently stripped (F11); `/v1/orders/book` accepts `?pair=` like every other -read (F15, additively); the trades feed no longer inverts trader and dealer on -buys (F16); `/v1/trades` includes `counterparty` after the deployment was brought -current with `main` (F22); an unscoped, unauthenticated `GET /v1/rfq` that lived -only on a soon-to-be-retired branch was fixed on `main` (F23). - -Two fixes concern funding and custody: funding an order locks only what the +Each theme below closes with the test that pins the fix. + +### Amounts are served at ledger precision + +Amounts must reach the client as exact decimal strings at ledger scale, never +re-floated through IEEE-754. The fills feed no longer routes deltas through +`parseFloat().toFixed` (F13); `/v1/swaps` serves the exact stored strings rather +than re-floating them (F20); `/v1/instruments` reports each instrument's +`decimals` so a client can learn scale from the API, and pre-fix rows were +backfilled rather than left wrong (F21). + +Proven by +[`decimal-money.test.ts`](../../services/operator-backend/test/decimal-money.test.ts) +(money amounts go through the BigInt decimal module, not IEEE-754) and +[`instruments-route.test.ts`](../../services/operator-backend/test/instruments-route.test.ts) +(`decimals` is decoded from the string the ledger sends, not dropped by a +`typeof === "number"` guard). + +### The read API stays uniform + +External clients depend on every read speaking the same shapes. The status wire +value stopped shipping a `PS_`-prefixed enum the dApp silently stripped (F11); +`/v1/orders/book` accepts `?pair=` like every other read (F15, additively); the +trades feed no longer inverts trader and dealer on buys (F16); `/v1/trades` +includes `counterparty` after the deployment was brought current with `main` +(F22); an unscoped, unauthenticated `GET /v1/rfq` that lived only on a +soon-to-be-retired branch was fixed on `main` (F23). + +Proven by +[`pool-status-normalisation.test.ts`](../../services/operator-backend/test/pool-status-normalisation.test.ts) +(the read path strips `PS_` so a client typed against `Active` still sees the +pool), +[`order-route-pair-param.test.ts`](../../services/operator-backend/test/order-route-pair-param.test.ts) +(`?pair=BASE/QUOTE` is accepted on the book and matches routes, `?base="e=` +still works), +[`indexer-trade-parties.test.ts`](../../services/operator-backend/test/indexer-trade-parties.test.ts) +(a buy is labelled trader/dealer the right way round and its counterparty +recorded), and +[`rfq-read-scoping.test.ts`](../../services/operator-backend/test/rfq-read-scoping.test.ts) +(the unfiltered `/v1/rfq` requires the admin token). + +### Funding locks only what an order needs + +Two fixes concern funding and custody. Funding an order locks only what the order needs and returns the change, so a party can place more than one order -(F12); the off-ratio liquidity add refunds the unmatched remainder and the hosted -receipt reports the settled amounts rather than echoing the request (F25). - -The hosted routes are the only path for a -walletless integrator, so gaps in them block external evaluation entirely. Fixes: -RFQ gained a hosted cancel, so a round trip has an exit other than expiry (F17); -order matching gained a hosted, unauthenticated trigger (`POST /v1/testnet/match`) -so matching and its atomic settlement can be verified from outside (F24); -`/v1/swaps` gained `?kind=` so liquidity events, not just swaps, are readable -(F26). The whole `/v1/testnet/*` surface and the faucet's per-IP party quota were -documented with their consequences (F14, F18). - -Some reports were answered by design: -`Holding_Split` is refused by the hosted relay because the relay admits only a -fixed choice allowlist, and splitting is a wallet concern the relay does not -expose (F19). - -One item remained open at the time of the report and has since been closed: a -resting order the book published but the matcher would not pair (F27). The cause -was a self-cross (a party's own bid and ask) which can never settle. The matcher -now applies self-trade prevention and no longer proposes it. +(F12); the off-ratio liquidity add refunds the unmatched remainder, and the +hosted receipt reports the settled amounts rather than echoing the request (F25). + +Proven by +[`normalize-funding.test.ts`](../../app/web/src/__tests__/normalize-funding.test.ts) +(a covering subset is locked and the surplus returned as unlocked change, with no +split handed to the wallet) and `testDvpAddOffRatioRefundsExcess` in +[`PoolLiquidityRulesTests.daml`](../../trading-tests/CantonDex/Tests/PoolLiquidityRulesTests.daml) +(the unmatched leg is refunded in the same settlement, never reaching the +reserves). + +### The hosted routes are the only path in + +For a walletless integrator the hosted routes are the whole surface, so a gap in +them blocks external evaluation entirely. RFQ gained a hosted cancel, so a round +trip has an exit other than expiry (F17); order matching gained a hosted, +unauthenticated trigger (`POST /v1/testnet/match`) so matching and its atomic +settlement can be verified from outside (F24); `/v1/swaps` gained `?kind=` so +liquidity events, not just swaps, are readable (F26). The whole `/v1/testnet/*` +surface and the faucet's per-IP party quota were documented with their +consequences (F14, F18). + +Proven by +[`swaps-kind-filter.test.ts`](../../services/operator-backend/test/swaps-kind-filter.test.ts) +(`?kind=` returns add- and remove-liquidity rows and composes with `?pair=`) and +[`order-fill-recording.test.ts`](../../services/operator-backend/test/order-fill-recording.test.ts) +(a discovered cross settles in exactly one submission, leaving no stranded +collateral). + +### Answered by design + +`Holding_Split` is refused by the hosted relay because the relay exposes only a +fixed set of settlement choices, and splitting is a wallet concern it does not +surface (F19). The boundary is described in +[Non-goals: the hosted testnet is a demo surface](../concepts/non-goals.md#the-hosted-testnet-is-a-demo-surface-not-a-wallet). + +### Closed after the report + +One item was open at the time of the report and has since been closed: a resting +order the book published but the matcher would not pair (F27). The cause was a +self-cross — a party's own bid and ask, which can never settle. The matcher now +applies self-trade prevention in +[`matching.ts`](../../services/operator-backend/src/order/matching.ts): + +```typescript +// Self-trade prevention: a party's own bid and ask must not match. The +// settle would build a transfer leg whose sender and receiver are the same +// party and abort, so this cross can never settle. Move to the next ask ... +if (buy.trader === sell.trader) { + ai += 1; + continue; +} +``` + +Proven by +[`matching.test.ts`](../../services/operator-backend/test/matching.test.ts) +("does not match a party against its own crossing order", while a bid still +crosses a different maker's ask and skips its own). ## How this loop is expected to continue @@ -94,3 +158,7 @@ The reference tracks the same standard the ecosystem builds against, and its hosted testnet is open for exactly this kind of evaluation. New reports open as issues on the implementation repository; confirmed findings are fixed with a regression test and this summary is updated. + +--- + +**Where to read next:** [Non-goals](../concepts/non-goals.md) · [HTTP API](http-api.md) · [Testing](testing.md) · [All docs](../README.md) diff --git a/docs/reference/http-api.md b/docs/reference/http-api.md index 7b54f8f3..7e3363a6 100644 --- a/docs/reference/http-api.md +++ b/docs/reference/http-api.md @@ -1,8 +1,60 @@ -# Operator backend API reference +# Operator backend HTTP API + +The operator backend exposes a small REST surface over its view of the ledger. +It does exactly two kinds of work, and the split is the thing to understand +before reading the endpoint tables: + +1. **Operator-observed reads.** The operator's active-contract-set view and its + indexer, projected into JSON the dApp renders — pairs, pools, order books, + a party's holdings, trade and swap history. Reads never move value and, with + two scoping exceptions below, need no authorization. +2. **Orchestration writes.** Choices the *operator* is an authorizer on — + creating a pair or pool, opening a DvP allocation request, settling a matched + trade, cancelling an order. These carry the operator's authority and are + gated by a bearer token. + +What this API deliberately does **not** do is author trader-authority writes. +Placing an order, allocating a holding, signing a swap — anything that spends a +trader's funds — is signed by the trader's own wallet under the CIP-0103 dApp +standard and never passes through this backend. The operator can *orchestrate* +a settlement but cannot *move* a counterparty's value; that boundary is what the +two-call flows below exist to preserve. + +```mermaid +flowchart LR + UI["dApp / integrator"] + subgraph op["Operator backend — this API"] + R["Reads
ACS + indexer → JSON"] + W["Orchestration writes
operator-signed choices"] + end + A["Trader wallet
(CIP-0103)"] + L[("Canton ledger")] + UI -->|GET| R --> L + UI -->|"POST + operator token"| W -->|operator authority| L + UI -.->|WalletIntent| A -.->|trader authority| L +``` + +A DvP flow crosses both lanes: the operator `POST …/request` returns an +allocation spec, the **wallet** authors the allocations that lock the trader's +funds, and the operator `POST …/settle` completes the atomic swap. The +value-moving step is the middle one, and it is never an endpoint here. + +## Conventions -All endpoints are served from the operator-backend HTTP shim at the -configured port (default 8080). Every response is JSON. Error responses -have the shape: +- **Base URL.** Served on the configured port (default `8080`); examples use + `http://localhost:8080`. +- **Versioning.** Every route is under `/v1`. +- **JSON everywhere.** Amounts are Daml `Decimal` **strings** at scale 10, never + JSON numbers — the API never round-trips a value through a float. (Derived + ratios that are not amounts — a 24h price change — are the one exception, and + are documented as such.) +- **Request id.** Every response carries `X-Request-Id`, echoed from the request + if supplied, otherwise generated. +- **Body limit.** POST bodies over 1 MiB are rejected with **413**. +- **CORS.** Default-deny: no `Access-Control-Allow-Origin` is emitted unless the + request origin is on the `ALLOWED_ORIGINS` allowlist. + +The error envelope: ```json { @@ -13,68 +65,113 @@ have the shape: } ``` -Every response also carries the `X-Request-Id` header (echoed from the -request if supplied, otherwise generated). +A handful of store-gated routes (see +[indexer-backed reads](#reads--history-stats-indexer-backed)) answer with a bare +`{ "error": "…" }` and **503** when their backing store is absent, rather than +the full envelope. + +## Authorization + +Three fail-closed gates, applied in this order: + +| Gate | Applies to | Requirement | +|---|---|---| +| **Admin token** | `/v1/admin/*` writes | `Authorization: Bearer $OPERATOR_ADMIN_TOKEN` | +| **Operator token** | every other state-changing route (pool swap/LP, order, RFQ, matched-trade, wallet relay) | `Authorization: Bearer $DEX_OPERATOR_API_TOKEN` | +| **Per-caller binding** *(optional)* | trader-subject writes | `X-Caller-Token` JWT whose `sub` is the caller's own party | + +Reads are open, except the *unfiltered* forms of `/v1/trades`, `/v1/rfq`, and +`/v1/rfq/history`, whose rows name both parties and so require the admin token. +On the in-memory dev server, `DEX_DEV_OPEN=1` opens the operator-write gate +without a token; see +[Local Setup → Exercising write paths](../getting-started.md#exercising-write-paths-in-demo-mode). + +When the operator token is unset and the dev bypass is off, an operator write +returns **401**. When per-caller binding is configured +(`callerJwtSecret`), a write whose subject party is not the caller's own — or +that carries no valid `X-Caller-Token` — returns **403**. Binding is off by +default (a single trusted backend); turn it on when the backend fronts +mutually-distrusting callers. + +--- ## Read endpoints -### `GET /v1/context` +Auth is **open** for every read below unless the row says otherwise. + +### Reads — context and market + +| Method · Path | Purpose | +|---|---| +| `GET /v1/context` | Static parties and factory CIDs the dApp needs to build wallet intents | +| `GET /v1/status` | Network id, ledger slot (offset), sync flag, server time | +| `GET /v1/pairs` | All `DexPair` contracts (whether or not they have a pool) | +| `GET /v1/pools` | All active pools | +| `GET /v1/instruments` | Instrument metadata, merged from the registry configs; `?ids=BTC,USDC` filters | +| `GET /v1/prices?pairs=` | Advisory pool mid-prices for fiat display | +| `GET /v1/credentials?holder=` | `Credential` contracts held by a party | -Returns `DexContext`: the static parties and factory CIDs the dApp -needs to build trader-authority intents. +`GET /v1/context` returns the `DexContext` — the operator holds the knowledge of +which admin governs which instrument and which factory to allocate against, so +it surfaces it here rather than making the dApp guess: ```json { - "operator": "...", - "lpRegistrar": "...", - "admin": "...", - "allocationFactoryCid": "...", - "settlementFactoryCid": "...", + "operator": "...", "lpRegistrar": "...", "admin": "...", + "allocationFactoryCid": "...", "settlementFactoryCid": "...", + "allocationFactoryExtraArgs": { "context": { "values": {} }, "meta": { "values": {} } }, + "allocationFactoryDisclosure": [ /* DisclosedContract[] for the wallet */ ], "network": "canton:devnet" } ``` -### `GET /v1/status` - -Health + slot snapshot. +`GET /v1/status` reports `slot` as the participant's latest offset (polled every +2s, with a local counter fallback so the UI's liveness pill keeps moving if the +poll fails): ```json { "network": "canton:devnet", "slot": 1234567, "synced": true, "serverTime": "2026-05-17T..." } ``` -### `GET /v1/pools` → `Pool[]` - -All active pools. `Pool` shape defined in -`services/operator-backend/src/types.ts`. - -### `GET /v1/pairs` → `DexPair[]` - -All trading pairs (whether or not they have pools). +`GET /v1/instruments` merges `decimals` from Registry.V2 `InstrumentConfig` with +`isin`/`cusip`/`description` from `InstrumentConfiguration`, keyed by +`instrumentId`, then unions in the instruments referenced by active pools so the +list is populated even before any config is registered. Both config templates +are `signatory admin`, so the endpoint reads as `admin` and `lpRegistrar`, not +as the operator. Metadata therefore exists only for instruments issued by a +registry this deployment hosts; a foreign registry's instrument reports `null` +fields until `registry-client` implements the standard's off-ledger +`metadata-v1` API. -### `GET /v1/orders?trader=:party` → `Order[]` - -Open orders for a specific trader. **400** if `trader` is missing. - -### `GET /v1/orders/book?pair=BASE/QUOTE` → `{ bids, asks }` -### `GET /v1/orders/matches?pair=BASE/QUOTE` → `{ matches }` +```json +[ { "instrumentId": "BTC", "symbol": "BTC", "decimals": 8, "isin": null, "cusip": null, "description": null } ] +``` -Each entry carries only the terms: `price`, `quantity`, `buyOrderCid`, -`sellOrderCid`. The orders themselves name their traders and allocations and -are not served here. +### Reads — order book -Resting book and crossable pairs for one market. `?base="e=` is also -accepted. **400** if neither form is supplied. +| Method · Path | Purpose | +|---|---| +| `GET /v1/orders?trader=` | Open orders for one trader (**400** without `?trader=`) | +| `GET /v1/orders/book?pair=BASE/QUOTE` | Resting bids and asks for one market | +| `GET /v1/orders/matches?pair=BASE/QUOTE` | Crossable pairs — a read-only preview | -### `GET /v1/holdings?owner=:party` → `Holding[]` +Both pair-scoped reads also accept `?base="e=`, and return **400** if +neither form resolves. `/v1/orders/matches` projects each cross down to its +terms — `price`, `quantity`, `buyOrderCid`, `sellOrderCid`. The `Order` +contracts themselves name their traders and allocations and are not served here; +the operator route that *acts* on a match +([`POST /v1/orders/match`](#order-lifecycle)) sits behind the operator token. -Holdings for the owner. **400** if `owner` is missing. Returns per-contract -(UTXO-style) rows. For a summed balance, use `/v1/balances` below. +### Reads — account -### `GET /v1/balances?owner=:party` → `Balance[]` +| Method · Path | Purpose | +|---|---| +| `GET /v1/holdings?owner=` | Per-contract (UTXO-style) holding rows (**400** without `?owner=`) | +| `GET /v1/balances?owner=` | The holding rows summed per instrument, `available` vs `locked` | -Aggregated balances for the owner: the `/v1/holdings` rows summed per -instrument, with `locked` (in open orders / swaps / allocations) split from -`available`. **400** if `owner` is missing. Exact decimal math. +`/v1/balances` saves every client re-deriving a balance from the UTXO-style +rows. `locked` is the portion committed to open orders, swaps, or allocations; +the split is exact decimal math: ```json [ @@ -83,294 +180,309 @@ instrument, with `locked` (in open orders / swaps / allocations) split from ] ``` -### `GET /v1/instruments` → `Instrument[]` +### Reads — history, stats, indexer-backed -Known instruments with metadata: `decimals` from the registry's -`InstrumentConfig`, `isin`/`cusip`/`description` from `InstrumentConfiguration`, -merged by `instrumentId` and unioned with the instruments referenced by active -pools (so it is populated even before any config is registered; fields are -`null` where no config exists). `symbol` is the instrument id. Optional -`?ids=BTC,USDC` filters. +These read the SQLite indexer and return **503** when the server was started +without a `db` handle. -Both config templates are `signatory admin`, so this reads as the configured -`admin` and `lpRegistrar` rather than as the operator. Metadata is therefore -available only for instruments issued by a registry this deployment hosts; a -foreign registry's instrument reports `null` until `registry-client` -implements the standard's off-ledger `metadata-v1` API. +| Method · Path | Purpose | Auth | +|---|---|---| +| `GET /v1/trades?trader=&pair=&limit=` | RFQ `MatchedTrade`s + the `SettledTrade` each order-book fill writes | open / **admin** unfiltered | +| `GET /v1/swaps?pair=&kind=&limit=` | Pool history; `kind` ∈ `swap`,`add_liquidity`,`remove_liquidity`,`state_change` (default `swap`) | open | +| `GET /v1/rfq/history?trader=&limit=` | Settled RFQ acceptances (trader, pair, winning dealer, rank) | open / **admin** unfiltered | +| `GET /v1/price-history?pair=&hours=` | Price points from the swaps feed (`hours` 1–720, default 24) | open | +| `GET /v1/stats/24h?pair=` | 24h price change, volume, swap count | open | +| `GET /v1/dealers` | Dealer registry — public list | open | -```json -[ { "instrumentId": "BTC", "symbol": "BTC", "decimals": 8, "isin": null, "cusip": null, "description": null } ] -``` +`/v1/trades` matches `?trader=` on either side: a party is `trader` on the +trades it initiated and `counterparty` on those it was matched into. `dealer` is +a role, set only where a signed policy receipt names one, so it is `null` on +order-book fills. On `/v1/swaps`, `inputAmount`/`outputAmount` are derived +textually from the signed reserve deltas the indexer stores — a positive +`baseDelta` means the pool gained base, i.e. the swapper sent base and received +quote — so the stored scale survives. `/v1/stats/24h`'s `priceChange24h` is the +one genuinely float-valued field on the API: it is a ratio, not an amount. -### Historical rows and the exactness cutover +### Reads — RFQ -The exactness and trade-labelling fixes are **forward-only**: rows written -before them keep the values they were recorded with. A pre-cutover swap can -differ from the ledger in the last decimal place, and a pre-cutover trade can -carry `trader` and `dealer` inverted on a buy. +| Method · Path | Purpose | Auth | +|---|---|---| +| `GET /v1/rfq?owner=` | RFQs and quotes scoped to one party | open / **admin** unfiltered | -`scripts/reindex-derived.ts` recomputes both in place, from data the indexer -already stores, with no ledger read: +A trader sees the RFQs they raised or were whitelisted for; a dealer sees the +quotes they posted or received. The operator observes *every* RFQ and quote — who +is asking, on what, in what size, and the price each dealer answered — so the +unscoped sweep is admin-only: -```bash -node --import tsx scripts/reindex-derived.ts --db --dry-run -node --import tsx scripts/reindex-derived.ts --db +```json +{ "rfqs": [ /* Rfq[] */ ], "quotes": [ /* RfqQuote[] */ ] } ``` -It reports every row it would change before changing anything, and is -idempotent. Run it once after upgrading, or expect the disagreement when -backfilling history. - -### `GET /v1/trades?trader=&pair=&limit=` → indexer rows - -Trade history for one trader: RFQ `MatchedTrade`s and the `SettledTrade` each -order-book fill writes as it settles. **400** without `?trader=`, unless the -request carries the admin token: a row names both parties. **503** if the -indexer is not configured. - -`?trader=` matches either side: a party appears as `trader` on the trades -it initiated and as `counterparty` on those it was matched into. `dealer` is a -role and is set only where a signed policy receipt names one, so it is null on -order-book fills; `counterparty` is populated either way. - -### `GET /v1/swaps?pair=&limit=` → indexer rows - -Amounts are strings, at the stored 10-decimal scale. `inputAmount` and -`outputAmount` are derived from the signed deltas textually, never through a -float. - -Pool swap history from the indexer. Swaps only: an LP add/remove and a -pause/resume also rotate the pool state, and are recorded with a `kind` of -`add_liquidity` / `remove_liquidity` / `state_change` rather than served here. - -### `GET /v1/rfq?owner=:party` → `{ rfqs: Rfq[], quotes: RfqQuote[] }` - -RFQs and quotes for one party: the RFQs they raised or were whitelisted for, -and the quotes they posted or received. **400** without `?owner=`, unless the -request carries the admin token: the operator observes every RFQ and quote, -so the unfiltered view is admin-only. +The `Pool`, `DexPair`, `Order`, `Holding`, `Balance`, and `Instrument` shapes are +defined in +[`services/operator-backend/src/types.ts`](../../services/operator-backend/src/types.ts). -### `GET /v1/rfq/history?trader=&limit=` → indexer rows - -Settled RFQ acceptances for one trader. **400** without `?trader=`, unless the -request carries the admin token: each row names the trader, the pair, the -winning dealer and its rank. - -### `GET /v1/admin/config` → `Record` +--- -All operator config key-values. +## Quote -## Quote endpoint +| Method · Path | Purpose | Auth | +|---|---|---| +| `POST /v1/swaps/quote` | Exact off-ledger swap quote | open | -### `POST /v1/swaps/quote` +A quote is advisory — the on-ledger `PoolRules_Swap` choice re-derives the output +from the current reserves and settles against that value, so the operator cannot +quote one number and settle another. Because the endpoint runs the *same* +function off-ledger, preview and settlement agree to the last digit (see +[Pricing](../concepts/pricing.md)). Supply `poolCid`; `poolId` is also accepted +and resolves either the ContractId or the logical id (e.g. `"BTC-USDC"`). ```json -// request — supply `poolCid` (the pool ContractId). `poolId` is also accepted -// and resolves EITHER the ContractId OR the logical pool id (e.g. "BTC-USDC"). +// request { "poolCid": "#2:0", "inputInstrumentId": "BTC", "inputAmount": "0.5" } -// response — the output plus the fields a trading client would otherwise -// recompute from reserves + feeBps (all exact, no floats): +// response — the output plus the fields a client would otherwise recompute { "outputAmount": "9496.5947516312", - "inputAmount": "0.5", - "inputInstrumentId": "BTC", - "outputInstrumentId": "USDC", + "inputInstrumentId": "BTC", "outputInstrumentId": "USDC", "feeBps": 30, - "feeAmount": "0.0015000000", // fee actually applied to the input - "executionPrice": "18993.18...", // output per unit input - "spotPrice": "20000.00...", // pre-trade reserve mid - "priceImpact": "0.0503...", // (spot - execution) / spot - "poolCid": "#2:0", - "poolId": "BTC-USDC" + "feeAmount": "0.0015000000", // fee applied to the input + "executionPrice": "18993.18...", // output per unit input + "spotPrice": "20000.00...", // pre-trade reserve mid + "priceImpact": "0.0503...", // (spot − execution) / spot + "poolCid": "#2:0", "poolId": "BTC-USDC" } ``` -Advisory; the on-ledger `PoolRules_Swap` choice re-validates with the latest -reserves. +--- ## Write endpoints -All POST endpoints return **400** for malformed JSON or missing required -fields, **413** if the body exceeds 1 MiB. - -### `POST /v1/rfq` - -Create an RFQ on a trader's behalf. +All writes below return **400** for malformed JSON or a missing/invalid field +(amounts must be `Decimal` strings, parties canonical `hint::fingerprint`, cids +non-empty), **413** over 1 MiB, and require the **operator** token unless noted. +Field-level specs live in +[`services/operator-backend/src/http/validate.ts`](../../services/operator-backend/src/http/validate.ts). + +### The two-call DvP pattern + +Every pool swap and LP move is a delivery-versus-payment settlement, and the +operator holds only one side of it. So each runs as two operator calls around one +wallet step: + +1. **`POST …/request`** — the operator opens the flow (for LP, by creating a + `LiquidityAllocationRequest`) and returns the allocation specs, factories, + choice contexts, and disclosures the wallet needs, alongside a quote. +2. The trader's **wallet** authors the allocations via + `AllocationFactory_Allocate`, locking the trader's funds under the trader's own + authority. +3. **`POST …/settle`** (or `POST /v1/pools/swap` for a swap) — the operator, and + the `lpRegistrar` on LP moves, exercises the settle choice: funds enter or + leave the pool and LP tokens mint or burn, atomically. + +If a wallet returns only an `updateId` (no created-event tree), +`POST /v1/pools/recover-dvp-allocations` recovers the created allocation cids +from the transaction tree so the settle can still be assembled. + +### Pool — swap and liquidity + +| Method · Path | Purpose | +|---|---| +| `POST /v1/pools/swap/request` | Open a swap; returns the allocation spec + choice context | +| `POST /v1/pools/swap` | Settle with the wallet-created allocation (`PoolRules_Swap`) | +| `POST /v1/pools/add-liquidity/request` | Open add-LP; create `LiquidityAllocationRequest`, return quote + specs | +| `POST /v1/pools/add-liquidity/settle` | `PoolLiquidityRules_SettleAddLiquidity` (operator + lpRegistrar) | +| `POST /v1/pools/remove-liquidity/request` | Open remove-LP | +| `POST /v1/pools/remove-liquidity/settle` | `PoolLiquidityRules_SettleRemoveLiquidity` (operator + lpRegistrar) | +| `POST /v1/pools/recover-dvp-allocations` | Recover created allocation cids from an `updateId`-only receipt | + +**An add off the reserve ratio is only partly taken.** LP tokens are minted +against whichever leg is short relative to the pool's ratio +(`min((base·S)/rb, (quote·S)/rq)`), and only the matching part of the other leg +enters the reserves. The `add-liquidity/request` response reports both parts, so +the receipt reflects what actually settled rather than what was asked: ```json -{ "trader": "...", "rfqId": "...", "pair": "BTC/USDC", "side": "RFQ_Buy", - "size": "0.5", "expiresAt": "2026-...", "whitelist": [...], "createdAt": "..." } +{ + "requestCid": "...", + "lpAmount": "29.6531048680", + "matchedBaseAmount": "0.1000000000", "matchedQuoteAmount": "8847.7436669408", + "refundedBaseAmount": "0.0000000000", "refundedQuoteAmount": "1152.2563330592", + "offRatioBps": "1152.2563330592", + "knownTotalLpSupply": "1581.0163443902", + "baseAmount": "0.1", "quoteAmount": "10000.0" +} ``` -### `POST /v1/rfq/:cid/cancel` → `204` +`matchedBaseAmount`/`matchedQuoteAmount` are the parts the minted LP tokens +represent and that reach the pool; `refundedBaseAmount`/`refundedQuoteAmount` are +the remainder, which `PoolLiquidityRules_SettleAddLiquidity` returns to the +depositor in the same transaction. At the reserve ratio both remainders are +zero, and the first deposit into an unfunded pool sets the ratio. -Cancel an open RFQ. +The optional `maxOffRatioBps` (`0`..`10000`) refuses the request with **400** +when `offRatioBps` exceeds it, before any contract is created — use it when a +partly-filled add is not what you want. Decimal rounding alone can leave a +sub-bps remainder on an otherwise on-ratio deposit, so `0` is stricter than it +looks; `1` is the usual "on ratio" check. -### `POST /v1/rfq/accept` +### Order lifecycle -Operator + trader co-sign the accept. Returns `{ tradeCid, receipt }`. +| Method · Path | Purpose | +|---|---| +| `POST /v1/orders/bind` | Bind a funded order to a settlement ref (full-tree or `updateId` recovery) | +| `POST /v1/orders/fund` | Fund a bound order | +| `POST /v1/orders/:cid/cancel` | Cancel an open order (**204**) | +| `POST /v1/orders/match` | Discover crossing orders and settle each atomically | -### `POST /v1/orders/bind`, `POST /v1/orders/fund`, `POST /v1/orders/:cid/cancel` +`POST /v1/orders/match` catches per match so one bad pair cannot stop the rest, +and reports the outcome in its status: **200** when all settled, **207** when +some failed, **502** when every one did. -Order lifecycle. See `services/operator-backend/src/order/index.ts` for -the input shapes (the HTTP shim is a thin pass-through). +```json +{ "matches": [ /* per-match results */ ], "settled": 3, "failed": 0 } +``` -### `POST /v1/pools/swap` +### Matched-trade (OTC) settlement -Operator-driven `PoolRules_Swap` exercise. The dApp first calls -`POST /v1/pools/swap/request`, passes the returned allocation spec + -choice context to the wallet, and then sends the wallet-created allocation -CID to this endpoint. +| Method · Path | Purpose | +|---|---| +| `POST /v1/matched-trades/request-allocations` | `MatchedTrade_RequestAllocations` | +| `POST /v1/matched-trades/settle` | `MatchedTrade_Settle` — one allocation batch per admin | +| `POST /v1/matched-trades/cancel` | `MatchedTrade_Cancel` — release allocations | -### `POST /v1/pools/add-liquidity/request` +`settle` and `cancel` carry a `batchesByAdmin` / `allocationsByAdmin` object +keyed by admin party; each admin's batch must cover exactly its own legs, or the +request is rejected with **400** before it reaches the ledger. -Operator opens the add-liquidity flow by creating a -`LiquidityAllocationRequest`. The trader's wallet then authors the -base-deposit, quote-deposit, and LP-receipt allocations via -`AllocationFactory_Allocate`. +### RFQ + +| Method · Path | Purpose | +|---|---| +| `POST /v1/rfq` | Create an RFQ on a trader's behalf | +| `POST /v1/rfq/:cid/cancel` | Cancel an open RFQ (**204**) | +| `POST /v1/rfq/accept` | Operator + trader co-sign the accept → `{ tradeCid, receipt }` | ```json -// request -{ - "poolCid": "#2:0", - "recipient": "lp::1220ab...", - "baseAmount": "0.1", - "quoteAmount": "10000.0", - "requestedAt": "2026-07-01T00:00:00Z", - "maxOffRatioBps": "50" // optional, see below -} -// response (the quote fields; the allocation specs, factories, choice -// contexts and disclosures the wallet needs are returned alongside them) -{ - "requestCid": "...", - "lpAmount": "29.6531048680", - "matchedBaseAmount": "0.1000000000", - "matchedQuoteAmount": "8847.7436669408", - "refundedBaseAmount": "0.0000000000", - "refundedQuoteAmount": "1152.2563330592", - "offRatioBps": "1152.2563330592", - "knownTotalLpSupply": "1581.0163443902", - "baseAmount": "0.1", - "quoteAmount": "10000.0" -} +// POST /v1/rfq +{ "trader": "...", "rfqId": "...", "pair": "BTC/USDC", "side": "RFQ_Buy", + "size": "0.5", "expiresAt": "2026-...", "whitelist": [], "createdAt": "..." } ``` -**A deposit off the reserve ratio is only partly taken.** LP tokens are minted -against whichever leg is short relative to the pool's reserve ratio -(`min((base·S)/rb, (quote·S)/rq)`), and the settle takes only the matching part -of the other leg into the reserves. `matchedBaseAmount` / `matchedQuoteAmount` -are the parts of the deposit the minted LP tokens represent and that reach the -pool; `refundedBaseAmount` / `refundedQuoteAmount` are the remainder, which -`PoolLiquidityRules_SettleAddLiquidity` refunds to the depositor in the same -transaction (the settle result reports what it took as `baseAdded` / -`quoteAdded`). In the example above, 1152.26 dUSD (11.5% of the quote leg) -comes back. At the reserve ratio both remainders are zero, and the first -deposit into an unfunded pool sets the ratio, so nothing is left over there -either. - -`maxOffRatioBps` (optional, `0`..`10000`, number or Decimal string) refuses -the request with **400** when `offRatioBps` exceeds it, before any contract is -created. Omitting it means no ceiling: the historical behaviour. Use it when -a partly-filled add is not what you want: the excess is refunded rather than -lost, but the position you get is smaller than the amounts you sent. -Decimal rounding alone can leave a sub-bps remainder on an otherwise on-ratio -deposit, so `0` is stricter than it looks; `1` is the usual "on ratio" check. -Compute the paired amount from `GET /v1/pools` reserves to stay on ratio, or -send a tolerance and re-quote when it refuses. - -### `POST /v1/pools/add-liquidity/settle` - -Operator + lpRegistrar settle (`PoolLiquidityRules_SettleAddLiquidity`): funds -enter the pool and LP tokens are minted to the LP, atomically. - -### `POST /v1/pools/remove-liquidity/request` - -Operator opens the remove-liquidity flow by creating a -`LiquidityAllocationRequest`. The trader's wallet then authors the -base-receipt, quote-receipt, and LP burn-sender allocations. - -### `POST /v1/pools/remove-liquidity/settle` - -Operator + lpRegistrar settle (`PoolLiquidityRules_SettleRemoveLiquidity`): -base + quote are delivered to the holder and the LP tokens burn to the -burn account, atomically. - -## Authentication - -**All state-changing routes require operator authorization**, not only -`/v1/admin/*`, but also the trader-facing writes (`/v1/pools/swap*`, -`/v1/rfq`, `/v1/orders/*`). They return **401** unless `DEX_OPERATOR_API_TOKEN` -is configured (send `Authorization: Bearer `) or, on the in-memory dev -server only, `DEX_DEV_OPEN=1` is set. Read (GET) routes need no auth. Admin -routes additionally require the `OPERATOR_ADMIN_TOKEN`. See -[Local Setup → Exercising write paths](../getting-started.md#exercising-write-paths-in-demo-mode). - -## Admin endpoints +`cancel` and `accept` act as the *fetched* RFQ's `trader`, which a body-field +binding cannot reach. With per-caller binding on, both resolve the caller from +the `X-Caller-Token` and reject a mismatch (**403**), so an operator-token holder +cannot cancel or accept on another trader's behalf. + +### Admin + +The admin routes require the **admin** token, except the config *read*, which is +open. The dealer routes additionally require the indexer (**503** without it). + +| Method · Path | Purpose | +|---|---| +| `POST /v1/admin/pairs` | Create a `DexPair` → `{ pairCid }` | +| `POST /v1/admin/pairs/:cid/fee-model` | Update the pair's fee model | +| `POST /v1/admin/pairs/:cid/active` | Activate / deactivate a pair | +| `POST /v1/admin/pairs/:cid/trading-mode` | Set the pair's trading mode | +| `POST /v1/admin/pools` | Create a pool → `{ poolCid }` | +| `GET /v1/admin/config` | Dump operator config (open read) | +| `PUT /v1/admin/config` | Set a key `{ key, value }` | +| `DELETE /v1/admin/config/:key` | Delete a key | +| `PUT /v1/admin/dealers` | Upsert a dealer | +| `DELETE /v1/admin/dealers/:party` | Remove a dealer | + +The pass-through bodies for the pair/pool routes are the service inputs in +[`services/operator-backend/src/admin/index.ts`](../../services/operator-backend/src/admin/index.ts). + +### Wallet relay — dev only + +| Method · Path | Purpose | Auth | +|---|---|---| +| `POST /v1/wallet/submit` | Forward shaped ledger commands under the operator JWT | operator + flag | + +Off by default: it returns **404** unless `DEX_DEV_WALLET_RELAY=1`. When on, the +forwarded `actAs` parties must be on the `DEX_DEV_RELAY_PARTIES` allowlist (else +**403**), the `commands` array and `commandId` are shape-checked, and the relay +follows the committed transaction tree to return the created allocation cids the +DvP settle path needs. It is a convenience for the walletless demo, not a +production authority path. -### `POST /v1/admin/pairs` → `{ pairCid }` -### `POST /v1/admin/pairs/:cid/fee-model` → `{ pairCid }` -### `POST /v1/admin/pairs/:cid/active` → `{ pairCid }` -### `POST /v1/admin/pairs/:cid/trading-mode` → `{ pairCid }` -### `POST /v1/admin/pools` → `{ poolCid }` -### `PUT /v1/admin/config` body `{ key, value }` -### `DELETE /v1/admin/config/:key` +--- ## Wallet intent shapes -The frontend never calls the on-chain ledger directly for trader- -authority writes. Instead it hands intents to the active -`WalletProvider`. Intent shapes are defined in -`app/web/src/wallet/types.ts`: +For trader-authority writes, the frontend hands an intent to the active +`WalletProvider` rather than calling the ledger — the wallet holds the trader's +key, this backend does not. Shapes are in +[`app/web/src/wallet/types.ts`](../../app/web/src/wallet/types.ts): | Intent | When | -|--------|------| +|---|---| | `AcceptAllocationRequestIntent` | Trader allocates holdings for an open order or swap | | `PlaceOrderIntent` | Trader places a new order | | `RequestSwapIntent` | Trader initiates a pool swap | -| `AddLiquidityIntent` | Trader authors the base/quote/LP-receipt allocations for an add-liquidity request | -| `RemoveLiquidityIntent` | Trader authors the base/quote-receipt and LP burn-sender allocations for a remove-liquidity request | +| `AddLiquidityIntent` | Trader authors the base / quote / LP-receipt allocations for an add | +| `RemoveLiquidityIntent` | Trader authors the base / quote-receipt and LP burn-sender allocations for a remove | | `PostRfqQuoteIntent` | Dealer posts a quote on an RFQ | -| `AcceptRfqIntent` | Trader accepts a dealer's quote (co-signed with operator) | +| `AcceptRfqIntent` | Trader accepts a dealer's quote (co-signed with the operator) | + +The wallet also exposes `SplitHoldingIntent` / `MergeHoldingsIntent` for holding +management; those are a wallet concern and have no operator endpoint. -## Error codes +--- + +## Status and error codes | `code` | HTTP | Meaning | -|--------|------|---------| -| `bad_request` | 400 | malformed JSON, missing fields, invalid types | -| `unauthorized` | 401 | missing or invalid admin token | -| `not_found` | 404 | route or resource not found | +|---|---|---| +| `bad_request` | 400 | malformed JSON, missing field, invalid amount / party / cid | +| `unauthorized` | 401 | missing or invalid operator / admin token | +| `forbidden` | 403 | per-caller party mismatch, or wallet-relay `actAs` not allowlisted | +| `not_found` | 404 | route or resource not found (also the disabled wallet relay) | | `payload_too_large` | 413 | body > 1 MiB | +| `not_supported` | 501 | a demo-mode limitation surfaced cleanly, not a server fault | | `internal_error` | 500 | unexpected server error | +Beyond the enveloped codes, `POST /v1/orders/match` returns **207** / **502** for +partial / total settlement failure, the wallet relay returns **502** +(`tree_fetch_failed`) when a committed transaction's tree cannot be fetched, and +indexer- or config-gated routes return a bare-`{ error }` **503** when their +store is absent. + +--- ## Examples -All examples assume the local backend on `http://localhost:8080`. Reads need no -auth; `/v1/admin/*` writes need `Authorization: Bearer $OPERATOR_ADMIN_TOKEN`. +Reads need no auth; `/v1/admin/*` writes need `Authorization: Bearer +$OPERATOR_ADMIN_TOKEN`, other writes `Authorization: Bearer +$DEX_OPERATOR_API_TOKEN` (or `DEX_DEV_OPEN=1` on the dev server). ```bash -# Read: trading pairs, pools, and a trader's holdings -curl -s http://localhost:8080/v1/pairs | python3 -m json.tool -curl -s http://localhost:8080/v1/pools | python3 -m json.tool -curl -s "http://localhost:8080/v1/holdings?owner=$TRADER" | python3 -m json.tool +# Reads: pairs, pools, a trader's aggregated balance +curl -s http://localhost:8080/v1/pairs | python3 -m json.tool +curl -s http://localhost:8080/v1/pools | python3 -m json.tool +curl -s "http://localhost:8080/v1/balances?owner=$TRADER" | python3 -m json.tool # Advisory swap quote (re-validated on-ledger by PoolRules_Swap) curl -s -X POST http://localhost:8080/v1/swaps/quote \ - -H 'Content-Type: application/json' \ - -d '{"poolId":"","inputInstrumentId":"BTC","inputAmount":"0.5"}' -# -> {"outputAmount":"9852.14..."} + -H 'content-type: application/json' \ + -d '{"poolCid":"#2:0","inputInstrumentId":"BTC","inputAmount":"0.5"}' +# -> {"outputAmount":"9496.59...", ...} -# Create an RFQ on a trader's behalf +# Create an RFQ on a trader's behalf (operator token) curl -s -X POST http://localhost:8080/v1/rfq \ - -H 'Content-Type: application/json' \ + -H "authorization: Bearer $DEX_OPERATOR_API_TOKEN" -H 'content-type: application/json' \ -d '{"trader":"'"$TRADER"'","rfqId":"rfq-1","pair":"BTC/USDC","side":"RFQ_Buy", "size":"0.5","expiresAt":"2026-12-31T00:00:00Z","whitelist":[],"createdAt":"2026-07-01T00:00:00Z"}' ``` -The `POST` bodies for the order lifecycle (`/v1/orders/bind`, `/v1/orders/fund`), -the pool DvP settle endpoints, and `/v1/admin/*` are the pass-through inputs -defined in `services/operator-backend/src/{order,pool,matched-trade,admin}/index.ts`. +> **Historical rows.** The exactness and trade-labelling fixes are forward-only: +> rows written before them keep their recorded values, so a pre-cutover swap can +> differ in the last decimal and a pre-cutover buy can carry `trader`/`dealer` +> inverted. +> [`services/operator-backend/scripts/reindex-derived.ts`](../../services/operator-backend/scripts/reindex-derived.ts) +> recomputes both in place (idempotent, `--dry-run` first) with no ledger read. --- -**Where to read next:** [Builder Guide](../guides/builder-guide.md) · [Choice Context](../guides/choice-context.md) · [All docs](../README.md) +**Where to read next:** [Builder Guide](../guides/builder-guide.md) · [Choice Context](../guides/choice-context.md) · [Allocation surface](allocation-surface.md) · [Pricing](../concepts/pricing.md) diff --git a/docs/reference/testing.md b/docs/reference/testing.md index f0440f02..86cc7c78 100644 --- a/docs/reference/testing.md +++ b/docs/reference/testing.md @@ -1,67 +1,223 @@ -# Canton-backed end-to-end integration test +# Testing + +This reference proves itself in layers. The Daml core is exercised by +in-script suites that run on an in-memory ledger with no Canton process at +all; the operator backend and the dApp have their own unit and integration +suites; and a small set of end-to-end paths drive the whole stack against a +live Canton participant. The design decision throughout is to test each +guarantee at the lowest layer that can hold it — value conservation and +authorization in Daml, projection and idempotency in the backend, command +composition in the dApp — and to reserve the slow, ledger-backed tests for the +seams that only a real engine exercises. + +| Layer | What it proves | Runner | Command | +|---|---|---|---| +| Daml in-script suites | choice logic, conservation, authorization, rounding | Daml Script (in-memory) | `dpm test` in `trading-tests/` | +| Backend | HTTP surface, matching, indexer projection, idempotency, auth | `node:test` (InMemoryLedger) | `npm test` in `services/operator-backend` | +| dApp | wallet-intent → command composition, funding planners, providers | Vitest + jsdom | `npm test` in `app/web` | +| HTTP smoke | every endpoint answers, auth gate holds | Bash + curl (InMemoryLedger) | `bash scripts/e2e-smoke.sh` | +| Live ledger | the JSON Ledger API driver + real settlement | `node:test` / `tsx` (Canton) | `CANTON_E2E=1 npm test`; `npm run localnet:dvp-e2e` | + +Everything above the last row runs offline and is what CI gates on. The last +row needs a Canton participant and is opt-in. + +## Daml in-script suites (`trading-tests/`) + +These are Daml Script tests: each is a `Script ()` that allocates parties, +submits commands, and asserts on the resulting contracts, all on the script +runner's in-memory ledger. No Canton, no JSON API, no backend — just the Daml +engine enforcing the same authorization and consumption rules it enforces in +production. Run them with: -Replaces the in-memory test harness with a real ledger driver -(`services/operator-backend/src/ledger/json-api.ts`) and a test that -runs the operator backend's RFQ accept flow against a live Canton -participant. +```bash +bash scripts/run-local-daml-tests.sh +``` -## What it verifies +which builds the `canton-dex-trading` DAR against the committed Token Standard +DARs (`scripts/build-trading-surface.sh`) and then runs both the core suite and +the reuse example. By hand: -The test covers the same ground as the existing `rfq.test.ts` -(`InMemoryLedger`-driven), but going through the real Daml engine -on a Canton participant via the JSON Ledger API: +```bash +(cd trading && dpm build) # -> trading/.daml/dist/canton-dex-trading-0.1.4.dar +(cd trading-tests && dpm test) # every script reports "ok" (97 scripts at time of writing) +``` -- `JsonApiLedger.submit` correctly serializes `submit-and-wait` - envelopes with `actAs`, `commandId`, and `disclosedContracts`. -- `Rfq` and `RfqQuote` creates land on-ledger. -- `RfqService.accept` co-submits `Rfq_Accept` under - `[trader, operator]`; the choice computes its own ranking + - receipt and creates a `MatchedTrade` whose - `policyReceipt` matches what the operator backend computed - off-chain. -- `verifyReceipt` (digest replay) holds against the on-chain receipt. +### The registry-fixture ladder + +Most of the suites differ not in the workflow they drive but in the *registry* +they drive it against, and that choice is load-bearing. A settlement bug can +hide behind a registry that doesn't hold real value, so the suites climb a +ladder from cheap-but-blind to slow-but-honest: + +| Fixture | Holds real holdings? | Good for | Used by | +|---|---|---|---| +| `MockRegistry` | no (empty `inputHoldingCids`) | choice plumbing, multi-party authority | `EndToEndTests` | +| `DexRegistry` over `MockRegistry` | no | the `RegistryApi` interface handshake | `TokenStandardHarnessTests` | +| `CantonDex.Registry.V2` | yes (locks, credits, mint/burn accounts) | settlement, conservation, DvP | `PoolLiquidityRulesTests`, `RegistryConservationTests`, `RfqSettlementTests`, `PoolStateInvariantTests`, `DvpMintBurnTests` | +| upstream `TestTokenV2_RegistryV2` | yes, with a real disclosed `TokenRules` context | cross-registry settlement, per-admin choice context | `RealRegistryDvpTests` | + +The header of [`RfqSettlementTests.daml`](../../trading-tests/CantonDex/Tests/RfqSettlementTests.daml) +records why the ladder exists: three separate wire shapes shipped wrong and +every one of them passed a holding-less harness test, because the harness +archives allocations without moving value. Settlement is only proven where +holdings really move — against `Registry.V2` and the upstream registry. + +### What each suite proves + +| Suite | Scripts | Proves | Fixture | +|---|---|---|---| +| [`InstrumentTests.daml`](../../trading-tests/CantonDex/Tests/InstrumentTests.daml) | 6 | instrument config create/update; mint request → registrar accept (with credential check) and requestor cancel; burn accept; transfer offer → accept and via `TransferPreapproval`; open issuance | instrument templates | +| [`EdgeCaseTests.daml`](../../trading-tests/CantonDex/Tests/EdgeCaseTests.daml) | 5 | rejection paths the happy-path suites skip: zero/negative mint and burn amounts (`ensure` clauses), mint-accept on `instrumentId` mismatch or missing issuer credentials | instrument templates | +| [`PolicyReceiptTests.daml`](../../trading-tests/CantonDex/Tests/PolicyReceiptTests.daml) | 10 | `PolicyReceipt` + `MatchedTrade` shape invariants: `policyReceiptValues` encoding, `foldPolicyReceiptIntoMetadata`, `isWellFormed`, and the authority guard that rejects a receipt whose `signedBy` is not the venue | pure | +| [`PoolRoundingTests.daml`](../../trading-tests/CantonDex/Tests/PoolRoundingTests.daml) | 5 | pool arithmetic always rounds in the pool's favour, so a swap, deposit, or withdrawal can never quietly pay out more than it should | pure (`PoolModel`) | +| [`EndToEndTests.daml`](../../trading-tests/CantonDex/Tests/EndToEndTests.daml) | 19 | the whole exchange front-to-back: pool funding, order funding (`OrderFundingRequest` → `Order_Fund`), `OrderMatchExecution_Execute` (limit-price, atomic forward-roll, closing an unbacked remainder), RFQ accept → `MatchedTrade` + `PolicyReceipt`, `PoolRules_Swap`, and DvP choice-context threading | `MockRegistry` | +| [`TokenStandardHarnessTests.daml`](../../trading-tests/CantonDex/Tests/TokenStandardHarnessTests.daml) | 1 | the matched-trade flow driven through the `RegistryApi` interface, mirroring `splice-token-standard-test-v2`'s `TradingAppV2` exercise | `DexRegistry` | +| [`PoolLiquidityRulesTests.daml`](../../trading-tests/CantonDex/Tests/PoolLiquidityRulesTests.daml) | 16 | DvP liquidity against real holdings: an atomic add funds base + quote and mints LP tokens in one flow; remove delivers base + quote to the holder and burns LP via the burn account; stale-quote rejection; the settle is co-controlled by operator + `lpRegistrar` | `Registry.V2` | +| [`PoolStateInvariantTests.daml`](../../trading-tests/CantonDex/Tests/PoolStateInvariantTests.daml) | 5 | `PoolState.reserves` always equals the sum of the live `PoolSlice` holdings: `PoolRules_ReconcileState` succeeds across an add → swap → remove lifecycle and fails on an omitted slice, an operator-fabricated state, or a foreign slice | `Registry.V2` | +| [`DvpMintBurnTests.daml`](../../trading-tests/CantonDex/Tests/DvpMintBurnTests.daml) | 2 | the delivery-versus-mint/burn mechanism on the V2 allocation surface: a mint credits the recipient, a burn archives with no credit. Its header also documents, deliberately, that the shipped test registry does *not* gate mint authorization | `Registry.V2` | +| [`RegistryConservationTests.daml`](../../trading-tests/CantonDex/Tests/RegistryConservationTests.daml) | 16 | settle-time conservation in the reference registry: an executor cannot draw more than the allocation's locked backing; roll-forward carries real locked backing; surplus returns to the authorizer; the `SettlementFactory` batch rejects per-instrument imbalance and coverage mismatches | `Registry.V2` | +| [`RfqSettlementTests.daml`](../../trading-tests/CantonDex/Tests/RfqSettlementTests.daml) | 4 | the RFQ round trip against real holdings: each side funds its own leg from its own inventory; a dealer stocked with the wrong asset fails at allocation *after* `Rfq_Accept` has consumed the RFQ; an expiry between accept and settle blocks the settle; one lapsed quote aborts the accept | `Registry.V2` | +| [`RealRegistryDvpTests.daml`](../../trading-tests/CantonDex/Tests/RealRegistryDvpTests.daml) | 6 | the per-admin choice context against a genuinely context-requiring upstream registry: a DvP add settles across two registries in one transaction (base/quote under `TestTokenV2_RegistryV2`, the LP mint under `Registry.V2`), and dropping the real disclosed context aborts the settle | `TestTokenV2_RegistryV2` + `Registry.V2` | + +A concept doc points at several of these as its worked proof — for example the +rounding rules in [`PoolRoundingTests.daml`](../../trading-tests/CantonDex/Tests/PoolRoundingTests.daml) +pin the exact case where naive `*` and `/` would leak value: + +```daml +testFloorDivStaysBelowExactQuotient = do + let numerator = 7000.0 : Decimal + numerator / 1007.0 === 6.9513406157 -- plain (/) rounds the last digit UP + PM.floorDiv 7000.0 1007.0 === 6.9513406156 -- floorDiv never overshoots + ... +``` + +### The reuse example (`examples/stable-pool/`) + +A separate Daml project consumes `canton-dex-trading-0.1.4.dar` as a +*data-dependency* and builds a StableSwap pool on top of it, without editing a +base template. `run-local-daml-tests.sh` runs it too; the three scripts prove +an external builder can ship a different curve on the same V2 substrate: + +```bash +(cd examples/stable-pool && dpm test) # 3 ok +``` + +## Backend tests (`services/operator-backend`) + +The backend suite runs on `node:test` against an `InMemoryLedger` that mimics +Daml choice semantics, so the HTTP surface, indexer, and pricing logic are all +tested without a Canton process. Type-check and run: + +```bash +cd services/operator-backend +npm run typecheck +npm test +``` + +The files group by concern: + +| Area | Representative files | What they cover | +|---|---|---| +| Matching & pricing | `matching.test.ts`, `pool.test.ts`, `order.test.ts`, `decimal-money.test.ts` | order-book aggregation and `matchOrdersForPair`, the AMM quote math, decimal-string money handling | +| RFQ & matched trade | `rfq.test.ts`, `matched-trade.test.ts`, `match-leg-shape.test.ts` | the RFQ accept flow end-to-end (the worked example: `RfqService.accept` → `MatchedTrade` + `PolicyReceipt`, with `verifyReceipt` digest replay), and the settlement batch wire shape | +| Indexer & idempotency | `idempotency.test.ts`, `indexer-projection-exactness.test.ts`, `indexer-migrations.test.ts`, `order-fill-recording.test.ts` | the replay/idempotency guard, exact decimal projection out of the store, schema migrations, order-fill recording | +| Auth & read scoping | `auth.test.ts`, `caller-auth.test.ts`, `read-exposure.test.ts`, `rfq-read-scoping.test.ts` | the write-route auth gate, CORS default-deny, and that party-scoped reads never over-expose | +| Ledger driver | `json-api-ledger.test.ts` | `JsonApiLedger.submit` serialization against a mocked `fetch` — create/exercise envelopes and the `updateId` → transaction-tree follow, with no live ledger | +| Docs as tests | `docs-governance-caveats.test.ts`, `docs-token-standard-scope.test.ts`, `docs-v2-only.test.ts` | assertions that keep the docs honest about scope and governance caveats | + +## dApp tests (`app/web`) + +The dApp suite runs on Vitest in a jsdom environment; a shared setup +(`src/__tests__/setup.ts`) installs jest-dom matchers and a default `fetch` +mock that shapes the backend's read endpoints, so components and services test +without a network. Run: + +```bash +cd app/web +npm test +``` + +The load-bearing seams: + +| Area | Files | What they cover | +|---|---|---| +| Command composition | `commands.test.ts` | snapshot tests of `composeCommands`: every `WalletIntent` maps to a stable set of Daml commands (the piece the wallet signs) | +| Funding planners | `ledger.test.ts`, `normalize-funding.test.ts` | `pickCoveringHoldingCids` / `pickExactHoldingCids` / `planSwapFunding`, and the read → split/merge → re-read → pick funding normalization | +| Wallet providers | `detection.test.ts`, `sdk-provider.test.ts`, `partylayer-provider.test.ts`, `walletconnect-provider.test.ts`, `wallet-store.test.ts` | wallet discovery and the one-row mapping, each provider's result shape and disconnect signal, and store lifecycle (no listener leaks) | +| UI | `pages.test.tsx`, `swap-decimal-strings.test.tsx` | page rendering against the mocked backend, and that swap inputs preserve decimal-string precision | + +## HTTP smoke test + +`scripts/e2e-smoke.sh` boots the dev backend (still `InMemoryLedger`) and curls +every key endpoint in sequence, asserting the response shape and the admin auth +gate, then shuts down. It needs only `node` and `curl` — no Canton: + +```bash +bash scripts/e2e-smoke.sh # "==> All smoke checks passed" +``` + +It walks the read endpoints (`/v1/status`, `/v1/context`, `/v1/pools`, +`/v1/pairs`, `/v1/orders`, `/v1/holdings`), a swap quote, the order book, the +price feed, and finally confirms `POST /v1/admin/pairs` is refused without auth. + +## Against a live Canton participant -The test is gated on `CANTON_E2E=1` so it stays out of the default -test run. Local runs against a sandbox take ~30s including Canton -boot. +The dev backend is in-memory. Two opt-in paths exercise the real JSON Ledger +API driver (`services/operator-backend/src/ledger/json-api.ts`) against an +actual Canton engine. -## Prerequisites +### The RFQ accept integration test (`CANTON_E2E=1`) + +`services/operator-backend/test/canton-e2e.test.ts` covers the same ground as +the in-memory `rfq.test.ts`, but routes every command through the real Daml +engine on a Canton participant. It verifies: + +- `JsonApiLedger.submit` serializes `submit-and-wait` envelopes with `actAs`, + `commandId`, and `disclosedContracts`. +- `Rfq` and `RfqQuote` creates land on-ledger. +- `RfqService.accept` co-submits `Rfq_Accept` under `[trader, operator]`; the + choice computes its own ranking + receipt and creates a `MatchedTrade` whose + `policyReceipt` matches what the backend computed off-ledger. +- `verifyReceipt` (digest replay) holds against the on-ledger receipt. -- `daml` CLI ≥ 3.4 on `$PATH`. -- The `canton-dex-trading` DAR built (`cd trading && dpm build`). +The test is gated on `CANTON_E2E=1` so it stays out of the default run; a local +sandbox run takes ~30s including Canton boot. -## Run +**Prerequisites:** `daml` CLI ≥ 3.4 on `$PATH`, and the `canton-dex-trading` +DAR built (`cd trading && dpm build`). -### 1. Boot a sandbox with the DEX DARs +**1. Boot a sandbox with the DEX DARs.** The trading DAR pulls its Token +Standard dependencies in on upload, but listing them explicitly avoids a +missing-dependency failure: ```bash daml sandbox \ --port 6865 \ --json-api-port 7575 \ --dar trading/.daml/dist/canton-dex-trading-0.1.4.dar \ - --dar trading/.daml/dist/splice-api-token-allocation-v2-current.dar \ - --dar trading/.daml/dist/splice-api-token-allocation-instruction-v2-current.dar \ - --dar trading/.daml/dist/splice-api-token-allocation-request-v2-current.dar \ - --dar trading/.daml/dist/splice-api-token-holding-v2-current.dar \ - --dar trading/.daml/dist/splice-api-token-metadata-v1-current.dar + --dar vendor/splice/dars/splice-api-token-allocation-v2-1.0.0.dar \ + --dar vendor/splice/dars/splice-api-token-allocation-instruction-v2-1.0.0.dar \ + --dar vendor/splice/dars/splice-api-token-allocation-request-v2-1.0.0.dar \ + --dar vendor/splice/dars/splice-api-token-holding-v2-1.0.0.dar \ + --dar vendor/splice/dars/splice-api-token-transfer-instruction-v2-1.0.0.dar \ + --dar vendor/splice/dars/splice-api-token-transfer-events-v2-1.0.0.dar \ + --dar vendor/splice/dars/splice-api-token-metadata-v1-1.0.0.dar ``` -(In practice the DAR depends on the others; uploading the top one -typically pulls them in. The list above is explicit so the test -won't fail on a missing dependency.) - -### 2. Allocate parties and obtain a JWT +**2. Allocate parties and obtain a JWT.** ```bash -# allocate daml ledger allocate-parties operator alice orca jump btc-admin - -# request a JWT for the operator (covers all parties via wildcard -# claims). Production deployments use a proper IAM. daml-helper request-token --party operator > /tmp/operator.jwt ``` -### 3. Run the test +The token must grant `actAs` for every party the test submits as — operator, +trader, both dealers, and the asset admin — and is sent as +`Authorization: Bearer ...` on every request. `daml-helper request-token` is +for local dev only; production deployments issue per-session tokens from a +proper IAM. + +**3. Run the test.** ```bash CANTON_E2E=1 \ @@ -75,8 +231,7 @@ CANTON_E2E=1 \ npm test --prefix services/operator-backend ``` -Expected output: the three Canton E2E cases (enabled by `CANTON_E2E=1`) -within the full backend suite: +The three Canton cases run inside the full backend suite: ``` ✔ Canton E2E: RFQ accept produces MatchedTrade with PolicyReceipt @@ -84,20 +239,12 @@ within the full backend suite: ✔ Canton E2E: rfq.cancel archives an open Rfq ``` -`npm test --prefix services/operator-backend` runs the entire backend -suite (~100 tests); the three lines above are the Canton-participant -cases. To run only the E2E file, replace the `npm test` line with +To run only this file, replace the `npm test` line with `node --import tsx --test services/operator-backend/test/canton-e2e.test.ts`. +When `CANTON_E2E` is unset, the suite emits a single skip line and the +in-memory `rfq.test.ts` still runs. -When `CANTON_E2E` is unset (or anything other than `1`), the Canton -test is automatically skipped: - -``` -﹣ Canton E2E (skipped: set CANTON_E2E=1 to enable) # SKIP -✔ RFQ accept end-to-end through operator backend -``` - -## How the JsonApiLedger driver maps to the JSON Ledger API +**How the driver maps to the JSON Ledger API:** | `LedgerSubmitter` method | JSON API call | |---|---| @@ -107,56 +254,61 @@ test is automatically skipped: | `query` | `POST /v2/state/active-contracts` | | `subscribe` | `GET /v2/updates/flats` (SSE) | -Errors are mapped from the JSON API's `{ errors: [...] }` body to -typed `LedgerError` instances. Contention errors (HTTP 409 / GRPC -ABORTED with substrings `contention` or `inconsistent`) are tagged -retryable so `retryOnContention` recovers automatically. +Errors are mapped from the JSON API's `{ errors: [...] }` body to typed +`LedgerError` instances. Contention errors (HTTP 409 / gRPC `ABORTED` carrying +`contention` or `inconsistent`) are tagged retryable, so `retryOnContention` +recovers automatically. When a case fails, the JSON API's response body is the +most useful artifact — the driver puts it in `LedgerError.detail`; set +`NODE_DEBUG=http,fetch` to see full request/response wire traffic. Common +failure modes: -## Authentication notes - -- The `CANTON_JSON_API_TOKEN` should grant `actAs` for every party - the test submits as: operator, trader, both dealers, and the asset - admin. -- Production deployments use a proper IAM that issues per-session - tokens; `daml-helper request-token` is for local dev only. -- The token is passed as `Authorization: Bearer ...` on every - request. +| Symptom | Cause | +|---|---| +| `401: invalid token` | JWT expired or scoped to the wrong party set | +| `404: template not found` | DAR not uploaded, or operator party can't see it | +| `409: contention` | Submission stale; the driver retries automatically | +| `400: requires authorizer X` | `actAs` doesn't include a party the choice needs | -## Out of scope +### The headless DvP round-trip (`localnet:dvp-e2e`) -- Pool initialization + add liquidity + swap end-to-end on a live - ledger. The `testPoolFullLifecycle` and `testPoolSwapEndToEnd` - Daml Script tests (`trading-tests/`) cover the same ground at the - on-chain level; a JSON Ledger API version can be added as an - additional integration test. -- Order placement through the `OrderFundingRequest` → - `OrderAllocationRequest` → trader-Accept → `Order_Fund` flow with - a real wallet. The wallet handoff lives in - `app/web/src/wallet/handoff.ts`; the integration test for that - needs a wallet emulator. -- The full registry HTTP API. The current test stubs `getFactories` - because the RFQ accept flow doesn't read factory CIDs. Tests that - exercise pool swaps will need a real registry-backed factory. - -## Debugging - -When a Canton test fails, the JSON API's response body is the most -useful artifact. The driver puts it in the `LedgerError.detail`. To -see full request/response wire traffic, set: +`scripts/localnet-dvp-e2e.ts` drives the one seam the browser dApp can't +automate: the trader's wallet authoring allocations. It stands in for a +CIP-0103 wallet, authoring the trader's three allocations for each DvP add and +remove, then settling — exercising the operator's full two-call flow +(request → wallet authors allocations → settle) plus a swap, against a live +LocalNet participant. From the backend (which has `tsx` on its path), with the +LocalNet `CANTON_*` environment exported: ```bash -NODE_DEBUG=http,fetch CANTON_E2E=1 ... npm test +npm run localnet:dvp-e2e --prefix services/operator-backend ``` -Common failure modes: +It is self-contained: it creates its own `Registry.V2`, registers +base/quote/LP instruments, mints to the trader, builds the pool contracts, then +runs add → swap → remove and asserts the on-ledger reserves and LP supply. -| Symptom | Cause | -|---|---| -| `401: invalid token` | JWT expired or scoped to wrong party set | -| `404: template not found` | DAR not uploaded, or operator party can't see it | -| `409: contention` | Submission stale; the driver retries automatically | -| `400: requires authorizer X` | `actAs` doesn't include all parties the choice needs | +## What CI runs + +`.github/workflows/ci.yml` gates every pull request on the offline layers: +commit-message hygiene, the Daml build plus upgrade-compatibility check +(`scripts/check-upgrade-compat.sh`), the backend typecheck + tests, the +frontend typecheck + build, and a Docker build smoke. The live-ledger paths +above are opt-in and not part of CI. + +## Out of scope + +- A pool add-liquidity + swap end-to-end over the *JSON Ledger API*. The + `PoolLiquidityRulesTests` and `RealRegistryDvpTests` Daml suites cover this + ground at the ledger level, and `localnet:dvp-e2e` covers it against a live + participant; a JSON-API-driven version can be added as another integration + test. +- The order-funding flow (`OrderFundingRequest` → trader-Accept → `Order_Fund`) + through a real browser wallet. The wallet handoff lives in + `app/web/src/wallet/`; an integration test for it needs a wallet emulator. +- The full registry HTTP API. The `CANTON_E2E` test stubs `getFactories` + because the RFQ accept flow reads no factory CIDs; tests that exercise pool + swaps will need a real registry-backed factory. --- -**Where to read next:** [Getting Started](../getting-started.md) · [Validator Test Plan](../guides/validator-test-plan.md) · [All docs](../README.md) +**Where to read next:** [Getting Started](../getting-started.md) · [Builder Guide](../guides/builder-guide.md) · [Validator Test Plan](../guides/validator-test-plan.md) · [All docs](../README.md) diff --git a/trading-tests/CantonDex/Tests/DvpMintBurnTests.daml b/trading-tests/CantonDex/Tests/DvpMintBurnTests.daml index 724d1210..efd3a8ec 100644 --- a/trading-tests/CantonDex/Tests/DvpMintBurnTests.daml +++ b/trading-tests/CantonDex/Tests/DvpMintBurnTests.daml @@ -1,3 +1,9 @@ +-- READER'S GUIDE (a concepts doc links here): this file proves our test +-- registry can create new tokens out of nothing ("mint") and destroy them +-- ("burn"), each as one atomic settlement. It matters because mint and burn are +-- how token supply enters and leaves the system; the scripts below check +-- exactly who is credited and who is debited so balances can't silently drift. +-- -- | Proves the delivery-versus-mint/burn mechanism on the V2 allocation -- surface against the holding-tracking RealRegistry (mirrors upstream -- TestDeliveryVersusBurnMint): a mint is a leg mintAccount -> recipient @@ -46,12 +52,23 @@ completed r = case r.output of V2.AllocationInstructionResult_Completed cid -> cid _ -> error "allocation must complete immediately" +-- Happy path (proves the mechanism, not the authorization model): mint 100 LP +-- into Alice's account, then burn all 100 back out, checking her holdings after +-- each step. Confirms a mint credits the receiver and a burn leaves nothing +-- behind — not even a stray locked coin from a settle that failed to archive. testDvpMintThenBurn : Script () testDvpMintThenBurn = do + -- Two parties: the registrar is the registry's admin (it does the minting and + -- burning), and alice is an ordinary user who will hold the tokens. registrar <- allocateParty "registrar" alice <- allocateParty "alice" now <- getTime + -- Stand up the registry contract. One contract answers as both the allocation + -- factory and the settlement factory (two interface views of the same cid, so + -- factoryCid and settleCid point at the same registry). alice is registered as + -- a user, aliceAcct is her plain holding account, and `settlement` names the + -- batch that all the legs below belong to. regCid <- submit registrar $ createCmd RR.RealRegistry with admin = registrar users = [alice] @@ -65,6 +82,10 @@ testDvpMintThenBurn = do meta = emptyMetadata -- ---- MINT 100 LP to alice via DvP ---- + -- A delivery-versus-payment leg only settles once BOTH sides have allocated. + -- mintSpec is the sender side (from the special mint account, authored by the + -- registrar); receiptSpec is the same leg's receiver side (Alice's account, + -- authored by Alice). Neither party moves anything until the batch settles. let mintLeg = V2.TransferLeg with transferLegId = "mint-lp" sender = mintAcct @@ -84,6 +105,8 @@ testDvpMintThenBurn = do authorizer = aliceAcct transferLegSides = Utils.legsToSides aliceAcct [mintLeg] + -- Each party allocates its own side of the mint leg (no holdings go in — the + -- tokens don't exist yet, they're being minted). mintRes <- submit registrar $ exerciseCmd factoryCid V2.AllocationFactory_Allocate with settlement; allocation = mintSpec; requestedAt = now inputHoldingCids = []; actors = [registrar]; extraArgs = emptyExtraArgs @@ -91,6 +114,8 @@ testDvpMintThenBurn = do settlement; allocation = receiptSpec; requestedAt = now inputHoldingCids = []; actors = [alice]; extraArgs = emptyExtraArgs + -- Both parties settle the batch together. This is the moment the mint takes + -- effect: Alice is credited 100 fresh LP. _ <- submit (actAs [registrar, alice]) $ exerciseCmd settleCid V2.SettlementFactory_SettleBatch with settlement @@ -110,6 +135,10 @@ testDvpMintThenBurn = do lpHolding.amount === 100.0 -- ---- BURN the 100 LP via DvP ---- + -- Burn is the mirror image of mint. Alice's account is the sender (so she must + -- lock the holding she is giving up), and the special burn account is the + -- receiver. Because the burn account's owner is None, nothing is ever credited + -- back — the tokens simply cease to exist when the batch settles. let burnLeg = V2.TransferLeg with transferLegId = "burn-lp" sender = aliceAcct @@ -129,6 +158,8 @@ testDvpMintThenBurn = do authorizer = burnAcct transferLegSides = Utils.legsToSides burnAcct [burnLeg] + -- Alice locks her actual 100-LP holding into the burn leg (that's what + -- inputHoldingCids carries); the registrar allocates the burn-account side. burnSenderRes <- submit alice $ exerciseCmd factoryCid V2.AllocationFactory_Allocate with settlement; allocation = burnSenderSpec; requestedAt = now inputHoldingCids = [toInterfaceContractId lpCid]; actors = [alice]; extraArgs = emptyExtraArgs @@ -136,6 +167,7 @@ testDvpMintThenBurn = do settlement; allocation = burnReceiverSpec; requestedAt = now inputHoldingCids = []; actors = [registrar]; extraArgs = emptyExtraArgs + -- Settle the burn batch. Alice's locked LP is archived here and disappears. _ <- submit (actAs [registrar, alice]) $ exerciseCmd settleCid V2.SettlementFactory_SettleBatch with settlement @@ -166,6 +198,8 @@ testDvpMintThenBurn = do -- `mkAllocMustFail alice adminMintAlloc`). testHarnessDoesNotGateMintAuthorization : Script () testHarnessDoesNotGateMintAuthorization = do + -- Same shape of setup as the happy path, but the second party is mallory, a + -- non-admin who should NOT be permitted to mint tokens. registrar <- allocateParty "registrar" mallory <- allocateParty "mallory" now <- getTime @@ -174,6 +208,9 @@ testHarnessDoesNotGateMintAuthorization = do let factoryCid : ContractId V2.AllocationFactory = toInterfaceContractId regCid settlement = V2.SettlementInfo with executors = [registrar]; id = "forge"; cid = None; meta = emptyMetadata + -- A one-sided mint: 1000 LP from the mint account to mallory, authorized + -- only by the mint account. A production registry would reject a non-admin + -- authoring this; here it is expected to slip through. forgedMintLeg = V2.TransferLeg with transferLegId = "forge-mint"; sender = mintAcct receiver = Utils.basicAccount mallory; amount = 1000.0 diff --git a/trading-tests/CantonDex/Tests/EndToEndTests.daml b/trading-tests/CantonDex/Tests/EndToEndTests.daml index 96fa2a61..298d2a07 100644 --- a/trading-tests/CantonDex/Tests/EndToEndTests.daml +++ b/trading-tests/CantonDex/Tests/EndToEndTests.daml @@ -1,3 +1,13 @@ +-- What this file proves, in plain terms: +-- +-- These scripts drive the whole exchange front-to-back against a stand-in +-- ("mock") token registry, so a reader can watch each concept actually +-- happen: funding a liquidity pool, funding and matching orders, accepting +-- dealer quotes, and settling trades. Two properties matter throughout -- +-- money is never created or destroyed as trades settle, and every step is +-- taken by the party actually allowed to take it. If these pass, the pieces +-- fit together and those two guarantees hold. +-- -- | End-to-end Daml Script tests of the full DEX workflows against the -- MockRegistry: -- @@ -59,8 +69,11 @@ setupPool , ContractId LP.LPTokenPolicy ) setupPool operator lpRegistrar admin = do + -- One BTC/USDC pool. The pool mints its own "BTC-USDC-LP" share token to + -- liquidity providers, issued by the LP registrar. Fee is 0.30% (30 bps). let poolId = "BTC-USDC" lpInstrumentId = V2.InstrumentId with admin = lpRegistrar; id = "BTC-USDC-LP" + -- Pool: the static config (the two sides, who runs it, the fee). poolCid <- submit operator $ createCmd Pool.Pool with poolId operator @@ -70,6 +83,8 @@ setupPool operator lpRegistrar admin = do quoteInstrumentId = "USDC" lpInstrumentId feeBps = 30 + -- PoolState: the live balances. Starts empty and Unfunded (no reserves, + -- no LP shares issued yet); the first deposit flips it to Active. stateCid <- submit operator $ createCmd PState.PoolState with poolId operator @@ -78,9 +93,13 @@ setupPool operator lpRegistrar admin = do reserves = Pool.PoolReserves with baseAmount = 0.0; quoteAmount = 0.0 totalLpSupply = 0.0 publicReaders = [] + -- PoolRules: the swap choices (trade against the pool). rulesCid <- submit operator $ createCmd PRules.PoolRules with operator + -- PoolLiquidityRules: the add/remove-liquidity choices, run jointly by the + -- operator and the LP registrar (both signatures are required). dvpCid <- submit (actAs [operator, lpRegistrar]) $ createCmd Dvp.PoolLiquidityRules with operator; lpRegistrar + -- LPTokenPolicy: tracks how many LP shares exist across all providers. policyCid <- submit lpRegistrar $ createCmd LP.LPTokenPolicy with lpRegistrar operator @@ -89,6 +108,10 @@ setupPool operator lpRegistrar admin = do active = True pure (poolId, poolCid, stateCid, rulesCid, dvpCid, policyCid) +-- Stand up the stand-in token registry: an allocation factory (which turns a +-- request to set aside funds into a locked allocation) and a settlement +-- factory (which atomically swaps the locked allocations). These replace a +-- real token issuer so the trading flows can be exercised in isolation. setupRegistries : Party -> [Party] -> Script ( ContractId V2.AllocationFactory , ContractId V2.SettlementFactory ) @@ -97,6 +120,13 @@ setupRegistries admin users = do settleCid <- submit admin $ createCmd Mock.MockSettlementFactory with admin; users; requireContext = False pure (toInterfaceContractId factoryCid, toInterfaceContractId settleCid) +-- Deposit liquidity into a pool the honest way: delivery-versus-payment (DvP), +-- meaning the base and quote go in and the LP shares come out as one +-- all-or-nothing swap. Steps: the operator opens the request, the depositor +-- sets aside the two deposits plus the mint receipt as locked allocations, +-- then operator and LP registrar jointly settle all three at once. The pool's +-- LP shares are the square root of base*quote, the standard constant-product +-- rule. dvpFundPool : Party -> Party @@ -115,9 +145,13 @@ dvpFundPool -> Script Dvp.PoolLiquidityRules_SettleAddResult dvpFundPool operator lpRegistrar factoryCid settleCid poolId poolCid stateCid policyCid dvpCid recipient baseAmount quoteAmount now extraArgs = do let lpAmount = PM.sqrtDecimal (baseAmount * quoteAmount) + -- Operator opens the add-liquidity request; it lists the three legs the + -- depositor must fund: base deposit, quote deposit, and the LP-mint receipt. reqCid <- submit operator $ exerciseCmd dvpCid Dvp.PoolLiquidityRules_RequestAddLiquidity with poolCid; recipient; baseAmount; quoteAmount; lpAmount; requestedAt = now; settleAt = None Some req <- queryContractId operator reqCid + -- The three legs, in order, and a helper that locks up each one via the + -- allocation factory (the mock completes instantly instead of pending). let baseSpec = head req.allocations quoteSpec = head (tail req.allocations) receiptSpec = head (tail (tail req.allocations)) @@ -160,15 +194,24 @@ dvpFundPool operator lpRegistrar factoryCid settleCid poolId poolCid stateCid po lpRegistrarExtraArgs = extraArgs -- 1. DvP add on an Unfunded pool keeps reserves backed ------------------ +-- +-- Asserts: depositing 10 BTC and 200,000 USDC into an empty pool turns it +-- Active, records exactly those reserves, and mints LP shares equal to +-- sqrt(base*quote). Why it matters: the reported balances always match what +-- was actually put in -- the pool is fully backed. testPoolFullLifecycle : Script () testPoolFullLifecycle = do + -- Roles: the venue operator, the LP-share registrar, the token admin, and + -- Alice, who supplies the liquidity. operator <- allocateParty "operator" lpRegistrar <- allocateParty "lp-registrar" admin <- allocateParty "admin" alice <- allocateParty "alice" now <- getTime + -- Stand up the mock registry and an empty BTC/USDC pool, then have Alice + -- deposit through the honest DvP path. (factoryCid, _settleCid) <- setupRegistries admin [operator, lpRegistrar, alice] (poolId, poolCid, stateCid, _rulesCid, dvpCid, policyCid) <- setupPool operator lpRegistrar admin initRes <- dvpFundPool @@ -187,6 +230,8 @@ testPoolFullLifecycle = do now emptyExtraArgs + -- Check the outcome: shares minted match the formula, the pool is now + -- Active, and its reserves and total shares equal exactly what went in. Some state <- queryContractId operator initRes.poolStateCid Some _baseSlice <- queryContractId operator initRes.baseSliceCid Some _quoteSlice <- queryContractId operator initRes.quoteSliceCid @@ -200,6 +245,11 @@ testPoolFullLifecycle = do pure () -- 2. TradeAllocationRequest accept now returns (no abort) --------------- +-- +-- Asserts: when the trader accepts a request to set aside funds for a trade, +-- the request completes cleanly and archives itself. Why it matters: this +-- accept step used to abort ("registry-specific wiring"); this pins that it +-- now succeeds so the settlement flow can proceed. testTradeAllocationRequestAccept : Script () testTradeAllocationRequestAccept = do @@ -211,7 +261,8 @@ testTradeAllocationRequestAccept = do (factoryCid, _) <- setupRegistries admin [operator, alice, bob] - -- Build a trade allocation request that Alice can accept. + -- Build the request: one leg moving 10 BTC from Alice to Bob, with the + -- operator named as the executor allowed to settle it. let aliceAccount = Utils.basicAccount alice bobAccount = Utils.basicAccount bob leg = V2.TransferLeg with @@ -250,6 +301,13 @@ testTradeAllocationRequestAccept = do pure () -- 3. Order funding flow with correct trader authority ------------------- +-- +-- Asserts the full path from "I want to place an order" to a funded order: +-- the trader requests, the operator binds it into a Pending order plus a +-- funding request, the trader (as her own wallet) locks the cash, and the +-- operator attaches that money and consumes the request. Ends Funded with the +-- funding request gone. Why it matters: the trader's own authority pays for +-- and backs the order, and no stray funding request is left dangling. testOrderFundingFlow : Script () testOrderFundingFlow = do @@ -314,6 +372,13 @@ testOrderFundingFlow = do pure () -- 4. RFQ accept produces a MatchedTrade with a real PolicyReceipt ------- +-- +-- RFQ = request-for-quote: the trader asks dealers to bid, then accepts one. +-- Asserts that when three dealers quote and the trader accepts, the winner is +-- picked by the stated policy (trusted dealers first, then best price, then +-- earliest), a trade is created, and it carries a signed receipt proving that +-- ranking. Why it matters: the choice of counterparty is auditable, not +-- arbitrary -- here the cheapest overall quote loses to a trusted one. testRfqAcceptProducesMatchedTradeWithReceipt : Script () testRfqAcceptProducesMatchedTradeWithReceipt = do @@ -340,7 +405,9 @@ testRfqAcceptProducesMatchedTradeWithReceipt = do whitelist = [orca, jump, galaxy] createdAt = now - -- Three dealers post quotes. + -- Three dealers post quotes. Galaxy is the cheapest but only whitelisted; + -- Jump and Orca are trusted, and Jump is the cheaper of the two -- so the + -- policy should rank Jump first. quoteOrca <- submit orca $ createCmd Rfq.RfqQuote with dealer = orca trader = alice @@ -399,9 +466,14 @@ testRfqAcceptProducesMatchedTradeWithReceipt = do pure () --- Rfq_Expire: operator-authority cleanup of an expired RFQ. Rfq_Cancel is +-- 5. Rfq_Expire: operator-authority cleanup of an expired RFQ. Rfq_Cancel is -- trader-controlled, so this is the only path that works once external -- wallets hold trader authority. +-- +-- Asserts the operator can sweep away an expired RFQ, but only after its +-- deadline, and that the trader cannot use this path at all. Why it matters: +-- stale quote requests get cleaned up by the venue without letting either +-- side expire something early. testRfqExpireOperatorCleanup : Script () testRfqExpireOperatorCleanup = do operator <- allocateParty "operator-exp" @@ -443,6 +515,9 @@ testRfqExpireOperatorCleanup = do -- via the mock factory; operator drives PoolRules_Swap which adjusts both -- pool allocations + the trader allocation and batch-settles them. -- +-- Why it matters: a trade against the pool moves reserves the right way and +-- only touches the slice it consumes, leaving the rest of the pool intact. +-- -- Verifies: -- - the swap completes without error -- - reserves update correctly @@ -599,8 +674,11 @@ testPoolSwapViaRequestSwap = do -- 7. Full MatchedTrade lifecycle: request → accept → settle ------------- -- --- Verifies the OTC settlement path end-to-end with two trader-created --- allocations and the operator's batch settle. +-- Verifies the OTC (over-the-counter, dealt directly not via the order book) +-- settlement path end-to-end: the operator requests allocations, each side +-- accepts and locks its own funds, and the operator settles both in one +-- batch. Why it matters: a bilateral trade settles atomically with each +-- party's own authority behind its leg. testMatchedTradeFullSettle : Script () testMatchedTradeFullSettle = do @@ -778,6 +856,8 @@ omeAlloc operator admin factoryCid party acct funding now = do V2.AllocationInstructionResult_Completed cid -> pure cid _ -> abort "alloc must complete" +-- A resting BTC/USDC order for 10 base at the given limit price, side, and +-- status, optionally bound to a funding allocation. omeOrder : Party -> Party -> Party -> Order.Side -> Decimal -> Order.OrderStatus -> Optional (ContractId V2.Allocation) -> Text -> Script (ContractId Order.Order) @@ -789,6 +869,9 @@ omeOrder operator admin trader side limitPrice status allocationCid ref = expiry = None; status; allocationCid settlementRef = Order.makeOrderRefFromText ref +-- A proposed fill of 4 base at the given price, naming the two orders and the +-- two allocations it intends to spend. Exercising its _Execute choice is what +-- the matching tests accept or reject. omeExec : Party -> V2.Account -> V2.Account -> ContractId Order.Order -> ContractId Order.Order -> ContractId V2.Allocation -> ContractId V2.Allocation @@ -1076,6 +1159,9 @@ testOrderMatchClosesUnbackedRemainder = do -- context-requiring mock factory: DvP add succeeds only when the caller -- supplies the marker context. +-- Same empty-pool setup as above, but the mock factories can be put in +-- "requireContext" mode -- they refuse to act unless the caller passes a +-- marker in the choice context. Used to prove the DEX forwards that context. mkPoolFixture : Party -> Party -> Party -> Bool -> Script @@ -1110,12 +1196,18 @@ mkPoolFixture operator lpRegistrar admin requireContext = do totalSupply = 0.0; active = True pure (poolId, poolCid, stateCid, dvpCid, policyCid, toInterfaceContractId factory, toInterfaceContractId settle) +-- The marker the context-requiring mock factories look for: a single flag in +-- the choice context. Supplying it is what makes those factories act. markerContext : ExtraArgs markerContext = ExtraArgs with context = ChoiceContext with values = TextMap.fromList [(Mock.dexChoiceContextKey, AV_Bool True)] meta = emptyMetadata +-- Asserts: with the marker context supplied, a DvP add against the +-- context-requiring factory succeeds and the pool goes Active. Why it +-- matters: the DEX actually passes the registry's required context through +-- (it doesn't quietly send an empty one). testDvpAddThreadsChoiceContext : Script () testDvpAddThreadsChoiceContext = do operator <- allocateParty "operator" @@ -1127,6 +1219,8 @@ testDvpAddThreadsChoiceContext = do Some state <- queryContractId operator res.poolStateCid state.status === Pool.PS_Active +-- The mirror image: with the marker omitted, the context-requiring factory +-- refuses the allocation. Confirms the check above is real, not vacuous. testDvpAddRejectsEmptyContext : Script () testDvpAddRejectsEmptyContext = do operator <- allocateParty "operator" diff --git a/trading-tests/CantonDex/Tests/PoolLiquidityRulesTests.daml b/trading-tests/CantonDex/Tests/PoolLiquidityRulesTests.daml index 9a2a3a18..a9190c6f 100644 --- a/trading-tests/CantonDex/Tests/PoolLiquidityRulesTests.daml +++ b/trading-tests/CantonDex/Tests/PoolLiquidityRulesTests.daml @@ -1,3 +1,8 @@ +-- Executable proof behind the pool-liquidity concept docs. Each script runs a +-- full add- or remove-liquidity flow and checks that assets land with the right +-- party, that LP tokens are minted or burned to match, and that unsafe shortcuts +-- (stale price, expired request, forged or oversized mint, one-party settle) fail. + -- | DvP liquidity at the boundary, against Registry.V2 (holding-tracking + -- iterated settlement + mint/burn accounts). Proves: -- - atomic add: LP funds base+quote and receives LP tokens in one flow; @@ -34,6 +39,8 @@ import CantonDex.Lp.Instrument qualified as Lp import CantonDex.Registry.V2 qualified as RegV2 import CantonDex.Trading.Utils qualified as Utils +-- The pool's LP-token identifier: what a provider receives in return for the +-- base+quote it deposits. lpId : Text lpId = "BTC-USDC-LP" @@ -54,27 +61,38 @@ data Fixture = Fixture with setup : [Party] -> Script Fixture setup = setupWithFee 30 +-- Stand up the whole fixture at a chosen fee: allocate the parties, create the +-- one registry, and seed the pool, its state, the LP-token policy, and the rules +-- contract — everything a test needs before driving an add or remove. setupWithFee : Int -> [Party] -> Script Fixture setupWithFee feeBps lps = do + -- Parties: `operator` runs the pool; `registry` is the token admin. Reusing one + -- party as both admin and LP registrar keeps the harness to a single registry. operator <- allocateParty "operator" registry <- allocateParty "registry" -- admin AND lpRegistrar + -- The single Registry.V2 contract; below it is viewed as both the allocation + -- factory and the settlement factory. `lps` are registered so they can allocate. reg <- submit registry $ createCmd RegV2.Registry with admin = registry; users = operator :: lps let factoryCid : ContractId V2.AllocationFactory = toInterfaceContractId reg settleCid : ContractId V2.SettlementFactory = toInterfaceContractId reg poolId = "BTC-USDC" lpInstrumentId = V2.InstrumentId with admin = registry; id = lpId + -- Seed the pool definition: the BTC/USDC pair, its registrar/admin, and fee. poolCid <- submit operator $ createCmd Pool with poolId; operator; lpRegistrar = registry; admin = registry baseInstrumentId = "BTC"; quoteInstrumentId = "USDC"; lpInstrumentId feeBps + -- Seed the pool's live state empty: no reserves, no LP supply, not yet funded. stateCid <- submit operator $ createCmd PS.PoolState with poolId; operator; lpRegistrar = registry status = PS_Unfunded reserves = PoolReserves with baseAmount = 0.0; quoteAmount = 0.0 totalLpSupply = 0.0; publicReaders = [] + -- The LP-token policy (mint/burn authority for this pool's LP token), empty. policyCid <- submit registry $ createCmd LP.LPTokenPolicy with lpRegistrar = registry; operator; lpInstrumentId totalSupply = 0.0; active = True + -- The rules contract whose Settle* choices drive every add and remove below. dvpCid <- submit (actAs [operator, registry]) $ createCmd Dvp.PoolLiquidityRules with operator; lpRegistrar = registry pure Fixture with @@ -99,6 +117,8 @@ mkAlloc factoryCid party settlement spec inputHoldingCids requestedAt = do V2.AllocationInstructionResult_Completed cid -> pure cid _ -> abort "allocation must complete" +-- Build a plain allocation spec: who administers it, who authorizes it, the +-- transfer legs it moves, and whether it is already committed. mkSpec : Party -> V2.Account -> [V2.TransferLeg] -> Bool -> V2.AllocationSpecification mkSpec admin authorizer legs committed = V2.AllocationSpecification with admin; authorizer @@ -132,6 +152,8 @@ requestAdd fx recipient baseAmount quoteAmount lpAmount settleAt now = poolCid = fx.poolCid; recipient; baseAmount; quoteAmount; lpAmount requestedAt = now; settleAt +-- The remove-side counterpart of `requestAdd`: the operator posts the request +-- naming the base/quote amounts to return and how much LP to burn. requestRemove : Fixture -> Party -> [Decimal] -> [Decimal] -> Decimal -> Optional Time -> Time -> Script (ContractId LAR.LiquidityAllocationRequest) @@ -222,6 +244,10 @@ dvpAddWithLp fx stateCid policyCid lp baseAmount quoteAmount lpAmount now = do baseAmount; quoteAmount; minLpTokens = 0.0; knownTotalLpSupply = 0.0 requestedAt = now; poolAdminExtraArgs = emptyExtraArgs; lpRegistrarExtraArgs = emptyExtraArgs +-- Happy path: a first add funds an empty pool and mints LP tokens to the +-- provider, all in one atomic settlement. Asserts the pool turns Active with the +-- deposited reserves and a matching LP supply, that the operator's two reserve +-- slices are recorded, and that Alice ends up holding the freshly minted LP token. testDvpAddLiquidity : Script () testDvpAddLiquidity = do alice <- allocateParty "alice" @@ -396,6 +422,8 @@ testStaleQuoteRejected = do bBaseSpec = depositSpec fx.registry bobAcct baseDepositLeg bQuoteSpec = depositSpec fx.registry bobAcct quoteDepositLeg bRcptSpec = mkSpec fx.registry bobAcct [lpMintLeg] False + -- Fund Bob and build his standard add allocations (two deposits + a mint + -- receipt); everything is well-formed here, only the quoted supply is stale. bBtc <- mintHolding fx.registry bob "BTC" 1.0 bUsdc <- mintHolding fx.registry bob "USDC" 20000.0 bReq <- requestAdd fx bob 1.0 20000.0 100.0 None now diff --git a/trading-tests/CantonDex/Tests/PoolRoundingTests.daml b/trading-tests/CantonDex/Tests/PoolRoundingTests.daml index 0bf478ca..e9625ee5 100644 --- a/trading-tests/CantonDex/Tests/PoolRoundingTests.daml +++ b/trading-tests/CantonDex/Tests/PoolRoundingTests.daml @@ -1,3 +1,8 @@ +-- What this file proves: the pool's arithmetic always rounds in the pool's +-- favour, so a swap, a deposit, or a withdrawal can never quietly pay out more +-- than it should. Each script below pins down one rounding rule with worked +-- numbers, showing the exact case where naive `*` and `/` would leak value. + -- | Pool payouts round DOWN, so the constant product never falls. `Decimal` is -- `Numeric 10`, so a payout cannot be floored once computed — the flooring has -- to happen at the multiplication and division that produce it. @@ -16,6 +21,8 @@ import CantonDex.Dex.PoolState qualified as PS import CantonDex.Tests.PoolLiquidityRulesTests qualified as PLT import CantonDex.Trading.Utils qualified as Utils +-- Division rounds DOWN: `floorDiv` never returns more than the true quotient +-- (plain `/` can round the last digit up), yet leaves exact quotients as-is. testFloorDivStaysBelowExactQuotient : Script () testFloorDivStaysBelowExactQuotient = do -- 7000 / 1007 = 6.95134061569016…, which `/` rounds UP. @@ -27,6 +34,9 @@ testFloorDivStaysBelowExactQuotient = do PM.floorDiv 1.0 8.0 === 0.125 pure () +-- Multiplication rounds DOWN: `floorMul` never returns more than the true +-- product (plain `*` can round the last digit up), yet leaves exact products +-- untouched. testFloorMulStaysBelowExactProduct : Script () testFloorMulStaysBelowExactProduct = do -- 0.0000000002 * 0.75 = 0.00000000015 exactly, which `*` rounds UP. @@ -38,12 +48,17 @@ testFloorMulStaysBelowExactProduct = do PM.floorMul 2.5 4.0 === 10.0 pure () +-- A lopsided deposit only takes the part of each side the pool's current price +-- actually backs and hands the rest back, rather than absorbing the unmatched +-- excess as a free gift to the parties already in the pool. +-- -- Deposits draw the ratio-matched share, which must be rounded off the exact -- product. Ceiling the quotient and then the product hands back the divisor's -- ulp multiplied by the far reserve, so the unmatched excess is absorbed -- unrefunded — the donation the refund path exists to prevent. testRatioMatchedDepositRoundsOnce : Script () testRatioMatchedDepositRoundsOnce = do + -- A pool weighted far toward quote (1000 base : 10 billion quote). let big = PoolReserves with baseAmount = 1000.0; quoteAmount = 10000000000.0 -- 1e-10 base pairs with 1e-10 * 1e10 / 1000 = 0.001 quote, not the whole leg. PM.ratioMatchedDeposit big 0.0000000001 1.0 === (0.0000000001, 0.001) @@ -63,32 +78,50 @@ testRatioMatchedDepositRoundsOnce = do -- A full redemption still drains the pool exactly rather than stranding an ulp. testFullRedemptionShareIsExact : Script () testFullRedemptionShareIsExact = do + -- The sole holder redeems everything: their share is total supply / supply. let supply = 1414.2135623731 share = PM.floorDiv supply supply + -- Owning the whole supply is exactly a 100% share, not 0.9999999999. share === 1.0 + -- Multiplying a reserve by that 100% share hands it back whole, to the last + -- unit, so nothing is left stranded in the pool. PM.floorMul 993.0486593843 share === 993.0486593843 pure () +-- The end-to-end proof: a real swap run through the full settlement machinery +-- must leave base*quote (the pool's invariant `k`) no lower than it started. +-- -- A zero-fee pool has no fee slack to absorb a rounding error. 7 USDC into a -- 1000/1000 pool prices at 7000/1007, whose rounded quotient sits above the -- exact one — paying that out drops base*quote below k. testSwapOutputRoundsDownToKeepConstantProduct : Script () testSwapOutputRoundsDownToKeepConstantProduct = do + -- Parties: alice supplies the pool's liquidity, bob is the trader. alice <- allocateParty "alice" bob <- allocateParty "bob" + -- Stand up the fixture (operator, registry, factories, empty pool) with a + -- ZERO fee, so there is no fee cushion to hide a rounding slip in. fx <- PLT.setupWithFee 0 [alice, bob] + -- The contract the operator drives trades through. rulesCid <- submit fx.operator $ createCmd PRules.PoolRules with operator = fx.operator now <- getTime + -- Seed the pool: alice deposits 1000 BTC + 1000 USDC, so reserves start 1000/1000. addRes <- PLT.dvpAdd fx fx.stateCid fx.policyCid alice 1000.0 1000.0 0.0 0.0 0.0 now + -- The value to protect: base*quote before the swap. let kBefore = 1000.0 * 1000.0 + -- Operator opens a swap request for bob: 7 USDC in. reqRes <- submit fx.operator $ exerciseCmd rulesCid PRules.PoolRules_RequestSwap with poolCid = fx.poolCid; swapper = bob inputInstrumentId = "USDC"; inputAmount = 7.0 + -- Allocation plumbing: give bob the 7 USDC and lock it into the settlement + -- the request named, the way a real trade would fund its input. usdcHold <- PLT.mintHolding fx.registry bob "USDC" 7.0 swapAlloc <- PLT.mkAlloc fx.factoryCid bob reqRes.settlement reqRes.allocationSpec [toInterfaceContractId usdcHold] now + -- Execute the swap (operator acts, reading the registry's holdings): 7 USDC + -- in, BTC out, no minimum-out floor enforced. swapRes <- submit (actAs [fx.operator] <> readAs [fx.registry]) $ exerciseCmd rulesCid PRules.PoolRules_Swap with expectedPoolId = fx.poolId; poolCid = fx.poolCid @@ -101,10 +134,15 @@ testSwapOutputRoundsDownToKeepConstantProduct = do factoryCid = fx.settleCid extraArgs = emptyExtraArgs + -- Read back the pool the swap left behind. state <- fromSome <$> queryContractId @PS.PoolState fx.operator swapRes.poolStateCid + -- The core guarantee: the swap did not lower base*quote. assertMsg "swap must not lower the constant product" (state.reserves.baseAmount * state.reserves.quoteAmount >= kBefore) + -- And the exact figures: BTC out is the DOWN-rounded quotient (0.0000000001 + -- less than plain division would give), leaving 993.0486593844 base against + -- the 1007 quote now in the pool. swapRes.amountOut === 6.9513406156 state.reserves.baseAmount === 993.0486593844 state.reserves.quoteAmount === 1007.0 diff --git a/trading-tests/CantonDex/Tests/PoolStateInvariantTests.daml b/trading-tests/CantonDex/Tests/PoolStateInvariantTests.daml index bb74e082..b578e467 100644 --- a/trading-tests/CantonDex/Tests/PoolStateInvariantTests.daml +++ b/trading-tests/CantonDex/Tests/PoolStateInvariantTests.daml @@ -1,3 +1,10 @@ +-- What this file proves, in plain terms: +-- A liquidity pool records how much of each asset it holds (its "reserves"). +-- The real assets sit in separate holding records called "slices". These +-- tests prove the recorded reserves always equal the sum of the actual +-- slices, and that the on-ledger reconcile check refuses to pass whenever the +-- two disagree, so the operator cannot quietly misstate the pool's balance. +-- -- | PoolState.reserves integrity. -- -- Drives the on-ledger audit anchor `PoolRules_ReconcileState` against the @@ -29,9 +36,14 @@ import CantonDex.Registry.V2 qualified as RegV2 import CantonDex.Tests.PoolLiquidityRulesTests qualified as PLT import CantonDex.Trading.Utils qualified as Utils +-- Setup helper: creates the PoolRules contract (the operator's on-ledger audit +-- rules) that every test below runs its reconcile check through. mkRules : PLT.Fixture -> Script (ContractId PRules.PoolRules) mkRules fx = submit fx.operator $ createCmd PRules.PoolRules with operator = fx.operator +-- Setup helper: builds the reconcile command. Reconcile re-adds up the given +-- slices and checks that total against the reserves recorded in the PoolState, +-- aborting if the two do not match. reconcileCmd : PLT.Fixture -> ContractId PRules.PoolRules -> ContractId PS.PoolState -> [ContractId PSlice.PoolSlice] @@ -43,10 +55,13 @@ reconcileCmd fx rulesCid stateCid sliceCids = poolStateCid = stateCid sliceCids +-- Asserts: the recorded reserves stay exactly correct at every point in a +-- pool's life; reconcile passes cleanly after each stage below. -- Full lifecycle: add -> swap (through Registry.V2) -> full remove, with a -- clean reconcile after every stage. testReconcileAfterAddSwapRemove : Script () testReconcileAfterAddSwapRemove = do + -- Two traders and a fresh, empty pool wired to the reconcile rules. alice <- allocateParty "alice" bob <- allocateParty "bob" fx <- PLT.setup [alice, bob] @@ -62,6 +77,8 @@ testReconcileAfterAddSwapRemove = do rec1.quoteTotal === 200000.0 -- Swap: bob swaps 100 USDC -> BTC through the same Registry.V2. + -- Register the swap intent, mint bob his 100 USDC, and package that holding + -- into an allocation the pool is allowed to draw from during the swap. reqRes <- submit fx.operator $ exerciseCmd rulesCid PRules.PoolRules_RequestSwap with poolCid = fx.poolCid; swapper = bob inputInstrumentId = "USDC"; inputAmount = 100.0 @@ -89,6 +106,8 @@ testReconcileAfterAddSwapRemove = do rec2.quoteTotal === 200100.0 -- Remove: alice redeems the full LP position; the pool drains. + -- Describe what alice gets back and gives up: a leg paying out the base + -- (BTC), a leg paying out the quote (USDC), and a leg burning her LP tokens. let lpAmount = PM.sqrtDecimal 2000000.0 baseOut = 10.0 - swapRes.amountOut quoteOut = 200100.0 @@ -102,6 +121,9 @@ testReconcileAfterAddSwapRemove = do transferLegId = "lp-quote-out-0"; sender = opAcct; receiver = aliceAcct amount = quoteOut; instrumentId = "USDC"; meta = emptyMetadata burnLeg = Lp.lpBurnLeg fx.registry aliceAcct PLT.lpId lpAmount + -- Find alice's unlocked LP holding and build the matching allocations: the + -- receipts that let the pool pay her the base and quote, plus the sender + -- allocation that surrenders her LP tokens to be burned. aliceHs <- query @RegV2.Holding alice let (lpHoldCid, _) = head (filter (\(_, h) -> h.instrumentId == PLT.lpId && not h.locked) aliceHs) reqCid <- PLT.requestRemove fx alice [baseOut] [quoteOut] lpAmount None now @@ -136,6 +158,7 @@ testReconcileAfterAddSwapRemove = do -- An incomplete slice list understates the sum and must abort. testReconcileFailsOnMissingSlice : Script () testReconcileFailsOnMissingSlice = do + -- Seed a pool with 10 BTC / 200000 USDC, so its two slices are known. alice <- allocateParty "alice" fx <- PLT.setup [alice] rulesCid <- mkRules fx @@ -149,11 +172,13 @@ testReconcileFailsOnMissingSlice = do -- trust boundary) is caught by reconcile against the real slices. testReconcileCatchesDesyncedReserves : Script () testReconcileCatchesDesyncedReserves = do + -- Seed a real pool (10 BTC / 200000 USDC) with two honest slices. alice <- allocateParty "alice" fx <- PLT.setup [alice] rulesCid <- mkRules fx now <- getTime addRes <- PLT.dvpAdd fx fx.stateCid fx.policyCid alice 10.0 200000.0 0.0 0.0 0.0 now + -- Then hand-build a PoolState that lies about the base reserve (999 BTC). badStateCid <- submit fx.operator $ createCmd PS.PoolState with poolId = fx.poolId; operator = fx.operator; lpRegistrar = fx.registry status = PS_Active @@ -167,11 +192,13 @@ testReconcileCatchesDesyncedReserves = do -- otherwise be inflated to match. testReconcileRejectsForeignSlice : Script () testReconcileRejectsForeignSlice = do + -- Seed this pool (10 BTC / 200000 USDC) with its two honest slices. alice <- allocateParty "alice" fx <- PLT.setup [alice] rulesCid <- mkRules fx now <- getTime addRes <- PLT.dvpAdd fx fx.stateCid fx.policyCid alice 10.0 200000.0 0.0 0.0 0.0 now + -- Fabricate a slice that names a different pool ("SOME-OTHER-POOL"). foreignSliceCid <- submit fx.operator $ createCmd PSlice.PoolSlice with poolId = "SOME-OTHER-POOL" operator = fx.operator @@ -190,6 +217,7 @@ testReconcileRejectsForeignSlice = do -- failure is attributable to the swapper == pool account. testSwapRejectsOperatorAsSwapper : Script () testSwapRejectsOperatorAsSwapper = do + -- Seed a pool (10 BTC / 200000 USDC) with alice's liquidity; bob will swap. alice <- allocateParty "alice" bob <- allocateParty "bob" fx <- PLT.setup [alice, bob] diff --git a/trading-tests/CantonDex/Tests/RfqSettlementTests.daml b/trading-tests/CantonDex/Tests/RfqSettlementTests.daml index faf60abf..5fda74b2 100644 --- a/trading-tests/CantonDex/Tests/RfqSettlementTests.daml +++ b/trading-tests/CantonDex/Tests/RfqSettlementTests.daml @@ -1,3 +1,8 @@ +-- Documentation anchor: this suite is the worked proof behind the RFQ +-- settlement concept. It walks a request-for-quote from a trader's request, +-- through competing dealer quotes, to a settlement in which each side's own +-- funds really move -- and pins the two ways bad wiring can strand locked money. +-- -- | The RFQ round trip, end to end, against REAL holdings. -- -- WHY THIS IS NOT IN TokenStandardHarnessTests. The harness registry @@ -50,6 +55,8 @@ import CantonDex.Dex.Rfq import CantonDex.Registry.V2 qualified as RegV2 import CantonDex.Trading.Utils qualified as Utils +-- The single instrument pair every scenario trades: BTC is the base asset, +-- USDC is the quote asset, and "BTC/USDC" is the label carried on the RFQ. base : Text base = "BTC" @@ -59,6 +66,8 @@ quote = "USDC" pairText : Text pairText = "BTC/USDC" +-- Everything a scenario needs to share: the five parties involved and the one +-- registry contract they all transact against. data Fixture = Fixture with admin : Party operator : Party @@ -69,13 +78,20 @@ data Fixture = Fixture with whitelisted : Party regCid : ContractId RegV2.Registry +-- | Stand up a fresh world for one scenario: allocate the parties, create the +-- reference registry, and mint each party's opening inventory. The scenarios +-- all start from the Fixture this returns. setup : Script Fixture setup = do + -- The cast: an admin that co-signs every holding, an operator that runs the + -- RFQ flow, one trader, and two competing dealers. admin <- allocateParty "dex-admin" operator <- allocateParty "dex-operator" trader <- allocateParty "trader" trusted <- allocateParty "dealer-trusted" whitelisted <- allocateParty "dealer-whitelisted" + -- The production reference registry, with the three trading parties (trader + -- plus both dealers) enrolled as users; the operator is not a user. regCid <- submit admin $ createCmd RegV2.Registry with admin; users = [trader, trusted, whitelisted] @@ -89,6 +105,8 @@ setup = do pure Fixture with admin; operator; trader; trusted; whitelisted; regCid +-- | Create one unlocked holding of `amount` in the registry, co-signed by the +-- admin and the owner. This is how every party's starting balance is seeded. mint : Party -> Party -> Text -> Decimal -> Script (ContractId RegV2.Holding) mint admin owner instrumentId amount = submit (actAs [admin, owner]) $ createCmd RegV2.Holding with diff --git a/website/astro.config.mjs b/website/astro.config.mjs index a71c3de1..29acca9d 100644 --- a/website/astro.config.mjs +++ b/website/astro.config.mjs @@ -17,6 +17,11 @@ export default defineConfig({ 'A full-stack Token Standard V2 (CIP-0112) reference DEX for the Canton Network.', customCss: ['./src/styles/custom.css'], social: [{ icon: 'github', label: 'GitHub', href: REPO }], + expressiveCode: { + // Daml has no bundled Shiki grammar; Haskell's is close enough to + // colour the inlined snippets. + shiki: { langAlias: { daml: 'haskell' } }, + }, // Client-side Mermaid rendering for the
 blocks that
       // sync-docs.mjs emits from ```mermaid fences.
       head: [