@@ -22,6 +27,7 @@ and runbooks for RFQs, prefunded orders, pools, swaps, and LP tokens.
Quick Start ·
+ Documentation Site ·
Features ·
Architecture ·
Workflows ·
@@ -35,8 +41,10 @@ and runbooks for RFQs, prefunded orders, pools, swaps, and LP tokens.
git clone https://github.com/srikanth-bitdynamics/Canton-Dex-Reference-Implementation.git
cd Canton-Dex-Reference-Implementation
-(cd services/operator-backend && npm install && npm run dev)
-(cd app/web && npm install && npm run dev)
+(cd services/operator-backend && npm ci)
+(cd app/web && npm ci && cp .env.example .env.local)
+
+# Then use the split-terminal Quick Start below.
```
@@ -52,10 +60,16 @@ Canton. It shows how market state, wallet-authorized funding, registry-defined
holdings, V2 allocations, and atomic settlement batches fit together in one
application.
+In AMM terms: a **holding** is a token balance, an **allocation** locks a
+trader's funds for a single trade, and a **settlement batch** is the one atomic
+step that exchanges them. The RFQs, orders, pools, swaps, and LP tokens named
+above are those Canton pieces assembled into an exchange.
+
It is designed to be:
- **Readable**: Daml templates and docs explain the workflow boundaries.
-- **Runnable**: local demo mode works without a Canton participant.
+- **Runnable**: the browser preview and Daml-engine tests run without a Canton
+ participant; real wallet settlement uses a configured participant.
- **Verifiable**: Daml tests and TypeScript tests cover the reference flows.
- **Forkable**: builders can reuse the Daml, backend, frontend, or docs.
@@ -64,6 +78,17 @@ It is designed to be:
> exchange. Production adopters should perform their own security review,
> operational hardening, compliance work, and version-compatibility checks.
+The repository has three deliberately different run modes:
+
+| Mode | Best for | Honest boundary |
+|---|---|---|
+| Browser preview | screens, seeded reads, quotes, wallet-intent UI | TypeScript in-memory ledger and Mock Wallet; no Daml or value settlement |
+| Daml-engine tests | choices, authorization, atomicity, conservation | Daml Script runner; no browser, backend, or Canton participant |
+| Live Canton proof | real Canton process, JSON Ledger API, package upload, distinct LP/swapper parties, and add → quote-bound swap → partial remove value movement | direct-ledger driver only; no backend HTTP, browser, external wallet, or persistent state |
+
+See [Getting started](docs/getting-started.md) for the commands and expected
+results for each mode.
+
## Why Canton DEX?
Token Standard V2 gives Canton applications a shared way to represent holdings,
@@ -82,7 +107,7 @@ workflows, not just diagrams.
);
diff --git a/app/web/src/services/api-auth.ts b/app/web/src/services/api-auth.ts
new file mode 100644
index 00000000..2198e325
--- /dev/null
+++ b/app/web/src/services/api-auth.ts
@@ -0,0 +1,103 @@
+// Runtime credentials for the operator HTTP API.
+//
+// These tokens are deliberately NOT Vite environment variables: VITE_* values
+// are compiled into the public JavaScript bundle. An operator or validator may
+// enter short-lived credentials in the Admin screen; they live only in this
+// browser tab's sessionStorage and are attached to write requests.
+//
+// A public/multi-user deployment should replace this manual handoff with its
+// own authenticated BFF/session issuer. Never distribute a shared long-lived
+// operator or admin token to ordinary traders.
+
+const OPERATOR_TOKEN_KEY = "canton-dex.operator-api-token";
+const ADMIN_TOKEN_KEY = "canton-dex.admin-api-token";
+const CALLER_TOKEN_KEY = "canton-dex.caller-token";
+
+export interface ApiSessionCredentials {
+ operatorToken: string;
+ adminToken: string;
+ callerToken: string;
+}
+
+function session(): Storage | null {
+ if (typeof window === "undefined") return null;
+ try {
+ return window.sessionStorage;
+ } catch {
+ // Storage can be disabled by browser policy. Reads remain available and
+ // the backend still fails closed for protected writes.
+ return null;
+ }
+}
+
+function read(key: string): string {
+ try {
+ return session()?.getItem(key)?.trim() ?? "";
+ } catch {
+ return "";
+ }
+}
+
+function write(key: string, value: string): void {
+ const storage = session();
+ if (!storage) return;
+ try {
+ const normalized = value.trim();
+ if (normalized) storage.setItem(key, normalized);
+ else storage.removeItem(key);
+ } catch {
+ // A privacy policy or exhausted quota can reject the write. The backend
+ // remains fail-closed; no credential is moved to a less-safe fallback.
+ }
+}
+
+export function getApiSessionCredentials(): ApiSessionCredentials {
+ return {
+ operatorToken: read(OPERATOR_TOKEN_KEY),
+ adminToken: read(ADMIN_TOKEN_KEY),
+ callerToken: read(CALLER_TOKEN_KEY),
+ };
+}
+
+export function setApiSessionCredentials(
+ credentials: ApiSessionCredentials,
+): void {
+ write(OPERATOR_TOKEN_KEY, credentials.operatorToken);
+ write(ADMIN_TOKEN_KEY, credentials.adminToken);
+ write(CALLER_TOKEN_KEY, credentials.callerToken);
+}
+
+export function clearApiSessionCredentials(): void {
+ const storage = session();
+ try {
+ storage?.removeItem(OPERATOR_TOKEN_KEY);
+ storage?.removeItem(ADMIN_TOKEN_KEY);
+ storage?.removeItem(CALLER_TOKEN_KEY);
+ } catch {
+ // Treat unavailable storage as already cleared from the app's point of
+ // view. Reads return empty strings and protected requests carry no token.
+ }
+}
+
+/** Headers required by the backend's fail-closed write and private-read gates. */
+export function apiAuthHeaders(
+ path: string,
+ method = "GET",
+): Record {
+ const normalizedMethod = method.toUpperCase();
+ const credentials = getApiSessionCredentials();
+ if (["GET", "HEAD", "OPTIONS"].includes(normalizedMethod)) {
+ return credentials.callerToken
+ ? { "X-Caller-Token": credentials.callerToken }
+ : {};
+ }
+ const bearer = path.startsWith("/v1/admin/")
+ ? credentials.adminToken
+ : credentials.operatorToken;
+ return {
+ ...(bearer ? { Authorization: `Bearer ${bearer}` } : {}),
+ ...(credentials.callerToken
+ ? { "X-Caller-Token": credentials.callerToken }
+ : {}),
+ };
+}
diff --git a/app/web/src/services/ledger.ts b/app/web/src/services/ledger.ts
index 7035fe62..758e98b7 100644
--- a/app/web/src/services/ledger.ts
+++ b/app/web/src/services/ledger.ts
@@ -9,13 +9,13 @@
// components below this layer should never reach past it.
import { OperatorApi, type SwapQuoteBinding } from './operator-api';
+import { apiAuthHeaders } from './api-auth';
import { handToWallet } from '@/wallet/handoff';
import { getProvider } from '@/wallet/registry';
import { coSignsAdmin } from '@/wallet/capabilities';
import { useWalletStore } from '@/wallet/store';
import type {
ContractId,
- DisclosedContract,
V2AllocationSpecification,
V2ExtraArgs,
V2SettlementInfo,
@@ -40,12 +40,6 @@ interface RequestAddResult {
quoteAmount: string;
allocations: V2AllocationSpecification[];
settlement: V2SettlementInfo;
- depositFactoryCid: string;
- lpFactoryCid: string;
- depositFactoryExtraArgs: V2ExtraArgs;
- lpFactoryExtraArgs: V2ExtraArgs;
- depositFactoryDisclosure: DisclosedContract[];
- lpFactoryDisclosure: DisclosedContract[];
}
interface RequestRemoveResult {
requestCid: string;
@@ -56,12 +50,6 @@ interface RequestRemoveResult {
quoteOuts: string[];
allocations: V2AllocationSpecification[];
settlement: V2SettlementInfo;
- depositFactoryCid: string;
- lpFactoryCid: string;
- depositFactoryExtraArgs: V2ExtraArgs;
- lpFactoryExtraArgs: V2ExtraArgs;
- depositFactoryDisclosure: DisclosedContract[];
- lpFactoryDisclosure: DisclosedContract[];
}
function connectedParty(): string {
@@ -74,6 +62,27 @@ const API_BASE = import.meta.env.VITE_API_BASE ?? 'http://localhost:8080';
const operator = new OperatorApi(API_BASE);
+async function discoverAllocationFactory(params: {
+ admin: string;
+ settlement: V2SettlementInfo;
+ allocation: V2AllocationSpecification;
+ requestedAt: string;
+ inputHoldingCids: string[];
+ actors: string[];
+}) {
+ return operator.getAllocationFactory({
+ admin: params.admin,
+ choiceArguments: {
+ settlement: params.settlement,
+ allocation: params.allocation,
+ requestedAt: params.requestedAt,
+ inputHoldingCids: params.inputHoldingCids,
+ actors: params.actors,
+ extraArgs: EMPTY_EXTRA_ARGS,
+ },
+ });
+}
+
async function getWalletNativeHoldings(owner: string): Promise {
const walletState = useWalletStore.getState();
const providerId = walletState.activeProviderId;
@@ -611,10 +620,6 @@ export interface DexContext {
operator: string;
lpRegistrar: string;
admin: string;
- allocationFactoryCid: string;
- settlementFactoryCid: string;
- allocationFactoryExtraArgs: V2ExtraArgs;
- allocationFactoryDisclosure: DisclosedContract[];
network: string;
}
@@ -779,15 +784,26 @@ export const ledger = {
quoteBinding: req.quoteBinding,
});
+ const requestedAt = new Date().toISOString();
+ const factory = await discoverAllocationFactory({
+ admin: params.pool.admin,
+ settlement: req.settlement as V2SettlementInfo,
+ allocation: req.allocationSpec as V2AllocationSpecification,
+ requestedAt,
+ inputHoldingCids,
+ actors: [params.swapperParty],
+ });
+
// 2. Wallet authors the exact terminal allocation.
const walletResult = await handToWallet({
kind: 'request-swap',
poolId: params.pool.contractId,
allocationSpec: req.allocationSpec as V2AllocationSpecification,
settlement: req.settlement as V2SettlementInfo,
- factoryCid: req.factoryCid,
- allocationFactoryExtraArgs: req.allocationFactoryExtraArgs,
- disclosure: req.allocationFactoryDisclosure,
+ requestedAt,
+ factoryCid: factory.factoryCid,
+ allocationFactoryExtraArgs: factory.extraArgs,
+ disclosure: factory.disclosure,
inputHoldingCids: inputHoldingCids as ContractId<'Holding'>[],
});
const swapperAllocationCid = walletResult.createdAllocationCids?.[0];
@@ -883,13 +899,23 @@ export const ledger = {
);
}
+ const requestedAt = new Date().toISOString();
+ const factory = await discoverAllocationFactory({
+ admin: params.context.admin,
+ settlement: bindRes.settlement as V2SettlementInfo,
+ allocation: bindRes.allocationSpec as V2AllocationSpecification,
+ requestedAt,
+ inputHoldingCids,
+ actors: [trader],
+ });
const walletRes = await handToWallet({
kind: 'fund-order',
- factoryCid: params.context.allocationFactoryCid as ContractId<'AllocationFactory'>,
- allocationFactoryExtraArgs: params.context.allocationFactoryExtraArgs,
- disclosure: params.context.allocationFactoryDisclosure,
+ factoryCid: factory.factoryCid,
+ allocationFactoryExtraArgs: factory.extraArgs,
+ disclosure: factory.disclosure,
settlement: bindRes.settlement as V2SettlementInfo,
allocationSpec: bindRes.allocationSpec as V2AllocationSpecification,
+ requestedAt,
inputHoldingCids: inputHoldingCids as ContractId<'Holding'>[],
hint: { instrumentId: lockInstrumentId, amount: lockAmount },
});
@@ -968,20 +994,34 @@ export const ledger = {
requestedAt,
}),
});
+ const holdingInputs = [
+ params.baseHoldingCids ?? [],
+ params.quoteHoldingCids ?? [],
+ [],
+ ];
+ const factories = await Promise.all(
+ req.allocations.map((allocation, index) =>
+ discoverAllocationFactory({
+ admin: allocation.admin,
+ settlement: req.settlement,
+ allocation,
+ requestedAt,
+ inputHoldingCids: holdingInputs[index] ?? [],
+ actors: [recipient],
+ }),
+ ),
+ );
const walletRes = await handToWallet({
kind: 'add-liquidity',
requestCid: req.requestCid,
settlement: req.settlement,
allocations: req.allocations,
- // Distinct factories per admin (deposits under pool.admin, LP receipt
- // under pool.lpRegistrar) — both come from /request, not context.
- depositFactoryCid: req.depositFactoryCid,
- lpFactoryCid: req.lpFactoryCid,
- depositFactoryExtraArgs: req.depositFactoryExtraArgs,
- lpFactoryExtraArgs: req.lpFactoryExtraArgs,
+ requestedAt,
+ factoryCids: factories.map((f) => f.factoryCid),
+ allocationFactoryExtraArgs: factories.map((f) => f.extraArgs),
// The request lives in our own DAR; accept needs no registry context.
allocationRequestExtraArgs: EMPTY_EXTRA_ARGS,
- disclosure: [...req.depositFactoryDisclosure, ...req.lpFactoryDisclosure],
+ disclosure: factories.flatMap((f) => f.disclosure),
baseHoldingCids: params.baseHoldingCids ?? [],
quoteHoldingCids: params.quoteHoldingCids ?? [],
});
@@ -1068,17 +1108,29 @@ export const ledger = {
requestedAt,
}),
});
+ const holdingInputs = [[], [], holderLpHoldingCids];
+ const factories = await Promise.all(
+ req.allocations.map((allocation, index) =>
+ discoverAllocationFactory({
+ admin: allocation.admin,
+ settlement: req.settlement,
+ allocation,
+ requestedAt,
+ inputHoldingCids: holdingInputs[index] ?? [],
+ actors: [params.holder],
+ }),
+ ),
+ );
const walletRes = await handToWallet({
kind: 'remove-liquidity',
requestCid: req.requestCid,
settlement: req.settlement,
allocations: req.allocations,
- depositFactoryCid: req.depositFactoryCid,
- lpFactoryCid: req.lpFactoryCid,
- depositFactoryExtraArgs: req.depositFactoryExtraArgs,
- lpFactoryExtraArgs: req.lpFactoryExtraArgs,
+ requestedAt,
+ factoryCids: factories.map((f) => f.factoryCid),
+ allocationFactoryExtraArgs: factories.map((f) => f.extraArgs),
allocationRequestExtraArgs: EMPTY_EXTRA_ARGS,
- disclosure: [...req.depositFactoryDisclosure, ...req.lpFactoryDisclosure],
+ disclosure: factories.flatMap((f) => f.disclosure),
lpHoldingCids: holderLpHoldingCids,
});
const cids = walletRes.createdAllocationCids;
@@ -1126,9 +1178,14 @@ async function fetchJson(
path: string,
init: RequestInit = {},
): Promise {
+ const method = init.method ?? 'GET';
const res = await fetch(`${API_BASE}${path}`, {
- headers: { 'Content-Type': 'application/json', ...(init.headers ?? {}) },
...init,
+ headers: {
+ 'Content-Type': 'application/json',
+ ...apiAuthHeaders(path, method),
+ ...(init.headers ?? {}),
+ },
});
if (!res.ok) throw new Error(`${res.status}: ${await res.text()}`);
if (res.status === 204) return undefined as T;
diff --git a/app/web/src/services/operator-api.ts b/app/web/src/services/operator-api.ts
index 706dac17..636d7559 100644
--- a/app/web/src/services/operator-api.ts
+++ b/app/web/src/services/operator-api.ts
@@ -3,6 +3,8 @@
// through `wallet/handoff.ts`; hosted RFQ routes are the documented relay
// exception.
+import { apiAuthHeaders } from "./api-auth";
+
export type Party = string;
export type ContractId<_T> = string;
export type Decimal = string;
@@ -21,6 +23,12 @@ export interface DisclosedContract {
synchronizerId?: string;
}
+export interface AllocationFactorySurface {
+ factoryCid: ContractId<"AllocationFactory">;
+ extraArgs: V2ExtraArgs;
+ disclosure: DisclosedContract[];
+}
+
export interface SwapQuoteBinding {
expectedPoolId: string;
poolStateCid: ContractId<"PoolState">;
@@ -114,6 +122,13 @@ export class OperatorApi {
return this.post("/v1/swaps/quote", req);
}
+ async getAllocationFactory(req: {
+ admin: Party;
+ choiceArguments: Record;
+ }): Promise {
+ return this.post("/v1/registry/allocation-factory", req);
+ }
+
// Operator builds the exact two-sided allocation against one pool snapshot;
// the wallet authorizes it and swap() settles that same quote binding.
async requestSwap(req: {
@@ -126,9 +141,6 @@ export class OperatorApi {
allocationSpec: unknown;
settlement: unknown;
quoteBinding: SwapQuoteBinding;
- factoryCid: ContractId<"AllocationFactory">;
- allocationFactoryExtraArgs: V2ExtraArgs;
- allocationFactoryDisclosure: DisclosedContract[];
}> {
return this.post("/v1/pools/swap/request", req);
}
@@ -169,9 +181,10 @@ export class OperatorApi {
}
async cancelRfq(rfqCid: ContractId<"Rfq">): Promise {
+ const path = `/v1/rfq/${encodeURIComponent(rfqCid)}/cancel`;
const res = await fetch(
- `${this.baseUrl}/v1/rfq/${encodeURIComponent(rfqCid)}/cancel`,
- { method: "POST" },
+ `${this.baseUrl}${path}`,
+ { method: "POST", headers: apiAuthHeaders(path, "POST") },
);
if (!res.ok) throw new Error(`${res.status}: ${await res.text()}`);
}
@@ -260,7 +273,9 @@ export class OperatorApi {
// === internals ============================================================
private async get(path: string): Promise {
- const res = await fetch(`${this.baseUrl}${path}`);
+ const res = await fetch(`${this.baseUrl}${path}`, {
+ headers: apiAuthHeaders(path, "GET"),
+ });
if (!res.ok) throw new Error(`${res.status}: ${await res.text()}`);
return (await res.json()) as T;
}
@@ -268,7 +283,10 @@ export class OperatorApi {
private async post(path: string, body: unknown): Promise {
const res = await fetch(`${this.baseUrl}${path}`, {
method: "POST",
- headers: { "Content-Type": "application/json" },
+ headers: {
+ "Content-Type": "application/json",
+ ...apiAuthHeaders(path, "POST"),
+ },
body: JSON.stringify(body),
});
if (!res.ok) throw new Error(`${res.status}: ${await res.text()}`);
diff --git a/app/web/src/services/rfq-policy.ts b/app/web/src/services/rfq-policy.ts
index d0e59fb5..867cd9b9 100644
--- a/app/web/src/services/rfq-policy.ts
+++ b/app/web/src/services/rfq-policy.ts
@@ -51,7 +51,8 @@ export function rankQuotes(
const postedCmp = a.postedAt.localeCompare(b.postedAt);
if (postedCmp !== 0) return postedCmp;
// deterministic tie-breaker on dealer party id
- return a.dealer.localeCompare(b.dealer);
+ // Daml compares Party text by code unit; localeCompare can disagree.
+ return a.dealer < b.dealer ? -1 : a.dealer > b.dealer ? 1 : 0;
});
}
diff --git a/app/web/src/vite-env.d.ts b/app/web/src/vite-env.d.ts
index ee7058e0..baeab255 100644
--- a/app/web/src/vite-env.d.ts
+++ b/app/web/src/vite-env.d.ts
@@ -2,16 +2,29 @@
interface ImportMetaEnv {
readonly VITE_API_BASE: string;
+ /** Published documentation URL opened by the app navigation. */
+ readonly VITE_DOCS_URL?: string;
/** Reown / WalletConnect Cloud project id. Get one at cloud.reown.com. */
readonly VITE_WC_PROJECT_ID?: string;
/** CAIP network id for the target Canton network, e.g. canton:devnet. */
readonly VITE_CANTON_NETWORK_ID?: string;
+ readonly VITE_CANTON_SYNCHRONIZER?: string;
+ readonly VITE_CANTON_DEX_PACKAGE_ID?: string;
+ readonly VITE_CANTON_DEFAULT_PARTY?: string;
+ readonly VITE_CANTON_USER_ID?: string;
+ readonly VITE_ENABLE_SDK?: string;
+ readonly VITE_WALLET_GATEWAY_URL?: string;
+ readonly VITE_WALLET_GATEWAY_NAME?: string;
+ readonly VITE_WALLET_SHOW_FULL_CATALOG?: string;
readonly VITE_ENABLE_PARTYLAYER?: string;
+ /** Enable the explicitly custodial RFQ write UI in a production build. */
+ readonly VITE_ENABLE_HOSTED_RFQ?: string;
readonly VITE_PARTYLAYER_APP_NAME?: string;
readonly VITE_PARTYLAYER_NETWORK?: string;
readonly VITE_PARTYLAYER_WALLET_IDS?: string;
readonly VITE_PARTYLAYER_REGISTRY_URL?: string;
readonly VITE_PARTYLAYER_REGISTRY_CHANNEL?: string;
+ readonly VITE_PARTYLAYER_CONNECT_TIMEOUT_MS?: string;
}
interface ImportMeta {
diff --git a/app/web/src/wallet/canton-direct-provider.ts b/app/web/src/wallet/canton-direct-provider.ts
index 34affa23..f730e845 100644
--- a/app/web/src/wallet/canton-direct-provider.ts
+++ b/app/web/src/wallet/canton-direct-provider.ts
@@ -1,18 +1,12 @@
-// Direct Canton ledger wallet provider.
+// Disabled Direct Canton experiment.
//
-// Lightweight fallback for testnet/dev. Submits intents to the Canton
-// JSON Ledger API directly using a bearer token, without WalletConnect
-// pairing flow. The operator backend translates the intent into the
-// concrete Daml command tree; this provider just signs and submits.
-//
-// Use cases:
-// - Dev sessions where the user already has a JWT and a participant URL
-// - Manual validation against a controlled testnet
-// - Smoke testing the dApp without a wallet round-trip
-//
-// NOT suitable for end users: relies on the user trusting a long-lived
-// JWT stored in localStorage. The Token Standard provider should be the
-// default for real wallets.
+// A participant JSON Ledger API can accept concrete Daml commands, but it does
+// not expose the DEX-specific `/v1/wallet/execute` intent endpoint that an older
+// version of this class called. Keeping a participant bearer token in browser
+// localStorage would also be an unsafe public-deployment pattern. The provider
+// registry therefore does not register this class, and both connect and submit
+// fail closed. Use the dapp SDK, PartyLayer, or WalletConnect for a real wallet;
+// use the development operator relay when explicitly testing backend signing.
import type {
WalletAccount,
@@ -21,49 +15,25 @@ import type {
WalletProvider,
WalletResult,
} from "./types";
-import { LiquidityAllocationUnsupportedError } from "./types";
-const LS_KEY = "canton-dex:direct:session";
-
-interface PersistedSession {
- ledgerUrl: string;
- token: string;
- party: string;
-}
+export const CANTON_DIRECT_DISABLED_MESSAGE =
+ "Direct Canton is intentionally unavailable: the participant API accepts concrete Daml commands, not DEX wallet intents. Use a supported external wallet or the DEV-only operator relay.";
export class CantonDirectProvider implements WalletProvider {
readonly id = "canton-direct";
- readonly label = "Direct Canton (advanced)";
+ readonly label = "Direct Canton (disabled)";
private status: WalletConnectionStatus = { kind: "disconnected" };
private readonly listeners = new Set<(s: WalletConnectionStatus) => void>();
- private session: PersistedSession | null = null;
constructor(
- private readonly defaultLedgerUrl: string,
- private readonly defaultToken: string,
- ) {
- // Auto-restore prior session on construction so a page reload keeps
- // the user signed in. Dev-only: in prod we never rehydrate a persisted
- // bearer-token session.
- const stored =
- import.meta.env.DEV && typeof window !== "undefined"
- ? window.localStorage.getItem(LS_KEY)
- : null;
- if (stored) {
- try {
- this.session = JSON.parse(stored) as PersistedSession;
- this.status = {
- kind: "connected",
- account: { party: this.session.party, label: this.label },
- providerId: this.id,
- };
- } catch {
- // Stored session was tampered — drop it.
- window.localStorage.removeItem(LS_KEY);
- }
- }
- }
+ // Preserve the old constructor shape for downstream imports while making
+ // it impossible to retain either credential.
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
+ _defaultLedgerUrl = "",
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
+ _defaultToken = "",
+ ) {}
getStatus(): WalletConnectionStatus {
return this.status;
@@ -80,74 +50,15 @@ export class CantonDirectProvider implements WalletProvider {
}
async connect(): Promise {
- if (this.status.kind === "connected") return this.status.account;
- // Never read/persist a long-lived bearer token outside dev.
- if (!import.meta.env.DEV) {
- const msg =
- "canton-direct is a dev-only provider and is disabled in production builds";
- // eslint-disable-next-line no-console
- console.error(`[wallet] ${msg}`);
- this.setStatus({ kind: "error", message: msg });
- throw new Error(msg);
- }
- if (!this.defaultLedgerUrl || !this.defaultToken) {
- const msg = "VITE_CANTON_LEDGER_URL and VITE_CANTON_AUTH_TOKEN must be set";
- this.setStatus({ kind: "error", message: msg });
- throw new Error(msg);
- }
- this.setStatus({ kind: "connecting" });
- try {
- const res = await fetch(new URL("/v2/users/current", this.defaultLedgerUrl).toString(), {
- headers: { Authorization: `Bearer ${this.defaultToken}` },
- });
- if (!res.ok) throw new Error(`ledger /v2/users/current returned ${res.status}`);
- const body = (await res.json()) as { primaryParty?: string; party?: string };
- const party = body.primaryParty ?? body.party;
- if (!party) throw new Error("ledger did not return a primary party");
- this.session = { ledgerUrl: this.defaultLedgerUrl, token: this.defaultToken, party };
- window.localStorage.setItem(LS_KEY, JSON.stringify(this.session));
- const account: WalletAccount = { party, label: this.label };
- this.setStatus({ kind: "connected", account, providerId: this.id });
- return account;
- } catch (e) {
- const msg = e instanceof Error ? e.message : String(e);
- this.setStatus({ kind: "error", message: msg });
- throw e;
- }
+ this.setStatus({ kind: "error", message: CANTON_DIRECT_DISABLED_MESSAGE });
+ throw new Error(CANTON_DIRECT_DISABLED_MESSAGE);
}
async disconnect(): Promise {
- this.session = null;
- window.localStorage.removeItem(LS_KEY);
this.setStatus({ kind: "disconnected" });
}
- async submit(intent: WalletIntent): Promise {
- if (this.status.kind !== "connected" || !this.session) {
- throw new Error("canton-direct: not connected");
- }
- if (intent.kind === "add-liquidity" || intent.kind === "remove-liquidity") {
- // This provider cannot surface the created allocation cids required by
- // the LP settle endpoint.
- throw new LiquidityAllocationUnsupportedError(this.id);
- }
- // The Direct provider forwards the intent verbatim to the operator
- // backend's intent-execution endpoint. The backend resolves it into
- // a Daml command tree and signs as the trader (using the same
- // direct bearer token under the hood). This is the simplest path
- // for testnet smoke flows.
- const res = await fetch(new URL("/v1/wallet/execute", this.session.ledgerUrl).toString(), {
- method: "POST",
- headers: {
- "Content-Type": "application/json",
- Authorization: `Bearer ${this.session.token}`,
- },
- body: JSON.stringify({ party: this.session.party, intent }),
- });
- if (!res.ok) {
- const text = await res.text();
- throw new Error(`wallet execute failed: ${res.status} ${text}`);
- }
- return (await res.json()) as WalletResult;
+ async submit(_intent: WalletIntent): Promise {
+ throw new Error(CANTON_DIRECT_DISABLED_MESSAGE);
}
}
diff --git a/app/web/src/wallet/capabilities.ts b/app/web/src/wallet/capabilities.ts
index 4197b6d2..54902c3e 100644
--- a/app/web/src/wallet/capabilities.ts
+++ b/app/web/src/wallet/capabilities.ts
@@ -50,8 +50,8 @@ export const WALLET_CAPABILITIES: Record = {
coSignsAdmin: false,
},
mock: {
- dvp: "ready",
- note: "Dev mock; deterministic cids.",
+ dvp: "dev-only",
+ note: "Dev only — returns deterministic placeholder cids; no ledger submission.",
coSignsAdmin: true,
},
partylayer: {
@@ -64,11 +64,6 @@ export const WALLET_CAPABILITIES: Record = {
note: "Settlement-accept only; cannot complete LP DvP.",
coSignsAdmin: false,
},
- "canton-direct": {
- dvp: "unsupported",
- note: "Settlement-accept only; cannot complete LP DvP.",
- coSignsAdmin: true,
- },
};
/**
diff --git a/app/web/src/wallet/commands.ts b/app/web/src/wallet/commands.ts
index 7fa9c27c..3c04f80d 100644
--- a/app/web/src/wallet/commands.ts
+++ b/app/web/src/wallet/commands.ts
@@ -116,7 +116,7 @@ function composeFundOrder(
intent.allocationSpec,
intent.inputHoldingCids,
ctx.party,
- ctx.now().toISOString(),
+ intent.requestedAt,
intent.allocationFactoryExtraArgs,
),
],
@@ -169,7 +169,7 @@ function composeRequestSwap(
intent.allocationSpec,
intent.inputHoldingCids,
ctx.party,
- ctx.now().toISOString(),
+ intent.requestedAt,
intent.allocationFactoryExtraArgs,
),
],
@@ -234,8 +234,7 @@ function composeAddLiquidity(
intent: Extract,
ctx: ComposeContext,
): ComposedCommands {
- assertFactoryReady(intent.depositFactoryCid, "add-liquidity");
- assertFactoryReady(intent.lpFactoryCid, "add-liquidity");
+ intent.factoryCids.forEach((cid) => assertFactoryReady(cid, "add-liquidity"));
if (intent.allocations.length !== 3) {
throw new Error(`add-liquidity: expected 3 allocation specs, got ${intent.allocations.length}`);
}
@@ -270,23 +269,21 @@ function batchingUtilityCommand(
requestCid: ContractId<"LiquidityAllocationRequest">;
settlement: V2SettlementInfo;
allocations: V2AllocationSpecification[];
- depositFactoryCid: ContractId<"AllocationFactory">;
- lpFactoryCid: ContractId<"AllocationFactory">;
- depositFactoryExtraArgs: V2ExtraArgs;
- lpFactoryExtraArgs: V2ExtraArgs;
+ requestedAt: string;
+ factoryCids: ContractId<"AllocationFactory">[];
+ allocationFactoryExtraArgs: V2ExtraArgs[];
allocationRequestExtraArgs: V2ExtraArgs;
disclosure: DisclosedContract[];
},
ctx: ComposeContext,
holdingsBySpec: string[][],
): ComposedCommands {
- const requestedAt = ctx.now().toISOString();
- const factoryCids = [intent.depositFactoryCid, intent.depositFactoryCid, intent.lpFactoryCid];
- const allocExtraArgs = [
- intent.depositFactoryExtraArgs,
- intent.depositFactoryExtraArgs,
- intent.lpFactoryExtraArgs,
- ];
+ const requestedAt = intent.requestedAt;
+ const factoryCids = intent.factoryCids;
+ const allocExtraArgs = intent.allocationFactoryExtraArgs;
+ if (factoryCids.length !== intent.allocations.length || allocExtraArgs.length !== intent.allocations.length) {
+ throw new Error("batching: each allocation requires its own factory and choice context");
+ }
// HoldingMap: GenMap ScopedAccount -> TextMap instrumentId -> [holding cids].
// A GenMap encodes as [key, value] pairs on the JSON Ledger API.
const buckets = new Map<
@@ -363,8 +360,7 @@ function composeRemoveLiquidity(
intent: Extract,
ctx: ComposeContext,
): ComposedCommands {
- assertFactoryReady(intent.depositFactoryCid, "remove-liquidity");
- assertFactoryReady(intent.lpFactoryCid, "remove-liquidity");
+ intent.factoryCids.forEach((cid) => assertFactoryReady(cid, "remove-liquidity"));
if (intent.allocations.length !== 3) {
throw new Error(`remove-liquidity: expected 3 allocation specs, got ${intent.allocations.length}`);
}
diff --git a/app/web/src/wallet/registry.ts b/app/web/src/wallet/registry.ts
index f50221c0..c433cd81 100644
--- a/app/web/src/wallet/registry.ts
+++ b/app/web/src/wallet/registry.ts
@@ -1,6 +1,5 @@
// Wallet provider registry. Single place to add or gate providers.
-import { CantonDirectProvider } from "./canton-direct-provider";
import { MockWalletProvider } from "./mock-provider";
import {
DEFAULT_PARTYLAYER_CONNECT_TIMEOUT_MS,
@@ -17,7 +16,6 @@ export type WalletProviderId =
| "partylayer"
| "token-standard"
| "walletconnect"
- | "canton-direct"
| "mock";
function optionalEnv(name: string): string | undefined {
@@ -56,36 +54,24 @@ function partyLayerClientFactory(networkId: string): () => Promise | null = null;
function buildRegistry(): Map {
+ // An older, now-disabled Direct Canton experiment persisted a participant
+ // bearer credential at this key. Remove it during app startup even though the
+ // provider itself is no longer constructed.
+ if (typeof window !== "undefined") {
+ try {
+ window.localStorage.removeItem("canton-dex:direct:session");
+ } catch {
+ // Storage can be unavailable in locked-down browser contexts. Direct
+ // Canton is still absent from the registry, so fail closed without
+ // preventing the safe wallet adapters from loading.
+ }
+ }
const projectId = (import.meta.env.VITE_WC_PROJECT_ID ?? "") as string;
const networkId = (import.meta.env.VITE_CANTON_NETWORK_ID ??
"canton:devnet") as string;
- const ledgerUrl = (import.meta.env.VITE_CANTON_LEDGER_URL ?? "") as string;
- // VITE_CANTON_AUTH_TOKEN is a long-lived bearer credential. It must never be
- // read into a production bundle. In prod we refuse to read it and
- // log an error so a misconfigured deploy is loud, not silently insecure.
- const authToken = devOnlyAuthToken();
const apiBase =
(import.meta.env.VITE_API_BASE ?? "http://localhost:8080") as string;
const enableSdk =
@@ -119,13 +105,16 @@ function buildRegistry(): Map {
),
);
}
- map.set("token-standard", new TokenStandardProvider(ledgerUrl, authToken, apiBase));
- if (projectId) map.set("walletconnect", new WalletConnectProvider(projectId, networkId));
- // canton-direct relies on a long-lived bearer token in localStorage, so it is
- // gated to dev like `mock`. `authToken` is already "" in prod.
- if (import.meta.env.DEV && ledgerUrl && authToken) {
- map.set("canton-direct", new CantonDirectProvider(ledgerUrl, authToken));
+ // This provider sends trader-authority commands through the operator relay.
+ // Keep the implementation available for local diagnosis, but do not expose
+ // it in a production bundle where it could be mistaken for self-custody.
+ if (import.meta.env.DEV) {
+ map.set("token-standard", new TokenStandardProvider(apiBase));
}
+ if (projectId) map.set("walletconnect", new WalletConnectProvider(projectId, networkId));
+ // Direct Canton is intentionally not registered. A participant accepts
+ // concrete Ledger API commands, not DEX wallet intents, and a browser should
+ // never retain its bearer credential. See canton-direct-provider.ts.
if (import.meta.env.DEV) map.set("mock", new MockWalletProvider());
return map;
@@ -149,11 +138,11 @@ export function getProvider(id: WalletProviderId): WalletProvider {
// operator effectively signs on the user's behalf. The relay is a dev-only
// convenience and is gated behind `import.meta.env.DEV` below.
//
-// Real-build preference order:
-// 1. PartyLayer when explicitly enabled (VITE_ENABLE_PARTYLAYER=1) — a real
-// external multi-wallet connector.
-// 2. WalletConnect when a project id is configured — a real external wallet.
-// 3. SDK when enabled — a real CIP-0103 wallet.
+// Real-build recommendation order follows the capability table:
+// 1. SDK when enabled — the full DvP path is implemented.
+// 2. PartyLayer when explicitly enabled — the path is implemented but remains
+// marked unproven until the selected wallet passes live validation.
+// 3. WalletConnect when configured — the current adapter is marked no-DvP.
// 4. `null` (no auto-default): the user must pick a provider in the Connect
// menu. We deliberately do NOT silently fall back to the operator relay.
// In dev builds we keep `token-standard` as the convenient default so local
@@ -163,9 +152,9 @@ function resolveDefaultProviderId(): WalletProviderId | null {
const hasWalletConnect = !!(import.meta.env.VITE_WC_PROJECT_ID ?? "");
const enableSdk = (import.meta.env.VITE_ENABLE_SDK ?? "") === "1";
+ if (enableSdk) return "sdk";
if (enablePartyLayer) return "partylayer";
if (hasWalletConnect) return "walletconnect";
- if (enableSdk) return "sdk";
// Dev convenience only: the operator relay default. Never in prod.
if (import.meta.env.DEV) return "token-standard";
// No safe real wallet configured: force an explicit pick rather than routing
diff --git a/app/web/src/wallet/sdk-provider.ts b/app/web/src/wallet/sdk-provider.ts
index bc1760f0..fc29bbad 100644
--- a/app/web/src/wallet/sdk-provider.ts
+++ b/app/web/src/wallet/sdk-provider.ts
@@ -39,7 +39,7 @@ export interface SdkProviderOptions {
}
// Structural mirror of core-wallet-discovery's WalletPickerEntry/Result (not
-// re-exported by @canton-network/dapp-sdk 1.1.0). The SDK calls our walletPicker
+// re-exported by @canton-network/dapp-sdk). The SDK calls our walletPicker
// with the discovered adapters and expects one back.
interface PickerEntry {
providerId: string;
@@ -53,7 +53,7 @@ interface PickerEntry {
// --- Browser CIP-103 wallet discovery ------------------------------------
//
-// @canton-network/dapp-sdk 1.1.0 does not re-export its internal
+// @canton-network/dapp-sdk does not re-export its internal
// injected/announced discovery helpers, so we mirror the standard CIP-103
// browser handshake here (same shape the SDK uses internally): read the
// `window.canton` injection namespace, and dispatch `canton:requestProvider`
@@ -371,7 +371,12 @@ export class SdkProvider implements WalletProvider {
try {
result = await this.sdk.prepareExecuteAndWait({
commandId: composed.commandId,
- commands: composed.commands as unknown as Record,
+ // The SDK deliberately types each Ledger API command payload as opaque;
+ // our composer supplies the same tagged command union with stricter
+ // inner fields.
+ commands: composed.commands as unknown as Parameters<
+ DappSDK["prepareExecuteAndWait"]
+ >[0]["commands"],
actAs: composed.actAs,
// Off-participant factory/request contracts (AllocationFactory, the
// AllocationRequest) the trader's participant does not host must be
@@ -379,7 +384,7 @@ export class SdkProvider implements WalletProvider {
...(composed.disclosedContracts && composed.disclosedContracts.length > 0
? { disclosedContracts: composed.disclosedContracts }
: {}),
- } as Parameters[0]);
+ });
} catch (e) {
// Surface the wallet or gateway's normalized error.
throw new Error(`wallet submission failed: ${describeWalletError(e)}`);
diff --git a/app/web/src/wallet/token-standard-provider.ts b/app/web/src/wallet/token-standard-provider.ts
index 261052e1..e3c370fa 100644
--- a/app/web/src/wallet/token-standard-provider.ts
+++ b/app/web/src/wallet/token-standard-provider.ts
@@ -1,10 +1,12 @@
-// Token Standard V2 wallet provider — Canton-native, no backend hop.
+// Development-only operator-signing relay.
//
-// The provider holds the user's JWT (from env or a per-user signing
-// session) and submits Daml commands directly to the participant's
-// JSON Ledger API at `/v2/commands/submit-and-wait`. The dApp never
-// signs as the trader; this provider IS the signing surface for
-// trader-authority actions.
+// Its provider id is `token-standard`, because the commands it composes use
+// the Token Standard V2 allocation interfaces. It is NOT a Token
+// Standard wallet and it is NOT self-custodial: the browser posts shaped Daml
+// commands to the operator backend's `/v1/wallet/submit` route, and that backend
+// submits them with its configured ledger credential. The registry exposes this
+// class only in Vite DEV builds. Production deployments must use an external
+// wallet through the dapp SDK, PartyLayer, or WalletConnect adapters.
//
// What each intent maps to on-ledger:
//
@@ -16,16 +18,15 @@
// remove-liquidity → CreateAndExercise BatchingUtilityV2.ExecuteBatch
// (accept + all 3 allocations in one command)
//
-// Connection lifecycle:
-// - connect() validates the ledger URL, fetches the user's primary
-// party via /v2/users/current, stores session in localStorage.
-// - reload() re-reads the localStorage session so reloads don't
-// drop the user.
+// Development connection lifecycle:
+// - connect() verifies the operator backend and uses the explicitly
+// configured demo party.
+// - reload() restores only the party and ledger user id from localStorage.
// - disconnect() clears the session.
//
-// Session storage is intentionally narrow — just party + token + url.
-// The party never changes during a session; the JWT is short-lived
-// and refreshed via the wallet's auth flow (out of scope here).
+// No participant JWT is read or stored here. Browser-to-backend write
+// authorization is supplied separately by apiAuthHeaders; the backend's ledger
+// credential remains server-side.
import type {
DisclosedContract,
@@ -40,6 +41,7 @@ import {
extractCreatedAllocationCids,
extractLiquidityAcceptanceCid,
} from "./commands";
+import { apiAuthHeaders } from "../services/api-auth";
const LS_KEY = "canton-dex:token-standard:session";
const SUBMIT_TIMEOUT_MS = 60_000;
@@ -52,8 +54,6 @@ const PACKAGE_PREFIX =
"#canton-dex-trading";
interface PersistedSession {
- ledgerUrl: string;
- token: string;
party: string;
userId: string;
}
@@ -85,29 +85,25 @@ function template(name: string): string {
export class TokenStandardProvider implements WalletProvider {
readonly id = "token-standard";
- readonly label = "Canton Wallet (Token Standard V2)";
+ readonly label = "Operator Relay (dev only)";
private status: WalletConnectionStatus = { kind: "disconnected" };
private readonly listeners = new Set<(s: WalletConnectionStatus) => void>();
private session: PersistedSession | null = null;
- constructor(
- // Kept for typed parity with other providers. Browser submissions
- // route through the operator backend's ledger proxy so local demos
- // do not require participant CORS configuration. Production wallet
- // integrations should hold their own credentials and submit through
- // a participant endpoint that allows the dApp origin.
- // eslint-disable-next-line @typescript-eslint/no-unused-vars
- _defaultLedgerUrl: string,
- // eslint-disable-next-line @typescript-eslint/no-unused-vars
- _defaultToken: string,
- private readonly apiBase: string,
- ) {
- if (typeof window === "undefined") return;
+ constructor(private readonly apiBase: string) {
+ if (!import.meta.env.DEV || typeof window === "undefined") return;
const stored = window.localStorage.getItem(LS_KEY);
if (!stored) return;
try {
- this.session = JSON.parse(stored) as PersistedSession;
+ const parsed = JSON.parse(stored) as Partial;
+ if (typeof parsed.party !== "string" || typeof parsed.userId !== "string") {
+ throw new Error("invalid operator-relay session");
+ }
+ // Rewrite the narrow shape so fields from an older implementation are
+ // not retained indefinitely in browser storage.
+ this.session = { party: parsed.party, userId: parsed.userId };
+ window.localStorage.setItem(LS_KEY, JSON.stringify(this.session));
this.status = {
kind: "connected",
account: { party: this.session.party, label: this.label },
@@ -135,18 +131,24 @@ export class TokenStandardProvider implements WalletProvider {
async connect(): Promise {
if (this.status.kind === "connected" && this.session)
return this.status.account;
+ if (!import.meta.env.DEV) {
+ const msg =
+ "the operator relay is development-only; configure an external wallet for production";
+ this.setStatus({ kind: "error", message: msg });
+ throw new Error(msg);
+ }
if (!this.apiBase) {
const msg =
- "Set VITE_API_BASE in .env.local to use the Token Standard provider";
+ "Set VITE_API_BASE in .env.local to use the development operator relay";
this.setStatus({ kind: "error", message: msg });
throw new Error(msg);
}
this.setStatus({ kind: "connecting" });
try {
- // Resolve the user's party. In production a CIP-0103 wallet
- // returns its own party id; on this testnet we use the env-
- // configured default since the shared JWT has no primary party.
+ // A real wallet returns its own party. This relay instead uses an
+ // explicitly configured demo party whose ledger rights are held by the
+ // backend credential.
const party =
(import.meta.env.VITE_CANTON_DEFAULT_PARTY as string | undefined) ??
null;
@@ -155,11 +157,11 @@ export class TokenStandardProvider implements WalletProvider {
"ledger-api-user";
if (!party) {
throw new Error(
- "Set VITE_CANTON_DEFAULT_PARTY in .env.local. In production a CIP-0103 wallet would provide this; on testnet the operator allocates parties up front.",
+ "Set VITE_CANTON_DEFAULT_PARTY in .env.local to use the development operator relay.",
);
}
- // Verify the backend can talk to the ledger (proves the JWT is
- // valid and the participant is reachable).
+ // This checks only that the backend is reachable. The first write is the
+ // point at which backend authorization and ledger submission are proven.
const health = await fetch(`${this.apiBase}/v1/status`);
if (!health.ok) {
throw new Error(
@@ -167,8 +169,6 @@ export class TokenStandardProvider implements WalletProvider {
);
}
this.session = {
- ledgerUrl: this.apiBase,
- token: "",
party,
userId,
};
@@ -192,8 +192,13 @@ export class TokenStandardProvider implements WalletProvider {
// -- intent dispatch -----------------------------------------------
async submit(intent: WalletIntent): Promise {
+ if (!import.meta.env.DEV) {
+ throw new Error(
+ "the operator relay is development-only; configure an external wallet for production",
+ );
+ }
if (this.status.kind !== "connected" || !this.session) {
- throw new Error("token-standard: not connected");
+ throw new Error("operator-relay: not connected");
}
switch (intent.kind) {
case "place-order":
@@ -205,11 +210,11 @@ export class TokenStandardProvider implements WalletProvider {
case "merge-holdings":
case "add-liquidity":
case "remove-liquidity":
- // DvP swap + LP add/remove: author the allocation(s) via the shared
- // composer and recover their created cids from the submit response
+ // DvP swap + LP add/remove: compose the allocation command(s), ask the
+ // operator backend to submit them, and recover their created cids.
// The backend's /v1/wallet/submit now follows the transaction
- // tree and returns createdEvents, so the operator-relay path CAN surface
- // the allocation cids the settle needs — no CIP-0103 wallet required.
+ // tree and returns createdEvents, so this development relay can surface
+ // the allocation cids that settle needs.
return this.submitComposed(intent);
}
}
@@ -242,7 +247,10 @@ export class TokenStandardProvider implements WalletProvider {
try {
const res = await fetch(`${this.apiBase}/v1/wallet/submit`, {
method: "POST",
- headers: { "Content-Type": "application/json" },
+ headers: {
+ "Content-Type": "application/json",
+ ...apiAuthHeaders("/v1/wallet/submit", "POST"),
+ },
body: JSON.stringify(body),
signal: controller.signal,
});
diff --git a/app/web/src/wallet/types.ts b/app/web/src/wallet/types.ts
index 80ea66b1..bbe634c5 100644
--- a/app/web/src/wallet/types.ts
+++ b/app/web/src/wallet/types.ts
@@ -90,6 +90,7 @@ export interface FundOrderIntent {
disclosure: DisclosedContract[];
settlement: V2SettlementInfo;
allocationSpec: V2AllocationSpecification;
+ requestedAt: string;
/** Holdings the wallet should propose to lock. */
inputHoldingCids: ContractId<"Holding">[];
/**
@@ -127,6 +128,7 @@ export interface RequestSwapIntent {
poolId: string;
allocationSpec: V2AllocationSpecification;
settlement: V2SettlementInfo;
+ requestedAt: string;
factoryCid: ContractId<"AllocationFactory">;
allocationFactoryExtraArgs: V2ExtraArgs;
disclosure: DisclosedContract[];
@@ -155,24 +157,23 @@ export interface MergeHoldingsIntent {
/**
* Trader provides liquidity (DvP). The operator has created a
* LiquidityAllocationRequest; the wallet authors the three allocations it
- * names — base deposit + quote deposit (under `depositFactoryCid` =
- * pool.admin) and the LP-token receipt (under `lpFactoryCid` =
- * pool.lpRegistrar) — via a CreateAndExercise of the token standard's
+ * names — base deposit + quote deposit (under pool.admin) and the LP-token
+ * receipt (under pool.lpRegistrar) — via a CreateAndExercise of the token standard's
* `BatchingUtilityV2.ExecuteBatch`, which accepts the request (leaving the
* acceptance receipt) and authors all three inside ONE Daml transaction / one
* top-level command for gateways that accept one command. `allocations`
- * is the canonical order [base deposit, quote deposit, LP receipt]; the
- * created cids are recovered operator-side from the single updateId for /settle.
+ * is the canonical order [base deposit, quote deposit, LP receipt].
+ * `factoryCids` and `allocationFactoryExtraArgs` are parallel to that order;
+ * each pair comes from registry discovery for the exact Allocate arguments.
*/
export interface AddLiquidityIntent {
kind: "add-liquidity";
requestCid: ContractId<"LiquidityAllocationRequest">;
settlement: V2SettlementInfo;
allocations: V2AllocationSpecification[];
- depositFactoryCid: ContractId<"AllocationFactory">;
- lpFactoryCid: ContractId<"AllocationFactory">;
- depositFactoryExtraArgs: V2ExtraArgs;
- lpFactoryExtraArgs: V2ExtraArgs;
+ requestedAt: string;
+ factoryCids: ContractId<"AllocationFactory">[];
+ allocationFactoryExtraArgs: V2ExtraArgs[];
/** Context for the AllocationRequest_Accept call (empty for the self-registry). */
allocationRequestExtraArgs: V2ExtraArgs;
disclosure: DisclosedContract[];
@@ -183,19 +184,18 @@ export interface AddLiquidityIntent {
/**
* Trader removes liquidity (DvP). Symmetric to add: the wallet
* authors the three allocations the request names — base receipt + quote
- * receipt (under `depositFactoryCid` = pool.admin) and the LP burn-sender
- * (under `lpFactoryCid` = pool.lpRegistrar, locking `lpHoldingCid`) — in
- * canonical order [base receipt, quote receipt, LP burn-sender].
+ * receipt (under pool.admin) and the LP burn-sender (under pool.lpRegistrar,
+ * locking `lpHoldingCids`) — in canonical order [base receipt, quote receipt,
+ * LP burn-sender]. The factory/context arrays use the same order.
*/
export interface RemoveLiquidityIntent {
kind: "remove-liquidity";
requestCid: ContractId<"LiquidityAllocationRequest">;
settlement: V2SettlementInfo;
allocations: V2AllocationSpecification[];
- depositFactoryCid: ContractId<"AllocationFactory">;
- lpFactoryCid: ContractId<"AllocationFactory">;
- depositFactoryExtraArgs: V2ExtraArgs;
- lpFactoryExtraArgs: V2ExtraArgs;
+ requestedAt: string;
+ factoryCids: ContractId<"AllocationFactory">[];
+ allocationFactoryExtraArgs: V2ExtraArgs[];
/** Context for the AllocationRequest_Accept call (empty for the self-registry). */
allocationRequestExtraArgs: V2ExtraArgs;
disclosure: DisclosedContract[];
@@ -230,9 +230,9 @@ export interface WalletResult {
* For multi-allocation intents (add/remove-liquidity), the created
* V2.Allocation cids in the SAME order as the intent's `allocations` —
* i.e. the order the AllocationFactory_Allocate commands were emitted. The
- * dApp forwards these to the operator-backend `/settle` call. Providers
- * that cannot extract created-contract cids from their submit response
- * MUST reject those intents rather than return this empty/partial.
+ * dApp forwards these to the operator-backend `/settle` call. An updateId-only
+ * provider omits this array and instead returns `auxiliaryCids.updateId`, which
+ * lets the operator recover the allocations from the transaction tree.
*/
createdAllocationCids?: string[];
/**
diff --git a/app/web/src/wallet/walletconnect-provider.ts b/app/web/src/wallet/walletconnect-provider.ts
index 0c430ff9..137e670d 100644
--- a/app/web/src/wallet/walletconnect-provider.ts
+++ b/app/web/src/wallet/walletconnect-provider.ts
@@ -9,7 +9,6 @@
// Environment configuration:
// VITE_WC_PROJECT_ID — Reown / WalletConnect Cloud project id (required)
// VITE_CANTON_NETWORK_ID — CAIP network id, e.g. "canton:devnet" (default: canton:devnet)
-// VITE_CANTON_LEDGER_URL — Validator JSON Ledger API URL for non-signing reads
//
// Method-string note:
// The Canton WalletConnect namespace methods follow CIP-0103 verb names
diff --git a/app/web/vite.config.ts b/app/web/vite.config.ts
index 95d423e2..4bee0b60 100644
--- a/app/web/vite.config.ts
+++ b/app/web/vite.config.ts
@@ -1,12 +1,37 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
-import path from 'path';
+import { resolve } from 'node:path';
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
- '@': path.resolve(__dirname, './src'),
+ '@': resolve(import.meta.dirname, './src'),
+ },
+ },
+ build: {
+ // The Canton wallet picker is published as one pre-bundled module. At the
+ // current lockfile SDK version it is about 580 kB minified (127 kB gzip), so
+ // Rolldown cannot divide it further. Keep that exception named and bound;
+ // all other third-party code is split into chunks no larger than 400 kB.
+ chunkSizeWarningLimit: 600,
+ rolldownOptions: {
+ output: {
+ codeSplitting: {
+ groups: [
+ {
+ name: 'canton-wallet-ui',
+ test: /node_modules[\\/]@canton-network[\\/]core-wallet-ui-components[\\/]/,
+ priority: 10,
+ },
+ {
+ name: 'vendor',
+ test: /node_modules[\\/]/,
+ maxSize: 400 * 1024,
+ },
+ ],
+ },
+ },
},
},
});
diff --git a/app/web/vitest.config.ts b/app/web/vitest.config.ts
index 7af4df47..fe303cd2 100644
--- a/app/web/vitest.config.ts
+++ b/app/web/vitest.config.ts
@@ -1,12 +1,12 @@
import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';
-import path from 'path';
+import { resolve } from 'node:path';
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
- '@': path.resolve(__dirname, './src'),
+ '@': resolve(import.meta.dirname, './src'),
},
},
test: {
diff --git a/docker-compose.yml b/docker-compose.yml
index 0fd65256..9371ca16 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -1,14 +1,15 @@
-version: "3.9"
-
services:
backend:
build:
context: .
dockerfile: Dockerfile.backend
- ports:
- - "8080:8080"
+ # Internal only: browsers enter through nginx on :80. Operators who need a
+ # direct diagnostic port can add a loopback-bound override explicitly.
+ expose:
+ - "8080"
environment:
PORT: "8080"
+ HOST: "0.0.0.0"
CANTON_LEDGER_URL: "${CANTON_LEDGER_URL}"
CANTON_LEDGER_TOKEN: "${CANTON_LEDGER_TOKEN}"
CANTON_OPERATOR: "${CANTON_OPERATOR}"
@@ -20,10 +21,19 @@ services:
CANTON_DEX_PACKAGE_ID: "${CANTON_DEX_PACKAGE_ID}"
CANTON_ALLOC_FACTORY_CID: "${CANTON_ALLOC_FACTORY_CID}"
CANTON_SETTLE_FACTORY_CID: "${CANTON_SETTLE_FACTORY_CID}"
+ CANTON_LP_ALLOC_FACTORY_CID: "${CANTON_LP_ALLOC_FACTORY_CID:-}"
+ CANTON_LP_SETTLE_FACTORY_CID: "${CANTON_LP_SETTLE_FACTORY_CID:-}"
DB_PATH: "/app/data/operator.db"
INDEXER_INTERVAL_MS: "${INDEXER_INTERVAL_MS:-5000}"
OPERATOR_ADMIN_TOKEN: "${OPERATOR_ADMIN_TOKEN}"
- ALLOWED_ORIGINS: "${ALLOWED_ORIGINS:-http://localhost:80}"
+ DEX_OPERATOR_API_TOKEN: "${DEX_OPERATOR_API_TOKEN}"
+ DEX_READ_ONLY: "${DEX_READ_ONLY:-0}"
+ DEX_CALLER_JWT_SECRET: "${DEX_CALLER_JWT_SECRET:-}"
+ DEX_CALLER_JWT_AUDIENCE: "${DEX_CALLER_JWT_AUDIENCE:-}"
+ DEX_HOSTED_RFQ_RELAY: "${DEX_HOSTED_RFQ_RELAY:-0}"
+ # Browsers serialize the default HTTP port as http://localhost (without
+ # :80), so this must match the actual Origin header exactly.
+ ALLOWED_ORIGINS: "${ALLOWED_ORIGINS:-http://localhost}"
volumes:
- backend-data:/app/data
restart: unless-stopped
@@ -34,9 +44,24 @@ services:
dockerfile: Dockerfile.frontend
args:
VITE_API_BASE: "${VITE_API_BASE:-}"
+ VITE_DOCS_URL: "${VITE_DOCS_URL:-https://srikanth-bitdynamics.github.io/Canton-Dex-Reference-Implementation/}"
+ VITE_APP_VERSION: "${VITE_APP_VERSION:-v0.6.0}"
VITE_WC_PROJECT_ID: "${VITE_WC_PROJECT_ID:-}"
VITE_CANTON_NETWORK_ID: "${VITE_CANTON_NETWORK_ID:-canton:devnet}"
- VITE_CANTON_LEDGER_URL: "${VITE_CANTON_LEDGER_URL:-}"
+ VITE_CANTON_SYNCHRONIZER: "${VITE_CANTON_SYNCHRONIZER:-}"
+ VITE_CANTON_DEX_PACKAGE_ID: "${VITE_CANTON_DEX_PACKAGE_ID:-#canton-dex-trading}"
+ VITE_ENABLE_SDK: "${VITE_ENABLE_SDK:-0}"
+ VITE_WALLET_GATEWAY_URL: "${VITE_WALLET_GATEWAY_URL:-}"
+ VITE_WALLET_GATEWAY_NAME: "${VITE_WALLET_GATEWAY_NAME:-}"
+ VITE_WALLET_SHOW_FULL_CATALOG: "${VITE_WALLET_SHOW_FULL_CATALOG:-0}"
+ VITE_ENABLE_PARTYLAYER: "${VITE_ENABLE_PARTYLAYER:-0}"
+ VITE_ENABLE_HOSTED_RFQ: "${VITE_ENABLE_HOSTED_RFQ:-0}"
+ VITE_PARTYLAYER_APP_NAME: "${VITE_PARTYLAYER_APP_NAME:-Canton DEX}"
+ VITE_PARTYLAYER_NETWORK: "${VITE_PARTYLAYER_NETWORK:-canton:devnet}"
+ VITE_PARTYLAYER_WALLET_IDS: "${VITE_PARTYLAYER_WALLET_IDS:-console,nightly,send}"
+ VITE_PARTYLAYER_CONNECT_TIMEOUT_MS: "${VITE_PARTYLAYER_CONNECT_TIMEOUT_MS:-180000}"
+ VITE_PARTYLAYER_REGISTRY_URL: "${VITE_PARTYLAYER_REGISTRY_URL:-}"
+ VITE_PARTYLAYER_REGISTRY_CHANNEL: "${VITE_PARTYLAYER_REGISTRY_CHANNEL:-stable}"
ports:
- "80:80"
depends_on:
diff --git a/docs/README.md b/docs/README.md
index 2eadab29..661df616 100644
--- a/docs/README.md
+++ b/docs/README.md
@@ -1,13 +1,25 @@
# Canton DEX — Documentation
-A full-stack, **Token Standard V2 (CIP-0112)** reference DEX for the Canton
-Network: Daml contracts, an operator backend, a React dApp with a CIP-0103
-wallet boundary, tests, and operator runbooks, covering RFQs, prefunded
-orders, constant-product pools, swaps, and LP tokens.
-
-New here? Read **[Understand the design in 15 minutes](concepts/design-tour.md)**,
-then use **[Getting Started](getting-started.md)** to run the full stack locally
-without a Canton participant.
+A full-stack code reference for a **Token Standard V2 (CIP-0112)** DEX on the
+Canton Network: Daml contracts, an operator backend, a React dApp with a
+CIP-0103 wallet boundary, tests, and operator runbooks. It covers RFQs,
+prefunded orders, constant-product pools, swaps, and LP tokens.
+
+**Rendered site:**
+[srikanth-bitdynamics.github.io/Canton-Dex-Reference-Implementation](https://srikanth-bitdynamics.github.io/Canton-Dex-Reference-Implementation/).
+The site is published from `main`; changes on a branch become public after they
+are merged and the GitHub Pages workflow finishes.
+
+New to Canton and Daml, but familiar with AMMs? Follow the canonical path below.
+It is the only ordered newcomer curriculum in this documentation.
+
+> **Three run modes.** The local browser preview uses a TypeScript in-memory
+> ledger and Mock Wallet; it does not settle value. Daml Script tests execute
+> real Daml semantics without a participant. The default live proof starts a
+> throwaway DPM sandbox and proves JSON Ledger API value movement without a
+> browser or wallet. A live browser write needs the larger configured
+> environment. [Getting started](getting-started.md) keeps these modes and their
+> success criteria separate.
> **Standards note.** This reference implements the Canton Network Token
> Standard **V2 (CIP-0112)** — the privacy/performance/accounting revision of
@@ -18,14 +30,39 @@ without a Canton participant.
---
+## Canonical newcomer learning path
+
+Follow these steps in order. The glossary is a companion, not another step.
+
+| Step | Read or run | You are done when… |
+|---:|---|---|
+| 1 | [Canton and Daml primer](concepts/canton-daml-primer.md) | You can distinguish a party from a participant, a template from a contract, and DEX state from token value. |
+| 2 | [Overview](concepts/overview.md) | You can explain the system boundary and the operator → wallet → operator swap authority sequence. |
+| 3 | [Getting started](getting-started.md) | You have installed the tools and run the preview, the Daml proof, and the throwaway live-Canton proof without confusing their boundaries. |
+| 4 | [AMM-first walkthrough](tutorials/amm-first-walkthrough.md) | You can trace `x*y=k` through `PoolState`, slices, allocation, and atomic settlement. |
+| 5 | [15-minute design tour](concepts/design-tour.md) | You can name the actors and the four workflow families. |
+| 6 | [Architecture](concepts/architecture.md) | You can locate market state, token custody, off-ledger orchestration, and the trust boundaries. |
+| 7 | [Workflow design](concepts/workflows.md) | You can follow swap, liquidity, order, and RFQ state transitions. |
+| 8 | [Make your first AMM code change](tutorials/make-your-first-amm-change.md) | A focused Daml test, the full suite, and the live sandbox proof pass after your edit. |
+| 9 | [Builder guide](guides/builder-guide.md) | You can identify every layer affected by the extension you want to build. |
+
+Keep the [Glossary](concepts/glossary.md) open while reading. If Daml syntax
+itself is new, the primer links the official language tutorial before asking
+you to edit source.
+
+---
+
## Find your path
| I want to… | Read, in order |
|---|---|
-| **Run it locally** | [Getting Started](getting-started.md) |
-| **Learn DEX and TSv2 from Daml** | [15-minute Design Tour](concepts/design-tour.md) → [Glossary](concepts/glossary.md) → [Workflows](concepts/workflows.md) → [Builder Guide](guides/builder-guide.md) |
-| **Understand the design** | [15-minute Design Tour](concepts/design-tour.md) → [Architecture](concepts/architecture.md) → [Workflows](concepts/workflows.md) |
-| **Build on / extend it** | [Getting Started](getting-started.md) → [Builder Guide](guides/builder-guide.md) → [HTTP API](reference/http-api.md) |
+| **Learn Canton/Daml from an AMM mental model** | Follow the [canonical newcomer learning path](#canonical-newcomer-learning-path) without skipping proof boundaries. |
+| **Preview the UI locally** | [Getting started — Mode 1](getting-started.md#mode-1-run-the-browser-preview) |
+| **Prove the Daml contracts locally** | [Getting started — Mode 2](getting-started.md#mode-2-run-the-daml-engine-proofs) → [Testing](reference/testing.md) |
+| **Prove value movement on real Canton** | [Getting started — Mode 3](getting-started.md#mode-3-run-the-default-live-canton-proof) → [Local Canton from a clean clone](guides/localnet.md) |
+| **Integrate a persistent/testnet environment** | [Local Canton](guides/localnet.md) → [Run on a testnet](guides/run-on-testnet.md) → [Validator test plan](guides/validator-test-plan.md) |
+| **Understand the design** | [Overview](concepts/overview.md) → [15-minute Design Tour](concepts/design-tour.md) → [Architecture](concepts/architecture.md) → [Workflows](concepts/workflows.md) |
+| **Build on / extend it** | Complete the [canonical newcomer learning path](#canonical-newcomer-learning-path), then use the [HTTP API](reference/http-api.md) as a lookup reference. |
| **Operate a venue** | [Deployment](guides/deployment.md) → [Operator Guide](guides/operator-guide.md) → [Operator Runbook](guides/operator-runbook.md) |
| **Integrate a registry** | [Registry Integration](guides/registry-integration.md) → [Choice Context](guides/choice-context.md) → [Allocation Surface](reference/allocation-surface.md) |
| **Trade in the dApp** | [Using the dApp](guides/using-the-dapp.md) |
@@ -39,56 +76,68 @@ The docs follow the [Diátaxis](https://diataxis.fr/) model, separating
learning (tutorial), tasks (how-to guides), understanding (concepts), and
lookup (reference).
-### Start here — tutorial
-| Page | What it covers |
-|---|---|
-| **[Getting Started](getting-started.md)** | Clone → build → run the whole stack (Daml core, backend, dApp) locally against the in-memory dev ledger, then test and explore. **Start here.** |
-
### Concepts — understand the design
+
| Page | Audience | What it explains |
|---|---|---|
+| **[Canton and Daml primer](concepts/canton-daml-primer.md)** | First-time Canton/Daml builder | The minimum ledger mental model needed to read this codebase. |
| **[15-minute Design Tour](concepts/design-tour.md)** | Daml developer, reviewer | The shortest code-backed path through actors, contracts, authority, custody, and all four settlement flows. |
| [Overview](concepts/overview.md) | Everyone | What the DEX is, the trust model, and how it maps onto Token Standard V2. |
| [Architecture](concepts/architecture.md) | Builder, integrator | The system model, component boundaries, and executor-authority constraints. |
| [Workflows](concepts/workflows.md) | Builder, integrator | The venue workflows, the actor model, and the design principles behind them. |
-| [Liquidity & Custody](concepts/liquidity-and-custody.md) | Integrator | How the pool represents and custodies LP liquidity (operator-custodied, DvP at the boundary). |
+| [Liquidity & Custody](concepts/liquidity-and-custody.md) | Integrator | How the pool represents and custodies LP liquidity (operator-custodied; delivery-versus-payment — DvP — at the boundary). |
| [LP Tokens](concepts/lp-tokens.md) | Builder, integrator | Why LP tokens are a single, unversioned V2 instrument per pool. |
| [Pricing](concepts/pricing.md) | Operator, integrator | Where prices come from — pool-derived, order book, RFQ — and the (absent) oracle attachment points. |
| [Glossary](concepts/glossary.md) | Everyone | The key terms: allocation, commitment, iterated settlement, DvP, slice, registrar, and more. |
| [Non-goals](concepts/non-goals.md) | Everyone | What the reference intentionally does not include, and why. |
+### Tutorials — learn by following one path
+
+| Page | Audience | Outcome |
+|---|---|---|
+| [Getting started](getting-started.md) | First-time builder | Install the tools and run the preview, Daml-engine proofs, and live-Canton sandbox proof without confusing their boundaries. |
+| [AMM-first walkthrough](tutorials/amm-first-walkthrough.md) | AMM developer new to Canton | Locate the quote math, map pool state to contracts, follow operator → trader → operator authority, and run arithmetic, choreography, and real-holding swap proofs. |
+| [Make your first AMM code change](tutorials/make-your-first-amm-change.md) | First-time Daml contributor | Complete one reproducible red/green edit and assess its Daml, backend, UI, and live-ledger impact. |
+
### Guides — do a task
+
| Page | Audience | Recipe |
|---|---|---|
| [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 | Swap, add/remove liquidity, place orders, accept an RFQ quote, 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 | Register a fungible asset or identify where gated/lifecycle behavior requires a custom registry. |
-| [Deployment](guides/deployment.md) | Operator | Local dev, Docker Compose, testnet, environment variables, production checklist. |
+| [Local Canton from a clean clone](guides/localnet.md) | Builder, integrator | Run the default throwaway DPM sandbox proof; optionally use a separately distributed DevKit for persistent LocalNet. |
+| [Deployment](guides/deployment.md) | Operator | Local dev, default DPM sandbox, optional DevKit LocalNet, Docker Compose, testnet, environment variables, and production checklist. |
| [Operator Guide](guides/operator-guide.md) | Operator | First-time deployment and day-to-day operations. |
| [Operator Runbook](guides/operator-runbook.md) | Operator, SRE | Recovery procedures, observability, and failure modes. |
| [Run on a Testnet](guides/run-on-testnet.md) | Operator | Point the operator backend and dApp at a Canton testnet. |
| [Registry Integration](guides/registry-integration.md) | Integrator | What the DEX assumes from an asset registry, and how to swap in your own. |
| [Choice Context](guides/choice-context.md) | Integrator | What the backend attaches to each transaction it submits (context + disclosure). |
-| [Validator Test Plan](guides/validator-test-plan.md) | QA, validator | The live end-to-end validation checklist. |
+| [Validator Test Plan](guides/validator-test-plan.md) | QA, validator | The live, boundary-labelled validation checklist. |
### Reference — look something up
+
| Page | Topic |
|---|---|
| [HTTP API](reference/http-api.md) | The operator-backend HTTP endpoints, wallet intents, and error codes. |
| [Allocation Surface](reference/allocation-surface.md) | The V2 allocation surface this reference relies on (committed allocations, iterated settlement). |
+| [Daml proof map](reference/daml-proof-map.md) | Named learning paths from one concept to its Daml choices and focused executable tests, with each fixture's limitations. |
| [Testing](reference/testing.md) | The test strategy, suite coverage, and opt-in live-ledger drivers. |
| [Ecosystem feedback](reference/ecosystem-feedback.md) | How the reference was evaluated externally, what was found, and what changed. |
---
## Also in the repo
-- **[Getting Started](getting-started.md)** doubles as the local test-suite
- reference (Daml, backend, and dApp commands with expected counts).
+
+- **[Getting started](getting-started.md)** is the local run-mode and component
+ check reference. It states the exact success signal and limitation for each
+ command.
- The [Builder Guide](guides/builder-guide.md) walks through the four workflow
families — pair listing, matched-trade/RFQ, prefunded orders,
and pool/swap/LP — with file and test pointers.
## Governance
+
[Contributing](../CONTRIBUTING.md) · [Code of Conduct](../CODE_OF_CONDUCT.md)
· [Security Policy](../SECURITY.md) · [License (Apache 2.0)](../LICENSE)
diff --git a/docs/concepts/architecture.md b/docs/concepts/architecture.md
index 3b5a434b..066860d7 100644
--- a/docs/concepts/architecture.md
+++ b/docs/concepts/architecture.md
@@ -1,16 +1,14 @@
# Canton DEX Architecture
-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 token-escrow contract; pool custody is expressed through standard
-V2 allocations. This page is the map of that
-split. [Non-goals](non-goals.md) records what the reference deliberately leaves
-out, and why.
-
-If this is your first code read, begin with the
-[15-minute design tour](design-tour.md). It follows one value movement at a time
-and links back into the templates and tests.
+This is Step 6 of the
+[canonical newcomer learning path](../README.md#canonical-newcomer-learning-path).
+Complete the [15-minute design tour](design-tour.md) first.
+
+Canton DEX keeps market logic—orders, pools, and RFQs—in its Daml contracts.
+Those contracts do not hold or move token value. Every settlement uses Token
+Standard V2 (CIP-0112) allocations and batch settlement, including pool
+custody. This page maps that boundary. [Non-goals](non-goals.md) records what
+the reference deliberately leaves out.
## The three layers
@@ -39,7 +37,7 @@ Three bands, top to bottom:
- **Off-ledger orchestration** indexes the ledger, matches orders, prices pools,
and submits operator-controlled choices. In the self-custodial order, swap,
and LP flows it cannot lock a user's holdings; those funds are locked only by
- an allocation the holder authors. The hosted RFQ relay is an explicitly
+ an allocation the holder authors. The operator-mediated RFQ path is an explicitly
separate demo authority model, described below.
- **On-ledger DEX contracts** enforce market rules — order limits,
cancellation, pool accounting, and RFQ acceptance — and drive settlement,
@@ -55,7 +53,7 @@ The trust boundary is the pair of dashed edges crossing into the ledger. For
self-custodial flows 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. [The executor-control constraint](#the-executor-control-constraint)
-makes that boundary precise. The hosted RFQ relay instead requires trader
+makes that boundary precise. The operator-mediated RFQ path instead requires trader
act-as rights and must not be mistaken for the self-custodial path.
## What settles value: the Token Standard V2 spine
@@ -80,8 +78,10 @@ Two properties of the V2 allocation surface are load-bearing here:
`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
+back a traded instrument. A listed base/quote pair currently keeps both ids under
+one registry admin; the LP registrar may differ. 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.
@@ -105,7 +105,7 @@ validated DEX choice → V2 batch settlement**. Each workflow has a named choice
because its validation differs; value movement itself always ends at the same
Token Standard settlement interface.
-### Follow one order end to end
+### Follow one order from intent through settlement
```mermaid
flowchart LR
@@ -306,36 +306,23 @@ The write surface is explicit rather than uniform:
Ongoing market transitions use named choices that recheck their inputs.
- Order funding, swap funding, and LP add/remove author trader allocations
through the wallet before an operator choice can settle them.
-- The hosted RFQ routes directly submit as the hosted trader, and RFQ accept
+- The operator-mediated RFQ routes submit as the configured trader, and RFQ accept
submits as both trader and operator. This requires corresponding ledger
- rights and is a demo relay boundary, not a self-custodial wallet path.
+ rights and is an authority-boundary example, not a self-custodial wallet path
+ or a public relay service.
The dApp (`app/web`) makes these paths visible through a wallet-provider boundary
and a separate operator API client; neither path changes the on-ledger choice
authorization.
-## What proves it end to end
-
-- [`EndToEndTests.daml`](../../trading-tests/CantonDex/Tests/EndToEndTests.daml)
- — fast choreography and authority checks against `MockRegistry`: order
- placement → operator bind → trader-funded `Order_Fund`, RFQ accept,
- swap construction, OTC settlement, and atomic order roll-forward. This suite
- does not prove value movement because its fixture has no holdings.
-- [`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.
-- [`RealRegistryDvpTests.daml`](../../trading-tests/CantonDex/Tests/RealRegistryDvpTests.daml)
- — add, remove, swap, and cross-admin matched-trade settlement against an
- upstream context-requiring V2 registry plus the reference LP registry.
-- [`RfqSettlementTests.daml`](../../trading-tests/CantonDex/Tests/RfqSettlementTests.daml)
- — RFQ buy and sell flows with exact holding deltas, no residual locks after a
- successful settlement, and explicit expiry behavior for unsettled allocations.
+## Where the proof lives
+
+Use the [Daml proof map](../reference/daml-proof-map.md) to connect an
+architecture claim to its source choice and focused Daml Script test. Use the
+[testing reference](../reference/testing.md) to understand the difference
+between mock choreography, real-holding tests, backend/UI tests, and live
+Canton proofs. Keeping the suite catalog in those reference pages avoids
+duplicating volatile test names here.
---
@@ -360,8 +347,9 @@ Three concrete upstream inputs shaped the architecture:
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.
+see [Workflows](workflows.md)), and it trades **arbitrary base/quote ids under
+one registry admin**, not hardcoded "cash vs asset" families. Pairing two asset
+admins is an explicit [app-layer limitation](non-goals.md#one-registry-admin-per-pair).
### Reference: reserves integrity in full
@@ -471,4 +459,6 @@ canton-dex/
---
-**Where to read next:** [Workflows](workflows.md) · [Pricing](pricing.md) · [Liquidity & Custody](liquidity-and-custody.md) · [Glossary](glossary.md) · [All docs](../README.md)
+**Next canonical step:** [Workflow design](workflows.md). Use
+[Pricing](pricing.md), [Liquidity and custody](liquidity-and-custody.md), and
+the [Glossary](glossary.md) as topic references.
diff --git a/docs/concepts/canton-daml-primer.md b/docs/concepts/canton-daml-primer.md
new file mode 100644
index 00000000..ea30686e
--- /dev/null
+++ b/docs/concepts/canton-daml-primer.md
@@ -0,0 +1,333 @@
+# Canton and Daml primer for DEX builders
+
+This is Step 1 of the
+[canonical newcomer learning path](../README.md#canonical-newcomer-learning-path).
+It assumes you understand AMM reserves, `x*y=k`, swaps, fees, and LP shares,
+but have not built a Canton application.
+
+This primer teaches the ledger concepts used by this repository. It is not a
+complete Daml language course. Before editing Daml, complete Digital Asset's
+official
+[Get started with Daml](https://archived.docs.digitalasset.com/build/3.5/tutorials/get-started/index.html)
+tutorial and
+[basic contracts lesson](https://archived.docs.digitalasset.com/build/3.5/tutorials/smart-contracts/contracts.html).
+Installation comes later in Step 3,
+[Getting started](../getting-started.md#prerequisites).
+
+By the end, you should be able to answer four questions while reading code:
+
+1. What data is a contract carrying?
+2. Which party can see it and which party must authorize a change?
+3. Which choice archives or creates contracts?
+4. Is the code changing DEX state, Token Standard value, or only an off-ledger
+ projection?
+
+## The shortest mental model
+
+Canton is the distributed-ledger system. Daml is the language and ledger model
+used to define application contracts and their authorized transitions.
+
+```mermaid
+flowchart LR
+ User[Trader] --> Wallet[Wallet]
+ Operator[Operator backend] --> API[Participant Ledger API]
+ Wallet --> API
+ API --> Daml[Daml contracts and choices]
+ API <--> Sync[Synchronizer]
+ Daml --> Visible[Per-party visible ledger state]
+```
+
+- A **party** is the on-ledger identity that authorizes actions. Trader, DEX
+ operator, asset admin, and LP registrar are distinct logical roles and are
+ normally separate parties in production; an explicitly documented local
+ learning setup may let some control roles share one party.
+- A **participant** is the Canton node through which hosted parties read their
+ visible ledger state and submit commands.
+- A **synchronizer** coordinates compatible participant transactions. It does
+ not make every contract globally visible like a public-chain full node.
+- A **Daml contract** is an immutable instance of a template.
+- A **choice** is a permitted transition on a contract. Its controller must
+ authorize the exercise.
+- A **transaction** is atomic: all commands and nested choices commit, or none
+ do.
+
+The frontend does not become a ledger client merely because it can call the
+operator backend. A self-custodial write crosses the trader's wallet because
+only the trader can authorize trader-controlled commands.
+
+## Templates become contracts
+
+A Daml `template` combines data, visibility, authorization, and operations. A
+shortened excerpt of
+[`DexPair.daml`](../../trading/CantonDex/Dex/DexPair.daml) illustrates all four:
+
+```daml
+template DexPair with
+ operator : Party
+ admin : Party
+ baseInstrumentId : Text
+ quoteInstrumentId : Text
+ active : Bool
+ where
+ signatory operator -- authorizes creation; always sees the contract
+ observer admin -- sees the contract; need not authorize creation
+
+ choice DexPair_SetActive : ContractId DexPair
+ with newActive : Bool
+ controller operator -- only the operator authorizes this transition
+ do create this with active = newActive
+```
+
+Read it from top to bottom:
+
+1. `DexPair` is the schema for one market listing.
+2. A created instance gets a contract ID, often called a `cid` in this repo.
+3. The `operator` is the signatory; `admin` is an observer.
+4. `DexPair_SetActive` is a consuming choice by default. Exercising it archives
+ the old pair contract and creates a successor with the new flag.
+
+That archive-and-create pattern is how immutable contracts represent state
+updates. Do not look for a database-style in-place mutation.
+
+### Signatory, observer, and controller are different roles
+
+| Role | Question it answers | In the excerpt |
+|---|---|---|
+| Signatory | Who authorizes contract creation and is a stakeholder? | `operator` |
+| Observer | Which additional stakeholder sees the contract? | `admin` |
+| Controller | Who authorizes this choice exercise? | `operator` |
+
+Visibility is deliberate. A contract that is visible to the operator is not
+automatically visible to every trader. Conversely, being able to see a
+contract does not grant authority to exercise every choice on it.
+
+## Commands become one atomic transaction
+
+A client submits commands such as “create this template” or “exercise this
+choice.” Choices can fetch other contracts and exercise nested choices. Canton
+commits the resulting transaction only if authorization, visibility,
+preconditions, and contract freshness all hold.
+
+A Daml Script test expresses the submitting authority explicitly:
+
+```daml
+pairCid <- submit operator $ createCmd DexPair with ...
+
+newPairCid <- submit operator $ exerciseCmd pairCid DexPair_SetActive with
+ newActive = False
+```
+
+The important word is `operator` after `submit`. Replacing it with an unrelated
+trader should fail because the choice controller is the operator. Tests use
+this property to document both the happy path and forbidden paths.
+
+### Consuming and nonconsuming choices
+
+- A **consuming choice** archives the contract it is exercised on. It may
+ create a successor, as `DexPair_SetActive` does.
+- A **nonconsuming choice** leaves that contract active. It is useful for a
+ stable rules contract that validates an operation without replacing itself.
+
+Do not assume “nonconsuming” means read-only. A nonconsuming choice may still
+exercise other contracts and create or archive application state inside the
+same transaction.
+
+## Parties are not services or users
+
+Keep these three concepts separate:
+
+| Concept | Example in this repo | Meaning |
+|---|---|---|
+| Human/application user | person using the Trade page | Off-ledger identity and session |
+| Daml party | `trader`, `operator`, `lpRegistrar` | Ledger identity named in contracts and authorization |
+| Canton participant | node exposing the Ledger API | Hosts parties, validates/submits commands, and stores their visible ledger state |
+
+A backend credential can submit as a party only when the participant grants
+the corresponding ledger rights. Writing `actAs: [trader]` in a request does
+not manufacture trader authority.
+
+Real Canton party IDs normally contain a hint and fingerprint, for example
+`alice::1220…`. Short names such as `trader-demo` in the browser preview are
+seed labels, not production party IDs.
+
+## Packages, DARs, and the Ledger API
+
+Daml source is built into a **DAR** (Daml Archive). A DAR contains one or more
+compiled packages and their dependencies. A Canton participant must know the
+packages before it can create those templates or exercise their choices.
+
+This repository separates three representations:
+
+```text
+trading/**/*.daml
+ │ dpm build
+ ▼
+trading/.daml/dist/canton-dex-trading-0.1.4.dar
+ │ upload / vet for the target network
+ ▼
+Canton participant
+ │ JSON Ledger API
+ ▼
+services/operator-backend
+```
+
+- `daml.yaml` pins the SDK version and declares DAR dependencies.
+- `dpm build` compiles the package (`dpm`, the Daml Package Manager, is the
+ SDK's build-and-test CLI used throughout this repo).
+- Uploading a DAR makes package code available to a participant; it does not
+ create parties, holdings, pools, or liquidity.
+- The backend's production ledger adapter sends JSON Ledger API commands and
+ reads transaction/contract data visible to its ledger user.
+
+The shortest proof that this package works on a real Canton process is the
+repository's DPM sandbox runner. From the repository root, run:
+
+```bash
+bash scripts/run-dpm-sandbox-proof.sh
+```
+
+It starts the Canton sandbox bundled with the pinned SDK, uploads the package
+closure, and runs a live holdings/allocation/DvP driver. It is intentionally
+throwaway. Its operator, asset admin, and LP registrar share the bootstrap
+party, while the LP/trader and swapper are separately allocated so real value
+moves between counterparties. Read
+[Local Canton from a clean clone](../guides/localnet.md) before treating that
+proof as evidence for any broader integration.
+
+## The Active Contract Set is current state
+
+The **Active Contract Set (ACS)** is the set of contracts that have been
+created and not archived, as visible to the querying party. For an AMM, the
+interesting active contracts include:
+
+- `Pool`: immutable pool configuration;
+- `PoolState`: aggregate reserves used for pricing;
+- `PoolSlice`: committed reserve inventory;
+- `PoolRules`: stable choices for swap validation and execution;
+- Token Standard `Holding` and `Allocation` contracts.
+
+The ACS is not a globally readable SQL table. Results depend on the querying
+party's visibility. The backend indexer projects ledger events into a database
+for API reads, but that database is a derived view, not the authorization or
+settlement source of truth.
+
+## Why a Canton AMM needs Token Standard contracts
+
+The DEX contracts define market intent and validation. Token Standard V2
+contracts represent and move value. This separation is the central design of
+the repository.
+
+| AMM idea | Daml/Token Standard representation |
+|---|---|
+| Trader's balance | one or more `Holding` contracts for an instrument |
+| Permission to use exact funds for a trade | trader-authored `Allocation` tied to settlement terms |
+| Pool reserves used for pricing | `PoolState.reserves` |
+| Pool inventory that backs those reserves | committed allocation slices represented by `PoolSlice` |
+| Atomic input-for-output exchange | `SettlementFactory_SettleBatch` inside the pool swap transaction |
+| LP share | a Token Standard V2 LP instrument held in ordinary `Holding` contracts |
+
+An allocation is intentionally narrower than an ERC-20 router allowance. It
+locks identified backing for a particular settlement specification and names
+the authorized settlement context. The operator can execute a valid settle; it
+cannot silently rewrite the trader's signed legs.
+
+## One swap, in Canton terms
+
+For a BTC-to-USDC swap, the flow is:
+
+```mermaid
+sequenceDiagram
+ actor T as Trader
+ participant D as dApp
+ participant O as Operator
+ participant W as Wallet
+ participant L as Canton / Daml
+ D->>O: Request quote and Daml-built allocation specification
+ O->>L: Exercise PoolRules_RequestSwap
+ L-->>O: Exact input/output legs bound to a pool snapshot
+ O-->>D: Wallet intent + disclosed context
+ D->>W: Ask trader to authorize allocation
+ W->>L: AllocationFactory_Allocate as trader
+ L-->>D: Trader allocation contract / correlated update
+ D->>O: Settle using that allocation
+ O->>L: PoolRules_Swap as operator
+ L->>L: Validate quote, settle batch, update state and slices atomically
+ L-->>T: Updated visible holdings
+```
+
+There are two authorities because there are two decisions:
+
+- The trader authorizes the exact value locked from the trader's holdings.
+- The operator authorizes execution against the venue's pool under on-ledger
+ rules.
+
+If the pool changed after the quote, the bound contract IDs are stale and the
+transaction fails rather than silently repricing the signed trade.
+
+## “In memory” means two different things here
+
+This distinction prevents a common first-day misunderstanding:
+
+| Name used in the repo | Engine | Enforces Daml? | Holds Token Standard value? | Runs Canton? |
+|---|---|---:|---:|---:|
+| Backend `InMemoryLedger` | TypeScript map + selected handlers | No | No | No |
+| Daml Script runner | Daml ledger engine | Yes | Yes, when the fixture creates real `Holding`s | No participant process |
+| DPM sandbox proof | Real throwaway Canton process + JSON Ledger API | Yes | Yes | Yes, one local sandbox process |
+| Optional DevKit LocalNet / remote testnet | Persistent Canton/Splice services | Yes | Yes | Yes |
+
+The browser preview uses the first row. `dpm test` uses the second. The default
+live proof uses the third. DevKit is only an optional, separately distributed
+manager for the fourth row; neither the DEX source nor its DARs depend on it at
+runtime. Passing one row is not evidence that the next row is configured.
+
+## Map the repository before reading details
+
+```text
+app/web/ user interface and wallet handoff
+ │ HTTP
+ ▼
+services/operator-backend/ orchestration, indexing, matching, ledger adapter
+ │ JSON Ledger API in live mode
+ ▼
+trading/CantonDex/Dex/ market-state templates and choices
+ │ nested Daml choices
+ ▼
+trading/CantonDex/Registry/ reference Token Standard holdings and settlement
+```
+
+`trading-tests/` drives the bottom two layers directly with Daml Script. The
+tests are therefore the best executable contract documentation, but they do
+not include the React dApp or HTTP backend.
+
+## A first reading exercise
+
+Open [`DexPair.daml`](../../trading/CantonDex/Dex/DexPair.daml) and answer:
+
+1. Which fields define the market and fee schedule?
+2. Who signs the contract?
+3. Who observes it?
+4. Which choices can change it?
+5. Does each choice mutate the old contract, or create a successor?
+
+Then open the beginning of
+[`PoolWorkflowTests.daml`](../../trading-tests/CantonDex/Tests/PoolWorkflowTests.daml).
+Its header explains what the mock-registry fixture proves, what it does not
+prove, and which pool scripts to read first. Use the
+[Daml proof map](../reference/daml-proof-map.md) to find the real-holding proof
+for each design claim.
+
+## You are ready to continue when…
+
+You can explain these statements in your own words:
+
+- A template is code; a contract is an active instance with a contract ID.
+- A party supplies ledger authority; a participant is a node, not an identity.
+- A choice describes a legal transition; its controller must authorize it.
+- Contract visibility is party-scoped, not globally broadcast.
+- The DEX validates market state, while Token Standard factories move value.
+- Mock Wallet contract IDs prove a UI handoff only.
+- Daml Script can prove contract behavior without proving the HTTP/live-network
+ integration.
+
+**Next canonical step:** [Overview](overview.md). Keep the
+[Glossary](glossary.md) open as a companion reference.
diff --git a/docs/concepts/design-tour.md b/docs/concepts/design-tour.md
index 827a20dc..b094baa5 100644
--- a/docs/concepts/design-tour.md
+++ b/docs/concepts/design-tour.md
@@ -1,11 +1,14 @@
# Understand the design in 15 minutes
-This page is the shortest path from Daml knowledge to the Canton DEX design. It
-explains which contracts carry market state, which party authorizes each step,
-and where Token Standard V2 moves value. Follow the links only when you need the
+This page is the shortest path from the core Daml vocabulary to the Canton DEX
+design. It explains which contracts carry market state, which party authorizes
+each step, and where Token Standard V2 moves value. If terms such as template,
+choice, controller, party, contract id, or active contract set are new, first
+read the [Canton and Daml primer](canton-daml-primer.md); it assumes no Canton
+background. Then return here and follow deeper links only when you need the
detail behind a statement.
-## 1. Start with the boundary
+## Start with the boundary
The DEX decides whether a market action is valid. A token registry owns holdings
and performs value movement.
@@ -30,7 +33,7 @@ The recurring workflow is:
The operator can decide when to propose an action. It cannot author a trader's
allocation or settle transfer legs outside the checks in the DEX choice.
-## 2. Know the actors
+## Know the actors
| Actor | What it controls | What it cannot do alone |
|---|---|---|
@@ -39,11 +42,12 @@ allocation or settle transfer legs outside the checks in the DEX choice.
| Asset registry admin | Registry implementation, factories, context, and token policy | Change a trader's signed DEX intent |
| LP registrar | LP instrument policy and mint/burn recording | Move reserve assets without the pool settlement path |
-The hosted RFQ demo is different: its relay has act-as rights for hosted parties.
-That convenience is not the self-custodial authority model used by wallet-funded
-orders, swaps, and liquidity.
+The operator-mediated RFQ example is different: its backend ledger user has
+act-as rights for configured parties. That authority model is not the
+self-custodial path used by wallet-funded orders, swaps, and liquidity, and the
+repository does not expose it as a public relay service.
-## 3. The Token Standard settlement spine
+## The Token Standard settlement spine
A `Holding` is spendable token value. An `Allocation` locks holdings for one
settlement and describes the sides its authorizer permits. A
@@ -63,7 +67,7 @@ not a privileged registry. Production assets may come from another registry that
implements the same V2 APIs. Read [Registry Integration](../guides/registry-integration.md)
for the exact assumptions.
-## 4. Pair and governance state
+## Pair and governance state
`DexPair` is the operator-signed listing record. It names one registry admin,
the base and quote instrument ids, enabled trading modes, and fees. The operator
@@ -81,12 +85,17 @@ Read:
- Workflow map: [Active workflows](workflows.md#active-workflow-map)
- Guide: [Add a trading pair](../guides/add-a-trading-pair.md)
-## 5. Signed pool swaps
+## Signed pool swaps
`PoolRules_RequestSwap` reads a precise pool snapshot and returns one allocation
specification containing both the trader's input side and every pool-to-trader
output side. The wallet signs that complete specification.
+A pool's reserves are not held as one balance per side: each side is a set of
+many small `PoolSlice` allocations (detailed in the next section). A swap
+consumes only an ordered few of them — the *output slice list* below — and leaves
+the rest untouched.
+
`PoolRules_Swap` then:
1. requires the same pool state, input slice, output slice list, and slippage
@@ -104,7 +113,7 @@ Read:
- Math: [Pricing](pricing.md)
- Proofs: `testPoolSwapViaRequestSwap` and `testRealRegistryDvpSwapSettles`
-## 6. Liquidity and pool custody
+## Liquidity and pool custody
The pool is split so each concern remains small:
@@ -124,28 +133,18 @@ batch per admin and passes each registry its own choice context.
### Why pool slices have no deadline
-Reserve slices are operator-authored with `committed = true` and
-`settlementDeadline = None`. Under the V2 withdrawal rule this means the
-authorizer cannot call `Allocation_Withdraw` later. This is intentional for the
-reference's long-lived inventory: the authorizer path and an LP cannot withdraw
-a routine slice. The operator remains the allocation executor and can cancel it
-when recovering or shutting down the pool.
-
-The consequence is explicit operator custody:
-
-- the LP holder is not the reserve allocation authorizer and has no unilateral
- slice withdrawal;
-- routine LP redemption requires the operator and LP registrar;
-- the operator is the settlement executor and can cancel reserve allocations;
-- if either service party disappears, the reference has no trustless LP exit.
-
-Adding an arbitrary deadline would not solve holder exit. It would instead give
-the operator authorizer a future withdrawal path and require a safe slice-renewal
-protocol. A production fork must choose and audit its own governed execution,
-allocation renewal, and emergency redemption design. See
+Reserve slices are `committed = true` with `settlementDeadline = None`, so under
+the V2 withdrawal rule no one — not even the LP — can unilaterally call
+`Allocation_Withdraw` on a routine slice. The operator holds custody: it is the
+settlement executor and the only party that can release reserves, and routine LP
+redemption needs the operator and LP registrar together. If either disappears,
+the reference has no trustless LP exit.
+
+This is a deliberate long-lived-custody choice. The full rationale — including
+why simply adding a deadline would not give holders an exit — is in
[Liquidity and Custody](liquidity-and-custody.md#availability-and-the-lp-exit-boundary).
-## 7. Prefunded orders
+## Prefunded orders
An order has two objects: an operator-signed `Order` containing market terms and
a trader-authored allocation containing the reserved funds.
@@ -177,10 +176,10 @@ Read:
- Atomic fill: [`OrderMatchExecution.daml`](../../trading/CantonDex/Dex/OrderMatchExecution.daml)
- Proof: `testOrderMatchRollsOrdersForwardAtomically`
-## 8. RFQ and OTC settlement
+## RFQ and OTC settlement
An RFQ records a trader request and dealer quotes. `Rfq_Accept` jointly requires
-the hosted trader and operator, records the ranking in a `PolicyReceipt`, and
+the trader and operator, records the ranking in a `PolicyReceipt`, and
creates a `MatchedTrade`. Each counterparty then authors an allocation and
`MatchedTrade_Settle` groups the transfer legs by registry admin before calling
that admin's settlement factory.
@@ -194,7 +193,7 @@ Read:
- Settlement: [`MatchedTrade.daml`](../../trading/CantonDex/Dex/MatchedTrade.daml)
- Real holdings proof: `testRfqBuySettlesAgainstRealHoldings`
-## 9. Cross-registry settlement
+## Cross-registry settlement
Factory contract ids are not sufficient. Before a registry choice, the operator
fetches that admin's choice context and disclosed contracts off-ledger. Context is
@@ -215,7 +214,7 @@ The evidence is intentionally both positive and negative:
Read [Choice Context](../guides/choice-context.md) for backend assembly and
submission details.
-## 10. Active and compatibility surfaces
+## Active and compatibility surfaces
Public source can contain declarations that are not active workflow APIs. Code
comments use one of these markers:
@@ -233,7 +232,7 @@ flows use `CantonDex.Registry.V2` or another V2 registry. `Order_Adjust` and
`Order_RecordPartialFill` are `[RETIRED]`; use
`OrderMatchExecution_Execute`.
-## 11. Choose the next detailed page
+## Choose the next detailed page
| If you want to understand | Read next |
|---|---|
@@ -243,3 +242,6 @@ flows use `CantonDex.Registry.V2` or another V2 registry. `Order_Adjust` and
| Registry assumptions and context | [Registry Integration](../guides/registry-integration.md) |
| What tests prove | [Testing](../reference/testing.md) |
| Deliberate limitations | [Non-goals](non-goals.md) |
+
+**Next canonical step:** [Architecture](architecture.md). Use the other rows
+above as topic references when you need their detail.
diff --git a/docs/concepts/glossary.md b/docs/concepts/glossary.md
index 9a03bf19..a544a057 100644
--- a/docs/concepts/glossary.md
+++ b/docs/concepts/glossary.md
@@ -1,9 +1,156 @@
# Glossary
-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/`).
+Key terms used across the Canton DEX docs and code. Start with **Canton and Daml
+foundations** if this is your first Canton application; the second section is a
+lookup for the Token Standard and exchange design. Where useful, entries link
+to the defining Daml module, an executable test, or a deeper concept page.
+
+For a connected explanation rather than isolated definitions, read the
+[Canton and Daml primer](canton-daml-primer.md).
+
+## Canton and Daml foundations
+
+### Active Contract Set (ACS)
+
+The contracts that have been created and not archived, as visible to the party
+making the query. The ACS is current ledger state, not a globally readable
+table: two parties can see different subsets. The backend indexer projects ACS
+and transaction events into its off-ledger read model.
+
+### Canton
+
+The distributed-ledger system on which this application runs. Canton connects
+participant nodes through synchronizers while preserving party-scoped
+visibility; Daml defines the contracts and transactions participants process.
+
+### Canton DevKit
+
+An optional, separately distributed development tool that can manage a
+persistent Docker-based Splice LocalNet. It is not required by the DEX source,
+DARs, backend, or default live proof. If DevKit is unavailable, use the
+repository's [DPM sandbox proof](../guides/localnet.md#path-a-portable-dpm-sandbox-proof).
+
+### Choice
+
+A named operation defined on a Daml template or interface. Exercising a choice
+can fetch, create, archive, or exercise other contracts in one transaction, but
+its [controller](#controller) must authorize it. A choice is consuming by
+default; a `nonconsuming choice` leaves its target contract active.
+
+### Command
+
+A client's request to create a contract or exercise a choice. One submission
+can contain multiple commands; the resulting Daml transaction either commits
+atomically or fails as a whole.
+
+### Contract / contract ID (CID)
+
+An immutable on-ledger instance of a [template](#template). Its contract ID
+identifies that exact active instance. When a consuming choice archives a
+contract and creates its successor, the successor has a new ID. Values such as
+`#mock-…:0` returned by Mock Wallet are UI placeholders, not Canton contract
+IDs.
+
+### Controller
+
+The party or parties whose authority is required to exercise one Daml choice.
+For example, `DexPair_SetActive` is controlled by the DEX operator. A party
+that can see the contract is not necessarily its choice controller.
+
+### Daml
+
+The smart-contract language and ledger model used by this reference. A Daml
+template declares contract data, stakeholders, and choices; the engine checks
+authorization and atomic transitions.
+
+### Daml Script
+
+A Daml library and runner for allocating test parties, submitting commands,
+querying contracts, and asserting results. `dpm test` runs this repository's
+Script declarations in a Daml ledger engine. It enforces Daml semantics but
+does not, by itself, start a Canton participant, backend, or browser.
+
+### DAR (Daml Archive)
+
+The build artifact containing compiled Daml packages and dependencies. Running
+`dpm build` in `trading/` produces the DEX DAR. Uploading a DAR makes its code
+available to a participant; it does not create application contracts or seed
+liquidity.
+
+### DPM sandbox
+
+The real Canton sandbox process bundled with the Daml SDK selected by DPM. The
+repository's default live proof starts it temporarily, uploads the package
+closure, runs a JSON Ledger API DvP driver, and removes its state after success.
+It is a one-process proof, not a persistent Splice LocalNet. See
+[Local Canton](../guides/localnet.md#path-a-portable-dpm-sandbox-proof).
+
+### JSON Ledger API
+
+The HTTP/JSON API used by this repository's live backend adapter to submit Daml
+commands and read ledger updates from a Canton participant. The local dev
+server replaces this adapter with a TypeScript `InMemoryLedger`, so it does not
+exercise the JSON Ledger API.
+
+### LocalNet
+
+A local network used for Canton/Splice development. In these docs, **DevKit
+LocalNet** means the optional persistent Docker-managed environment; it is
+distinct from the default throwaway [DPM sandbox](#dpm-sandbox). Neither is a
+production topology.
+
+### Observer
+
+A contract stakeholder explicitly granted visibility without being required to
+authorize its creation. Observing a contract does not automatically grant
+authority to exercise its choices.
+
+### Package / package ID
+
+A compiled unit of Daml code with a content-derived package ID. Template IDs on
+a live ledger include the package identity. The repository's package name and
+version help humans find the DAR, but deployments must use the package IDs
+actually uploaded and vetted on their network.
+
+### Participant
+
+A Canton node that hosts parties, exposes Ledger APIs, validates submissions,
+and stores the ledger data visible to its hosted parties. A participant is
+infrastructure; it is not the same thing as a [party](#party).
+
+### Party
+
+A logical on-ledger identity that can authorize Daml actions and be named as a
+stakeholder. Traders, the DEX operator, the asset admin, and the LP registrar
+are parties. Real Canton party IDs normally include a fingerprint such as
+`alice::1220…`; `trader-demo` is only a local seed label.
+
+### Signatory
+
+A party that authorizes a Daml contract's creation and remains a stakeholder
+with visibility while it is active. Signatories are declared in the template's
+`where` block.
+
+### Synchronizer
+
+Canton infrastructure that coordinates transaction sequencing and confirmation
+between connected participants. It does not turn every participant into a
+public full node or make every contract visible to everyone.
+
+### Template
+
+A Daml definition containing the fields, signatories, observers, and choices
+for one kind of contract. `PoolState` is a template; each live pool-state
+contract is an instance with its own contract ID.
+
+### Transaction
+
+The atomic result of one submission: all creates, exercises, nested choices,
+and archives commit together or none commit. A pool swap relies on this so
+Token Standard settlement, reserve-slice updates, and `PoolState` replacement
+cannot partially succeed.
+
+## Token Standard and DEX terms
### Allocation
A Token Standard V2 contract that locks a holder's [holding](#holding) for one
@@ -50,6 +197,14 @@ traditional-accounting revision of CIP-0056 that adds the allocation +
settlement surface this DEX is built on. Often written "Token Standard V2" or
"TSv2".
+### Boundary slice
+
+The last reserve slice in the ordered set that a swap or liquidity removal draws
+on to cover an amount. Earlier slices in the set are consumed in full; the
+boundary slice is usually only partially drawn, so its unused remainder is
+re-wrapped into a fresh `PoolSlice`. Selecting an ordered prefix this way keeps
+each swap touching only a few slices rather than the whole pool.
+
### Committed allocation
An [allocation](#allocation) authored with `committed = True`, so the authorizer
cannot unilaterally withdraw it before its deadline and the executor has an
@@ -110,8 +265,8 @@ only supply and knows nothing about pools or orders. See [LP Tokens](lp-tokens.m
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
+Template [`MatchedTrade`](../../trading/CantonDex/Dex/MatchedTrade.daml); its
+allocation and batch-settlement behavior is proven in
[`RfqSettlementTests`](../../trading-tests/CantonDex/Tests/RfqSettlementTests.daml).
### Mint / burn account
@@ -134,9 +289,10 @@ mint/burn mechanism is proven as part of atomic add/remove settlement in
### Operator
The venue operator: it orchestrates matching, binds orders, and submits the
settlement batches it is authorized to submit. It cannot settle a trader's
-holdings without that trader's allocation. The hosted RFQ relay is a separate
-authority model in which the backend ledger user is explicitly granted act-as
-rights for hosted parties.
+holdings without that trader's allocation. The operator-mediated RFQ path is a
+separate authority model in which the backend ledger user is explicitly granted
+act-as rights for configured parties; it is not a public relay supplied by the
+repository.
### Over-lock
Locking more backing than a settlement strictly needs. Token Standard V2 accepts
@@ -194,4 +350,7 @@ 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)
+**Where to read next:** [Canton and Daml primer](canton-daml-primer.md) ·
+[AMM-first walkthrough](../tutorials/amm-first-walkthrough.md) ·
+[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 1cfd44a5..f37851d1 100644
--- a/docs/concepts/liquidity-and-custody.md
+++ b/docs/concepts/liquidity-and-custody.md
@@ -117,7 +117,7 @@ transaction — so holdings and reserves change co-atomically, or nothing change
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
+drives each Daml settlement path 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.
@@ -200,4 +200,6 @@ renewal problem. See [Non-goals](non-goals.md#lp-redemption-has-an-explicit-live
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)
+**Where to read next:** [LP Tokens](lp-tokens.md) · [Pricing](pricing.md) ·
+[Non-goals](non-goals.md) · [Registry Integration](../guides/registry-integration.md) ·
+[All docs](../README.md)
diff --git a/docs/concepts/non-goals.md b/docs/concepts/non-goals.md
index e672e9eb..76910ec3 100644
--- a/docs/concepts/non-goals.md
+++ b/docs/concepts/non-goals.md
@@ -21,7 +21,7 @@ and points at the guide or contract where the excluded work would live.
| Fair ordering and MEV resistance | The operator privately observes orders and chooses match timing and submission order | A production sequencing, auction, or independently attested matching design |
| 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, not a production wallet integration | The user's own compatible wallet or a deployment-specific delegation/co-submission flow |
+| Self-custody onboarding | The included signing relay is a development diagnostic, not a production wallet or public onboarding service | The user's own compatible wallet or a deployment-specific delegation/co-submission flow |
| Trustless LP emergency redemption | Reserve slices are operator-authored and removal is co-controlled by the operator and LP registrar | A production pool-governance and emergency-exit design |
| 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) |
@@ -33,7 +33,7 @@ framework that a caller parameterises into arbitrary flows. The settlement
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
+priority, and deterministic RFQ eligibility 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
@@ -79,10 +79,11 @@ 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).
+[TradeWorkflowTests.daml](../../trading-tests/CantonDex/Tests/TradeWorkflowTests.daml)
+proves that two trader allocations settle in one operator batch.
+[OrderWorkflowTests.daml](../../trading-tests/CantonDex/Tests/OrderWorkflowTests.daml)
+proves that `OrderMatchExecution_Execute` refuses a fill outside either order's
+limit price.
## Fair ordering and private MEV
@@ -116,40 +117,40 @@ 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 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
+`CantonDex.Registry.V2` is a self-contained reference registry so the DEX can
+run a complete local settlement flow 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. Integrating another conforming registry also requires
-its factory discovery, choice context, disclosures, and metadata endpoint.
-
-## 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 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 reference flows can be exercised from a browser
-without one. The `/v1/testnet/*` relay surface and the faucet's per-IP party
-quota 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 deployment choice does not change the application boundary:
-self-custodial users connect through a compatible wallet and registry, while a
-hosted party authorizes only the allowlisted demo operations exposed by the
-relay. Registry choice context and disclosures still determine whether a given
-external instrument can participate in a settlement.
+what a registry must provide. A deployment may issue its demo assets through
+this registry; integrating another conforming registry also requires its factory
+discovery, choice context, disclosures, and metadata endpoint.
+
+## The development relay is not a wallet
+
+The repository includes `POST /v1/wallet/submit` only for local developer
+diagnosis. It is disabled by default, requires `DEX_DEV_WALLET_RELAY=1`, is
+registered by the dApp only in a development build, and restricts submissions
+to `DEX_DEV_RELAY_PARTIES`. The production-oriented testnet server does not
+enable it. It does not create parties, mint faucet assets, impose public-user
+quotas, or implement a `/v1/testnet/*` surface.
+
+That relay is not self-custody: the backend forwards commands with its ledger
+credential and therefore needs permission to act for every requested party. A
+real deployment must instead use a compatible wallet (PartyLayer or a
+CIP-0103 provider), or deliberately design and secure its own delegation or
+co-submission service. The repository neither provisions nor promises a public
+hosted deployment. See [connecting a wallet](../guides/using-the-dapp.md#connecting-a-wallet)
+and the [historical ecosystem feedback](../reference/ecosystem-feedback.md).
+
+The separately named `DEX_HOSTED_RFQ_RELAY` option is narrower: it can enable
+the existing RFQ create/cancel/accept routes in `testnet-server.ts`, with
+mandatory caller-JWT binding. It still does not create or fund parties, publish
+a hostname, or add a `/v1/testnet/*` API. Whoever enables it owns the custodial
+authority, identity, abuse-prevention, and operations design.
## LP redemption has an explicit liveness dependency
@@ -182,8 +183,8 @@ reference settlement flow.
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
+a hardened production service. There is no HA, rate limiting, public gateway or
+faucet, 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
@@ -194,7 +195,7 @@ line.
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
+one Node process. They show what an integrator needs to read and orchestrate, not the
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)).
diff --git a/docs/concepts/overview.md b/docs/concepts/overview.md
index 75692c2a..c1fad090 100644
--- a/docs/concepts/overview.md
+++ b/docs/concepts/overview.md
@@ -1,26 +1,26 @@
# Overview
-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.
+This is Step 2 of the
+[canonical newcomer learning path](../README.md#canonical-newcomer-learning-path).
+Complete the [Canton and Daml primer](canton-daml-primer.md) first. This page
+shows what the DEX does, where authority sits, and how its main pieces connect.
## What Canton DEX is
-Canton DEX is a runnable **Token Standard V2 (CIP-0112) reference exchange** for
-the Canton Network. An exchange has two separate jobs: decide the terms of a
-trade, then move both sides' assets without either party taking settlement risk.
-This reference shows four ways to decide the terms:
+Canton DEX is a runnable **Token Standard V2 (CIP-0112) reference exchange** on
+Canton. An exchange first agrees the terms, then moves both assets atomically.
+This reference shows four ways to agree the terms:
- an **automated market maker (AMM)** calculates a price from two pool reserves;
- an **order book** crosses compatible buy and sell limit orders;
- a **request for quote (RFQ)** lets selected dealers quote a larger trade; and
- an **OTC matched trade** records terms the two parties already agreed.
-All four use the same value-movement boundary. The holder locks funds in a Token
-Standard V2 allocation, the DEX choice validates the market-specific terms, and
-the registry settles every transfer leg atomically. There is no custom
-off-ledger balance model. Self-custodial swap, order, and liquidity flows keep
-trader authority in the wallet; the hosted RFQ demo uses an explicitly
-documented operator relay.
+All four move value the same way. The holder locks funds in a Token Standard V2
+allocation. A DEX choice validates the terms. The registry then settles every
+leg atomically. There is no custom off-ledger balance model. For swaps, orders,
+and liquidity, the trader keeps authority in the wallet. The RFQ demo uses a
+separate operator-mediated authority model.
The repo ships the Daml package, operator backend, React dApp with a CIP-0103
wallet boundary, tests, and runbooks. Its demo stack runs without a Canton
@@ -49,18 +49,21 @@ Suppose a trader wants to sell `0.1 BTC` into a BTC/USDC pool:
`PoolSlice` contracts reference the committed allocations that actually back
those reserves.
2. The backend reads `PoolState` to show an estimated USDC output.
- `PoolRules_RequestSwap` returns the exact input-allocation specification the
- wallet must authorize; it does not fix the eventual execution price.
+ `PoolRules_RequestSwap` validates a named state and ordered slice snapshot,
+ calculates the exact output from that snapshot, and returns the complete
+ input-and-output allocation specification the wallet must authorize.
3. The trader's wallet locks `0.1 BTC` in a V2 allocation. The operator cannot
create this allocation on the trader's behalf in the self-custodial flow.
4. The backend submits `PoolRules_Swap` with that allocation and the reserve
- slices needed for the output.
-5. The choice calculates the execution price from current state, checks the trader's
- minimum output, verifies every allocation, and calls
+ slices bound into the request.
+5. The choice re-derives the same output from the bound state, checks the
+ minimum, bound contract IDs, exact signed legs, and allocations, then calls
`SettlementFactory_SettleBatch`.
6. BTC moves to the pool and USDC moves to the trader atomically. The choice
recreates `PoolState` and binds the remaining reserve value to successor
- slices. If any check fails, neither side moves.
+ slices. If the pool changed after the request, the bound contract IDs are
+ stale and the swap fails instead of silently repricing; the request must be
+ recreated. If any check fails, neither side moves.
The other workflows change how terms are formed and what state is recreated;
they do not invent a different custody or settlement mechanism.
@@ -82,10 +85,11 @@ decentralized operator; [Non-goals](non-goals.md) explains each boundary.
There are two submission paths, split by **who is allowed to sign what**. A
wallet signs trader-authored allocations for orders, swaps, and liquidity. The
-operator backend submits listing, matching, and settlement commands. The hosted
-RFQ demo also relays trader-authority commands, so its ledger user must have
-act-as rights for the hosted trader; that exception is not a self-custodial
-wallet model. Both paths submit into `canton-dex-trading`, whose trading
+operator backend submits listing, matching, and settlement commands. The
+operator-mediated RFQ example also submits trader-authority commands, so its
+ledger user must have act-as rights for the configured trader; that exception is
+not a self-custodial wallet model or a public relay service. Both paths submit
+into `canton-dex-trading`, whose trading
surfaces settle through a Token Standard V2 registry.
```mermaid
@@ -103,7 +107,7 @@ flowchart TB
Trader -->|"reads + orchestration APIs"| Operator
Trader -->|"signs trader-authority commands"| Wallet
- Operator -->|"operator submissions + hosted RFQ relay"| Ledger
+ Operator -->|"operator submissions + mediated RFQ"| Ledger
Wallet -->|"trader-authority submissions"| Ledger
```
@@ -144,6 +148,12 @@ allocate-then-settle-a-batch pattern:
| **RFQ** | `Rfq` / `RfqQuote` → `Rfq_Accept` → `MatchedTrade` | the dealer's quoted price |
| **OTC** | `MatchedTrade` → `MatchedTrade_Settle` | leg amounts both sides pre-agreed |
+`DexPair.active` and `DexPair.tradingMode` tell off-ledger discovery and routing
+which surfaces to expose. In this reference they are not on-ledger settlement
+gates: `PoolRules` and `OrderMatchExecution` do not fetch `DexPair`. A production
+fork that needs a ledger-enforced listing pause must bind and validate the pair
+contract in its terminal choices.
+
Settlement is **grouped by registry admin**. One DEX choice can call one batch
per admin inside the same Daml transaction, so every batch succeeds or the
whole transaction aborts. `MatchedTrade_Settle` shows the shape:
@@ -180,14 +190,15 @@ settlement rather than a call into a router.
For DvP settlement, the operator cannot spend a trader's holdings without a
trader-authored allocation. 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 hosted RFQ UI uses a different trust model: its
-backend co-submits as the hosted trader and operator, and therefore needs both
-ledger authorities. [Architecture](architecture.md) draws these boundaries;
+signs it over CIP-0103. The operator-mediated RFQ UI uses a different trust
+model: its backend co-submits as the configured trader and operator, and
+therefore needs both ledger authorities. [Architecture](architecture.md) draws these boundaries;
[Workflows](workflows.md) shows how each flow choreographs them.
-## How to read these docs
+## Reference map
-Read top to bottom for the design, or jump to the row that matches your question.
+The canonical learning order lives in the [documentation index](../README.md#canonical-newcomer-learning-path).
+Use this table only to look up a topic while reading:
| Doc | What you'll learn |
|---|---|
@@ -197,6 +208,7 @@ Read top to bottom for the design, or jump to the row that matches your question
| [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. |
+| [Daml proof map](../reference/daml-proof-map.md) | Exact source choices, focused Daml Script tests, and commands for each design claim. |
| [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. |
@@ -205,21 +217,13 @@ Read top to bottom for the design, or jump to the row that matches your question
> do their own security review, operational hardening, compliance work, and
> version-compatibility checks.
-The tests separate fast workflow choreography from real-value settlement:
-
-- **AMM pool** — [`testPoolSwapEndToEnd`](../../trading-tests/CantonDex/Tests/EndToEndTests.daml)
- checks the choice choreography against `MockRegistry`, while
- [`testRealRegistryDvpSwapSettles`](../../trading-tests/CantonDex/Tests/RealRegistryDvpTests.daml)
- proves exact value movement against a context-requiring V2 registry.
-- **Order book** — [`testOrderFundingFlow`](../../trading-tests/CantonDex/Tests/EndToEndTests.daml)
- proves intent → operator binding → trader-authored allocation → funded
- order; [`testPartialFillUsesRolledFundingBudget`](../../trading-tests/CantonDex/Tests/RegistryConservationTests.daml)
- proves a partial fill retains real locked backing.
-- **RFQ** — [`testRfqBuySettlesAgainstRealHoldings`](../../trading-tests/CantonDex/Tests/RfqSettlementTests.daml)
- proves the accepted quote, policy receipt, exact balance deltas, and lock
- cleanup against real holdings.
-- **OTC** — [`testMatchedTradeSettlesPerAdminLegSubsets`](../../trading-tests/CantonDex/Tests/RealRegistryDvpTests.daml)
- settles a cross-admin trade atomically against real registry holdings.
+## Where the executable proof lives
+
+The [Daml proof map](../reference/daml-proof-map.md) connects each design claim
+to its current source choice and focused test. The
+[testing reference](../reference/testing.md) explains what mock choreography,
+real-holding Daml Script, backend, UI, and live-Canton tests each prove. Test
+names stay there so this concept page remains readable when suites move.
---
@@ -238,4 +242,5 @@ the exact Splice release is recorded in
[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)
+**Next canonical step:** [Getting started](../getting-started.md).
+Keep the [Glossary](glossary.md) open as a companion reference.
diff --git a/docs/concepts/workflows.md b/docs/concepts/workflows.md
index 82e7c2e6..9878d495 100644
--- a/docs/concepts/workflows.md
+++ b/docs/concepts/workflows.md
@@ -1,11 +1,14 @@
# Canton DEX workflow design
-Each state transition has a named app choice. A terminal, value-moving choice
-validates the workflow's business rules and delegates settlement to Token
-Standard V2. It may call more than one `SettlementFactory_SettleBatch` when the
-instruments have different registry admins, but those calls remain atomic inside
-one Daml transaction. The app contracts own market state; the registry owns
-holdings and settlement.
+This is Step 7 of the
+[canonical newcomer learning path](../README.md#canonical-newcomer-learning-path).
+Complete [Architecture](architecture.md) first.
+
+Each state transition has a named app choice. A value-moving choice validates
+the business rules, then asks Token Standard V2 to settle. Different registry
+admins may require more than one `SettlementFactory_SettleBatch`, but all
+batches remain atomic inside one Daml transaction. App contracts own market
+state; the registry owns holdings and settlement.
## The common workflow in five steps
@@ -60,7 +63,7 @@ may exercise them.
| Place order | `OrderFundingRequest_Bind` | `AllocationFactory_Allocate` | `Order_Fund` | pending order becomes funded |
| Match orders | funded buy + sell orders | already prefunded | `OrderMatchExecution_Execute` | atomic fill; each remainder rolls forward |
| Cancel order | funded or partially filled order | already prefunded | `Order_Cancel` | order closes and remaining funding unlocks |
-| Accept RFQ | `Rfq` + `RfqQuote` | `Rfq_Accept` under the hosted authority model | `Rfq_Accept` | `MatchedTrade` and policy receipt are created; no value moves yet |
+| Accept RFQ | `Rfq` + `RfqQuote` | `Rfq_Accept` under the operator-mediated authority model | `Rfq_Accept` | `MatchedTrade` and policy receipt are created; no value moves yet |
| Settle RFQ / OTC | `MatchedTrade` allocation requests | each counterparty authors its allocation | `MatchedTrade_Settle` | bilateral legs settle atomically |
## Actors and core contracts
@@ -77,6 +80,12 @@ 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.
+`DexPair.active` and `DexPair.tradingMode` are listing metadata for off-ledger
+discovery and routing. They are deliberately absent from the value-moving table
+above: neither `PoolRules` nor `OrderMatchExecution` fetches a pair contract, so
+changing those fields does not itself block a direct Daml settlement. Bind and
+validate `DexPair` in terminal choices if a fork needs an on-ledger market gate.
+
## The settlement shape every workflow shares
Two mechanics recur below and are worth stating once, because they are the
@@ -145,12 +154,8 @@ settleResult <- exercise factoryCid V2.SettlementFactory_SettleBatch with
extraArgs
```
-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).
+For the focused choreography and real-holding checks behind this section, see
+[Daml proof map — AMM pool](../reference/daml-proof-map.md#amm-pool).
## Add and remove liquidity
@@ -169,12 +174,23 @@ sequenceDiagram
LP->>L: BatchingUtility_ExecuteBatch
Note over LP,L: one wallet approval: Accept + 3 Allocate actions
D->>O: POST /v1/pools/add-liquidity/settle (allocation cids)
+ O->>L: PreviewAddAllocations
+ O->>O: discover exact allocation factories + choice contexts
+ O->>L: allocate operator/registrar sides
+ O->>L: PreviewAddSettlement
+ O->>O: discover exact settlement factories + choice contexts
O->>L: PoolLiquidityRules_SettleAddLiquidity
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
+The previews are read-only Daml choices. They return the exact canonical V2
+choice arguments, which the backend sends to each registry's operation-specific
+off-ledger discovery endpoint before exercising the real allocate or settle
+choice. This avoids guessing a factory contract or reusing context from a
+different operation.
+
+`PoolLiquidityRules_SettleAddLiquidity` then 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
@@ -210,12 +226,8 @@ LP has no unilateral exit if the operator or registrar becomes unavailable.
See [Availability and the LP exit boundary](liquidity-and-custody.md#availability-and-the-lp-exit-boundary)
and [LP redemption has an explicit liveness dependency](non-goals.md#lp-redemption-has-an-explicit-liveness-dependency).
-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).
+The add, refund, remove, and full-redemption proofs are cataloged in
+[Daml proof map — AMM pool](../reference/daml-proof-map.md#amm-pool).
## Order lifecycle
@@ -285,11 +297,8 @@ expiry, instruments, and backing, but cannot prove fair intake ordering or stop
censorship and private reordering among valid fills. This distinction is
documented as [Fair ordering and private MEV](non-goals.md#fair-ordering-and-private-mev).
-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).
+For the limit, roll-forward, backing, and cancellation proofs, see
+[Daml proof map — Resting orders](../reference/daml-proof-map.md#resting-orders).
## RFQ and OTC block trades
@@ -299,8 +308,8 @@ ranked.
```mermaid
sequenceDiagram
- actor T as Hosted trader
- actor Dl as Hosted dealer
+ actor T as Trader
+ actor Dl as Dealer
participant O as Operator backend
participant L as Ledger
T->>O: POST /v1/rfq (create Rfq)
@@ -321,18 +330,25 @@ ranks the considered quotes, records the winner and its rank in a
published policy was applied, not that the price was good), and copies the RFQ's
`expiresAt` onto the trade's `settlementDeadline`.
+`RfqQuote.tier` is dealer-declared in this reference. The operator observes the
+quote and endorses the considered set by co-authorizing `Rfq_Accept`; there is no
+separate on-ledger tier-administration contract. Policy v2.0 ranks tier, later
+expiry, earlier posting time, then dealer party id; price is deliberately not a
+ranking key, and the trader still chooses which considered quote to accept.
+
The included RFQ page covers creation, quote review, and acceptance through
-hosted-party relay routes: the backend ledger user
-must have act-as rights for the trader (and dealer when it authors quotes), while
+operator-mediated API routes: the backend ledger user must have act-as rights
+for the trader (and dealer when it authors quotes), while
accept also needs operator authority. This is distinct from the wallet-authored
allocation flow used by pools and orders. A self-custodial deployment must
-replace the relay with a wallet, delegation, or co-submission mechanism that
-supplies the same controllers.
+replace that example with a wallet, delegation, or co-submission mechanism that
+supplies the same controllers. The repository does not provision a public RFQ
+relay or party-onboarding service.
The page's **Accepted** tab means that `Rfq_Accept` created the `MatchedTrade`;
it does not mean balances moved. The following allocation requests and
`MatchedTrade_Settle` are available through the Daml and operator-service flow
-and are covered by the settlement tests, but the hosted RFQ page does not drive
+and are covered by the settlement tests, but the RFQ page does not drive
those later steps.
```daml
@@ -349,13 +365,15 @@ that trade cannot settle after the deadline; their owners must cancel or
withdraw them to release the locked holdings. Integrators therefore need to
leave enough time between acceptance, wallet funding, and settlement.
-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).
+For receipt, real-holding, deadline, and cancellation proofs, see
+[Daml proof map — RFQ and OTC](../reference/daml-proof-map.md#rfq-and-otc).
+
+### Explicit exits and recovery choices
+
+Failure and abandonment are explicit choices, not hidden background cleanup.
+The [resting-order](../reference/daml-proof-map.md#resting-orders) and
+[RFQ/OTC](../reference/daml-proof-map.md#rfq-and-otc) proof tables identify the
+controller and resulting contract/fund-state checks for each exit.
## Pool lifecycle
@@ -366,11 +384,15 @@ emergency stop:
stateDiagram-v2
[*] --> Unfunded: pool created
Unfunded --> Active: first add-liquidity settles
- Active --> Active: swap / add / remove
+ Active --> Active: swap / add / partial remove
+ Active --> Unfunded: final LP removal
Active --> Paused: PoolRules_Pause
Paused --> Active: PoolRules_Resume
```
+The mock lifecycle, real first-funding, and complete-redemption checks are in
+[Daml proof map — AMM pool](../reference/daml-proof-map.md#amm-pool).
+
---
## Reference
@@ -378,9 +400,12 @@ stateDiagram-v2
### 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.
+ `InstrumentId`s, fee model, and mode (`TM_OrderBook`, `TM_Pool`, or `TM_Both`).
+ `active` and `tradingMode` guide off-ledger listing/routing only; they are not
+ fetched by the active settlement choices. There is no separate `DexRules`
+ admission contract yet; a production fork can add one if listing needs
+ multi-party approval. Source and focused checks are in
+ [Daml proof map — Pair listing metadata](../reference/daml-proof-map.md#pair-listing-metadata).
- **Pool creation.** `DexOperator` creates a `Pool` for a `DexPair` and the LP
instrument definition (an `InstrumentConfig` in the reference
registry). The pool starts `Unfunded` with a constant-product invariant until
@@ -430,4 +455,7 @@ interfaces and no separate `DexRules` governance contract.
---
-**Where to read next:** [Architecture](architecture.md) · [Liquidity and custody](liquidity-and-custody.md) · [Non-goals](non-goals.md) · [Pricing](pricing.md) · [Builder Guide](../guides/builder-guide.md) · [Allocation Surface](../reference/allocation-surface.md) · [All docs](../README.md)
+**Next canonical step:** [Make your first AMM code change](../tutorials/make-your-first-amm-change.md).
+Use [Liquidity and custody](liquidity-and-custody.md),
+[Pricing](pricing.md), [Non-goals](non-goals.md), and the
+[Allocation Surface](../reference/allocation-surface.md) as topic references.
diff --git a/docs/getting-started.md b/docs/getting-started.md
index 942447ea..2ad7060b 100644
--- a/docs/getting-started.md
+++ b/docs/getting-started.md
@@ -1,199 +1,402 @@
-# Local Setup & Testing
+# Getting started: choose what you want to prove
-One page to clone, build, run, test, and explore the whole reference DEX on
-your machine: the Daml core, the operator backend, the dApp, and the scripts.
-The local path needs **no Canton participant**: the dev backend ships an
-in-memory ledger, so you can have the full stack up in a few minutes.
+This is Step 3 of the
+[canonical newcomer learning path](README.md#canonical-newcomer-learning-path).
+Steps 1–2 establish the Canton/Daml vocabulary and system boundary. This page
+installs the tools and turns that model into three increasingly realistic
+local proofs.
-> Quick start
-> ```bash
-> git clone https://github.com/srikanth-bitdynamics/Canton-Dex-Reference-Implementation.git && cd Canton-Dex-Reference-Implementation
-> bash scripts/run-local-daml-tests.sh # Daml build + tests
-> (cd services/operator-backend && npm ci && npm run dev) # backend → :8080
-> (cd app/web && cp .env.example .env.local && npm ci && npm run dev) # dApp → :5173
-> ```
+This repository has three useful local experiences, but they do not prove the
+same thing. Start by choosing the result you need:
-## What's in the repo
+| Mode | What you run | What it proves | What it does **not** prove |
+|---|---|---|---|
+| **1. Browser preview** | React dApp + operator backend + seeded `InMemoryLedger` | The screens render, reads and quotes are wired, and wallet intents have the expected shape | Daml authorization, wallet signatures, Token Standard allocations, or value settlement |
+| **2. Daml-engine tests** | `dpm test` through the repository scripts | Daml choices, party authorization, atomicity, rounding, and value conservation in the Daml Script runner | The browser, backend, JSON Ledger API, or a multi-node Canton deployment |
+| **3. Live Canton proof** | `scripts/run-dpm-sandbox-proof.sh` | A real throwaway Canton process, JSON Ledger API, package upload, distinct LP/swapper parties, and add → quote-bound swap → partial remove DvP (delivery-versus-payment) settlement | Browser/backend HTTP integration, external-wallet compatibility, production-grade rights/topology, persistent state, or production readiness |
-| Path | Component | Stack |
-|---|---|---|
-| `trading/` | `canton-dex-trading` Daml package — pool/swap/LP, orders, RFQ, matched-trade, reference V2 registry | Daml 3.5 |
-| `trading-tests/` | in-script test suites for the Daml core | Daml |
-| `services/operator-backend/` | operator HTTP API, JSON-LAPI driver, idempotency, indexer, recovery; in-memory dev ledger | TypeScript / Node |
-| `app/web/` | the dApp — Trade / Pools / Orders / RFQ / Portfolio / Admin + wallet layer | TypeScript / React / Vite |
-| `scripts/` | build, smoke, registry-bootstrap, and LocalNet/testnet drivers | bash / ts-node |
-| `vendor/splice/dars/` | canonical Splice 0.6.12 Token Standard release DARs (committed build inputs) | Daml |
-| `docs/` | architecture, workflows, operator runbook, deployment, this page | — |
-
-> **One-command sanity check.** After installing (below), `bash
-> scripts/e2e-smoke.sh` boots the in-memory backend, exercises every key
-> endpoint, verifies the responses, and exits non-zero on any failure — no Canton
-> participant needed.
+Within this step, run Mode 1, then Mode 2, then Mode 3. You may jump directly
+to a mode when you only need its proof, but a first-time reader should keep the
+order. Mode 3 is a separate throwaway Canton proof; it does not turn the Mode 1
+browser preview into a live wallet dApp.
+
+If `template`, `choice`, `party`, `participant`, or `DAR` are still unfamiliar,
+pause and return to Step 1, the
+[Canton and Daml primer](concepts/canton-daml-primer.md).
## Prerequisites
-| Tool | Version | For |
-|---|---|---|
-| DPM | latest ([install](https://docs.digitalasset.com/build/3.4/dpm/dpm.html)); resolves the pinned **SDK 3.5.2** automatically | building + testing the Daml core |
-| Node.js | **24+** | backend + dApp |
-| npm | 10+ | install/test |
-| (optional) Docker | recent | only for the real-Canton paths below |
-The Token Standard dependencies are the **canonical Splice 0.6.12 release
-DARs**, committed under `vendor/splice/dars/` (the exact package ids the
-network vets — see `vendor/splice/VENDOR_PIN.md`). No extra download or
-source build is needed; `dpm build` consumes them directly. Refresh them for a
-newer Splice release with `scripts/fetch-splice-dars.sh`.
+### For the browser preview
----
+- [Node.js 24 or newer](https://nodejs.org/en/download).
+- npm 10 or newer (installed with Node.js).
+- [Git](https://git-scm.com/downloads/).
+- `curl` is optional, but useful for checking the backend independently of the
+ browser.
-## 1. Daml core — `trading/`
+Check the installed versions:
```bash
-bash scripts/run-local-daml-tests.sh
+node --version # expected: v24.x.x or newer
+npm --version # expected: 10.x.x or newer
+git --version
```
-This builds the `canton-dex-trading` DAR (against the committed canonical
-Token Standard DARs) and runs the suites. Or by hand:
+
+
+
+### Additional tools for Daml builds, tests, and the live proof
+
+- A JDK 17 or newer. CI uses
+ [Eclipse Temurin 17](https://adoptium.net/temurin/releases/?version=17).
+- [DPM](https://archived.docs.digitalasset.com/build/3.5/dpm/manual-install.html), the
+ Daml Package Manager.
+- The Daml SDK pinned by this repository: 3.5.2.
+- Bash and `curl` for the default live-Canton proof.
+
+Digital Asset keeps the version-pinned 3.5 manuals in its official documentation
+archive. The links above intentionally use that archive so their commands match
+this repository's SDK instead of a newer toolchain.
+
+If Daml syntax itself is new, complete Digital Asset's official
+[Get started with Daml](https://archived.docs.digitalasset.com/build/3.5/tutorials/get-started/index.html)
+tutorial and its
+[basic contracts lesson](https://archived.docs.digitalasset.com/build/3.5/tutorials/smart-contracts/contracts.html)
+before the first code-change tutorial. The repository primer explains this
+application's mental model; the official tutorial teaches the language.
+
+After installing Java and DPM, install the pinned SDK once:
+
+```bash
+java -version
+dpm --version
+dpm install 3.5.2
+```
+
+`dpm --version` reports the DPM version, not the Daml SDK version. The
+`sdk-version: 3.5.2` entries in `trading/daml.yaml` and
+`trading-tests/daml.yaml` select the installed SDK when those packages build.
+
+The Token Standard dependencies are committed DAR files under
+`vendor/splice/dars/`; a first build does not need to download or compile
+Splice source. Their release and package IDs are recorded in
+[`../vendor/splice/VENDOR_PIN.md`](../vendor/splice/VENDOR_PIN.md).
+
+## Mode 1: run the browser preview
+
+The preview uses seeded TypeScript objects, not a Canton participant. Keep the
+backend and frontend running in separate terminals: each development server is
+a foreground process.
+
+### 1. Clone and install
+
+Run these one-time setup commands in any terminal:
```bash
-(cd trading && dpm build) # produces canton-dex-trading-0.1.4.dar
-(cd trading-tests && dpm test) # every script should report "ok"
+git clone https://github.com/srikanth-bitdynamics/Canton-Dex-Reference-Implementation.git
+cd Canton-Dex-Reference-Implementation
+
+(cd services/operator-backend && npm ci)
+(cd app/web && npm ci && cp .env.example .env.local)
```
-This exercises the V2-native templates (pool/swap/LP, orders, RFQ,
-matched-trade), the reference registry (`Registry/V2.daml`) implementing V2
-Holding/Allocation/Settlement, and the conservation/invariant tests.
----
+If you already cloned the repository, start from its root and run only the two
+parenthesized install commands.
-## 2. Operator backend — `services/operator-backend/`
+### 2. Terminal 1 — start the backend
+
+From the repository root:
-In-memory dev ledger, no Canton needed:
```bash
cd services/operator-backend
-npm ci
-npm run dev # listens on http://localhost:8080
+ALLOWED_ORIGINS=http://localhost:5173 npm run dev
```
-On boot it seeds a demo BTC/USDC pair + pool and a demo trader with holdings.
-Smoke it:
+
+`ALLOWED_ORIGINS` is required. The backend denies cross-origin browser access
+when this allowlist is absent; the fact that `curl` works does not mean the
+browser is allowed to read the same endpoint.
+
+Leave the process running. A successful start ends with lines like:
+
+```text
+[operator-backend] dev server listening at http://127.0.0.1:8080
+[operator-backend] parties: operator=operator-demo, lpRegistrar=lp-registrar-demo, admin=admin-demo, trader=trader-demo
+```
+
+The backend seeds:
+
+- one active `BTC/USDC` pair and constant-product pool;
+- two reserve slices per side;
+- `0.2500000000 BTC` and `5000.0000000000 USDC` for `trader-demo`.
+
+### 3. Terminal 2 — start the dApp
+
+Open a second terminal at the repository root:
+
```bash
-curl -s http://localhost:8080/v1/pairs | python3 -m json.tool
-curl -s http://localhost:8080/v1/pools | python3 -m json.tool
+cd app/web
+npm run dev
+```
+
+Vite prints a local URL, normally:
+
+```text
+Local: http://localhost:5173/
```
-> Port note: `localhost:8080` can collide with Docker’s IPv6 bind on macOS. If
-> `/v1/pairs` returns "method not allowed", run on another port and point the
-> dApp at it: `PORT=8091 npm run dev` and set `VITE_API_BASE=http://127.0.0.1:8091`.
-### Exercising write paths in demo mode
+Open . The Trade and Pools pages should show the seeded
+`BTC/USDC` market. Connect **Mock Wallet (dev)** to view the seeded
+`trader-demo` portfolio. The header must say `in-memory preview`, the status pill
+must say `Preview · no Canton`, and the page warning must state that wallet
+actions do not settle token value. Those labels are part of the safety boundary.
-Read paths (`/v1/pairs`, `/v1/pools`, `/v1/holdings`, `/v1/swaps/quote`) work
-with no configuration. **State-changing routes** — `/v1/pools/swap*`,
-`/v1/rfq`, `/v1/orders/*`, `/v1/admin/*` — are auth-gated and return **401**
-unless an operator token is configured or the dev bypass is on. To exercise
-writes against the in-memory demo, set one flag:
+### 4. Terminal 3 — verify the boundary
+
+Use a third terminal to distinguish a backend problem from a browser problem:
```bash
-DEX_DEV_OPEN=1 npm run dev
+curl -sS http://localhost:8080/v1/status
+curl -sS http://localhost:8080/v1/pairs
+curl -sS http://localhost:8080/v1/pools
```
-`DEX_DEV_OPEN=1` opens the operator-write gate **and** (because the dev server
-seeds bare-hint parties like `trader-demo`) auto-relaxes party validation. It
-does not emulate wallet signatures or fabricate V2 allocations: allocation-
-backed writes return `501 not_supported` on the in-memory ledger. Use the local
-Canton flow below to exercise a real swap, order, or liquidity settlement.
+The status response contains the following stable fields; `slot` and
+`serverTime` change on every run:
-Demo-mode flags (in-memory dev server only; never set in production):
+```json
+{"network":"preview:in-memory","slot":0,"synced":true,"serverTime":""}
+```
-| Env | Effect |
-|---|---|
-| `DEX_DEV_OPEN=1` | open the operator-write gate; also auto-allows the seeded bare parties |
-| `DEX_ALLOW_BARE_PARTIES` | override the bare-party relaxation (`=0` to force strict `hint::hexfingerprint`) |
-| `DEX_DEV_WALLET_RELAY=1` | enable the dev wallet-relay endpoint |
-| `DEX_OPERATOR_API_TOKEN` | require this bearer token on writes instead of the open bypass |
+The pair and pool responses are JSON arrays containing `BTC`, `USDC`, and
+`BTC-USDC`. If those commands succeed but the dApp reports a network error,
+check that Terminal 1 includes exactly the origin printed by Vite in
+`ALLOWED_ORIGINS`.
+
+
+
+### What is safe to explore in this mode
-> A swap is always `/v1/pools/swap/request` → wallet-authorized allocation →
-> `/v1/pools/swap`. There is no synthetic single-step settlement path.
+Use the preview to:
+
+- inspect seeded pairs, pool reserves, prices, holdings, and order-book views;
+- request a swap quote and observe fee and price-impact changes;
+- inspect the screens and the wallet handoff sequence;
+- see which HTTP calls the dApp makes in the browser developer tools.
+
+Do not use it as evidence that a trade settled. The Mock Wallet waits briefly,
+logs the intent, and returns fake contract IDs such as `#mock-…:0`. It has no
+key and signs nothing. The backend's `InMemoryLedger` implements selected
+TypeScript handlers and does not enforce Daml authorization or Token Standard
+value conservation.
+
+Write routes are deliberately closed by default. Without an operator token or
+the development bypass, a state-changing request returns:
+
+```json
+{"error":"state-changing routes require DEX_OPERATOR_API_TOKEN to be configured (or DEX_DEV_OPEN=1 for the dev server)","code":"unauthorized"}
+```
+
+with HTTP status `401`.
+
+To inspect more of the UI's write orchestration, stop Terminal 1 with
+`Ctrl+C` and restart it with the explicit development-only bypass:
-Tests + typecheck:
```bash
-npm run typecheck # tsc, clean
-npm test # node:test
+ALLOWED_ORIGINS=http://localhost:5173 DEX_DEV_OPEN=1 npm run dev
+```
+
+This bypass opens the non-admin operator-write gate and permits the seeded
+short party names. Administrative `/v1/admin/*` routes still require
+`OPERATOR_ADMIN_TOKEN`. The bypass does not create wallet signatures or V2
+allocations. Canonical swap, order-funding, and liquidity paths can reach an
+unimplemented multi-step Daml choice and return HTTP `501` with:
+
+```json
+{"error":"choice … is not implemented by the in-memory dev ledger. This flow requires a real Canton participant…","code":"not_supported","requestId":"…"}
```
----
+That is an expected boundary of Mode 1, not a completed exchange flow. Never
+set `DEX_DEV_OPEN=1` outside this local dev server.
-## 3. dApp — `app/web/`
+## Mode 2: run the Daml-engine proofs
+
+From the repository root:
```bash
-cd app/web
-cp .env.example .env.local # then edit (see Wallets below)
-npm ci
-npm run dev # Vite dev server → http://localhost:5173
+dpm install 3.5.2
+bash scripts/run-local-daml-tests.sh
+```
+
+The script first builds
+`trading/.daml/dist/canton-dex-trading-0.1.4.dar`, then runs the
+`trading-tests` package. A successful run includes:
+
+```text
+==> Building canton-dex-trading (deps: vendor/splice/dars/*.dar)
+canton-dex-trading built successfully.
+…
+testRealRegistryDvpSwapSettles: ok
```
-Open `http://localhost:5173` → the Trade / Pools / Orders / RFQ / Portfolio /
-Admin pages render the seeded backend state. Connect **Mock Wallet (dev)** to
-exercise the full trade/LP/order flows with deterministic cids and no external
-wallet.
-Tests:
+At this revision, the package declares 111 Daml Script tests. Every displayed
+test must end in `ok`, and the command must exit with status 0.
+Workflow-specific mock-registry modules prove choreography without holdings;
+real-holding suites prove value movement inside the Daml engine. The
+[testing reference](reference/testing.md) explains that distinction, and the
+[Daml proof map](reference/daml-proof-map.md) lists focused commands.
+
+To run only the real-holding swap proof after the DAR has been built:
+
```bash
-npm test # vitest
+cd trading-tests
+dpm test -p testRealRegistryDvpSwapSettles
```
-### Wallet options (set in `app/web/.env.local`)
-| Provider | Enable | Notes |
-|---|---|---|
-| **Mock (dev)** | (always available in dev) | deterministic cids; best for local UI testing |
-| **WalletConnect** | `VITE_WC_PROJECT_ID=` | web3-native path; get an id at cloud.reown.com |
-| **CIP-0103 SDK** | `VITE_ENABLE_SDK=1` | `@canton-network/dapp-sdk`; needs a CIP-0103 wallet |
-| **PartyLayer** | `VITE_ENABLE_PARTYLAYER=1` | `VITE_PARTYLAYER_WALLET_IDS=console,nightly,send[,loop]` |
-| Token-standard relay | dev builds only | operator co-signs; labelled "dev only" — not for prod |
+This mode runs the Daml engine in the Script test runner. It is materially
+stronger than the TypeScript `InMemoryLedger`, but it is still not a running
+Canton participant and does not exercise the browser or JSON Ledger API.
-Backend API base is `VITE_API_BASE` (default `http://localhost:8080`).
+Step 4, [Trace one AMM swap from formula to Daml settlement](tutorials/amm-first-walkthrough.md),
+will unpack what that test proves after you complete the live checkpoint below.
----
+
-## Scripts reference (`scripts/`)
-| Script | What it does |
-|---|---|
-| `run-local-daml-tests.sh` | build the DAR + run the Daml test suites |
-| `e2e-smoke.sh` | quick end-to-end smoke across the stack |
-| `bootstrap-registry.ts` | create the asset-admin and LP-registrar `Registry.V2` contracts and register configured instruments |
-| `localnet-dvp-e2e.ts` | LP add / swap / remove DvP round-trip on a LocalNet (`npm run localnet:dvp-e2e` from the backend) |
-| `testnet-v2registry-trade.ts` | drive a V2 registry trade against a testnet participant |
-| `fetch-splice-dars.sh` | refresh the committed TSv2 DARs from a Splice release |
-| `build-trading-surface.sh` | build the `canton-dex-trading` surface |
-| `deploy-testnet.sh` | upload the DAR + seed a pair/pool on a testnet participant |
-
----
-
-## Running the full test suite
-| Component | Command | Expected |
+## Mode 3: run the default live-Canton proof
+
+The default live path uses the Canton sandbox bundled with the pinned DPM SDK.
+It requires no Canton DevKit, Docker, external wallet, or pre-existing network.
+From the repository root run:
+
+```bash
+bash scripts/run-dpm-sandbox-proof.sh
+```
+
+The script performs the integration work that Mode 2 deliberately skips:
+
+1. installs SDK 3.5.2 idempotently and builds the DEX DAR;
+2. reserves all six Canton ports and starts a throwaway `dpm sandbox` on those
+ loopback ports;
+3. waits for the JSON Ledger API to become ready;
+4. creates one unrestricted user only inside this unauthenticated local
+ sandbox, then uses three parties: the bootstrap operator/admin/LP-registrar,
+ a distinct LP/trader, and a distinct swapper;
+5. uploads the current trading DAR selected by `trading/daml.yaml`; that DAR
+ carries its Token Standard dependency closure;
+6. creates real registry, holding, pool, slice, and LP-policy state;
+7. executes add liquidity → quote-bound swap → half-LP removal through the
+ JSON Ledger API;
+8. checks balances, exact reserves, reserve-slice reconciliation after every
+ phase, LP holding/supply/policy consistency, `x*y` nondecrease,
+ reserve-per-LP, and total value conservation;
+9. stops Canton and removes the temporary state after a pass (logs are kept on
+ failure).
+
+The visible phases include:
+
+```text
+==> Installing the pinned SDK and building the DEX
+==> Starting throwaway Canton sandbox on reserved loopback ports
+==> Uploading the package closure
+==> Running the live-Canton DvP proof
+==> PASS: portable live-Canton proof completed
+ The throwaway sandbox is now stopping; no persistent ledger state remains.
+```
+
+If a phase fails, the script exits non-zero and prints the preserved temporary
+directory containing `canton.log` and `canton.stdout.log`.
+
+### What this live proof establishes
+
+This is the first local mode that starts Canton and submits through the real
+JSON Ledger API. A pass establishes that the current package closure uploads
+and that real V2 holdings move atomically across an add, a snapshot-bound swap,
+and a partial LP redemption. The assertions cover both accounting state and
+its backing slices, not merely successful command submission.
+
+The LP/trader and swapper are separate from the operator and from each other.
+Operator, asset admin, and LP registrar deliberately share the bootstrap
+party; one unrestricted sandbox-only user can act for all three parties.
+Authentication is disabled and its bearer value is a non-secret placeholder.
+The proof therefore does **not** establish:
+
+- production-grade separation of operator, admin, and registrar credentials;
+- the operator HTTP server or React browser path;
+- a CIP-0103, PartyLayer, WalletConnect, or other external wallet;
+- a persistent Splice LocalNet or multi-participant topology;
+- production identity, security, operations, governance, or compliance.
+
+Read [Local Canton from a clean clone](guides/localnet.md) for every phase and
+failure mode. That guide also documents an **optional** persistent DevKit
+LocalNet. `canton-devkit` is a separately distributed development helper; the
+DEX code and DARs have no runtime dependency on it. If it is not already
+available in your environment, use the DPM sandbox path.
+
+### From live proof to live browser integration
+
+A real browser settlement is a larger deployment. It additionally needs full
+Canton party IDs and separated ledger rights, long-lived registry and market
+state, backend package/contract configuration, credentials, explicit CORS, and
+a compatible wallet that returns enough correlation data for settlement.
+Continue with:
+
+- [Run on a testnet](guides/run-on-testnet.md) — participant-backed backend and
+ wallet configuration.
+- [Deployment](guides/deployment.md) — backend/Docker environment and bootstrap
+ options.
+- [Validator test plan](guides/validator-test-plan.md) — validate the configured
+ live system rather than assuming it works.
+- [Testing reference](reference/testing.md) — the proof boundary of every test
+ layer and live driver.
+
+Do not call the browser path complete until a real trader party's pre-trade and
+post-trade holdings differ by the expected amounts and the corresponding Canton
+transaction is visible to the authorized parties.
+
+## Repository map for a first code read
+
+| Path | Read it for | Skip on the first pass |
|---|---|---|
-| Daml core | `bash scripts/run-local-daml-tests.sh` | every script reports `ok` |
-| Backend | `cd services/operator-backend && npm run typecheck && npm test` | clean |
-| dApp | `cd app/web && npm test` | clean |
-| End-to-end (in-memory) | `bash scripts/e2e-smoke.sh` | green |
-
-## Optional: run against a real Canton ledger
-The dev backend is in-memory. To run on real Canton:
-- **LocalNet**: a self-contained Canton + Splice network on one host; build the
- DAR, upload it + the V2 DARs, seed a pair/pool, point the backend at the
- participant (`CANTON_LEDGER_URL`), and run `npm run start`. See
- `docs/guides/deployment.md`.
-- **Testnet**: `scripts/deploy-testnet.sh` uploads the DAR + seeds; record the
- vetted package id + seed CIDs in `docs/guides/run-on-testnet.md`.
-
----
+| `trading/CantonDex/Dex/` | DEX templates and choices: pair, pool, swap, liquidity, order, RFQ | registry internals |
+| `trading/CantonDex/Registry/V2.daml` | Reference holdings, allocations, and batch settlement | detailed choice context until the workflow is clear |
+| `trading-tests/CantonDex/Tests/` | Executable examples and invariants | boilerplate fixtures; start from the named tests in the AMM tutorial |
+| `services/operator-backend/src/` | HTTP orchestration, matcher, ledger adapters | production recovery on the first pass |
+| `app/web/src/` | Pages, wallet intents, and API calls | individual wallet-provider implementations |
+| `vendor/splice/dars/` | Pinned binary Token Standard dependencies | do not try to learn Daml from binary DARs |
+
+The [Canton and Daml primer](concepts/canton-daml-primer.md) explains how these
+layers meet. The [glossary](concepts/glossary.md) is the lookup page for names
+encountered in code.
+
+## Component checks
+
+These checks are useful after the first preview. They are independent; none of
+them turns Mode 1 into a real Canton settlement.
+
+| Component | Command from repository root | Success signal | Limitation |
+|---|---|---|---|
+| Daml | `bash scripts/run-local-daml-tests.sh` | every Daml test is `ok`; exit 0 | Script runner, not a participant |
+| Live Canton | `bash scripts/run-dpm-sandbox-proof.sh` | ends with `==> PASS: portable live-Canton proof completed`; exit 0 | unrestricted throwaway user and direct JSON API driver; no browser, external wallet, or operator HTTP server |
+| Backend | `cd services/operator-backend && npm run typecheck && npm test` | TypeScript exits cleanly; TAP ends with `# fail 0` | mocked/in-memory ledgers unless live tests are explicitly configured |
+| dApp | `cd app/web && npm test && npm run build` | Vitest reports all tests passed; Vite writes `dist/` | mocked browser/API environment |
+| HTTP smoke | `bash scripts/backend-http-smoke.sh` | ends with `==> All backend HTTP smoke checks passed` | selected reads, quote, and auth gate only; no browser, wallet, or settlement |
+
+Run `npm ci` in the backend and dApp directories before their component checks.
+The HTTP smoke script expects backend dependencies to be installed already;
+the DPM sandbox proof installs them itself when its `tsx` runner is absent.
## Troubleshooting
-| Symptom | Fix |
+
+| Symptom | Meaning and fix |
|---|---|
-| backend `/v1/*` → "method not allowed" | Docker owns `:8080`; use `PORT=8091 npm run dev` + `VITE_API_BASE=http://127.0.0.1:8091` |
-| dApp can’t reach backend (CORS) | start backend with `ALLOWED_ORIGINS=http://localhost:5173` |
-| dev relay wallet needs a party | set `VITE_CANTON_DEFAULT_PARTY=trader-demo` (dev only) |
-| `dpm: command not found` | install DPM (see prerequisites link) and re-open the shell |
-| stale `node_modules` after branch switch | `rm -rf node_modules && npm ci` |
-
-See also: [Overview](concepts/overview.md), [Architecture](concepts/architecture.md),
-[Workflows](concepts/workflows.md), the [Builder Guide](guides/builder-guide.md)
-workflow tour, [Operator Runbook](guides/operator-runbook.md), and the full
-[documentation index](README.md).
+| Browser says network/CORS error, but `curl` works | Restart the backend with `ALLOWED_ORIGINS=http://localhost:5173`; use the exact origin Vite printed. |
+| A write returns `401` | Expected in the default preview. Use `DEX_DEV_OPEN=1` only if you intentionally want the local write-orchestration preview. |
+| A flow returns `501 not_supported` | Expected when it needs an allocation-backed Daml choice absent from the TypeScript in-memory ledger. Use Mode 2 to prove the contract or Mode 3 for a real-ledger proof. |
+| A result contains `#mock-…:0` | It came from Mock Wallet; it is not a Canton contract ID and proves no submission occurred. |
+| `/v1/*` says “method not allowed” on port 8080 | Another process, often Docker, owns the port. Start the backend with `PORT=8091 ALLOWED_ORIGINS=http://localhost:5173 npm run dev`, then set `VITE_API_BASE=http://127.0.0.1:8091` in `app/web/.env.local` and restart Vite. |
+| `dpm: command not found` | Install DPM using the prerequisites link, then open a new shell. |
+| DPM cannot find SDK 3.5.2 | Run `dpm install 3.5.2`, then retry from a directory containing the relevant `daml.yaml`. |
+| The DPM sandbox proof fails | Use the preserved log directory printed by the script; check Java 17, local memory, and port-binding errors. |
+| Native npm dependency fails to install | Confirm Node 24 is active, remove only that component's `node_modules`, and rerun `npm ci` in the same component. |
+
+**Next canonical step:** [AMM-first walkthrough](tutorials/amm-first-walkthrough.md).
+Use the [testing reference](reference/testing.md) when you need the complete
+proof matrix, or return to [all documentation](README.md).
diff --git a/docs/guides/add-a-trading-pair.md b/docs/guides/add-a-trading-pair.md
index f3ce958c..0dcd9840 100644
--- a/docs/guides/add-a-trading-pair.md
+++ b/docs/guides/add-a-trading-pair.md
@@ -51,6 +51,7 @@ Operator-signed, submitted by the operator backend:
```bash
curl -X POST http://localhost:8080/v1/admin/pairs \
+ -H "Authorization: Bearer $OPERATOR_ADMIN_TOKEN" \
-H 'Content-Type: application/json' \
-d '{
"baseInstrumentId": "ETH",
@@ -90,6 +91,7 @@ needs:
```bash
curl -X POST http://localhost:8080/v1/admin/pools \
+ -H "Authorization: Bearer $OPERATOR_ADMIN_TOKEN" \
-H 'Content-Type: application/json' \
-d '{
"baseInstrumentId": "ETH",
@@ -132,9 +134,11 @@ add-liquidity DvP used for every later deposit:
## Step 4 — Surface and 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`.
+The dApp's `/v1/pairs` returns the new listing and the Pools page shows the pool
+once it is seeded. The current Trade page is pool-driven: it reads active pools
+and does not filter them through `DexPair.active` or `tradingMode`. Treat those
+fields as discovery metadata unless your application adds an off-ledger filter
+or an on-ledger terminal-choice gate.
```bash
curl -s http://localhost:8080/v1/pairs | jq '.[] | select(.baseInstrumentId=="ETH")'
diff --git a/docs/guides/builder-guide.md b/docs/guides/builder-guide.md
index 4180e085..2dc9505e 100644
--- a/docs/guides/builder-guide.md
+++ b/docs/guides/builder-guide.md
@@ -1,9 +1,11 @@
# Builder guide
-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).
+This is Step 9, the final step in the
+[canonical newcomer learning path](../README.md#canonical-newcomer-learning-path).
+Complete [Make your first AMM code change](../tutorials/make-your-first-amm-change.md)
+first. This guide helps you plan a behavior-changing extension without
+crossing the DEX, Token Standard, registry, backend, or wallet boundaries by
+accident.
## Three layers, one boundary
@@ -57,23 +59,32 @@ oracle integration, custody, and a compliance/KYC layer. Those belong in forks o
deployment-specific services, not the shared templates. See
[Non-goals](../concepts/non-goals.md).
+## Before extending the AMM
+
+Do not start a second learning route here. Follow the canonical path through
+the tested first-change tutorial, then use the
+[Daml proof map — AMM pool](../reference/daml-proof-map.md#amm-pool) to locate
+the exact source choice and smallest proof for the behavior you plan to alter.
+The [allocation surface](../reference/allocation-surface.md) is the lookup page
+for the Token Standard contracts beneath those choices.
+
## 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.
+The Daml test suite exercises four families. Treat the sections below as a
+builder's lookup map; the newcomer curriculum remains the canonical path in the
+documentation index.
### 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.
+ mode (`OrderBook` / `Pool` / `Both`), and an `active` flag. The mode and flag
+ guide off-ledger discovery/routing; they are not fetched by `PoolRules` or
+ `OrderMatchExecution` and therefore are not on-ledger settlement gates.
- `Registry/V2.daml` — the reference registry's V2 interfaces plus its
registry-specific `InstrumentConfig` (precision, supply bookkeeping,
placeholder requirement records, optional ISIN/CUSIP).
-- Proven by
- [`RegistryConservationTests.daml`](../../trading-tests/CantonDex/Tests/RegistryConservationTests.daml)
- and [`PoolLiquidityRulesTests.daml`](../../trading-tests/CantonDex/Tests/PoolLiquidityRulesTests.daml).
+- Source and focused checks: [Daml proof map — Pair listing metadata](../reference/daml-proof-map.md#pair-listing-metadata).
### B. OTC and RFQ settlement
A bilateral block trade settles as one atomic batch.
@@ -84,9 +95,7 @@ A bilateral block trade settles as one atomic batch.
- `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`).
+- Source and focused checks: [Daml proof map — RFQ and OTC](../reference/daml-proof-map.md#rfq-and-otc).
### C. Resting orders backed by a V2 allocation
A limit order rests in the book, funded by the trader's own locked allocation.
@@ -99,9 +108,7 @@ A limit order rests in the book, funded by the trader's own locked allocation.
uncommitted, allowing the trader to withdraw through the standard allocation
interface if the venue is unavailable; a later match then fails safely.
- `Dex/OrderMatchExecution.daml` — the atomic match (see the matcher section below).
-- Proven by
- [`EndToEndTests.daml`](../../trading-tests/CantonDex/Tests/EndToEndTests.daml)
- (`testOrderFundingFlow`, `testOrderRemainderFundingArithmetic`).
+- Source and focused checks: [Daml proof map — Resting orders](../reference/daml-proof-map.md#resting-orders).
### D. Constant-product pool
An AMM whose reserves are committed allocations.
@@ -116,10 +123,7 @@ An AMM whose reserves are committed allocations.
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).
+- Source and focused checks: [Daml proof map — AMM pool](../reference/daml-proof-map.md#amm-pool).
## The off-ledger matcher: where a fork does most of its work
@@ -159,8 +163,9 @@ go through the connected wallet over the CIP-0103 dApp standard
specifications, the dApp composes the command, and the wallet signs and submits.
`Rfq_Accept` is jointly controlled by trader and operator; deployments must
provide both authorities through wallet/delegation or an explicitly enabled
-co-submission path. The included RFQ page uses the last option for hosted demo
-parties; it is not part of the wallet-intent surface.
+co-submission path. The included RFQ page demonstrates the last option with
+configured parties; it is not part of the wallet-intent surface or a public
+relay service.
Read endpoints (`/v1/pools`, `/v1/trades`, …) are operator-observed and served from
the backend's indexer cache. Keep self-custodial allocation writes on the wallet
@@ -180,9 +185,9 @@ named allocation in one transaction. Deploy that DAR alongside the DEX DAR.
|---|---|
| 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). |
+| Use a different registry | Keep the DEX services behind `registry-client`, then configure discovery, choice context, disclosures, and metadata for the target registry. `CantonDex.Testing.MockRegistry` appears only in Daml test fixtures and is not the deployed backend. See [Registry integration](registry-integration.md). |
| Add a pricing curve (StableSwap, weighted) | Add curve-specific configuration and rules, then reuse the V2 allocation and settlement pattern. No generic curve interface is defined by this package. |
-| Add a fee policy | Extend `Pool.feeBps` / `DexPair.feeModel` and the `constantProductOut` quote math. |
+| Change the executable pool fee | Update `Pool.feeBps` and the `constantProductOut` quote math. Mirror the value into `DexPair.feeModel` only where off-ledger listing consumers need it; that record does not gate or price `PoolRules_Swap`. |
| Add an RFQ policy (oracle-weighted, multi-tier) | `Rfq.policyCmp` defines the ordering used by `applyPolicyPairs`; 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). |
@@ -192,21 +197,14 @@ contracts own asset semantics.
## Your first change
-A concrete loop for extending a choice — say, adding an optional referral party
-to a swap:
-
-1. **Edit the Daml.** Append an `Optional` field (e.g. `referral : Optional
- Party`) to `Pool`, or add a clearly named referral choice to `PoolRules` — see
- [Upgrade discipline](#upgrade-discipline) for why additions go at the end of
- the record.
-2. **Build the DAR:** `(cd trading && dpm build)`.
-3. **Run the tests:** `(cd trading-tests && dpm test)`. The suite includes
- `EndToEndTests.daml::testPoolSwapEndToEnd`, which exercises the full swap path
- your change touches.
-4. **Verify the boundary:** run `bash scripts/run-local-daml-tests.sh`, then
- exercise the affected HTTP and wallet path. A separate package can consume
- the DAR as a data dependency, but this repository does not claim a generic
- pool interface that makes curve implementations interchangeable.
+Use [Make your first AMM code change](../tutorials/make-your-first-amm-change.md)
+for the complete red/green loop: exact edits, focused test, layer-impact check,
+full local suite, and live sandbox proof.
+
+For later changes, distinguish a new choice from a new template field. A new
+choice can leave existing contract construction sites intact. A new field
+changes the serialized template shape and every construction site must supply
+it; follow [Upgrade discipline](#upgrade-discipline) before making that edit.
## Upgrade discipline
@@ -220,14 +218,20 @@ fresh lineage.
## Testing
```bash
-cd trading-tests && dpm test # in-script Daml suites
+cd trading-tests
+dpm test # every in-script Daml suite
+dpm test -p testDexPairLifecycleUpdates # one named design proof
+dpm test --files CantonDex/Tests/LifecycleChoiceTests.daml
```
-The commands and expected outcomes are in [Getting Started](../getting-started.md).
+Use `-p ` while reading one workflow, then run the whole suite before
+handoff. Exact source/test links and focused commands are in the
+[Daml proof map](../reference/daml-proof-map.md); broader commands and expected
+outcomes are in [Getting Started](../getting-started.md).
Testnet smoke test:
```bash
-node --import tsx scripts/testnet-v2registry-trade.ts # real V2-standard trade
+npm --prefix services/operator-backend run live:matched-trade # real V2-standard trade
```
Keep deployment-specific responsibilities outside the reference core — custody,
@@ -278,6 +282,9 @@ app/web/
## Where to read next
-- **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)
+You have completed the canonical newcomer path. Choose the task that matches
+your extension:
+
+- **Reference:** [HTTP API](../reference/http-api.md) · [Allocation surface](../reference/allocation-surface.md) · [Daml proof map](../reference/daml-proof-map.md)
+- **Deeper design:** [Liquidity and custody](../concepts/liquidity-and-custody.md) · [Pricing](../concepts/pricing.md) · [Non-goals](../concepts/non-goals.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 645d866e..869fa9e4 100644
--- a/docs/guides/choice-context.md
+++ b/docs/guides/choice-context.md
@@ -1,245 +1,346 @@
-# Choice context and disclosure retrieval
-
-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`. |
+# Choice context and registry discovery
+
+This guide explains how the DEX discovers a Token Standard V2 factory, obtains
+the context required for one specific choice, and supplies disclosed contracts
+to Canton. Read [Registry integration](registry-integration.md) first if the
+registry boundary is new to you.
+
+The important rule is:
+
+> A registry lookup belongs to one concrete operation. Send that operation's
+> choice arguments, use the returned factory and context for that operation,
+> and do not reuse the response for a later choice.
+
+The repository follows the operation-specific V2 OpenAPI committed under
+[`vendor/splice/token-standard`](../../vendor/splice/token-standard/). It does
+not invent admin-wide generic factory or context endpoints.
+
+## 1. The problem in one picture
+
+The operator knows what it wants to settle, but it does not own the asset
+registry. The registry may require configuration, permissions, or credential
+contracts that the operator cannot see.
```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
+sequenceDiagram
+ participant App as dApp or operator
+ participant Daml as Daml preview choice
+ participant Registry as Registry V2 HTTP API
+ participant Canton as Canton participant
+
+ App->>Daml: Build the exact candidate choice argument
+ Daml-->>App: SettlementFactory_SettleBatch argument
+ App->>Registry: POST { choiceArguments }
+ Registry-->>App: factoryId + choiceContext + disclosedContracts
+ App->>Canton: Exercise with factory/context + disclosures
+ Canton->>Canton: Revalidate current contracts and settle atomically
```
-## Where the riders are assembled
+Allocation creation is slightly different: the dApp already has the allocation
+specification, selected holding CIDs, timestamp, and actors, so it constructs
+the candidate `AllocationFactory_Allocate` argument directly. Settlement flows
+use a Daml preview because Daml, not TypeScript, owns the authoritative batch.
-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:
+## 2. Three values that must stay together
+
+One factory lookup returns a normalized
+[`FactoryChoiceContextRef`](../../services/registry-client/src/types.ts):
```typescript
-export async function fetchChoiceContext(
- registry: RegistryClient,
- admin: Party,
-): Promise {
- const ctx = await registry.getChoiceContext(admin);
+{
+ factoryCid,
+ context: { values: { /* registry-defined */ } },
+ disclosure: [ /* created-event blobs */ ]
+}
+```
+
+| Value | Where it goes | Why it is needed |
+|---|---|---|
+| `factoryCid` | The Daml factory choice | Selects the registry contract that implements allocate or settle. |
+| `context.values` | `choiceArgument.extraArgs.context` | Carries registry-defined data for this operation. |
+| `disclosure` | The Ledger API submission's `disclosedContracts` | Makes otherwise invisible contracts available for transaction validation. |
+
+The small
+[`asChoiceContext`](../../services/operator-backend/src/ledger/choice-context.ts)
+helper only converts the normalized response into Daml's `ExtraArgs` shape:
+
+```typescript
+export function asChoiceContext(ctx: ChoiceContextRef) {
return {
- extraArgs: { context: ctx.context, meta: { values: {} } },
+ 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`):
+Discovery remains at each call site. That makes it difficult to accidentally
+ask for context without the operation's exact arguments.
+
+## 3. Canonical V2 HTTP endpoints
+
+The client is
+[`services/registry-client/src/index.ts`](../../services/registry-client/src/index.ts).
+Its source OpenAPI files are
+[`allocation-instruction-v2.yaml`](../../vendor/splice/token-standard/splice-api-token-allocation-instruction-v2/openapi/allocation-instruction-v2.yaml)
+and
+[`allocation-v2.yaml`](../../vendor/splice/token-standard/splice-api-token-allocation-v2/openapi/allocation-v2.yaml).
+
+| Operation | Method and path | Request body |
+|---|---|---|
+| Find an allocation factory | `POST /registry/allocation-instruction/v2/allocation-factory` | `{ "choiceArguments": }` |
+| Find a settlement factory | `POST /registry/allocation/v2/settlement-factory` | `{ "choiceArguments": }` |
+| Cancel one allocation | `POST /registry/allocations/v2/{allocationId}/choice-contexts/cancel` | `{ "meta": { ... } }` |
+| Withdraw one allocation | `POST /registry/allocations/v2/{allocationId}/choice-contexts/withdraw` | `{ "meta": { ... } }` |
+
+The factory endpoints return the upstream wire shape:
+
+```json
+{
+ "factoryId": "#factory-cid",
+ "choiceContext": {
+ "choiceContextData": { "values": {} },
+ "disclosedContracts": []
+ }
+}
+```
+
+The registry client validates this untrusted response and normalizes
+`factoryId`, `choiceContextData`, and `disclosedContracts`. A bare TypeScript
+cast is not used.
+
+### Why responses are not cached
+
+Choice context may depend on the exact allocation, holdings, actors, deadline,
+or current registry state. Two calls with the same admin are not evidence that
+the second operation can reuse the first response. The HTTP client performs a
+fresh lookup for every operation.
+
+There is also no 404-to-empty fallback. A missing canonical endpoint is an
+integration error; silently inserting empty context could turn a registry
+policy failure into a confusing ledger rejection.
+
+## 4. Allocation creation: dApp to registry to wallet
+
+For a swap, order, or liquidity request, the operator first returns settlement
+terms and an allocation specification. The wallet chooses the holdings it will
+lock. The dApp then builds the candidate allocation choice:
```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 },
- },
+const choiceArguments = {
+ settlement,
+ allocation,
+ requestedAt,
+ inputHoldingCids,
+ actors,
+ extraArgs: EMPTY_EXTRA_ARGS,
+};
+
+const surface = await operator.getAllocationFactory({
+ admin: allocation.admin,
+ choiceArguments,
});
```
-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)):
+The dApp calls the backend proxy
+`POST /v1/registry/allocation-factory`. The proxy passes the same
+`choiceArguments` to `RegistryDiscovery.getAllocationFactory`; it does not
+reconstruct or simplify them. The returned context replaces the empty
+placeholder when the wallet authors the actual
+`AllocationFactory_Allocate` command.
+
+```mermaid
+flowchart LR
+ R["Operator returns settlement + allocation spec"]
+ W["Wallet selects input holdings"]
+ A["dApp builds complete Allocate candidate"]
+ P["DEX backend proxy"]
+ G["Registry allocation-factory endpoint"]
+ S["Wallet signs and submits Allocate"]
+ R --> W --> A --> P --> G --> P --> S
+```
+
+The trader, not the operator, authorizes the wallet submission. The backend
+proxy discovers data; it does not grant trader authority.
+
+**Code:** [`app/web/src/services/ledger.ts`](../../app/web/src/services/ledger.ts)
+and
+[`services/operator-backend/src/http/index.ts`](../../services/operator-backend/src/http/index.ts).
+
+## 5. Settlement: preview, discover, execute
+
+Settlement arguments contain exact transfer legs and allocation CIDs. Building
+them independently in TypeScript would duplicate security-sensitive Daml
+logic. Each supported settlement flow obtains the candidate
+`SettlementFactory_SettleBatch` argument from Daml before querying the registry.
+
+### Pool swap
+
+1. `PoolRules_PreviewSwapSettlement` reads the current pool and returns the
+ candidate settlement batch.
+2. The backend calls `getSettlementFactory(pool.admin, previewResult)`.
+3. `PoolRules_Swap` receives that factory, its context, and disclosures.
+4. The real choice re-reads current state and enforces quote binding,
+ constant-product calculation, allocation binding, and minimum output.
+
+**Code:** [`PoolRules.daml`](../../trading/CantonDex/Dex/PoolRules.daml) and
+[`pool/index.ts`](../../services/operator-backend/src/pool/index.ts).
+
+### Matched trade
+
+`MatchedTrade_PreviewSettlement` returns one exact batch argument per registry
+admin. The backend performs one settlement-factory lookup per admin, keeps each
+context with its own batch, merges disclosures by contract ID, and exercises
+`MatchedTrade_Settle`.
+
+**Code:** [`MatchedTrade.daml`](../../trading/CantonDex/Dex/MatchedTrade.daml)
+and
+[`matched-trade/index.ts`](../../services/operator-backend/src/matched-trade/index.ts).
+
+### Order match
+
+The backend create-and-exercises an ephemeral
+`OrderMatchExecution_PreviewSettlement` wrapper. That value-free transaction
+leaves no active wrapper contract. It then performs registry discovery and
+create-and-exercises a fresh `OrderMatchExecution_Execute` wrapper.
+
+The execute choice does not trust the earlier preview: it revalidates the live
+orders and allocations, settles both funding allocations, rolls forward any
+remainders, and records the trade in one value-moving transaction.
+
+**Code:**
+[`OrderMatchExecution.daml`](../../trading/CantonDex/Dex/OrderMatchExecution.daml)
+and [`order/index.ts`](../../services/operator-backend/src/order/index.ts).
+
+## 6. Cancellation and withdrawal are allocation-specific
+
+Cancel and withdraw context is queried with an allocation ID:
```typescript
-disclosedContracts: req.disclosure ?? [],
+const context = await registry.getAllocationCancelContext(
+ admin,
+ allocationCid,
+);
+```
+
+A matched trade with three allocations performs three lookups, even if two
+allocations have the same admin. The resulting `ExtraArgs` values remain paired
+with their allocation CIDs. Treating context as one cached value per admin
+would lose that binding.
+
+The order cancellation path performs the same lookup for its funding
+allocation. When a pending order has no allocation, no registry allocation is
+being cancelled, so empty `ExtraArgs` is sufficient for the app choice.
+
+## 7. Atomic add/remove liquidity boundary
+
+This is the one workflow where the standard HTTP preflight cannot be performed
+with exact arguments in the current design.
+
+`PoolLiquidityRules_SettleAddLiquidity` and
+`PoolLiquidityRules_SettleRemoveLiquidity` create operator/registrar
+allocations and immediately settle them inside the same Daml transaction. Their
+contract IDs do not exist before that transaction. The standard settlement
+factory endpoint expects the candidate `SettleBatch` argument, including those
+allocation IDs.
+
+```mermaid
+flowchart TD
+ Q["HTTP preflight needs future allocation CIDs"]
+ T["Atomic Daml transaction creates those CIDs"]
+ Q -. "CIDs do not exist yet" .-> T
+ T --> C["Create temporary allocations"]
+ C --> S["Settle them immediately"]
```
-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 disclosures needed by the single transaction.
-Context selection is keyed by admin, never by list position, so every batch
-receives its own registry context. Disclosed contracts are transaction-wide,
-deduplicated by contract id, and have no positional settlement meaning (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`, includes every required disclosure exactly once, and does
-not assign meaning to disclosure-array order; and
-[`pool.test.ts`](../../services/operator-backend/test/pool.test.ts) — split-admin
-add and remove map distinct pool-admin and LP-registrar contexts to the matching
-choice fields while deduplicating their shared disclosure.
-
-## Endpoints the operator queries
-
-Per registry, the operator backend fetches:
-
-| Example lookup | Returns | Used by |
-|--------------------------------------------|-------------------------------------------|---------|
-| `GET /registry/factories/:admin` | `(AllocationFactory, SettlementFactory)` CIDs + disclosure | `PoolRules_Swap`, matched-trade settle |
-| `GET /registry/choice-context/:admin` | `ChoiceContextRef` (`context` + disclosure) | Pool, MatchedTrade, any registry-touching token-standard choice |
-
-These endpoints are examples for this reference implementation. A production
-registry may use different paths, payloads, or discovery mechanisms as long as
-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.
-
-## Disclosure retrieval and caching
-
-The `registry-client` owns two caches:
-
-1. Allocation/Settlement factory CIDs (plus disclosure) per admin. Stale on admin
- re-publish, when the registry archives + recreates.
-2. Choice-context refs per admin, honouring `choiceContextTtlMs` when configured.
-
-The factory cache holds entries until it is flushed. The client exposes
-`invalidateAll()` for a full flush after a known factory archive or re-publish.
-There is no registry-side event stream driving invalidation, so an integration
-must explicitly flush the cache when its registry republishes factories.
-
-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 |
+The repository handles this limitation explicitly:
+
+- `FixedRegistryClient` supports the configured reference self-registry. Its
+ factory CIDs are deployed with the operator, its context is empty, and its
+ required disclosures are known before the transaction.
+- The generic HTTP `RegistryClient` throws
+ `RegistryError("unsupported", ...)` before an add/remove settlement is
+ submitted. It does not send placeholder CIDs and does not pretend a 404 means
+ empty context.
+- A context-requiring external registry needs a workflow redesign for atomic
+ liquidity settlement. One option is a recoverable prepare-then-settle
+ protocol with explicit expiry, cancellation, idempotency, and cleanup. An
+ interactive transaction-authoring design is another possibility if the
+ selected Canton/wallet stack can supply registry data at the correct stage.
+ Either approach changes the protocol and must be threat-modelled; it is not a
+ configuration switch in this reference.
+
+This limitation applies to the backend's **atomic add/remove settlement
+integration**, not to allocation discovery, swaps, matched trades, order
+matches, or allocation cancellation.
+
+The Daml tests against a context-requiring registry prove that the Daml choices
+thread context correctly when it is supplied. They do not manufacture a way
+for an HTTP client to know future contract IDs.
+
+## 8. Disclosure handling
+
+The backend passes normalized disclosure to the JSON Ledger API as
+`disclosedContracts`. When a transaction has several registry operations,
+[`mergeDisclosures`](../../services/operator-backend/src/ledger/disclosure.ts)
+deduplicates identical entries by contract ID. It rejects two different
+payloads claiming the same contract ID.
+
+Disclosure is transaction-wide. Its array position has no relationship to a
+settlement batch; batch-to-context association stays in the choice argument.
+
+## 9. Failure behavior
+
+The client raises a typed `RegistryError` and fails closed:
+
+| Kind | Meaning | Expected response |
+|---|---|---|
+| `not-found` | A canonical endpoint returned 404 | Fix registry routing/deployment; do not submit empty context. |
+| `auth` | Registry returned 401 or 403 | Refresh or correct registry credentials. |
+| `transport` | Other non-success HTTP response | Retry only according to operator policy; the error is marked retryable. |
+| `malformed` | JSON or response shape is invalid | Treat the registry response as untrusted and stop. |
+| `factory-stale` | A fixed registry has no mapping for the admin | Correct the deployment's per-admin factory map. |
+| `unsupported` | Standards-correct discovery is impossible for this workflow | Redesign or use the documented self-registry path; never substitute placeholders. |
+
+## 10. Executable proofs
+
+| Question | Proof |
|---|---|
-| Factory CID stale | Refetch from `factories/:admin`; backoff on repeated failures |
-| Choice-context disclosure stale | Flush the registry client, refetch, and retry once |
-| Settlement batch rejected by factory | Cancel the trade, surface to operator monitoring |
-
-The `registry-client` module raises a typed `RegistryError` — with a `kind`
-(`factory-stale`, `auth`, `transport`, or `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
-
-`V2.AllocationFactory.AllocationFactory_Allocate`
-
-Required inputs:
-- `actors : [Party]` — the trader (for prefunded order or trade
- allocation) or operator (for committed pool-fund allocation).
-- `allocation : V2.AllocationSpecification` — with `admin` set
- correctly; `nextIterationFunding` for prefunded shapes; `committed =
- True` for pool-fund shapes.
-- `requestedAt : Time` — current ledger time (operator passes through
- from the request).
-- `inputHoldingCids : [ContractId V2.Holding]` — chosen by the
- trader's wallet from their ACS to cover the funding amount.
-- `extraArgs.context` — registry-specific context (typically empty for
- test registries; production may carry credential proofs or rate
- limits).
-
-### Allocation request acceptance
-
-`V2.AllocationRequest_Accept` (on `TradeAllocationRequest` or
-`OrderAllocationRequest`)
-
-Required inputs:
-- `actors : [Party]` — typically `[trader]`. Operator can also accept if
- the implementation allows.
-- `extraArgs.context` — empty for the reference self-registry; production
- registries may require their own context fields.
-
-The wallet composes this with `AllocationFactory_Allocate` in the
-same submission to avoid creating duplicate allocations.
-
-### Settlement
-
-`V2.SettlementFactory.SettlementFactory_SettleBatch`
-
-Required inputs:
-- `settlement : V2.SettlementInfo` — exactly the
- `mkTradeSettlementInfo` output (or `poolSettlement`).
-- `transferLegs : [V2.TransferLeg]` — the legs being settled, in the
- order the allocations expect.
-- `allocations : [V2.FinalizedAllocation]` — every allocation whose
- authorizer participates in the legs. For iterated settlement, each
- finalized allocation carries any settlement-time
- `extraTransferLegSides` and the desired `nextIterationFunding`.
-- `actors : [Party]` — `[venue/operator]`.
-- `extraArgs.context` — registry-supplied choice context for the
- allocation admin. Self-registries may return empty context.
-
-### Iterated settlement
-
-`V2.FinalizedAllocation.extraTransferLegSides` and
-`V2.FinalizedAllocation.nextIterationFunding` on
-`SettlementFactory_SettleBatch`.
-
-Required inputs:
-- `extraTransferLegSides` — concrete settlement leg-sides supplied by
- the app choice once the trade or pool action is known.
-- `nextIterationFunding` — `Some` when the settlement should create a
- next-iteration allocation for remaining pool/order funding; `None`
- when the allocation terminates at this settlement.
-- `extraArgs.context` — registry-supplied choice context for the
- settlement admin. Self-registries may return empty context.
-
-### Registry administration is separate
-
-The DEX does not mint, burn, or transfer base/quote holdings through custom app
-choices. The reference registry's `Registry_Mint` and `Registry_Burn` choices
-are bootstrap/admin utilities; peer-to-peer transfers use the standard
-`V2.TransferFactory` and `V2.TransferInstruction` interfaces. A different
-registry may require choice context for those operations, but that context is
-not part of a DEX settlement request.
+| Is the exact request body sent, normalized, and never cached? | [`registry-client.test.ts`](../../services/operator-backend/test/registry-client.test.ts) |
+| Does a two-admin trade keep preview arguments, contexts, and disclosures separate? | [`matched-trade.test.ts`](../../services/operator-backend/test/matched-trade.test.ts) |
+| Does order matching preview before one atomic value-moving execute? | [`match-leg-shape.test.ts`](../../services/operator-backend/test/match-leg-shape.test.ts) and [`order-fill-recording.test.ts`](../../services/operator-backend/test/order-fill-recording.test.ts) |
+| Is the fixed atomic-liquidity path explicit, and does generic HTTP discovery fail before submission? | [`pool.test.ts`](../../services/operator-backend/test/pool.test.ts) |
+| Are split-admin Daml contexts kept in their correct fields? | `testDvpSettleThreadsBothAdminContexts` in [`ChoiceContextWorkflowTests.daml`](../../trading-tests/CantonDex/Tests/ChoiceContextWorkflowTests.daml) |
+| Does a context-requiring registry reject missing context? | `testRealRegistryDvpRejectsMissingContext` in [`RealRegistryDvpTests.daml`](../../trading-tests/CantonDex/Tests/RealRegistryDvpTests.daml) |
+
+## Reference: Daml choice fields
+
+### `AllocationFactory_Allocate`
+
+- `settlement`: settlement identity and executors.
+- `allocation`: the exact allocation specification.
+- `requestedAt`: the operation timestamp.
+- `inputHoldingCids`: holdings selected by the wallet.
+- `actors`: parties authorizing allocation creation.
+- `extraArgs`: registry context returned for this operation.
+
+### `SettlementFactory_SettleBatch`
+
+- `settlement`: the settlement identity.
+- `transferLegs`: exact movements being settled.
+- `allocations`: finalized allocations, including extra leg sides and any
+ next-iteration funding.
+- `actors`: settlement executors.
+- `extraArgs`: registry context returned for this batch.
+
+The DEX does not use custom base/quote mint, burn, or balance choices during a
+trade. Issuance remains registry administration; the DEX composes allocation
+and settlement surfaces.
---
-**Where to read next:** [Registry Integration](registry-integration.md) · [Allocation Surface](../reference/allocation-surface.md) · [All docs](../README.md)
+**Where to read next:** [Registry integration](registry-integration.md) ·
+[Allocation surface](../reference/allocation-surface.md) ·
+[Daml proof map](../reference/daml-proof-map.md)
diff --git a/docs/guides/deployment.md b/docs/guides/deployment.md
index 6dd6a1bd..76c43d02 100644
--- a/docs/guides/deployment.md
+++ b/docs/guides/deployment.md
@@ -1,14 +1,23 @@
# Deployment guide
-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.
-
-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).
+Five ways to run the reference DEX, ordered by how much infrastructure you
+bring. **Local dev** is an in-memory UI/read-model demo; the **DPM sandbox** is
+the default reproducible live-ledger learning path; **DevKit LocalNet** is an
+optional persistent Splice environment; **Docker Compose** packages the edge
+in front of a remote participant; and **direct testnet** runs that backend
+under your own process supervisor. Pick the mode that proves the boundary you
+care about.
+
+In the packaged topology the participant credential is server-side; it is
+never compiled into the dApp. Use separate least-privilege credentials where
+your participant supports them: registry bootstrap needs the registry admins,
+while runtime pool administration/settlement needs the operator and LP
+registrar (plus the read rights described below). Trader allocations are
+authored by a wallet. The arbitrary token-standard command relay is
+development-only and is hard-disabled in `testnet-server.ts`. A narrower
+hosted-RFQ authority relay exists as an explicit opt-in for custodial demos; it
+is not self-custody and requires per-caller binding. See the
+[authorization boundaries](run-on-testnet.md#6-wallet-and-http-authorization-boundaries).
## 1. Local dev (no Canton)
@@ -36,7 +45,55 @@ 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
+## 2. DPM sandbox (default live Canton proof)
+
+This is the recommended learning and ledger-integration path. It requires the
+pinned DPM SDK and Java 17, but it does **not** require Canton DevKit, Docker, a
+pre-existing participant, or an external wallet:
+
+```bash
+bash scripts/run-dpm-sandbox-proof.sh
+```
+
+The wrapper builds the current DAR, starts a throwaway SDK sandbox on six
+reserved loopback ports, allocates a bootstrap operator/admin/LP-registrar
+party plus distinct LP/trader and swapper parties, uploads the package closure,
+and proves add liquidity → quote-bound
+swap → half-LP removal through the JSON Ledger API. It asserts exact balances,
+reserves, slice reconciliation, LP supply, `x*y`, reserve-per-LP, and total
+value conservation, then tears the sandbox down after a pass.
+
+This is a direct-ledger integration proof. It deliberately bypasses the
+operator HTTP server, React dApp, and wallet transport. See [Local Canton from
+a clean clone](localnet.md#path-a-portable-dpm-sandbox-proof) for the phase log,
+party model, failure artifacts, and exact proof boundary.
+
+## 3. DevKit LocalNet (optional persistent Canton)
+
+Use this only when your environment already provides the separately
+distributed `canton-devkit` executable and Docker. The adapter starts or reuses
+a named Splice/Canton LocalNet, maps its credential without printing the JWT,
+allocates distinct live roles when overrides are absent, builds/uploads the
+package closure, and runs the same DvP round trip:
+
+```bash
+bash scripts/run-localnet-roundtrip.sh canton-dex
+```
+
+The instance remains available for contract inspection. Stop its containers
+while preserving ledger volumes with:
+
+```bash
+canton-devkit localnet down --name canton-dex
+```
+
+DevKit is a network lifecycle and credential adapter here; neither the DEX
+application nor its DAR has a runtime dependency on it. See [Local Canton from
+a clean clone](localnet.md#path-b-optional-persistent-devkit-localnet) for the
+prerequisite check, role allocation, inspection commands, and destructive
+cleanup warning.
+
+## 4. Docker Compose
The packaged edge, for running against a remote Canton testnet or MainNet. Two
containers come up:
@@ -53,43 +110,56 @@ flowchart LR
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")]
+ A -->|"JSON Ledger API (configured operator/LP rights)"| P[("Canton participant CANTON_LEDGER_URL")]
```
-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.
+nginx is the only published ingress: Compose uses `expose: 8080` for the
+backend's private service-network port and publishes only frontend `:80`.
+Operator-API traffic takes the path above; production wallet calls use the
+selected wallet adapter rather than a participant token embedded in the
+browser.
```bash
cp services/operator-backend/.env.example .env
-# Edit .env: CANTON_LEDGER_URL, CANTON_LEDGER_TOKEN, party ids, synchronizer,
-# package id — see Environment variables below.
+# Edit .env: ledger URL/token, party ids, package/synchronizer ids, asset and
+# (when distinct) LP registry factory cids, and both HTTP write tokens.
+
+# Also add one production wallet configuration to .env, or export it for this
+# Compose invocation. Example:
+export VITE_ENABLE_PARTYLAYER=1
+export VITE_PARTYLAYER_NETWORK=canton:testnet
+export VITE_PARTYLAYER_WALLET_IDS=console,nightly,send
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:
+frontend's explicitly declared safe/public `VITE_*` build args. Rebuild the
+frontend to change them. HTTP API bearer tokens are backend runtime variables,
+never Vite build arguments. See [`docker-compose.yml`](../../docker-compose.yml)
+for the exact wiring. Persistent state lives in the `backend-data` volume (the
+SQLite indexer DB).
+
+The following command destroys the `backend-data` Docker volume, including the
+local index and idempotency records. It does not roll back Canton ledger state:
```bash
docker compose down -v && docker compose up -d
```
-## 3. Testnet deployment (no containers)
+## 5. 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.
+[`scripts/deploy-testnet.sh`](../../scripts/deploy-testnet.sh) runs only the
+phases it can prove: build DARs → upload the package closure → run the registry
+bootstrap. It does not allocate parties, start the backend, mint holdings, or
+fund a pool. Exact allocated party ids must already exist and the participant
+JWT must hold their rights.
```bash
export CANTON_LEDGER_URL=...
@@ -97,14 +167,27 @@ export CANTON_LEDGER_TOKEN=...
export CANTON_OPERATOR=...
export CANTON_LP_REGISTRAR=...
export CANTON_ADMIN=...
-export OPERATOR_ADMIN_TOKEN=... # for the seed step
+export CANTON_DEX_PACKAGE_ID=...
+
+bash scripts/deploy-testnet.sh
+```
+
+Each default stage is skippable once proved: `DEPLOY_SKIP_BUILD=1`,
+`DEPLOY_SKIP_UPLOAD=1`, `DEPLOY_SKIP_BOOTSTRAP=1`. The script stops on upload or
+bootstrap failure and prints no success line for a suppressed error.
+
+After starting the backend, opt into pair plus **unfunded** pool creation:
+```bash
+DEPLOY_SKIP_BUILD=1 \
+DEPLOY_SKIP_UPLOAD=1 \
+DEPLOY_SKIP_BOOTSTRAP=1 \
+DEPLOY_SEED_MARKETS=1 \
bash scripts/deploy-testnet.sh
```
-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.
+That phase requires `OPERATOR_ADMIN_TOKEN`, checks backend health first, and
+queries existing contracts before creating missing market metadata.
### Manual: run the backend
@@ -114,7 +197,7 @@ npm install
export CANTON_LEDGER_URL=...
export CANTON_LEDGER_TOKEN=...
# ... (see Environment variables below)
-npm start # runs testnet-server.ts
+npm run testnet # runs testnet-server.ts
```
The full walkthrough — smoke checks, package-hash alignment, and the PartyLayer
@@ -132,7 +215,10 @@ export CANTON_LEDGER_TOKEN=...
export CANTON_ADMIN=...
export CANTON_LP_REGISTRAR=...
export CANTON_OPERATOR=...
-node --import tsx scripts/bootstrap-registry.ts
+export CANTON_DEX_PACKAGE_ID=...
+
+cd services/operator-backend
+node --import tsx ../../scripts/bootstrap-registry.ts
```
The script is idempotent: running it twice is a no-op. See
@@ -144,20 +230,21 @@ 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`](../../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.
+A second registry under `CANTON_ADMIN` is always created when the admin differs
+from the LP registrar. The optional `registryV2` config block overrides its
+users and instrument list; otherwise the top-level `instruments` list is used.
+When both roles are the same party, bootstrap reuses the single registry.
+
+The testnet server has an explicit per-admin map for the two reference
+registrars. `CANTON_ALLOC_FACTORY_CID` / `CANTON_SETTLE_FACTORY_CID` identify
+the asset admin's registry. When `CANTON_LP_REGISTRAR != CANTON_ADMIN`, the
+separate `CANTON_LP_ALLOC_FACTORY_CID` /
+`CANTON_LP_SETTLE_FACTORY_CID` pair identifies the LP registry. In the
+reference `Registry.V2`, the same registry cid implements both interfaces, so
+the two values within each pair are equal. Full/write mode refuses to start if
+a required mapping is absent; explicit `DEX_READ_ONLY=1` may use display-only
+`PENDING_*` placeholders. A venue listing arbitrary third-party admins should
+replace this two-admin map with registry API discovery.
## Environment variables
@@ -166,42 +253,64 @@ 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:
+**Always required** — both full and intentional read-only modes exit at boot if
+any is missing:
| Var | Purpose |
|-----|---------|
| `CANTON_LEDGER_URL` | JSON Ledger API base URL |
-| `CANTON_LEDGER_TOKEN` | Bearer JWT for the participant (operator authority) |
+| `CANTON_LEDGER_TOKEN` | Server-side participant JWT with the read/actAs rights needed by the enabled runtime flows |
| `CANTON_OPERATOR` | Operator party id |
| `CANTON_LP_REGISTRAR` | LP registrar party id |
| `CANTON_ADMIN` | Asset admin party id |
+| `CANTON_DEX_PACKAGE_ID` | Vetted DEX package hash or package-name prefix used to qualify every template id |
+
+**Required in full/write mode:**
+
+| Var | Purpose |
+|-----|---------|
+| `CANTON_ALLOC_FACTORY_CID` | Asset-admin AllocationFactory cid |
+| `CANTON_SETTLE_FACTORY_CID` | Asset-admin SettlementFactory cid |
+| `CANTON_LP_ALLOC_FACTORY_CID` | LP-registry AllocationFactory cid when LP registrar differs from asset admin |
+| `CANTON_LP_SETTLE_FACTORY_CID` | LP-registry SettlementFactory cid when LP registrar differs from asset admin |
+| `OPERATOR_ADMIN_TOKEN` | Bearer token for `/v1/admin/*` writes |
+| `DEX_OPERATOR_API_TOKEN` | Bearer token for every other state-changing HTTP route |
**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 |
+| `HOST` | `127.0.0.1` (`0.0.0.0` in the container) | HTTP bind address; keep loopback for a directly proxied process, bind all interfaces inside a container |
| `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`.
+| `DEX_READ_ONLY` | `0` | Set `1` to start intentionally without write tokens or factory cids; state-changing routes return 401 while read-only `POST /v1/swaps/quote` remains available. |
+| `DEX_CALLER_JWT_SECRET` / `DEX_CALLER_JWT_AUDIENCE` | — | Optional party binding for private reads and trader-subject writes using `X-Caller-Token`. |
+| `DEX_HOSTED_RFQ_RELAY` | `0` | Custodial opt-in for RFQ create/cancel/accept under hosted trader authority; requires caller JWT binding and participant rights for those traders. |
+| `ALLOWED_ORIGINS` | — | Exact CSV CORS allowlist; unset is default-deny (no allow-origin header). |
+
+**Frontend build args** are public and baked into the static assets. Compose
+declares the complete supported set under its `frontend.build.args`, including
+API/docs/network metadata plus WalletConnect, dApp SDK, gateway, and PartyLayer
+configuration. The canonical descriptions and safe defaults are in
+[`app/web/.env.example`](../../app/web/.env.example); no participant or HTTP
+API bearer token is an accepted production build argument.
## Production checklist
-- [ ] `OPERATOR_ADMIN_TOKEN` set to a strong random value
-- [ ] `ALLOWED_ORIGINS` narrowed to your dApp host (not unset / `*`)
+- [ ] Separate strong `OPERATOR_ADMIN_TOKEN` and `DEX_OPERATOR_API_TOKEN` values set
+- [ ] Tokens delivered through a trusted session/BFF or short-lived validator tab—not compiled as `VITE_*`
+- [ ] `ALLOWED_ORIGINS` contains only the exact dApp host (unset denies all cross-origin browsers)
+- [ ] Multi-user deployments enable caller binding so account/history reads and trader-subject writes are party-scoped
- [ ] `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)
+- [ ] Asset factory pair set to the live asset registry cid; LP factory pair also set when the registrar differs
+- [ ] `/v1/status` reports `synced: true` after a genuine participant ledger-end probe (not merely HTTP 200)
+- [ ] Exactly one tested production wallet path enabled; no DEV-only provider or relay relied upon
+- [ ] Hosted RFQ is either off on both tiers, or deliberately enabled with both `DEX_HOSTED_RFQ_RELAY=1` and `VITE_ENABLE_HOSTED_RFQ=1`, mandatory caller binding, and scoped trader rights
+- [ ] Backend is private behind ingress and runs as the image's non-root `node` user
- [ ] 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)
diff --git a/docs/guides/localnet.md b/docs/guides/localnet.md
new file mode 100644
index 00000000..b67c3bf0
--- /dev/null
+++ b/docs/guides/localnet.md
@@ -0,0 +1,199 @@
+# Local Canton from a clean clone
+
+This repository does **not** require Canton DevKit. It supports two local
+network experiences with different boundaries:
+
+| Path | Additional prerequisite | What it proves | What it does not prove |
+|---|---|---|---|
+| **DPM sandbox proof (default)** | none beyond the pinned DPM SDK | Real Canton process, JSON Ledger API, current DEX DAR with its Token Standard closure, distinct LP/swapper parties, and add → swap → remove DvP settlement | Splice wallet/scan UIs, multi-participant topology, browser/backend HTTP, external wallet |
+| **DevKit LocalNet (optional)** | a separately distributed `canton-devkit` executable and Docker | Full persistent Splice LocalNet services plus the same DEX live driver | Production topology/security and an automated browser-wallet test |
+
+The DEX application and its DARs have no runtime dependency on DevKit. The
+optional script is a lifecycle and credential adapter: it starts or reuses the
+named developer network, but the separately distributed `canton-devkit`
+executable must already be installed.
+
+## Prerequisites
+
+Both paths need:
+
+- Node.js 24 or newer
+- Java 17
+- DPM and the SDK version pinned in `trading/daml.yaml`
+- `curl`, Bash, and npm
+
+Verify them from the repository root:
+
+```bash
+node --version
+java -version
+dpm --version
+curl --version
+```
+
+The default proof does not need Docker. The optional DevKit path does.
+
+## Path A: portable DPM sandbox proof
+
+Run:
+
+```bash
+bash scripts/run-dpm-sandbox-proof.sh
+```
+
+The script performs these visible phases:
+
+1. Installs SDK 3.5.2 idempotently and builds `canton-dex-trading`.
+2. Reserves all six Canton ports, releases them together, and starts the SDK's
+ `dpm sandbox` immediately on those concrete loopback ports.
+3. Waits for `/v2/state/ledger-end`; readiness is proven, not assumed.
+4. Creates one unrestricted user only inside this unauthenticated throwaway
+ sandbox. The bootstrap party is operator/admin/LP registrar; the script then
+ allocates a distinct LP/trader party and a distinct swapper party.
+5. Uploads exactly the newly built trading DAR selected by
+ `trading/daml.yaml`; the DAR embeds its Token Standard dependency closure.
+6. Runs the direct JSON-API driver through add liquidity, a quote-bound swap,
+ and redemption of half the LP position.
+7. Checks exact balances and reserves, active-slice sums after every phase, LP
+ holding/supply/policy agreement, `x*y` nondecrease, reserve-per-LP, and
+ aggregate base/quote value conservation.
+8. Stops Canton and removes its temporary state after a pass.
+
+The final checkpoint is:
+
+```text
+==> PASS: portable live-Canton proof completed
+ The throwaway sandbox is now stopping; no persistent ledger state remains.
+```
+
+If a phase fails, the script preserves its temporary directory and prints the
+path containing `canton.log` and `canton.stdout.log`. It never prints a JWT—the
+DPM sandbox has authentication disabled and the placeholder bearer value is not
+a credential.
+
+### Party and credential model
+
+The proof needs real counterparties: the LP/trader, swapper, and operator are
+three distinct Canton parties. This prevents a deposit or swap from degenerating
+into a transfer from a party to itself. The operator party also acts as asset
+admin and LP registrar for this self-contained fixture, however, and the single
+sandbox user has `CanExecuteAsAnyParty`, `CanReadAsAnyParty`, and
+`ParticipantAdmin` rights. That is deliberately convenient throwaway setup,
+not a production authorization model.
+
+Focused Daml tests cover finer-grained controller failures with separate
+parties. A deployment sign-off must additionally prove its actual users, JWTs,
+and least-privilege rights with the
+[Validator Test Plan](validator-test-plan.md).
+
+### What this proof intentionally bypasses
+
+The driver submits JSON Ledger API commands directly. It does not start:
+
+- the operator HTTP server;
+- the React dApp;
+- a wallet extension or PartyLayer;
+- a multi-participant Splice network.
+
+Passing it is live-ledger integration evidence, not browser full-stack E2E
+evidence. The [testing boundary matrix](../reference/testing.md)
+is the authoritative scope definition.
+
+## Path B: optional persistent DevKit LocalNet
+
+Use this only when your development environment already distributes
+`canton-devkit`:
+
+```bash
+command -v canton-devkit
+canton-devkit version
+```
+
+If the first command prints nothing, skip this path. The repository does not
+silently download or install an unpinned network manager. Use Path A, an
+organization-approved DevKit installation, or the official Canton Network
+Quickstart selected by your deployment team.
+
+Docker must be running. Then execute:
+
+```bash
+bash scripts/run-localnet-roundtrip.sh canton-dex
+```
+
+The integration wrapper:
+
+1. runs `canton-devkit localnet doctor`;
+2. starts or reuses the named `0.6.12` instance;
+3. imports the app-provider endpoint and JWT inside the process without
+ printing the token;
+4. discovers the ledger user's primary party;
+5. builds/uploads the DEX package closure; and
+6. allocates an LP/trader and swapper through the standard JSON Ledger API when
+ explicit party overrides are absent; and
+7. executes the same add → quote-bound swap → half-LP-remove driver.
+
+Unlike Path A, it deliberately leaves the instance running so you can inspect
+contracts and transactions:
+
+```bash
+canton-devkit localnet status --name canton-dex
+canton-devkit localnet contracts --help
+canton-devkit localnet tx --help
+```
+
+Stop containers while preserving the instance volumes:
+
+```bash
+canton-devkit localnet down --name canton-dex
+```
+
+The following is destructive and deletes that named instance's ledger state:
+
+```bash
+canton-devkit localnet remove --name canton-dex
+```
+
+### Override the generated live parties
+
+By default the wrapper uses the app-provider primary party for operator/admin/
+LP-registrar and allocates missing LP/trader and swapper parties through the
+JSON Ledger API. To exercise pre-provisioned parties instead, ensure the DevKit
+ledger user can act as them, then run:
+
+```bash
+DEX_LOCALNET_OPERATOR="" \
+DEX_LOCALNET_ADMIN="" \
+DEX_LOCALNET_TRADER="" \
+DEX_LOCALNET_SWAPPER="" \
+bash scripts/run-localnet-roundtrip.sh canton-dex
+```
+
+The LP registrar currently follows `DEX_LOCALNET_ADMIN` for the self-registry
+test fixture. The trader and swapper must each differ from the operator. A
+production deployment normally uses distinct roles and the participant-specific
+setup in [Run against a Canton testnet](run-on-testnet.md).
+
+## Path C: bring your own participant
+
+Neither local launcher is required when you already have a participant. Export
+the exact contract-party/package environment listed in
+[Testing](../reference/testing.md#live-canton-probes), run the backend package
+script from `services/operator-backend`, and treat every live probe as
+state-mutating. For a long-lived deployment, follow
+[Run against a Canton testnet](run-on-testnet.md).
+
+## Troubleshooting
+
+| Symptom | Meaning and action |
+|---|---|
+| `dpm: command not found` | Install DPM first; the portable proof cannot start Canton without the pinned SDK. |
+| Java class-version/startup error | Activate Java 17 and rerun `java -version`. |
+| Canton is not ready after 120 seconds | Read the preserved log directory printed by the proof; check memory and port-binding errors. |
+| `/v2/packages` rejects a DAR | The target participant does not accept the committed dependency hash or the DEX DAR was not rebuilt. On a governed network, vet the exact package closure. |
+| `USER_NOT_FOUND` | The driver user was not created on a manual participant. The portable script creates it automatically only in its throwaway sandbox. |
+| `PERMISSION_DENIED` for `actAs` | The participant JWT user lacks rights for one of `CANTON_OPERATOR`, `CANTON_ADMIN`, `CANTON_LP_REGISTRAR`, or `CANTON_TRADER`. |
+| `canton-devkit: command not found` | DevKit is optional; use the DPM sandbox proof or install it through an approved distribution. |
+
+---
+
+**Where to read next:** [AMM-first walkthrough](../tutorials/amm-first-walkthrough.md) · [Testing](../reference/testing.md) · [Deployment](deployment.md)
diff --git a/docs/guides/operator-guide.md b/docs/guides/operator-guide.md
index d4c36971..a28530b4 100644
--- a/docs/guides/operator-guide.md
+++ b/docs/guides/operator-guide.md
@@ -47,9 +47,16 @@ bash scripts/fetch-splice-dars.sh
bash scripts/build-trading-surface.sh
```
-Outputs `.daml/dist/canton-dex-*.dar`.
+The current DEX DAR is written under `trading/.daml/dist/`; the deployment
+script derives its exact filename from `trading/daml.yaml` so a stale DAR is
+never selected by a broad glob.
-### 2. Upload DARs, allocate parties, bootstrap the registry
+### 2. Upload DARs and bootstrap the registries
+
+Allocate the operator, LP registrar, and asset-admin parties through your
+participant first. This repository cannot make that participant-specific
+governance decision for you. Then provide the exact allocated party ids and a
+vetted DEX package hash (or a supported `#package-name` reference):
```bash
export CANTON_LEDGER_URL=https://your-participant:7575
@@ -57,18 +64,32 @@ export CANTON_LEDGER_TOKEN=$(...) # JWT for ledger-api-user
export CANTON_OPERATOR=op::1220::...
export CANTON_LP_REGISTRAR=lp::1220::...
export CANTON_ADMIN=admin::1220::...
+export CANTON_DEX_PACKAGE_ID=
-./scripts/deploy-testnet.sh
+bash scripts/deploy-testnet.sh
```
-The script is idempotent. It uploads DARs, allocates the parties if they don't
-exist, runs `bootstrap-registry.ts` to create reference-registry
-`InstrumentConfig` contracts for BTC / USDC / ETH and the LP
-instruments, and (if `OPERATOR_ADMIN_TOKEN` is set) seeds an initial BTC/USDC
-pair.
+The default run builds and uploads the exact DAR (including its embedded
+dependency closure), then idempotently creates `Registry.V2` plus configured
+`InstrumentConfig` contracts. It does **not** allocate parties, start the
+backend, mint holdings, fund a pool, or create market metadata by default.
+Record the final `assetRegistryCid` and `lpRegistryCid` values. A single
+`Registry.V2` contract implements both factory interfaces for its admin, so
+each allocation/settlement pair below uses the same registry cid:
+
+```bash
+export CANTON_ALLOC_FACTORY_CID=
+export CANTON_SETTLE_FACTORY_CID=
+# Required only when CANTON_LP_REGISTRAR differs from CANTON_ADMIN:
+export CANTON_LP_ALLOC_FACTORY_CID=
+export CANTON_LP_SETTLE_FACTORY_CID=
+```
-Skip flags for re-runs: `DEPLOY_SKIP_BUILD=1`, `DEPLOY_SKIP_UPLOAD=1`,
-`DEPLOY_SKIP_PARTIES=1`, `DEPLOY_SKIP_SEED=1`.
+Current re-run flags are `DEPLOY_SKIP_BUILD=1`, `DEPLOY_SKIP_UPLOAD=1`, and
+`DEPLOY_SKIP_BOOTSTRAP=1`. After the backend is running, the separate opt-in
+`DEPLOY_SEED_MARKETS=1` phase can create a pair plus an **unfunded** pool; it
+still does not mint or deposit value. See [Run on a testnet](run-on-testnet.md)
+for the complete order and checkpoints.
### 3. Start the operator backend
@@ -76,8 +97,8 @@ Skip flags for re-runs: `DEPLOY_SKIP_BUILD=1`, `DEPLOY_SKIP_UPLOAD=1`,
cd services/operator-backend
cp .env.example .env
# Fill in: CANTON_LEDGER_URL, CANTON_LEDGER_TOKEN, party ids,
-# OPERATOR_ADMIN_TOKEN, DEX_OPERATOR_API_TOKEN,
-# ALLOWED_ORIGINS, DB_PATH
+# CANTON_DEX_PACKAGE_ID, asset/LP factory CIDs,
+# OPERATOR_ADMIN_TOKEN, DEX_OPERATOR_API_TOKEN, ALLOWED_ORIGINS, DB_PATH
npm install
npm start
```
@@ -163,12 +184,16 @@ 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 listing active metadata | `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.
+`DexPair.active`, `tradingMode`, and `feeModel` are listing/discovery metadata
+in this revision. Updating them preserves the pair's history, but the pool and
+order terminal choices do not fetch `DexPair`; therefore this flag alone is
+**not** an on-ledger trading halt. Use `PoolRules_Pause` for pools, stop
+off-ledger order routing, and add an explicit terminal-choice gate if your
+production policy requires pair-wide enforcement.
### Create a pool
diff --git a/docs/guides/operator-runbook.md b/docs/guides/operator-runbook.md
index b5b4661c..ce5dd91e 100644
--- a/docs/guides/operator-runbook.md
+++ b/docs/guides/operator-runbook.md
@@ -13,9 +13,10 @@ Canton operational concern, not a DEX one — see [Out of scope](#out-of-scope-f
## Roles and party model
-The reference deployment expects four distinct parties. Keeping them logically
-separate is part of the design. Collapsing them is acceptable for a single-
-operator dev instance but should not be the production posture.
+The reference uses four logical roles and can involve many trader, LP, and
+asset-admin parties. Keeping control roles separate is the recommended
+production posture; a local learning instance may intentionally share a party
+where the setup guide says so.
| Party | Owns | Signs |
| ------------- | ----------------------------------------------------------------------------- | ------------------------------------------------------------------- |
@@ -35,17 +36,17 @@ In rough order of dependency:
1. **Allocate parties.** `operator`, `lpRegistrar`, base-asset `admin`,
quote-asset `admin`, and any traders / LPs you want to onboard.
-2. **Bring up registries.** For each `admin`, create:
- - `MockAllocationFactory` (or the production registry's allocation
- factory) with `users` = the parties that will exercise on it
- - `MockSettlementFactory` (or production) with the same `users`
- - the registry-specific instrument definition for each instrument the admin
- manages. In the reference registry this is `InstrumentConfig`; keep its
- requirement lists empty unless your registry replaces the placeholder
- verifier with issuer-authorized evidence checks
+2. **Bring up real registries.** Run the idempotent
+ [`bootstrap-registry.ts`](../../scripts/bootstrap-registry.ts) path to create
+ `Registry.V2` plus each `InstrumentConfig`, or configure a conforming
+ external Token Standard V2 registry. `MockAllocationFactory` and
+ `MockSettlementFactory` are Daml-test fixtures only: they do not create or
+ move holdings and must not be used as a deployment recipe. When asset admin
+ and LP registrar differ, record both registry cids for the backend's
+ per-admin factory mapping.
3. **List trading pairs.** Operator creates a `DexPair` per pair with the
- chosen `tradingMode` and `feeModel`. Pairs are toggled `active` to gate
- trading without archiving the pair record.
+ chosen `tradingMode` and `feeModel`. These fields are listing metadata in
+ this revision; they do not independently gate pool/order terminal choices.
4. **Create LP infrastructure (per pool).**
- `lpRegistrar` creates the LP token's registry-specific instrument
definition. In the reference registry this is one `InstrumentConfig`
@@ -58,14 +59,19 @@ In rough order of dependency:
add-liquidity DvP request/allocate/settle flow as later LPs; the settle
creates the first `PoolSlice` contracts and transitions the state to
`PS_Active`.
-6. **Open the order book / swap surface.** Once pools are funded and pairs
- 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`](../../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).
+6. **Open the order book / swap surface.** Once registries and holdings are
+ live, pools are funded, `PoolRules` is active, and the operator's off-ledger
+ routing policy allows the market, traders may submit `OrderFundingRequest`,
+ liquidity adds/removes via the DvP `/request` flow, `Rfq`, etc.
+
+The focused [`PoolWorkflowTests.daml`](../../trading-tests/CantonDex/Tests/PoolWorkflowTests.daml),
+[`OrderWorkflowTests.daml`](../../trading-tests/CantonDex/Tests/OrderWorkflowTests.daml),
+[`TradeWorkflowTests.daml`](../../trading-tests/CantonDex/Tests/TradeWorkflowTests.daml),
+and [`ChoiceContextWorkflowTests.daml`](../../trading-tests/CantonDex/Tests/ChoiceContextWorkflowTests.daml)
+walk DEX choices against mock factories, but those fixtures do not hold value.
+They are not deployment validators. Use the [testnet guide](run-on-testnet.md)
+for bring-up and the [Daml proof map](../reference/daml-proof-map.md) plus the
+self-contained live AMM round trip for value movement.
## Operator-driven cleanup (on-ledger)
@@ -146,7 +152,7 @@ operators do not need a parallel database to explain a trade.
| Question | Where to look on-ledger |
| ----------------------------------------------------- | ------------------------------------------------------------------------------------------------------ |
| Why did this RFQ accept go to this dealer? | `MatchedTrade.policyReceipt`, also folded into `SettlementInfo.meta` via `dex.policy.*` keys |
-| What pair / fee policy applied at trade time? | `DexPair.feeModel`, `DexPair.tradingMode`, `DexPair.active` at the trade's `createdAt` |
+| What pool fee was executed? | The immutable `Pool.feeBps` used by `PoolRules`; `DexPair.feeModel` is listing metadata and is not consumed by that choice |
| Where did this pool's reserves come from? | Each `PoolSlice` is an `Allocation` CID, each carrying its admin, authorizer, and committed funding |
| What's the current head slice / boundary candidate? | each active `PoolSlice` for the pool (query the ACS by `poolId`); the aggregate is `PoolState.reserves.baseAmount`/`quoteAmount` |
| Did this trader's funding accept? | The `OrderAllocationRequest` archive event plus the corresponding `Allocation` create event |
@@ -155,8 +161,9 @@ operators do not need a parallel database to explain a trade.
Off-ledger telemetry the operator should also collect:
-- **Latency** per workflow (`OrderFundingRequest_Bind` → `Order_Fund`,
- `Rfq_Accept` → `MatchedTrade_Settle`, `PoolRules_Swap` end-to-end).
+- **Latency** per explicitly named workflow boundary
+ (`OrderFundingRequest_Bind` → `Order_Fund`, `Rfq_Accept` →
+ `MatchedTrade_Settle`, or request → settlement around `PoolRules_Swap`).
- **Failure counts** per choice, especially slippage rejections, allocation
conservation failures, and registry choice-context rejections.
- **Slice-count distributions** per pool side, to flag when consolidation
@@ -370,12 +377,15 @@ guard.
## Single-operator dev shortcut
-For local exploration, collapse `operator` / `lpRegistrar` / `admin` into one
-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
+For local exploration, the control roles `operator` / `lpRegistrar` / `admin`
+may share one party, and the in-memory dev server may use `DEX_DEV_OPEN=1` to
+bypass the operator-token gate. Do not collapse a value-moving counterparty
+into that party: a real registry rejects the self-transfer created when the LP
+or swapper equals the operator. The portable sandbox proof therefore allocates
+distinct LP/trader and swapper parties even though its three control roles
+share the bootstrap party. Production should normally separate the control
+roles too 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)
diff --git a/docs/guides/registry-integration.md b/docs/guides/registry-integration.md
index ed6b58a0..b5f9bdce 100644
--- a/docs/guides/registry-integration.md
+++ b/docs/guides/registry-integration.md
@@ -1,4 +1,4 @@
-# Registry Prerequisites
+# Registry integration prerequisites
What the DEX assumes from an asset registry. Token Standard V2 standardizes the
holding/allocation/settlement interfaces; it does not standardize a particular
@@ -22,15 +22,15 @@ flowchart LR
AF["AllocationFactory"]
SF["SettlementFactory"]
H[("Holding")]
- CC(["Choice-context endpoint (off-ledger)"])
+ CC(["Operation-specific V2 endpoints (off-ledger HTTP)"])
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
+ OB -.->|"POST exact choiceArguments"| CC
+ CC -.->|"factory + context + disclosures"| SF
```
Solid arrows are on-ledger interface choices; dashed arrows are off-ledger
@@ -99,7 +99,7 @@ trades, the registry must provide:
| Assumption | Where it shows up |
|---|---|
| `instrumentId` is stable across the instrument's lifetime | Order, Pool, MatchedTrade, Rfq all key on it |
-| Factory and choice-context discovery is admin-controlled | The operator fetches these off-ledger and flushes its registry-client cache after a registry republishes factories or disclosures |
+| Factory and choice-context discovery is admin-controlled | The app performs a fresh operation-specific V2 lookup with the concrete choice arguments; it does not reuse one admin-level cached context across operations |
| Allocation creation can consume one or more holdings and return change | The trader's wallet selects holdings; the registry factory validates and locks them |
| Allocation factory accepts arbitrary `AllocationSpecification` shapes (prefunded, with-legs, committed or uncommitted, with `nextIterationFunding`) | Orders require both deadline-committed and trader-withdrawable GTC shapes; pools require committed inventory |
| Settlement factory enforces transfer-leg consistency with allocations | OTC / matched-trade settlement and `PoolRules_Swap` rely on the factory to validate, not the DEX |
@@ -115,15 +115,25 @@ TTL; that does not imply another registry will accept the same lifetime.
## Registry API surface (Daml + OpenAPI)
-Token Standard V2 registries are expected to expose both the Daml
-interfaces and the standard OpenAPI endpoints (the specs ship alongside
-each API package in `canton-network/splice` under `token-standard/`). The
-reference registry implements the Daml surfaces used by this DEX; its
-off-ledger integration is represented by the factory and choice-context
-endpoints the backend's registry-client consumes
-(see [Choice Context](choice-context.md)). A production registry should
-implement the standard OpenAPI so V2-compliant wallets and apps can discover
-factories and context without bespoke integration.
+Token Standard V2 registries are expected to expose both the Daml interfaces
+and the standard OpenAPI endpoints. The specs used here are committed beside
+the vendored packages under [`vendor/splice/token-standard`](../../vendor/splice/token-standard/).
+The backend client uses the canonical operation-specific POST endpoints for
+allocation-factory discovery, settlement-factory discovery, and per-allocation
+cancel/withdraw context. Every factory request includes the concrete Daml JSON
+`choiceArguments`; responses are runtime-validated and are not cached. See
+[Choice context](choice-context.md#3-canonical-v2-http-endpoints) for the exact
+paths, bodies, and response shape.
+
+The configured reference self-registry is a deliberate adapter, not a second
+HTTP protocol. `FixedRegistryClient` resolves deployed factory CIDs per admin
+and returns empty context. This is also the only backend adapter currently able
+to drive atomic add/remove liquidity: those Daml choices create temporary
+allocations and settle them in the same transaction, so their future CIDs
+cannot appear in an exact HTTP preflight request. Generic HTTP discovery fails
+with `RegistryError("unsupported", ...)` before submission for that workflow.
+Swaps, matched trades, order matches, allocation creation, and cancellation use
+the canonical operation-specific discovery path.
The DEX's own flows are exercised against a standard-shaped registry, not only
its reference one. `testMatchedTradeViaTokenStandardRegistry` in
@@ -134,7 +144,10 @@ a bespoke one. `testRealRegistryDvpAddSettles` and `testRealRegistryDvpSwapSettl
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.
+aborts when that registry's disclosed context is dropped. These are Daml
+composition proofs: the tests already possess the context contracts. They do
+not remove the off-ledger future-CID limitation for the backend's atomic
+liquidity HTTP preflight.
## Mint / Burn / Transfer prerequisites
@@ -209,14 +222,18 @@ registries are expected to enforce at least what `Registry.V2` does.
## Choice-context retrieval the DEX needs
When the operator or trader builds a transaction that touches a registry
-contract, the registry may require extra disclosed contracts or context. In the
-reference registry this context is empty. External registries may return
-disclosed configuration, rights, or credential contracts. The DEX
-operator backend's **registry-client** module is responsible for fetching the
-registry-specific context and attaching it to the choice arguments.
-
-See [Choice Context](choice-context.md) for the exact
-inputs each registry choice expects.
+contract, the registry may require extra disclosed contracts or context. The
+reference self-registry's context is empty. External registries may return
+disclosed configuration, rights, or credential contracts.
+
+The DEX's `registry-client` takes the exact operation arguments, calls the
+matching standard endpoint, validates the wire response, and returns the
+factory CID, context, and disclosures as one value. Settlement arguments come
+from non-value-moving Daml previews for swaps and matched trades, and from an
+ephemeral create-and-exercise preview for order matches. Cancel/withdraw
+context is looked up per allocation ID, not once per admin. See
+[Choice context](choice-context.md) for the complete choreography and the
+atomic-liquidity exception.
## Registry-specific lifecycle changes
@@ -265,10 +282,13 @@ legs — is rejected rather than settled.
## What the DEX does not assume
-- It does not require the reference registry for base or quote assets. An
- alternative must implement the V2 holding, allocation, and settlement APIs
- used by the workflow and provide compatible factory/context discovery. The
- included LP path still uses the concrete `LPTokenPolicy` component.
+- It does not require the reference registry for base or quote assets in the
+ allocation, swap, order, or matched-trade flows. An alternative must
+ implement the V2 holding, allocation, and settlement APIs and the canonical
+ operation-specific discovery endpoints. Atomic add/remove liquidity is the
+ documented exception: the current backend requires the configured
+ empty-context self-registry adapter until that workflow is redesigned. The
+ included LP path also uses the concrete `LPTokenPolicy` component.
- It does not assume holding precision is uniform. Each registry may expose its
own display scale or amount constraints; the DEX treats amounts as `Decimal`
and lets the registry enforce its own limits.
diff --git a/docs/guides/run-on-testnet.md b/docs/guides/run-on-testnet.md
index cb7e11cd..5399e084 100644
--- a/docs/guides/run-on-testnet.md
+++ b/docs/guides/run-on-testnet.md
@@ -1,72 +1,150 @@
# Run against a Canton testnet
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.
+**operator backend** (configured operator/LP authority, ledger reads, the
+indexer) and the **web app** (reads plus wallet-authority commands). This guide points
+both at a participant whose operator, LP registrar, and asset-admin parties are
+already allocated. The repository automates package build/upload, registry
+bootstrap, and optional pair/unfunded-pool creation. It deliberately does **not**
+allocate parties or claim to fund a pool: party allocation is participant-
+specific, and first funding requires an LP-authorized wallet flow.
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 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`.
+- Node.js 24, Java 17, DPM with the SDK pinned by `trading/daml.yaml`, and the
+ backend/frontend dependencies installed with `npm ci`.
+- A Canton participant JSON Ledger API URL. For a compact validator setup, its
+ server-side JWT can `actAs` the operator and LP registrar and read the
+ configured registrars; pool creation and LP settlement require those control
+ roles. Registry bootstrap additionally needs `actAs` for each registry admin.
+ In production, prefer separate least-privilege bootstrap and runtime users.
+- The target network must accept the exact Token Standard V2 package hashes in
+ `vendor/splice/dars/`. A production network may require its governance/vetting
+ process before upload.
- Operator, LP registrar, and asset-admin parties allocated on the participant.
- 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
+## 1. Prepare the ledger
-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.
+Copy the backend environment template, fill the participant values, and load it
+into the current shell. `npm run testnet` does not implicitly read `.env`.
```bash
-cd services/operator-backend
+cp services/operator-backend/.env.example services/operator-backend/.env
+# Edit services/operator-backend/.env. Do not commit it.
+
+set -a
+source services/operator-backend/.env
+set +a
+```
+
+Use two different high-entropy HTTP API tokens:
+
+```bash
+export DEX_OPERATOR_API_TOKEN=""
+export OPERATOR_ADMIN_TOKEN=""
+```
+
+These are credentials for the DEX HTTP service, not the participant JWT. A
+full-mode testnet server refuses to start without both. For an intentional
+read-only deployment, set `DEX_READ_ONLY=1`; every state-changing HTTP route
+then returns 401 (the read-only `POST /v1/swaps/quote` computation remains open).
+
+Build, upload, and bootstrap the on-ledger registries:
+
+```bash
+bash scripts/deploy-testnet.sh
+```
-export CANTON_LEDGER_TOKEN=""
+Expected final line:
+
+```text
+==> Deployment phases completed without a suppressed error
+```
+
+The script does not allocate parties, start the backend, create a market by
+default, mint holdings, or fund a pool. Each successful phase mutates the target
+ledger and is not rolled back if a later phase fails.
+
+Record the `assetRegistryCid` and `lpRegistryCid` fields printed by the final
+`bootstrap complete` log. Each reference `Registry.V2` implements both factory
+interfaces for its own admin, so the two values within a factory pair are the
+same registry cid:
+
+```bash
+export CANTON_ALLOC_FACTORY_CID=""
+export CANTON_SETTLE_FACTORY_CID=""
+
+# Required only when CANTON_LP_REGISTRAR differs from CANTON_ADMIN:
+export CANTON_LP_ALLOC_FACTORY_CID=""
+export CANTON_LP_SETTLE_FACTORY_CID=""
+```
+
+The included server maps the configured asset admin and LP registrar
+separately. A venue that lists additional third-party admins should replace
+this two-admin configuration with discovery from each admin's registry API, as
+described in [Registry integration](registry-integration.md).
+
+## 2. Start the operator backend
+
+The backend runs `src/testnet-server.ts`. Keep the loaded environment in this
+terminal. The process reads credentials from the environment and does not write
+them to disk.
+
+```bash
+cd services/operator-backend
-CANTON_LEDGER_URL="https://" \
-CANTON_OPERATOR="" \
-CANTON_LP_REGISTRAR="" \
-CANTON_ADMIN="" \
-CANTON_NETWORK="canton:testnet" \
-CANTON_SYNCHRONIZER="" \
-CANTON_DEX_PACKAGE_ID="#canton-dex-trading" \
-PORT=8080 \
npm run testnet
```
| 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_LEDGER_TOKEN` | yes | Server-side JWT with the read/actAs rights required by the enabled operator and LP flows. |
| `CANTON_OPERATOR` | yes | Operator (venue) party id. |
| `CANTON_LP_REGISTRAR` | yes | LP registrar party id. |
| `CANTON_ADMIN` | yes | Asset-admin party id. |
+| `DEX_OPERATOR_API_TOKEN` | yes in full mode | Bearer token for every non-admin HTTP write. |
+| `OPERATOR_ADMIN_TOKEN` | yes in full mode | Separate bearer token for `/v1/admin/*` writes. |
+| `DEX_READ_ONLY` | optional | Set `1` to start without API tokens and reject every state-changing route. |
| `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_DEX_PACKAGE_ID` | yes | Template-id prefix. Use the vetted concrete package hash, or `#canton-dex-trading` only where package-name resolution is acceptable. |
| `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). |
+| `CANTON_ALLOC_FACTORY_CID`, `CANTON_SETTLE_FACTORY_CID` | yes in full mode | Asset-admin Registry cid, repeated because it implements both interfaces. |
+| `CANTON_LP_ALLOC_FACTORY_CID`, `CANTON_LP_SETTLE_FACTORY_CID` | yes in full mode when LP registrar differs | LP registrar's Registry cid, again repeated for both interfaces. |
+| `ALLOWED_ORIGINS` | yes for cross-origin browser access | Exact comma-separated web origins. Unset is default-deny. |
+| `DEX_CALLER_JWT_SECRET`, `DEX_CALLER_JWT_AUDIENCE` | optional | Bind private reads and trader-subject writes to `X-Caller-Token.sub` in a multi-user deployment. |
+| `DEX_HOSTED_RFQ_RELAY` | optional, default `0` | Custodial RFQ create/cancel/accept under hosted trader authority; enabling it requires caller binding and participant rights for those traders. |
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
+Verify the backend before opening a browser:
+
+```bash
+curl -fsS http://localhost:8080/v1/status
+```
+
+Do not continue unless the response contains `"synced":true`. HTTP 200 with
+`synced:false` means the most recent participant ledger-end probe failed; check
+the URL, participant token, and startup/indexer logs.
-The dApp reads its network and backend base URL at build time.
+## 3. Start the web app
+
+The dApp reads its public network/backend settings at build time. A production
+build deliberately excludes Mock, Direct Canton, and the operator command
+relay, so **choose and configure at least one real wallet provider**. This
+example enables PartyLayer; replace the wallet ids with adapters supported by
+your target network. The alternatives are the dApp SDK gateway
+(`VITE_ENABLE_SDK=1`) or WalletConnect (`VITE_WC_PROJECT_ID=...`).
```bash
cd app/web
@@ -74,16 +152,43 @@ cd app/web
VITE_API_BASE="http://localhost:8080" \
VITE_CANTON_NETWORK_ID="canton:testnet" \
VITE_CANTON_SYNCHRONIZER="" \
+VITE_ENABLE_PARTYLAYER=1 \
+VITE_PARTYLAYER_NETWORK="canton:testnet" \
+VITE_PARTYLAYER_WALLET_IDS="console,nightly,send" \
+VITE_DOCS_URL="https://srikanth-bitdynamics.github.io/Canton-Dex-Reference-Implementation/" \
npm run build
npm run preview
```
-Open . The header should show the configured network and
-the backend status should report `synced: true`. The full frontend variable list
-is [`app/web/.env.example`](../../app/web/.env.example).
+Open . The backend must allow this exact origin:
-## Smoke checks
+```bash
+export ALLOWED_ORIGINS="http://localhost:4173"
+```
+
+Set `ALLOWED_ORIGINS` before starting (or restart) the backend. The header
+should show the configured network, `/v1/status` should report `synced: true`,
+and **Connect Wallet** should list the provider you deliberately enabled. If it
+lists no production-capable provider, stop—the browser cannot author the
+trader allocations required by the flow. The full frontend variable list is
+[`app/web/.env.example`](../../app/web/.env.example).
+
+### Authorize protected writes in the validator browser
+
+Open **Admin → API session credentials** and enter short-lived copies of
+`DEX_OPERATOR_API_TOKEN` and `OPERATOR_ADMIN_TOKEN`. They are stored only in
+that tab's `sessionStorage`, never in the built JavaScript. Trader settle calls
+use the operator token; `/v1/admin/*` calls use the admin token. If per-caller
+binding is enabled, also enter the caller JWT issued for the connected party.
+
+This manual token handoff is for a validator/operator acceptance run. A public
+multi-user dApp should obtain scoped, expiring credentials from its authenticated
+BFF/session service. Do not distribute the venue's long-lived shared tokens to
+ordinary traders and do not create `VITE_*` token variables—Vite embeds them in
+public assets.
+
+## 4. Smoke checks
```bash
curl -s http://localhost:8080/v1/status | python3 -m json.tool
@@ -99,22 +204,62 @@ Expected:
- `/v1/pairs` and `/v1/pools` return the on-ledger contracts visible to the
operator party.
-## Bootstrap a pair and pool
+## 5. Create a pair and an unfunded pool
-Use the admin endpoints in [operator-guide.md](operator-guide.md):
+With the backend still running, use a second terminal that has the same
+environment loaded:
-- `POST /v1/admin/pairs`
-- `POST /v1/admin/pools`
+```bash
+set -a
+source services/operator-backend/.env
+set +a
+
+DEPLOY_SKIP_BUILD=1 \
+DEPLOY_SKIP_UPLOAD=1 \
+DEPLOY_SKIP_BOOTSTRAP=1 \
+DEPLOY_SEED_MARKETS=1 \
+bash scripts/deploy-testnet.sh
+```
+
+This phase first requires `/v1/status` to succeed. It queries existing pairs and
+pools, creates only missing BTC/USDC metadata, and stops on any HTTP failure. It
+creates an **unfunded** pool; it does not fabricate reserves or LP holdings.
-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.
+Expected checkpoint:
-## Wallet boundary
+```bash
+curl -fsS http://localhost:8080/v1/pairs
+curl -fsS http://localhost:8080/v1/pools
+```
-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.
+The pair should be present, and the pool should report an unfunded/zero-reserve
+state. The first LP must next run the same wallet-authorized
+request → allocations → settle flow used for later deposits. Exact admin curl
+alternatives are in [Operator Guide](operator-guide.md).
+
+## 6. Wallet and HTTP authorization boundaries
+
+Operator/LP-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
+arbitrary command relay cannot be enabled in the deployed server.
+
+The RFQ HTTP create/cancel/accept endpoints are a separate custodial exception:
+they submit as the RFQ trader and are disabled by default in
+`testnet-server.ts`. A deployment that deliberately enables
+`DEX_HOSTED_RFQ_RELAY=1` must give its participant user rights for each hosted
+trader and configure `DEX_CALLER_JWT_SECRET` so `X-Caller-Token.sub` binds every
+request to that trader. Its production UI controls also require
+`VITE_ENABLE_HOSTED_RFQ=1`; leaving either side off keeps writes disabled. Do
+not describe that mode as self-custodial.
+
+The browser's follow-up request to the backend is still a protected HTTP write:
+it carries the operator API token entered for this tab. That token authorizes
+the backend client; it does not replace the wallet's on-ledger authorization.
+When per-caller binding is enabled, `X-Caller-Token.sub` must also equal the
+trader party named by the request. The dApp sends the same token on its scoped
+orders, holdings, balances, trades, and RFQ reads; an admin token may bypass the
+party comparison for operational inspection.
---
@@ -148,20 +293,22 @@ adapter id. Optional registry overrides are documented in
**Validate the flow.**
-1. Open the app, click **Connect Wallet**, and select **PartyLayer**. Approve
+1. In **Admin → API session credentials**, configure the short-lived operator
+ token and, when enabled, the connected party's caller JWT.
+2. 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
+3. 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:
+4. Run a small trader-authority action, such as:
- **Trade** → small pool swap
- **Pools** → add liquidity or remove liquidity
- **Orders** → place a prefunded order
-4. Confirm the wallet approval returns an `updateId`. PartyLayer receipts may
+5. Confirm the wallet approval returns an `updateId`. PartyLayer receipts may
not include created contract ids directly; the operator backend recovers the
created `Allocation`, `LiquidityAllocationAcceptance`, or order-funding
evidence by reading the committed transaction tree for that `updateId`.
-5. Confirm the operator settle step completes and the app refreshes holdings,
+6. 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:
diff --git a/docs/guides/using-the-dapp.md b/docs/guides/using-the-dapp.md
index e433b9eb..4d4dfe94 100644
--- a/docs/guides/using-the-dapp.md
+++ b/docs/guides/using-the-dapp.md
@@ -2,8 +2,8 @@
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
+order, or trade an RFQ block. The external-wallet authority boundary — the dApp
+does not hold your key or submit with your ledger authority — 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
@@ -13,40 +13,73 @@ mock wallet locally) and wants to trade.
## Connecting a wallet
-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.
+The **Connect Wallet** button opens one combined picker. It asks each enabled
+integration what it can reach — a dapp-sdk gateway, injected or announced
+browser wallets, and PartyLayer's catalog — then adds any enabled
+single-provider rows. Picking a row routes the connection back to the adapter
+that discovered it. The dApp never connects a wallet automatically.
-| Provider | When to use | Required env |
+### External-wallet integrations
+
+These adapters keep user authority in an external wallet. “Production-facing”
+means that the architecture has the correct authority boundary; it does not
+replace live validation of the particular wallet, participant, packages, and
+network you deploy.
+
+| Picker integration | Current scope | Enable with |
|---|---|---|
-| **Token Standard V2** | Local dev / testnet only (routes writes through the operator signing relay; dev builds only) | `VITE_API_BASE`, `VITE_CANTON_DEFAULT_PARTY` |
-| **WalletConnect** | External CIP-0103 wallets (mobile / hardware) | `VITE_WC_PROJECT_ID` |
-| **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 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
-are issued locally by the deployment's own Token Standard V2 registry. This is an
-interim arrangement until the general-purpose validator and wallet tooling (DA
-Utilities) supports Token Standard V2, at which point users bring their own party
-and V2 assets. See [Non-goals](../concepts/non-goals.md#the-hosted-testnet-is-a-demo-surface-not-a-wallet).
+| **Canton wallet (dapp SDK / CIP-0103)** | Composes the Daml commands and delegates authorization and submission to a CIP-0103 wallet. The current capability table marks its update-id discovery path DvP-ready. | `VITE_ENABLE_SDK=1`; optionally set `VITE_WALLET_GATEWAY_URL` and `VITE_WALLET_GATEWAY_NAME` |
+| **PartyLayer** | Opens PartyLayer's configured wallet catalog. Its update-id discovery path is implemented, but deliberately marked **unproven** until the selected wallet and deployment pass the live validator plan. | `VITE_ENABLE_PARTYLAYER=1` plus the PartyLayer variables in `.env.example` |
+| **WalletConnect** | Connects an external wallet through Reown. The current adapter is marked **no DvP** and explicitly rejects LP add/remove, so enable it only for wallet/intent combinations you have validated. | `VITE_WC_PROJECT_ID` and `VITE_CANTON_NETWORK_ID` |
+
+When more than one is enabled, the picker adds a single **recommended** badge
+using the capability order: dapp SDK (DvP-ready), PartyLayer (unproven pending
+live validation), then WalletConnect (currently no-DvP). That badge is only a
+UI hint; the user still chooses and approves the connection. If none is
+configured in a production build, no development relay is silently substituted.
+
+### Development-only adapters
+
+| Picker integration | What it actually proves | Required env |
+|---|---|---|
+| **Operator Relay (dev only)** | Uses the `token-standard` provider id, but is not a Token Standard wallet. The browser composes commands and `/v1/wallet/submit` submits them with the backend's configured ledger authority. This tests orchestration, not self-custody. | Frontend: `VITE_API_BASE`, `VITE_CANTON_DEFAULT_PARTY`. Backend: `DEX_DEV_WALLET_RELAY=1` and an exact `DEX_DEV_RELAY_PARTIES` allowlist. |
+| **Mock Wallet (dev)** | Returns deterministic placeholder contract ids so the UI can be explored. It submits no ledger transaction. | none |
+
+`CantonDirectProvider` is intentionally **not registered**. Its former path sent
+a DEX intent to `/v1/wallet/execute`, but a Canton participant exposes a command
+API rather than that DEX-specific endpoint. Shipping a participant bearer token
+in browser storage would also be unsafe. Use the dapp SDK, PartyLayer, or
+WalletConnect for external authorization; use the operator relay only for an
+explicit local development exercise.
+
+Once connected, the active party appears in the top bar and clicking the
+connected pill disconnects. Reconnection and persistence belong to the chosen
+external wallet/SDK; do not assume every provider stores or restores the same
+session. The development relay stores only its configured demo party and ledger
+user id. It never stores a participant JWT.
+
+For a testnet or public deployment, use a submit-capable external wallet. Do not
+compile participant, operator, or admin bearer credentials into the browser
+bundle.
+
+This repository does not provision a public DEX hostname, party faucet, or
+browser custody service. An operator deploying it must supply the Canton
+participant, parties, assets, API origin, and wallet/onboarding design. The
+development-only signing relay is explained under
+[Non-goals](../concepts/non-goals.md#the-development-relay-is-not-a-wallet).
---
## How a trade is authorised
-Read this once and the pool/order screens follow. **The dApp holds no keys.** A
-DvP action is a three-step handshake: the dApp asks the operator for a
-Daml-built spec, your wallet signs that spec (locking the named funds), and the
-operator settles against it. The wallet carries *your* authority; the operator
-carries *its own*. The hosted RFQ screen is a separate relay flow described
-below.
+Read this once and the pool/order screens follow. With an external-wallet
+adapter, **the dApp holds no keys**. A DvP action is a three-step handshake: the
+dApp asks the operator for a Daml-built spec, your wallet authorizes that spec
+(locking the named funds), and the operator settles against it. The wallet
+carries *your* authority; the operator carries *its own*. The development
+operator relay does not satisfy this self-custody boundary: its backend submits
+using configured ledger rights. The included operator-mediated RFQ screen uses
+a separate authority flow described below.
```mermaid
sequenceDiagram
@@ -175,20 +208,28 @@ Bilateral block trades. You publish a request, whitelisted dealers quote, and
you accept one. Acceptance creates a `MatchedTrade` and policy receipt; token
funding and settlement are separate steps.
+This screen's writes use the explicitly custodial hosted-RFQ mode, not the
+connected wallet. Production builds disable its New / Accept / Cancel controls
+unless `VITE_ENABLE_HOSTED_RFQ=1`; the backend independently requires
+`DEX_HOSTED_RFQ_RELAY=1` and `DEX_CALLER_JWT_SECRET`. Enable both only for a
+deployment that deliberately provisions trader `actAs` rights and issues a
+short-lived caller JWT bound to the connected party. Reads remain usable while
+writes are disabled.
+
1. Open **RFQ** → click **+ New RFQ**.
2. Pick pair, side, size, and validity window. Select dealers from the whitelist
on the right.
-3. Send. The hosted trader screen creates the RFQ. A dealer integration must
- observe that contract and create `RfqQuote` contracts; this reference does
- not include a dealer quote-entry screen. Visible quotes stream into the
- expanded row.
+3. Send. The included operator-mediated screen creates the RFQ. A dealer
+ integration must observe that contract and create `RfqQuote` contracts;
+ this reference does not include a dealer quote-entry screen. Visible quotes
+ stream into the expanded row.
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. On the hosted demo, the backend
+5. Click **Accept** on the dealer you want. In this reference flow, the backend
submits `Rfq_Accept` with its configured trader and operator authorities; a
`PolicyReceipt` records the ranking applied. This is not a self-custodial
wallet approval flow.
@@ -200,7 +241,7 @@ Accepted RFQs move to the **Accepted** tab; those that expire with no acceptance
(or no quotes) move to **Expired**. The page does not claim that acceptance
itself moved balances. The later `MatchedTrade` allocation and settle choices
are demonstrated by the Daml tests and operator API, but are not driven by this
-hosted RFQ screen.
+RFQ screen.
---
@@ -233,9 +274,10 @@ dApp passes only the intent verb.
| 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` + `fund-order` | [`OrderFundingRequest`](../../trading/CantonDex/Dex/OrderFundingRequest.daml) → funded [`Order`](../../trading/CantonDex/Dex/Order.daml) |
-RFQ create, cancel, and accept are not wallet intents in this app. The hosted
-RFQ page calls the operator API, whose ledger user must be authorized for the
-hosted parties involved.
+RFQ create, cancel, and accept are not wallet intents in this app. The
+operator-mediated RFQ page calls the operator API, whose ledger user must be
+authorized for the configured parties involved. This is an implementation
+example, not a public relay service supplied by the repository.
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
diff --git a/docs/guides/validator-test-plan.md b/docs/guides/validator-test-plan.md
index 48e70db4..b87abea8 100644
--- a/docs/guides/validator-test-plan.md
+++ b/docs/guides/validator-test-plan.md
@@ -1,256 +1,333 @@
# Canton Testnet Validator — Live Test Plan
-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
-
-1. Confirm each wallet provider enabled for the deployment connects against a
- real participant; development-only providers are checked separately.
-2. Verify each wallet intent translates correctly into on-ledger Token Standard
- V2 transactions.
-3. Confirm operator-driven settlement flows use AllocationFactory +
- SettlementFactory, and separately verify that hosted RFQ relay parties are
- explicitly authorized.
-4. Validate indexer + history endpoints reflect on-ledger state.
-5. Stress-test idempotency and graceful shutdown.
+Use this manual checklist to sign off one deployed DEX environment. It combines
+boundaries that the automated suites intentionally test separately: a real
+participant, authenticated backend, browser dApp, and real wallet. Record
+evidence for each scenario; running a ledger script is useful corroboration,
+not a substitute for the browser path.
+
+## Know what each path proves
+
+| Path | Includes | Does not prove |
+|---|---|---|
+| Offline pre-flight | Daml Script, backend tests, dApp tests, backend HTTP smoke | participant compatibility, real wallet, live state |
+| Live RFQ test | RFQ service, JSON API, Daml engine | HTTP auth, browser/wallet, token settlement |
+| Live AMM round trip | JSON API, Registry.V2, add → quote-bound swap → partial remove DvP with reserve, slice, LP-supply, invariant, and conservation checks | backend HTTP, browser, real wallet |
+| Existing-pool probe | JSON API, existing pool, add and swap | backend HTTP, real wallet, remove |
+| Matched-trade probe | JSON API, allocations, settlement | RFQ/order matching, AMM, HTTP, real wallet |
+| This plan | deployed backend + dApp + wallet + participant | production load, security audit, disaster recovery |
+
+The exact environment and expected output for every automated path is in the
+[Testing reference](../reference/testing.md).
+
+## Safety and evidence
+
+All live writes mutate ledger state. Use dedicated test parties and a dedicated
+pool; do not seed a production pool. A failed script can leave earlier
+transactions committed because there is no cross-transaction rollback. Before
+starting, create an evidence directory outside the repository and record:
+
+- deployment name, Git commit, DAR package id, synchronizer id, and timestamp;
+- operator, admin, LP registrar, trader, LP, swapper, and dealer party ids;
+- backend and dApp URLs, but never bearer tokens or wallet secrets;
+- each command, exit code, run id, relevant contract/update ids, and screenshots;
+- cleanup performed after the run.
+
+Mark each scenario **Pass**, **Fail**, **Blocked**, or **N/A**. A blocked wallet
+or auth path is not a pass merely because a raw JSON API script succeeds.
## Prerequisites
-- Canton testnet validator with JSON Ledger API reachable (e.g.,
- `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...`), exported as
- `CANTON_SYNCHRONIZER`.
-- Docker / Docker Compose installed on the test runner host.
-- `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.
+- An already-running Canton validator/participant with JSON Ledger API access.
+- The current trading DAR and its Token Standard V2 dependencies uploaded.
+- Real party ids for every role used by the scenario.
+- A ledger JWT with only the rights needed by the backend or probe.
+- A synchronizer id and the DEX/Token Standard package ids.
+- Node.js 24, npm, DPM with the SDK pinned by `trading/daml.yaml`, curl, and
+ Docker Compose if Phase 8 is in scope.
+- A submit-capable CIP-0103/PartyLayer/WalletConnect wallet supported by the
+ deployment. The development mock wallet does not prove live submission.
+
+The backend does **not** load `services/operator-backend/.env` automatically.
+Export variables into the process environment (or use your deployment's secret
+injection) before `npm start`. At minimum, full live mode needs:
+
+```text
+CANTON_LEDGER_URL CANTON_LEDGER_TOKEN
+CANTON_OPERATOR CANTON_LP_REGISTRAR
+CANTON_ADMIN CANTON_DEX_PACKAGE_ID
+CANTON_ALLOC_FACTORY_CID
+CANTON_SETTLE_FACTORY_CID DEX_OPERATOR_API_TOKEN
+OPERATOR_ADMIN_TOKEN
+```
-## Pre-flight (offline)
+When `CANTON_LP_REGISTRAR != CANTON_ADMIN`, full mode also requires
+`CANTON_LP_ALLOC_FACTORY_CID` and `CANTON_LP_SETTLE_FACTORY_CID` for the LP
+registry. `CANTON_SYNCHRONIZER` is strongly recommended and may be required by
+the target participant's routing policy.
-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.
+`CANTON_USER_ID`, `CANTON_NETWORK`, `DB_PATH`, `INDEXER_INTERVAL_MS`, `HOST`,
+and `PORT` are optional. `DEX_CALLER_JWT_SECRET` and
+`DEX_CALLER_JWT_AUDIENCE` enable per-caller party binding for private reads and
+trader-subject writes; if enabled, the dApp also needs a short-lived caller JWT
+whose `sub` is the connected party.
+`DEX_HOSTED_RFQ_RELAY` remains `0` unless a deliberately custodial RFQ scenario
+is in scope; enabling it makes caller binding mandatory.
-```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
-```
+Do not put `DEX_OPERATOR_API_TOKEN`, `OPERATOR_ADMIN_TOKEN`, or the participant
+JWT in a `VITE_*` variable. The Admin page can hold short-lived API tokens in
+the current tab's `sessionStorage`; a public deployment should replace that
+manual test handoff with an authenticated BFF/session issuer.
-- [`run-local-daml-tests.sh`](../../scripts/run-local-daml-tests.sh) — builds
- `canton-dex-trading` and runs the `trading-tests` suite. 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 — Offline pre-flight
-## Phase 0 — Build & upload DARs
+Run from the repository root after installing dependencies:
```bash
-export CANTON_LEDGER_URL=...
-export CANTON_LEDGER_TOKEN=...
-export CANTON_OPERATOR=...
-export CANTON_LP_REGISTRAR=...
-export CANTON_ADMIN=...
-
-./scripts/deploy-testnet.sh
+bash scripts/run-local-daml-tests.sh
+(cd services/registry-client && npm ci && npm run typecheck)
+(cd services/operator-backend && npm ci && npm run typecheck && npm run typecheck:live-scripts && npm test)
+(cd app/web && npm ci && npm test && npm run build)
+bash scripts/backend-http-smoke.sh
```
Expected:
-- `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
- config as "created" (or "already configured" on a re-run).
-- The same run reports `Registry.V2 created` (or "already present") for the
- lpRegistrar. Liquidity cannot be allocated until this step has run.
-- If a `registryV2` block is configured, a second registry under
- `CANTON_ADMIN` plus one line per instrument. Nothing can be minted
- until that has run.
-
-## Phase 1 — Backend boot
+
+- [ ] Every command exits 0.
+- [ ] The Daml runner reports every selected script `ok`.
+- [ ] The HTTP smoke ends with `All backend HTTP smoke checks passed`.
+- [ ] The smoke is recorded only as an in-memory selected-route check; it does
+ not prove successful writes or live Canton.
+
+## Phase 1 — Deployment readiness
+
+Follow [Run on a Testnet](run-on-testnet.md) for build, upload, party, and
+registry bootstrap. Then capture independent evidence:
+
+- [ ] `canton-dex-trading` resolves to the expected package id.
+- [ ] The Token Standard V2 allocation request/instruction packages required by
+ the live probes resolve to the expected ids.
+- [ ] Operator, admin, LP registrar, test traders, LP, swapper, and dealers are
+ allocated and connected to the intended synchronizer.
+- [ ] The asset-admin and (when distinct) LP-registrar `Registry.V2` contracts
+ plus required base/quote/LP instruments exist.
+- [ ] `CANTON_ALLOC_FACTORY_CID` and `CANTON_SETTLE_FACTORY_CID` identify the
+ intended asset-admin registry, not `PENDING_*` placeholders.
+- [ ] With distinct registrars, both `CANTON_LP_*_FACTORY_CID` values identify
+ the LP registry rather than reusing the asset registry.
+- [ ] The test pool is uniquely identified by pair and `POOL_ID` if more than
+ one pool uses that pair.
+
+## Phase 2 — Backend and authentication
+
+With the environment exported, start the live server:
```bash
cd services/operator-backend
-npm install
+npm ci
npm start
```
-Expected logs (JSON, one per line):
-```
-{"ts":"...","level":"info","msg":"server started","component":"testnet-server","url":"...","ledger":"..."}
+In a second terminal:
+
+```bash
+curl -fsS http://127.0.0.1:8080/v1/status
+curl -fsS http://127.0.0.1:8080/v1/context
+curl -fsS http://127.0.0.1:8080/v1/pools
+curl -sS -o /dev/null -w '%{http_code}\n' \
+ -X POST -H 'Content-Type: application/json' -d '{}' \
+ http://127.0.0.1:8080/v1/admin/pairs
```
-Health checks:
-- [ ] `curl http://localhost:8080/v1/status` returns `{network, slot, synced:true}`
-- [ ] `curl http://localhost:8080/v1/context` returns operator/admin/lpRegistrar + factory CIDs
-- [ ] `curl http://localhost:8080/v1/pools` returns `[]` (no pools yet) or seeded pools
+- [ ] Startup logs identify the expected ledger URL, parties, network, DB, and
+ `mode:"full"`.
+- [ ] Status reports `synced:true`; context contains the expected parties and
+ factory CIDs.
+- [ ] The unauthenticated admin write returns 401.
+- [ ] A request with a wrong admin token returns 401.
+- [ ] A request with a wrong operator token to a non-admin write returns 401.
+- [ ] If caller binding is enabled, a missing/invalid `X-Caller-Token` returns
+ 401, while a valid token for a different party returns 403.
+- [ ] The same caller-binding check covers scoped orders, holdings, balances,
+ trades, RFQ history, and RFQ/quote reads; an admin token can inspect them.
+- [ ] Read-only mode, if tested, was explicitly started with `DEX_READ_ONLY=1`
+ and is not signed off for write scenarios.
+
+Use the payload examples in [HTTP API](../reference/http-api.md) for an
+authenticated write; do not use `{}` as a success-case payload.
-## Phase 2 — Frontend boot
+## Phase 3 — dApp and wallet connection
+
+Create `app/web/.env.local` with only public deployment configuration. For a
+local Vite validation run:
```bash
cd app/web
-cp .env.example .env.local
-# Set:
-# VITE_API_BASE=http://localhost:8080
-# VITE_CANTON_LEDGER_URL=$CANTON_LEDGER_URL
-# VITE_CANTON_AUTH_TOKEN=$CANTON_LEDGER_TOKEN
-# VITE_CANTON_NETWORK_ID=canton:testnet
-# VITE_WC_PROJECT_ID=... (optional, for WalletConnect)
-npm install
+npm ci
npm run dev
```
-Open . Smoke checks:
-- [ ] All 6 pages render without crash (TradePage, Pools, Orders, RFQ,
- Portfolio, Admin)
-- [ ] Error boundaries do NOT trigger (no red banners)
-- [ ] Connect Wallet menu shows: Token Standard, WalletConnect (if
- configured), Mock (DEV only — should be absent in prod build)
-
-## Phase 3 — Wallet provider validation
-
-### 3.1 Token Standard provider
-- [ ] Click "Connect Wallet" → Token Standard
-- [ ] Connection succeeds; party id displayed
-- [ ] Reload page; session persists, no re-prompt
-- [ ] Click Disconnect; localStorage `canton-dex:token-standard:session` cleared
-
-### 3.2 WalletConnect (if `VITE_WC_PROJECT_ID` set)
-- [ ] Connect → QR modal opens, scannable
-- [ ] After mobile wallet pairing, primary party returned
-- [ ] Cancel during pairing surfaces error message, NOT a stuck state
-
-### 3.3 Direct Canton (advanced fallback)
-- [ ] With `VITE_CANTON_LEDGER_URL` + `VITE_CANTON_AUTH_TOKEN` set,
- Direct Canton appears in the menu
-- [ ] Connect succeeds via `/v2/users/current`
-
-## Phase 4 — Operator admin operations
-
-Requires `OPERATOR_ADMIN_TOKEN`.
-
-- [ ] Create new pair (DOGE/USDC) via Admin page → 200, pair listed
- via `GET /v1/pairs`
-- [ ] Toggle pair active/inactive → state reflected on next GET
-- [ ] Update fee model → new fees take effect
-- [ ] Create pool for DOGE/USDC → 200, pool listed via `GET /v1/pools`
-- [ ] Admin write WITHOUT bearer token → 401 with structured error
- envelope
-
-## 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
-- [ ] Operator backend observes and binds via OrderFundingRequest_Bind
-- [ ] Order appears in `GET /v1/orders?trader=...`
-
-### 5.2 Order matching
-- [ ] Place a crossing sell order (price ≤ existing buy)
-- [ ] `POST /v1/orders/match {base,quote}` returns 1 match
-- [ ] After settle, both orders archived (or remaining qty updated for partial)
-- [ ] The settled fill appears in `GET /v1/trades` (a `SettledTrade` row, with
- `dealer` null and both parties across `trader` / `counterparty`)
-
-### 5.3 Pool swap
-- [ ] Quote: `POST /v1/swaps/quote` returns positive output for 0.01 BTC
-- [ ] Submit swap intent through wallet → on-ledger PoolRules_Swap exercised
-- [ ] Pool reserves update; swap appears in `GET /v1/swaps`
-
-### 5.4 Add liquidity (two-call DvP)
-- [ ] `POST /v1/pools/add-liquidity/request` → operator creates a
- LiquidityAllocationRequest
-- [ ] Wallet authors the base-deposit, quote-deposit, and LP-receipt
- allocations via AllocationFactory_Allocate
-- [ ] `POST /v1/pools/add-liquidity/settle` → operator + lpRegistrar
- settle (PoolLiquidityRules_SettleAddLiquidity); funds enter the pool and
- LP tokens are minted to the LP atomically
-- [ ] LP tokens minted (visible in Portfolio page LP section)
-- [ ] Pool reserves grow proportionally
-
-### 5.5 Remove liquidity (two-call DvP)
-- [ ] `POST /v1/pools/remove-liquidity/request` → operator creates a
- LiquidityAllocationRequest
-- [ ] Wallet authors the holder's base-receipt + quote-receipt + 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
-
-### 5.6 RFQ
-- [ ] Trader creates RFQ via `POST /v1/rfq`
-- [ ] Dealer posts a quote (separate wallet session)
-- [ ] Trader+operator co-sign accept via `POST /v1/rfq/accept`
-- [ ] PolicyReceipt returned and matches verifyReceipt()
-- [ ] After expiry, `sweepExpired` cancels stale RFQs (verify via
- logs after manually setting an RFQ's expiry in the past)
-
-## Phase 6 — Resilience
-
-- [ ] Send SIGTERM to backend; logs show graceful shutdown
- (indexer stop → http close → db close)
-- [ ] Restart backend; indexer resumes from last persisted offset
-- [ ] Crash backend mid-submission; idempotency table prevents
- duplicate commit on restart
-- [ ] Submit malformed JSON to `/v1/swaps/quote` → 400 with `code: bad_request`
-- [ ] Submit oversized body (>1 MiB) → 413 with `code: payload_too_large`
-
-## Phase 7 — Observability
-
-- [ ] Every request log line has `requestId`, `method`, `path`,
- `status`, `durationMs`
-- [ ] Every error log line goes to stderr (verify by redirecting)
-- [ ] `X-Request-Id` header echoed back when supplied; generated otherwise
-
-## Phase 8 — Frontend validation
-
-- [ ] Error boundary triggered by throwing in a child component
- surfaces the retry card without taking down the page shell
-- [ ] Disconnect mid-transaction shows clear error message
-- [ ] Page refresh after disconnect → no auto-reconnect, clean
- "Connect Wallet" state
-
-## Phase 9 — Docker compose deployment
-
-- [ ] `docker-compose up` brings both services up
-- [ ] Frontend at port 80 proxies `/v1/*` to backend
-- [ ] `docker-compose restart backend` does not lose indexer state
- (volume persistence)
-- [ ] CORS narrowed when `ALLOWED_ORIGINS` set
-
-## Sign-off
-
-Mark this plan ✅ once every checkbox above is verified against a
-real Canton testnet validator.
+Open and validate all six routes: Trade, Pools, Orders,
+RFQ, Portfolio, and Admin.
+
+- [ ] No route triggers its error boundary.
+- [ ] The intended production-capable wallet is shown and connects.
+- [ ] The connected party is the dedicated test trader.
+- [ ] Reload and disconnect behave as documented by that provider.
+- [ ] A cancelled/rejected wallet approval returns the UI to a usable state.
+- [ ] The mock and dev-only relay/direct providers are not treated as evidence
+ of production wallet compatibility.
+
+For this controlled validation only, enter short-lived operator/admin API
+tokens in **Admin → API session credentials**. If per-caller binding is enabled,
+enter the test trader's scoped caller JWT too.
+
+- [ ] Browser network requests attach the admin token only to `/v1/admin/*`
+ writes and the operator token only to other writes.
+- [ ] Credentials disappear when the tab session is cleared.
+- [ ] No token appears in screenshots, console output, committed files, or the
+ built JavaScript bundle.
+
+## Phase 4 — Automated live corroboration
+
+Use a throwaway LocalNet for self-contained probes. Use the shared validator
+only with dedicated parties/pools and explicit approval to leave test state.
+Export each script's full environment from the [Testing
+reference](../reference/testing.md#live-canton-probes), then run from
+`services/operator-backend`:
+
+```bash
+CANTON_LIVE_RFQ=1 npm run test:live:rfq
+npm run live:roundtrip
+npm run testnet:seed-pool
+npm run live:matched-trade
+```
+
+- [ ] RFQ test checks exact RFQ/quote/trade CIDs and the stored policy receipt.
+- [ ] AMM round trip prints its unique run id and passes exact add, swap, and
+ partial-remove reserve/holding/slice/LP assertions plus the documented
+ invariant and conservation checks.
+- [ ] Existing-pool probe passes add/swap reserve, holding, slice, and invariant
+ assertions against the selected dedicated pool.
+- [ ] Matched-trade probe passes the sender/receiver holding assertions.
+- [ ] Results are mapped only to the boundaries in the table at the top; in
+ particular, the direct JSON API round trip is not cited as evidence for
+ the backend HTTP, browser, or real-wallet transport.
+
+## Phase 5 — Browser trader and admin flows
+
+For each scenario, capture the wallet approval, backend request id, resulting
+ledger update/contract ids, and the refreshed UI state. Use small test amounts.
+
+### Admin
+
+- [ ] Create or select a dedicated test pair and pool with the admin credential.
+- [ ] Update its supported fee/trading configuration and observe it on the next
+ GET.
+- [ ] Repeat one write without the admin credential and observe 401.
+
+### Order lifecycle
+
+- [ ] Place a non-crossing order; the wallet signs the trader-authorized
+ funding transaction and the order appears in `/v1/orders`.
+- [ ] Cancel it and verify the active contract disappears.
+- [ ] Place crossing buy/sell orders using two test traders, run the match route,
+ and verify the resulting fill/history and balances.
+
+### Swap
+
+- [ ] Obtain a positive quote for a small input.
+- [ ] Approve and submit the wallet intent, then verify input/output balances and
+ the exact reserve transition.
+- [ ] Verify the swap/history projection after at least one indexer interval.
+
+### Add liquidity
+
+- [ ] The request route creates one `LiquidityAllocationRequest`.
+- [ ] The wallet authors base-deposit, quote-deposit, and LP-receipt
+ allocations.
+- [ ] The settle route consumes the request/allocations atomically; reserves and
+ LP supply increase by the expected values and the LP holding is visible.
+
+### Remove liquidity
+
+- [ ] The request route creates a remove `LiquidityAllocationRequest`.
+- [ ] The wallet authors base-receipt, quote-receipt, and LP-burn allocations.
+- [ ] Settle reduces reserves and LP supply by the expected values and delivers
+ base/quote holdings to the LP.
+
+The automated AMM round trip corroborates remove-liquidity directly through
+the JSON Ledger API. This manual scenario is still required to establish the
+different boundary under review here: browser state, backend authorization,
+wallet approval/transport, and the deployed party-rights configuration.
+
+### RFQ
+
+- [ ] Trader creates an RFQ and a whitelisted dealer posts a quote from a
+ separate authorized session.
+- [ ] Trader/operator accept returns a verifying `PolicyReceipt`; the exact
+ receipt is stored on the resulting `MatchedTrade`.
+- [ ] Fund and settle the matched trade, then verify both assets moved. The live
+ RFQ automated test stops before this step and cannot substitute for it.
+- [ ] Create an already-expired fixture through an approved test setup, invoke
+ the deployment's RFQ sweep job, and verify the operator archives it. The
+ reference exposes `sweepExpired` as a service method but does not include
+ a standalone scheduler/CLI, so mark this **Blocked** if the deployment has
+ no job entrypoint.
+
+## Phase 6 — Failure handling and observability
+
+- [ ] Malformed JSON returns 400 with `code:"bad_request"` and a request id.
+- [ ] A body over 1 MiB returns 413 with `code:"payload_too_large"`.
+- [ ] A supplied `X-Request-Id` is echoed; otherwise the server creates one.
+- [ ] Request logs contain request id, method, path, status, and duration.
+- [ ] Ledger/authorization failures preserve a useful structured error without
+ leaking JWTs or API tokens.
+- [ ] Send SIGTERM to the backend process, observe graceful HTTP/indexer/DB
+ shutdown, restart with the same `DB_PATH`, and verify status/history.
+
+Do not claim crash/idempotency recovery from the restart check alone. A
+mid-submission fault requires a controlled fault-injection harness and evidence
+that only one ledger update committed; mark it **Blocked** if that harness is
+not available.
+
+## Phase 7 — Frontend failure states
+
+- [ ] Disconnect/reject during a transaction shows a clear retryable error.
+- [ ] Refresh after disconnect returns to a clean Connect Wallet state.
+- [ ] Stop the backend temporarily; each page shows a bounded error state and
+ recovers after the backend restarts.
+- [ ] An authorization failure is distinguishable from wallet rejection and
+ from ledger validation failure.
+
+## Phase 8 — Docker Compose, if deployed that way
+
+Run from the repository root with all Compose variables exported:
+
+```bash
+docker compose up --build
+```
+
+- [ ] Backend starts in the intended full/read-only mode; neither API token is
+ blank in full mode.
+- [ ] Frontend on port 80 proxies `/v1/*` to the backend.
+- [ ] `docker compose restart backend` retains indexer state in the named
+ volume.
+- [ ] `ALLOWED_ORIGINS` is restricted to the deployed dApp origin.
+- [ ] Secrets are injected at runtime and absent from the frontend image/bundle.
+
+## Cleanup and sign-off
+
+- [ ] Cancel every still-cancellable RFQ, quote, order, allocation request, and
+ matched trade created by the test.
+- [ ] Record contracts that cannot be cleaned up safely (for example the
+ accepted RFQ test's unmatched trade) and the owner responsible.
+- [ ] Stop/remove the throwaway LocalNet. For shared testnet state, do not delete
+ or mutate contracts outside the recorded run ids and dedicated pool.
+- [ ] Remove tokens from `sessionStorage`, shell history where applicable, and
+ temporary environment files; revoke short-lived credentials.
+- [ ] Attach the Pass/Fail/Blocked/N/A matrix and evidence links to the release
+ record. Any required **Fail** or **Blocked** scenario prevents sign-off.
---
diff --git a/docs/reference/allocation-surface.md b/docs/reference/allocation-surface.md
index 4cedc8bc..3ae33a19 100644
--- a/docs/reference/allocation-surface.md
+++ b/docs/reference/allocation-surface.md
@@ -158,7 +158,7 @@ transaction and references the returned allocation. There is no separate
funding-mutation step between settlement and order roll-forward.
`testOrderRemainderFundingArithmetic` in
-[`trading-tests/CantonDex/Tests/EndToEndTests.daml`](../../trading-tests/CantonDex/Tests/EndToEndTests.daml)
+[`OrderWorkflowTests.daml`](../../trading-tests/CantonDex/Tests/OrderWorkflowTests.daml)
checks the matcher-side residual calculation. The real-holding conservation
checks are in
[`RegistryConservationTests.daml`](../../trading-tests/CantonDex/Tests/RegistryConservationTests.daml),
diff --git a/docs/reference/daml-proof-map.md b/docs/reference/daml-proof-map.md
new file mode 100644
index 00000000..1bcd94b0
--- /dev/null
+++ b/docs/reference/daml-proof-map.md
@@ -0,0 +1,132 @@
+# Daml design-to-test proof map
+
+Use this page when a design statement says “proven by.” Each row links the
+on-ledger choice to the smallest Daml Script that demonstrates the stated
+property and gives a focused command. Run commands from `trading-tests/`.
+
+```bash
+cd trading-tests
+dpm test -p
+```
+
+## Read the fixture before trusting the claim
+
+The repository has two kinds of Daml fixture:
+
+- The four `*WorkflowTests.daml` suites use `MockRegistry`. They prove DEX choice choreography,
+ authority, contract consumption/recreation, and the allocation specification
+ passed to settlement. They do **not** prove real holding balances.
+- Rows that claim real value movement point to suites using
+ `CantonDex.Registry.V2` holdings, including `PoolLiquidityRulesTests.daml`,
+ `PoolRoundingTests.daml`, `PoolStateInvariantTests.daml`,
+ `RealRegistryDvpTests.daml`, `RegistryConservationTests.daml`,
+ `RfqSettlementTests.daml`, and the real-value lifecycle tests. These can prove
+ locked backing, exact balance movement, release, and conservation in Daml Script.
+
+Neither fixture starts a Canton participant or drives the HTTP API, browser, or
+external wallet. Those are separate integration proofs.
+
+## Pair listing metadata
+
+Source: [`DexPair`](../../trading/CantonDex/Dex/DexPair.daml) and its
+operator-controlled update choices.
+
+| Claim | Executable proof | Focused command |
+|---|---|---|
+| Fee model, active flag, trading mode, and readers recreate one successor listing and preserve unrelated fields. | [`testDexPairLifecycleUpdates`](../../trading-tests/CantonDex/Tests/DexPairTests.daml) | `dpm test -p testDexPairLifecycleUpdates` |
+| The registry admin observes the pair but cannot exercise the operator-controlled update. | [`testDexPairUpdatesRequireOperator`](../../trading-tests/CantonDex/Tests/DexPairTests.daml) | `dpm test -p testDexPairUpdatesRequireOperator` |
+| Maker/taker fee counters accumulate the configured arithmetic; no test claims those counters move or collect assets. | [`testDexPairRecordsMatchedTradeFees`](../../trading-tests/CantonDex/Tests/DexPairTests.daml) | `dpm test -p testDexPairRecordsMatchedTradeFees` |
+
+`active` and `tradingMode` are not settlement gates in this reference. That is
+a dependency fact visible in the source: [`PoolRules`](../../trading/CantonDex/Dex/PoolRules.daml)
+and [`OrderMatchExecution`](../../trading/CantonDex/Dex/OrderMatchExecution.daml)
+do not fetch or accept a `DexPair` contract. The tests above intentionally prove
+listing behavior only; do not cite them as pause/enforcement tests.
+
+## AMM pool
+
+Core sources:
+
+- pricing and ratio math — [`ratioMatchedDeposit`](../../trading/CantonDex/Dex/PoolModel.daml)
+ and [`constantProductOut`](../../trading/CantonDex/Dex/PoolModel.daml);
+- exact quote construction and swap — [`PoolRules_RequestSwap`](../../trading/CantonDex/Dex/PoolRules.daml),
+ [`PoolRules_Swap`](../../trading/CantonDex/Dex/PoolRules.daml);
+- add/remove DvP — [`PoolLiquidityRules_SettleAddLiquidity`](../../trading/CantonDex/Dex/PoolLiquidityRules.daml),
+ [`PoolLiquidityRules_SettleRemoveLiquidity`](../../trading/CantonDex/Dex/PoolLiquidityRules.daml);
+- real allocation and batch implementation — [`AllocationFactory`](../../trading/CantonDex/Registry/V2.daml),
+ [`SettlementFactory`](../../trading/CantonDex/Registry/V2.daml).
+
+| Claim | Executable proof | Focused command |
+|---|---|---|
+| Swap output rounds down and does not reduce `x*y` through decimal overpayment. | [`testSwapOutputRoundsDownToKeepConstantProduct`](../../trading-tests/CantonDex/Tests/PoolRoundingTests.daml) | `dpm test -p testSwapOutputRoundsDownToKeepConstantProduct` |
+| The Daml-built request specification reaches `PoolRules_Swap` and its mock settlement choice against the same bound state and slices. | [`testPoolSwapViaRequestSwap`](../../trading-tests/CantonDex/Tests/PoolWorkflowTests.daml) | `dpm test -p testPoolSwapViaRequestSwap` |
+| A context-requiring V2 registry consumes actual trader backing and creates the output holding; a changed signed output is rejected. | [`testRealRegistryDvpSwapSettles`](../../trading-tests/CantonDex/Tests/RealRegistryDvpTests.daml) | `dpm test -p testRealRegistryDvpSwapSettles` |
+| A stale add-liquidity quote cannot settle against a successor pool state. | [`testStaleQuoteRejected`](../../trading-tests/CantonDex/Tests/PoolLiquidityRulesTests.daml) | `dpm test -p testStaleQuoteRejected` |
+| Add liquidity moves real base/quote backing and mints real LP holdings in one DvP flow. | [`testDvpAddLiquidity`](../../trading-tests/CantonDex/Tests/PoolLiquidityRulesTests.daml) | `dpm test -p testDvpAddLiquidity` |
+| Off-ratio excess is returned rather than donated or used to mint shares. | [`testDvpAddOffRatioRefundsExcess`](../../trading-tests/CantonDex/Tests/PoolLiquidityRulesTests.daml) | `dpm test -p testDvpAddOffRatioRefundsExcess` |
+| Complete LP redemption drains multiple slices, returns real assets, burns every LP holding, and changes state to `Unfunded`. | [`testDvpMultiSliceRemove`](../../trading-tests/CantonDex/Tests/PoolLiquidityRulesTests.daml) | `dpm test -p testDvpMultiSliceRemove` |
+| Pool initialization, pause, and resume are actual state transitions; pause rejects a swap and resume preserves reserve and LP-supply accounting. | [`testPoolFullLifecycle`](../../trading-tests/CantonDex/Tests/PoolWorkflowTests.daml) | `dpm test -p testPoolFullLifecycle` |
+| Aggregate reserves equal the active slice sums after add, swap, and complete remove. | [`testReconcileAfterAddSwapRemove`](../../trading-tests/CantonDex/Tests/PoolStateInvariantTests.daml) | `dpm test -p testReconcileAfterAddSwapRemove` |
+| Liquidity settlement requires both operator and LP registrar authority. | [`testSettleRequiresCoControl`](../../trading-tests/CantonDex/Tests/PoolLiquidityRulesTests.daml) | `dpm test -p testSettleRequiresCoControl` |
+
+## Resting orders
+
+Source path: [`OrderFundingRequest_Bind`](../../trading/CantonDex/Dex/OrderFundingRequest.daml)
+→ [`Order_Fund`](../../trading/CantonDex/Dex/Order.daml) →
+[`OrderMatchExecution_Execute`](../../trading/CantonDex/Dex/OrderMatchExecution.daml)
+or [`Order_Cancel`](../../trading/CantonDex/Dex/Order.daml).
+
+| Claim | Executable proof | Focused command |
+|---|---|---|
+| Trader intent becomes an operator-bound pending order, then a trader-authored allocation is attached to it. | [`testOrderFundingFlow`](../../trading-tests/CantonDex/Tests/OrderWorkflowTests.daml) | `dpm test -p testOrderFundingFlow` |
+| A match outside either signed limit fails. | [`testOrderMatchEnforcesLimitPrice`](../../trading-tests/CantonDex/Tests/OrderWorkflowTests.daml) | `dpm test -p testOrderMatchEnforcesLimitPrice` |
+| Settlement and both partial-order roll-forwards occur atomically. | [`testOrderMatchRollsOrdersForwardAtomically`](../../trading-tests/CantonDex/Tests/OrderWorkflowTests.daml) | `dpm test -p testOrderMatchRollsOrdersForwardAtomically` |
+| A real partial fill can spend only the funding budget carried into its next allocation iteration. | [`testPartialFillUsesRolledFundingBudget`](../../trading-tests/CantonDex/Tests/RegistryConservationTests.daml) | `dpm test -p testPartialFillUsesRolledFundingBudget` |
+| Cancelling a funded order consumes the real allocation and returns its locked holding unlocked. | [`testOrderCancelReleasesRealFunding`](../../trading-tests/CantonDex/Tests/LifecycleChoiceTests.daml) | `dpm test -p testOrderCancelReleasesRealFunding` |
+| Trader controls pre-bind cancel; operator controls reject. | [`testOrderFundingRequestCancelAndRejectAuthority`](../../trading-tests/CantonDex/Tests/LifecycleChoiceTests.daml) | `dpm test -p testOrderFundingRequestCancelAndRejectAuthority` |
+| Operator can abort an unexecuted match proposal without touching referenced orders or allocations. | [`testOrderMatchExecutionAbort`](../../trading-tests/CantonDex/Tests/LifecycleChoiceTests.daml) | `dpm test -p testOrderMatchExecutionAbort` |
+
+## RFQ and OTC
+
+Source path: [`Rfq_Accept`](../../trading/CantonDex/Dex/Rfq.daml) creates a
+`MatchedTrade`; [`MatchedTrade_RequestAllocations`](../../trading/CantonDex/Dex/MatchedTrade.daml)
+and [`MatchedTrade_Settle`](../../trading/CantonDex/Dex/MatchedTrade.daml)
+move its value, while [`MatchedTrade_Cancel`](../../trading/CantonDex/Dex/MatchedTrade.daml)
+is the abandoned-trade exit.
+
+| Claim | Executable proof | Focused command |
+|---|---|---|
+| RFQ accept ranks quotes and records a policy receipt on the resulting trade; it does not move balances yet. | [`testRfqAcceptProducesMatchedTradeWithReceipt`](../../trading-tests/CantonDex/Tests/TradeWorkflowTests.daml) | `dpm test -p testRfqAcceptProducesMatchedTradeWithReceipt` |
+| Accepted RFQ terms settle against real holdings with exact balance deltas and no stranded locks. | [`testRfqBuySettlesAgainstRealHoldings`](../../trading-tests/CantonDex/Tests/RfqSettlementTests.daml) | `dpm test -p testRfqBuySettlesAgainstRealHoldings` |
+| The inherited RFQ deadline blocks later settlement; the failed transaction leaves the allocations and locked funds unchanged. | [`testExpiryBetweenAcceptAndSettleBlocksTheSettle`](../../trading-tests/CantonDex/Tests/RfqSettlementTests.daml) | `dpm test -p testExpiryBetweenAcceptAndSettleBlocksTheSettle` |
+| A cross-admin OTC trade uses per-admin batches but remains one atomic Daml transaction. | [`testMatchedTradeSettlesPerAdminLegSubsets`](../../trading-tests/CantonDex/Tests/RealRegistryDvpTests.daml) | `dpm test -p testMatchedTradeSettlesPerAdminLegSubsets` |
+| Cancelling a proposed trade archives its requests/allocations and returns real sender backing without executing the leg. | [`testMatchedTradeCancelReleasesRealFunding`](../../trading-tests/CantonDex/Tests/LifecycleChoiceTests.daml) | `dpm test -p testMatchedTradeCancelReleasesRealFunding` |
+| Trader controls RFQ cancellation; dealer controls quote withdrawal. | [`testRfqCancelAndQuoteWithdrawAuthority`](../../trading-tests/CantonDex/Tests/LifecycleChoiceTests.daml) | `dpm test -p testRfqCancelAndQuoteWithdrawAuthority` |
+
+## Token Standard safety properties used by every surface
+
+| Claim | Executable proof | Focused command |
+|---|---|---|
+| Executor-supplied extra legs cannot exceed locked allocation backing. | [`testExtraLegBeyondBackingRejected`](../../trading-tests/CantonDex/Tests/RegistryConservationTests.daml) | `dpm test -p testExtraLegBeyondBackingRejected` |
+| Roll-forward carries actual locked backing, not an accounting-only budget. | [`testRollForwardCarriesLockedBacking`](../../trading-tests/CantonDex/Tests/RegistryConservationTests.daml) | `dpm test -p testRollForwardCarriesLockedBacking` |
+| An uncommitted allocation is withdrawable only by its authorizer. | [`testUncommittedAllocationWithdrawsOnlyAsAuthorizer`](../../trading-tests/CantonDex/Tests/RegistryConservationTests.daml) | `dpm test -p testUncommittedAllocationWithdrawsOnlyAsAuthorizer` |
+| A committed allocation is authorizer-withdrawable after its deadline, but not before. | [`testCommittedAllocationWithdrawsOnlyAfterDeadline`](../../trading-tests/CantonDex/Tests/RegistryConservationTests.daml) | `dpm test -p testCommittedAllocationWithdrawsOnlyAfterDeadline` |
+| A deadline-free committed pool allocation is not unilaterally withdrawable. | [`testCommittedAllocationWithoutDeadlineCannotBeWithdrawn`](../../trading-tests/CantonDex/Tests/RegistryConservationTests.daml) | `dpm test -p testCommittedAllocationWithoutDeadlineCannotBeWithdrawn` |
+
+## Run by module, then run everything
+
+```bash
+cd trading-tests
+dpm test --files CantonDex/Tests/DexPairTests.daml
+dpm test --files CantonDex/Tests/LifecycleChoiceTests.daml
+dpm test --files CantonDex/Tests/PoolLiquidityRulesTests.daml
+dpm test --files CantonDex/Tests/RealRegistryDvpTests.daml
+dpm test
+```
+
+The final command is the release check. A focused test explains one invariant;
+the complete suite catches interactions between workflows.
+
+**Where to read next:** [Builder guide](../guides/builder-guide.md) ·
+[Workflow design](../concepts/workflows.md) ·
+[Testing reference](testing.md)
diff --git a/docs/reference/ecosystem-feedback.md b/docs/reference/ecosystem-feedback.md
index d6b6d08d..b85ff47f 100644
--- a/docs/reference/ecosystem-feedback.md
+++ b/docs/reference/ecosystem-feedback.md
@@ -4,57 +4,52 @@ This page records how the reference implementation was evaluated by external
parties, what they found, and what changed as a result. It is maintained as the
single summary of that loop.
+> **Status of the old hosted integration.** The evaluation below used a
+> separately operated deployment during a historical feedback round. This
+> repository does **not** provision a public hostname, public party faucet, or
+> `/v1/testnet/*` API, and it does not promise that the old deployment remains
+> available. Treat the linked reports as provenance for the feedback—not as
+> current setup instructions. The API implemented in this tree is listed in
+> [HTTP API](http-api.md); run it against a participant you control by following
+> the [local live-ledger guide](../guides/localnet.md).
+
## External integration (reuse proof point)
-The reference DEX is integrated as an adapter in
+During that feedback round, the reference DEX was integrated as an adapter in
[**canton-trading-toolkit**](https://github.com/olevasyliev/canton-trading-toolkit),
an independent, open-source, venue-agnostic trading client for the Canton
-Network. The toolkit is live-validated on mainnet against an unrelated spot AMM
-(Cantex) and connects to a perpetuals testnet (Ekiden); this DEX is a third
-adapter (`DexRefAdapter`). The same client code that
-trades on an unrelated mainnet venue drives quotes, swaps, orders, matching, RFQ
-and liquidity on this one, entirely through the hosted testnet routes: the only
-path open to a party with no wallet of its own.
-
-The integration is reproducible from outside with no operator credentials:
-
-```
-git clone https://github.com/olevasyliev/canton-trading-toolkit
-cd canton-trading-toolkit && pip install -e .
-PYTHONPATH=src python3 scripts/dexref_testnet_report.py # reads only
-PYTHONPATH=src python3 scripts/dexref_testnet_report.py --execute # trades
-```
-
-The client allocates its own parties from the public faucet and exercises every
-flow against `https://testnet-dex.bitdynamics.cc`. An external developer built a
-working integration against the hosted testnet, from the public repository, and
-published it.
+Network. Its `DexRefAdapter` supplied useful independent feedback on quotes,
+swaps, orders, matching, RFQ, and liquidity. The adapter and the reports are
+external artifacts. Their deployment wrapper—including any party provisioning,
+rate limits, or convenience endpoints—is not implemented by this repository.
## Evaluation and feedback
-The integrator ran six rounds against the hosted testnet between 2026-07-27 and
-2026-07-29, plus an earlier round against the repository's local demo mode. Each
-round is a scripted run of dozens of assertions measured through the public
-routes. The reports are public:
+The integrator reported six rounds against the separately operated deployment
+between 2026-07-27 and 2026-07-29, plus an earlier round against the
+repository's local demo mode. The reports are public:
- Hosted testnet report:
[srikanth-bitdynamics/Canton-Dex-Reference-Implementation#126](https://github.com/srikanth-bitdynamics/Canton-Dex-Reference-Implementation/issues/126)
- Local demo mode report:
[canton-dev-fund#312 comment](https://github.com/canton-foundation/canton-dev-fund/issues/312#issuecomment-5044174855)
-Because the integrator has no privileged access, the findings are exactly what any
-external builder would hit.
+The reports document what that external client observed at the time. The
+regression tests named below are the durable evidence for behavior in the
+current repository.
## Findings and resulting changes
-Every finding from the six rounds was addressed. They fall into a few themes.
-Each theme below closes with the test that pins the fix.
+The findings that changed this repository fall into a few themes. Changes made
+only in the old external deployment wrapper are not presented as current API
+features. Each theme below closes with the checked-in 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 routes deltas directly through
-`parseFloat().toFixed`; `/v1/swaps` serves the exact stored strings; and
+re-floated through IEEE-754. The indexer derives reserve deltas with the
+fixed-point decimal module, stores them as strings, and `/v1/swaps` serves those
+exact strings. In addition,
`/v1/instruments` reports each instrument's `decimals` so a client can learn
scale from the API. Existing projection rows can be reindexed after an upgrade.
@@ -91,7 +86,7 @@ recorded), and
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
at a time. An off-ratio liquidity add refunds the unmatched remainder, and the
-hosted receipt reports settled amounts rather than echoing requested amounts.
+settlement result reports settled amounts rather than echoing requested amounts.
Proven by
[`normalize-funding.test.ts`](../../app/web/src/__tests__/normalize-funding.test.ts)
@@ -101,15 +96,15 @@ split handed to the wallet) and `testDvpAddOffRatioRefundsExcess` in
(the unmatched leg is refunded in the same settlement, never reaching the
reserves).
-### The hosted routes are the only path in
+### External clients need a complete, documented API
-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. Order matching gained a hosted testnet
-trigger (`POST /v1/testnet/match`) so matching and its atomic settlement can be
-verified from outside. `/v1/swaps` accepts `?kind=` so liquidity events, not
-just swaps, are readable. The `/v1/testnet/*` surface and the faucet's per-IP
-party quota are documented with their consequences.
+The feedback exposed missing operations in the external deployment wrapper.
+The corresponding capabilities that remain in this repository use the normal
+operator API: `POST /v1/rfq/:cid/cancel`, operator-authenticated
+`POST /v1/orders/match`, and `GET /v1/swaps?kind=`. The first two are writes and
+therefore require the appropriate operator and caller authority described in
+[HTTP API](http-api.md#authorization). There is no
+`/v1/testnet/*` namespace or public faucet in this tree.
Proven by
[`swaps-kind-filter.test.ts`](../../services/operator-backend/test/swaps-kind-filter.test.ts)
@@ -120,10 +115,12 @@ 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. 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).
+Holding preparation is a wallet concern in self-custodial flows. The only
+generic command relay in this repository is the explicitly development-only
+`POST /v1/wallet/submit`; it is disabled by default, requires an operator token,
+and restricts `actAs` parties when enabled. It is not a public onboarding or
+custody service. The boundary is described in
+[Non-goals: the development relay is not a wallet](../concepts/non-goals.md#the-development-relay-is-not-a-wallet).
### Self-trade prevention
@@ -148,10 +145,11 @@ crosses a different maker's ask and skips its own).
## How this loop is expected to continue
-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.
+The reference tracks the same standard the ecosystem builds against. Integrators
+can evaluate a checkout with the repository's local live-ledger proof or deploy
+their own instance, then open a reproducible issue on the implementation
+repository. Confirmed findings should be fixed with a regression test and this
+summary updated.
---
diff --git a/docs/reference/http-api.md b/docs/reference/http-api.md
index 4caca31a..3a32da21 100644
--- a/docs/reference/http-api.md
+++ b/docs/reference/http-api.md
@@ -9,23 +9,29 @@ before reading the endpoint tables:
a party's holdings, trade and swap history. Reads never move value and, with
two scoping exceptions below, need no authorization.
2. **Orchestration writes.** Administrative and settlement commands the
- operator is authorized to submit, plus explicitly documented hosted-party
- RFQ relay routes. These are gated by a bearer token.
+ operator is authorized to submit, plus the explicitly documented
+ operator-mediated RFQ routes. These are gated by a bearer token.
Order funding, holding allocation, swaps, and LP actions preserve a
self-custodial boundary: a trader wallet authors the allocation and this API
-only requests or settles it. The RFQ endpoints are the exception. They submit
-as hosted trader parties, and RFQ acceptance also submits as the operator, so
-the backend ledger user must hold those act-as rights. Do not expose those
-routes as a self-custodial production API without replacing that authority
-model.
+only requests or settles it. The RFQ write endpoints are a custodial exception.
+They submit as configured trader parties, and acceptance also submits as the
+operator, so the backend ledger user must hold those act-as rights.
+`testnet-server.ts` disables that relay by default; opting in requires
+per-caller JWT binding. Do not describe or expose that authority model as
+self-custodial.
+
+The server in this repository has no `/v1/testnet/*` namespace, party faucet,
+or public-host provisioning. Those are deployment concerns, not hidden API
+routes. The only generic signing relay is the development-only endpoint
+documented below.
```mermaid
flowchart LR
UI["dApp / integrator"]
subgraph op["Operator backend — this API"]
R["Reads ACS + indexer → JSON"]
- W["Orchestration writes operator commands + hosted RFQ relay"]
+ W["Orchestration writes operator commands + mediated RFQ"]
end
A["Trader wallet (CIP-0103)"]
L[("Canton ledger")]
@@ -79,26 +85,35 @@ Three fail-closed gates, applied in this order:
|---|---|---|
| **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 |
+| **Per-caller binding** *(optional)* | party-scoped reads and 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.
+Market reads are open. Account and party-history reads require an explicit
+`owner` or `trader`; when per-caller binding is enabled, that party must match a
+valid `X-Caller-Token` (**401** missing/invalid, **403** mismatch). An admin token
+may read any party. The *unfiltered* forms of `/v1/trades`, `/v1/rfq`, and
+`/v1/rfq/history` require the admin token because their rows name both parties.
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).
+[Local Setup → Exercising write paths](../getting-started.md#what-is-safe-to-explore-in-this-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
+(`callerJwtSecret`), a party-scoped read or trader-subject write with no valid
+`X-Caller-Token` returns **401**; a valid token for a different party returns
+**403**. Binding is off by
default (a single trusted backend); turn it on when the backend fronts
mutually-distrusting callers.
+The optional custodial RFQ mode is stricter: `testnet-server.ts` refuses to
+enable `DEX_HOSTED_RFQ_RELAY=1` unless `DEX_CALLER_JWT_SECRET` is present. For
+that mode, per-caller binding is mandatory rather than optional.
+
---
## Read endpoints
-Auth is **open** for every read below unless the row says otherwise.
+Auth is **open** for market reads unless the row says otherwise. Rows marked
+*caller-bound* require the party token only when per-caller binding is enabled.
### Reads — context and market
@@ -125,9 +140,10 @@ it surfaces it here rather than making the dApp guess:
}
```
-`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):
+`GET /v1/status` reports `slot` as the participant's latest ledger-end offset,
+polled every two seconds. `synced` reflects the **most recent** probe. A failed
+configured-participant probe keeps the last real offset and returns
+`synced:false`; only the no-Canton in-memory dev server uses a local counter:
```json
{ "network": "canton:devnet", "slot": 1234567, "synced": true, "serverTime": "2026-05-17T..." }
@@ -151,7 +167,7 @@ fields until `registry-client` implements the standard's off-ledger
| Method · Path | Purpose |
|---|---|
-| `GET /v1/orders?trader=` | Open orders for one trader (**400** without `?trader=`) |
+| `GET /v1/orders?trader=` | Open orders for one trader; caller-bound (**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 |
@@ -166,8 +182,8 @@ the operator route that *acts* on a match
| 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` |
+| `GET /v1/holdings?owner=` | Per-contract (UTXO-style) holding rows; caller-bound (**400** without `?owner=`) |
+| `GET /v1/balances?owner=` | Caller-bound holding totals per instrument, `available` vs `locked` |
`/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;
@@ -187,9 +203,9 @@ without a `db` handle.
| Method · Path | Purpose | Auth |
|---|---|---|
-| `GET /v1/trades?trader=&pair=&limit=` | accepted RFQ `MatchedTrade`s + the `SettledTrade` each order-book fill writes | open / **admin** unfiltered |
+| `GET /v1/trades?trader=&pair=&limit=` | accepted RFQ `MatchedTrade`s + the `SettledTrade` each order-book fill writes | caller-bound / **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=` | RFQ lifecycle rows, including accepted quotes (trader, pair, winning dealer, rank) | open / **admin** unfiltered |
+| `GET /v1/rfq/history?trader=&limit=` | RFQ lifecycle rows, including accepted quotes (trader, pair, winning dealer, rank) | caller-bound / **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 |
@@ -207,7 +223,7 @@ one genuinely float-valued field on the API: it is a ratio, not an amount.
| Method · Path | Purpose | Auth |
|---|---|---|
-| `GET /v1/rfq?owner=` | RFQs and quotes scoped to one party | open / **admin** unfiltered |
+| `GET /v1/rfq?owner=` | RFQs and quotes scoped to one party | caller-bound / **admin** unfiltered |
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
@@ -374,6 +390,13 @@ request is rejected with **400** before it reaches the ledger.
| `POST /v1/rfq/:cid/cancel` | Cancel an open RFQ (**204**) |
| `POST /v1/rfq/accept` | Operator + trader co-sign the accept → `{ tradeCid, receipt }` |
+These three writes are disabled (`404`) by default in `testnet-server.ts`.
+`DEX_HOSTED_RFQ_RELAY=1` enables the custodial mode only when
+`DEX_CALLER_JWT_SECRET` is also configured; the participant user must have
+`actAs` rights for every configured trader. This flag does not provision
+parties or make the server a public service. Reads remain available when the
+mode is disabled.
+
```json
// POST /v1/rfq
{ "trader": "...", "rfqId": "...", "pair": "BTC/USDC", "side": "RFQ_Buy",
@@ -412,12 +435,14 @@ The pass-through bodies for the pair/pool routes are the service inputs in
|---|---|---|
| `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.
+Only the in-memory `dev-server.ts` can enable this route with
+`DEX_DEV_WALLET_RELAY=1`; `testnet-server.ts` hard-disables it even if that
+variable leaks into a deployment environment. In dev, forwarded `actAs`
+parties must be on `DEX_DEV_RELAY_PARTIES` (else **403**), the `commands` array
+and `commandId` are shape-checked, and the relay follows the committed
+transaction tree to return created allocation cids. It is a walletless local
+diagnostic, not a public faucet, hosted-party service, or production authority
+path.
---
diff --git a/docs/reference/testing.md b/docs/reference/testing.md
index 140de334..c580619e 100644
--- a/docs/reference/testing.md
+++ b/docs/reference/testing.md
@@ -1,25 +1,32 @@
# 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.
+in-script suites that run on an in-memory ledger with no Canton process; the
+operator backend and dApp have their own offline suites; a backend-process
+smoke checks selected HTTP routes; and opt-in probes exercise narrower seams
+against a live Canton participant.
+
+The word **end-to-end** is reserved here for a path whose stated boundaries are
+actually present. None of the automated paths currently includes all of a
+browser, real wallet transport, authenticated operator HTTP server, and live
+Canton participant. The [Validator Test Plan](../guides/validator-test-plan.md)
+is the manual deployment sign-off for those combined boundaries.
+
+| Path | Boundaries present | What it proves | What it does **not** prove | Command |
+|---|---|---|---|---|
+| Daml in-script suites | Daml Script engine | choice logic, conservation, authorization, rounding | Canton process, JSON API, backend, browser, wallet | `bash scripts/run-local-daml-tests.sh` |
+| Backend suite | backend services/routes + `InMemoryLedger` | HTTP shapes, matching, projection, idempotency, auth | real Daml authorization or participant wire compatibility | `(cd services/operator-backend && npm test)` |
+| dApp suite | React/jsdom + mocked fetch/providers | wallet-intent composition, funding planners, UI state | real browser wallet, backend, Canton | `(cd app/web && npm test)` |
+| Backend HTTP smoke | backend process + curl + `InMemoryLedger` | selected reads/quote routes and one admin 401 | dApp, successful writes, wallet, Canton, every API endpoint | `bash scripts/backend-http-smoke.sh` |
+| Live RFQ service integration | backend service + shared `JsonApiLedger` + Canton | RFQ create/quote/accept/list/cancel and receipt agreement | HTTP server, token settlement, registry factories, browser, wallet | `CANTON_LIVE_RFQ=1 npm run test:live:rfq` from `services/operator-backend` |
+| Self-contained live AMM round trip | raw JSON API + Canton | Registry.V2 setup; add → quote-bound swap → partial remove; exact balance/reserve/slice/LP/invariant/conservation checks | backend HTTP, dApp, browser auth, real wallet transport | `npm run live:roundtrip` from `services/operator-backend` |
+| Existing-pool add/swap probe | raw JSON API + Canton | mint → add → swap on an existing pool; exact reserve/balance/invariant checks | backend HTTP, dApp, wallet transport, remove | `npm run testnet:seed-pool` from `services/operator-backend` |
+| Matched-trade settlement probe | raw JSON API + Canton | V2 allocations and `MatchedTrade_Settle` move one instrument | AMM, RFQ acceptance, backend HTTP, dApp, wallet | `npm run live:matched-trade` from `services/operator-backend` |
+
+The first four rows run without an external participant; the first three are CI
+gates, while the backend HTTP smoke is a manual pre-flight. CI also type-checks
+the live driver sources, but does not connect to a participant. Every live row
+is opt-in and changes ledger state.
## Daml in-script suites (`trading-tests/`)
@@ -50,7 +57,7 @@ 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` |
+| `MockRegistry` | no (empty `inputHoldingCids`) | choice plumbing, multi-party authority | `PoolWorkflowTests`, `OrderWorkflowTests`, `TradeWorkflowTests`, `ChoiceContextWorkflowTests` |
| `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` |
| upstream `TestTokenV2_RegistryV2` | yes, with a real disclosed `TokenRules` context | cross-registry settlement, per-admin choice context | `RealRegistryDvpTests` |
@@ -68,8 +75,13 @@ change, and balance conservation. Value movement is therefore tested against
| [`InstrumentTests.daml`](../../trading-tests/CantonDex/Tests/InstrumentTests.daml) | 6 | the standalone lifecycle sample retained in the package lineage: config updates, credential-gated mint, burn, transfer offers, and preapproval | `CantonDex.Instrument` sample (not used by DEX workflows) |
| [`EdgeCaseTests.daml`](../../trading-tests/CantonDex/Tests/EdgeCaseTests.daml) | 5 | rejection paths for the standalone lifecycle sample: invalid mint/burn amounts, instrument mismatch, and missing issuer credentials | `CantonDex.Instrument` sample (not used by DEX workflows) |
| [`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 | workflow choreography and authority across pool funding, order funding/matching, RFQ accept, OTC settlement, swap, and choice-context threading; it does not prove value movement because the fixture has no holdings | `MockRegistry` |
+| [`PoolRoundingTests.daml`](../../trading-tests/CantonDex/Tests/PoolRoundingTests.daml) | 5 | four focused arithmetic proofs plus one holding-backed swap prove that pool-favouring rounding also preserves the settlement invariant | pure `PoolModel` (4); `Registry.V2` holding-backed swap (1) |
+| [`PoolWorkflowTests.daml`](../../trading-tests/CantonDex/Tests/PoolWorkflowTests.daml) | 3 | pool initialization, pause/resume, quote/state binding, swap replacement, and request-to-settlement choreography; it does not prove value movement | `MockRegistry` |
+| [`OrderWorkflowTests.daml`](../../trading-tests/CantonDex/Tests/OrderWorkflowTests.daml) | 7 | order funding, allocation binding, limit enforcement, rejection paths, and atomic remainder roll-forward; it does not prove locked backing | `MockRegistry` |
+| [`TradeWorkflowTests.daml`](../../trading-tests/CantonDex/Tests/TradeWorkflowTests.daml) | 4 | allocation-request consumption, RFQ ranking receipts and expiry, and bilateral settlement assembly; it does not prove balance movement | `MockRegistry` |
+| [`ChoiceContextWorkflowTests.daml`](../../trading-tests/CantonDex/Tests/ChoiceContextWorkflowTests.daml) | 5 | allocation and split-admin settlement choice contexts reach the correct registry factories and missing context is rejected; it does not prove value movement | context-requiring `MockRegistry` factories |
+| [`DexPairTests.daml`](../../trading-tests/CantonDex/Tests/DexPairTests.daml) | 3 | operator-only listing updates, consuming replacement/visibility, and fee-counter accounting; explicitly does not claim that listing metadata gates pool or order execution | pure listing state |
+| [`LifecycleChoiceTests.daml`](../../trading-tests/CantonDex/Tests/LifecycleChoiceTests.daml) | 5 | the exits that happy-path suites can obscure: request cancel/reject, funded-order cancel, matched-trade cancel, RFQ cancel/quote withdraw, and match abort, including controller failures and release of real locked holdings | `Registry.V2` where value release matters |
| [`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` |
@@ -91,9 +103,11 @@ testFloorDivStaysBelowExactQuotient = do
## 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:
+The backend suite runs on `node:test` against a TypeScript `InMemoryLedger`
+fixture that implements only the selected choices needed by these service and
+route tests. It does not execute Daml. This keeps HTTP, indexer, and pricing
+tests fast while the Daml and live-Canton layers prove the ledger behavior.
+Type-check and run:
```bash
cd services/operator-backend
@@ -106,11 +120,11 @@ 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 (`RfqService.accept` → `MatchedTrade` + `PolicyReceipt`, with `verifyReceipt` digest replay), and the settlement batch wire shape |
+| RFQ & matched trade | `rfq.test.ts`, `matched-trade.test.ts`, `match-leg-shape.test.ts` | the RFQ accept path through the service boundary (`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 |
+| Docs as tests | `docs-governance-caveats.test.ts`, `docs-hosted-scope.test.ts`, `docs-token-standard-scope.test.ts`, `docs-v2-only.test.ts` | assertions that keep the docs honest about governance, Token Standard, and hosted-deployment boundaries |
## dApp tests (`app/web`)
@@ -133,168 +147,228 @@ The load-bearing seams:
| 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
+## Backend HTTP smoke
-`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:
+[`scripts/backend-http-smoke.sh`](../../scripts/backend-http-smoke.sh) starts the development
+backend with `InMemoryLedger`, checks selected reads and quotes, confirms that
+an unauthenticated admin write returns 401, and stops the process. It does not
+start the dApp or Canton, submit a successful write, or exercise a wallet.
+
+Install the backend dependencies once, then run the script from the repository
+root:
```bash
-bash scripts/e2e-smoke.sh # "==> All smoke checks passed"
+(cd services/operator-backend && npm ci)
+bash scripts/backend-http-smoke.sh
+# final line: ==> All backend HTTP 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.
+The script needs Bash, Node.js, npm, curl, and grep. Set `PORT` to use a port
+other than 18080. It refuses to reuse a port already serving `/v1/status`. On
+failure it prints the retained backend-log path; on success it removes its
+temporary directory.
-## Against a live Canton participant
+## Live Canton probes
-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.
+The probes below require an **already-running participant** with the required
+DARs uploaded. LocalNet start/stop is deliberately separate from these test
+commands. If using `canton-devkit`, its lifecycle command is
+`canton-devkit localnet`; its environment does not supply the DEX role or
+package-id variables listed below. The repository's optional adapter uses the
+app-provider primary party for operator/admin and allocates an LP/trader and
+swapper through the JSON Ledger API. The self-contained driver's synchronizer
+id is optional on a single-synchronizer participant; the other raw scripts
+still require one.
-### The RFQ accept integration test (`CANTON_E2E=1`)
+> **State warning:** every live probe submits commands and can leave contracts
+> behind after success or failure. Use a throwaway LocalNet where possible. On
+> a shared testnet, use dedicated parties/pools and record the printed run id.
+> There is no automatic rollback.
-`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:
+### RFQ service integration
-- `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.
+[`canton-live-rfq.test.ts`](../../services/operator-backend/test/live/canton-live-rfq.test.ts)
+uses the real `JsonApiLedger` and backend `RfqService`, without starting the
+HTTP server. It creates an RFQ and quotes, accepts one, verifies the returned
+receipt, queries the resulting `MatchedTrade`, checks exact CIDs in the list
+case, and verifies cancel archives an RFQ. It does not fund or settle the
+`MatchedTrade`; the accepted trade remains on-ledger.
-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.
+Required environment:
-**Prerequisites:** DPM with the SDK version pinned by `trading/daml.yaml`, and
-the `canton-dex-trading` DAR built (`cd trading && dpm build`).
+| Variable | Meaning |
+|---|---|
+| `CANTON_JSON_API_URL` | participant JSON Ledger API base URL |
+| `CANTON_JSON_API_TOKEN` | JWT with `actAs` for operator, trader, and both dealers |
+| `CANTON_OPERATOR_PARTY` | RFQ operator and trade venue |
+| `CANTON_TRADER_PARTY` | RFQ trader |
+| `CANTON_DEALER_JUMP`, `CANTON_DEALER_ORCA` | two quote dealers |
+| `CANTON_BTC_ADMIN` | asset-admin party written into the resulting trade |
-**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:
+After building and uploading the current trading DAR and allocating the
+parties, run from the backend directory:
```bash
-daml sandbox \
- --port 6865 \
- --json-api-port 7575 \
- --dar trading/.daml/dist/canton-dex-trading-0.1.4.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
+cd services/operator-backend
+CANTON_LIVE_RFQ=1 \
+ CANTON_JSON_API_URL=https://participant.example \
+ CANTON_JSON_API_TOKEN=... \
+ CANTON_OPERATOR_PARTY=... \
+ CANTON_TRADER_PARTY=... \
+ CANTON_DEALER_JUMP=... \
+ CANTON_DEALER_ORCA=... \
+ CANTON_BTC_ADMIN=... \
+ npm run test:live:rfq
```
-**2. Allocate parties and obtain a JWT.**
+The live test lives under `test/live/`, outside the ordinary `test/*.test.ts`
+glob, so `npm test` cannot discover or submit it. When `CANTON_LIVE_RFQ` is
+absent, the explicit `npm run test:live:rfq` command emits one skipped test.
-```bash
-daml ledger allocate-parties operator alice orca jump btc-admin
-daml-helper request-token --party operator > /tmp/operator.jwt
-```
+The driver's main mappings are:
-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.
+| `LedgerSubmitter` method | JSON API call |
+|---|---|
+| `submit` | `POST /v2/commands/submit-and-wait` |
+| `query` | `GET /v2/state/ledger-end`, then `POST /v2/state/active-contracts` |
+| `subscribe` | `GET /v2/updates/flats` (SSE) |
-**3. Run the test.**
+### Self-contained AMM round-trip probe
+
+[`live-amm-roundtrip.ts`](../../scripts/live-amm-roundtrip.ts) creates a unique
+`Registry.V2`, registers base/quote/LP instruments, mints the deposit assets,
+creates the pool contracts, authors the LP's three allocations, and settles one
+add-liquidity DvP. In full mode it then has the swapper authorize the exact
+quote-bound input allocation, executes a quote-to-base swap, and redeems half
+the LP position through three LP-authored remove allocations.
+
+The driver asserts:
+
+- the exact holding and reserve deltas for every phase;
+- `PoolState.reserves` equals the active `PoolSlice` sums after add, swap, and
+ remove;
+- LP holdings, `PoolState.totalLpSupply`, and `LPTokenPolicy.totalSupply`
+ agree after mint and burn;
+- the constant product does not decrease after the fee-bearing swap;
+- reserve value per remaining LP token does not decrease after redemption; and
+- aggregate unlocked holdings plus pool reserves conserve both instruments.
+
+It does not call the operator HTTP API, render the dApp, exercise browser
+authentication, or use a real wallet transport. The script directly authors
+the allocations that a wallet would normally submit.
+
+Required variables are `CANTON_LEDGER_URL`, `CANTON_LEDGER_TOKEN`,
+`CANTON_DEX_PACKAGE_ID`, `CANTON_ALLOC_INSTR_PACKAGE_ID`, `CANTON_OPERATOR`,
+`CANTON_ADMIN`, and `CANTON_TRADER`. `CANTON_SWAPPER` is optional and defaults
+to the trader. `CANTON_USER_ID` defaults to `ledger-api-user`;
+`CANTON_SYNCHRONIZER` is also optional, and an omitted value lets a
+single-synchronizer participant route commands automatically. The JWT must be
+allowed to act as every distinct configured party. `CANTON_ADMIN` is also the
+asset issuer and LP registrar in this self-contained fixture. The trader must
+differ from the operator because an add cannot self-transfer; full mode also
+requires the swapper to differ from the operator.
```bash
-CANTON_E2E=1 \
- CANTON_JSON_API_URL=http://localhost:7575 \
- CANTON_JSON_API_TOKEN=$(cat /tmp/operator.jwt) \
- CANTON_OPERATOR_PARTY=operator \
- CANTON_TRADER_PARTY=alice \
- CANTON_DEALER_JUMP=jump \
- CANTON_DEALER_ORCA=orca \
- CANTON_BTC_ADMIN=btc-admin \
- npm test --prefix services/operator-backend
+cd services/operator-backend
+npm run live:roundtrip
+# PASS: add -> swap -> partial remove settled real holdings; ...
```
-The three Canton cases run inside the full backend suite:
+The final output includes the unique run, registry, and pool identifiers left
+on the participant. `npm run localnet:amm-roundtrip` is the full-round-trip
+compatibility alias. For a fast diagnostic that intentionally stops after the
+first DvP, use `npm run live:add-liquidity`; it still requires trader and
+operator to be different parties.
+
+### Existing-pool add and swap probe
+
+[`seed-testnet-pool.ts`](../../scripts/seed-testnet-pool.ts) discovers an
+existing registry and pool, mints test assets, performs one add-liquidity and
+one swap, and checks exact reserves and holdings, slice reconciliation, and
+that the constant-product invariant did not decrease. It does not test remove
+liquidity, HTTP, the dApp, or wallet transport. It adds assets to the selected
+pool on every run, so use a dedicated test pool.
+
+Required variables are `CANTON_LEDGER_URL`, `CANTON_LEDGER_TOKEN`,
+`CANTON_SYNCHRONIZER`, `CANTON_DEX_PACKAGE_ID`,
+`CANTON_ALLOC_INSTR_PACKAGE_ID`, and `CANTON_OPERATOR`. Optional selectors and
+amounts are documented at the top of the script: `CANTON_USER_ID`,
+`CANTON_LP`, `CANTON_SWAPPER`, `CANTON_REGISTRY_CID`,
+`CANTON_LP_REGISTRY_CID`, `POOL_BASE`, `POOL_QUOTE`, `POOL_ID`, `SEED_BASE`,
+`SEED_QUOTE`, `SWAP_IN`, and `SWAP_IN_SIDE`. The JWT must be allowed to act as
+the operator and all asset-admin, LP-registrar, LP, and swapper parties resolved
+by the script.
+```bash
+cd services/operator-backend
+npm run testnet:seed-pool
```
-✔ Canton E2E: RFQ accept produces MatchedTrade with PolicyReceipt
-✔ Canton E2E: rfq.list returns visible RFQs and quotes
-✔ Canton E2E: rfq.cancel archives an open Rfq
-```
-
-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.
-
-**How the driver maps to the JSON Ledger API:**
-
-| `LedgerSubmitter` method | JSON API call |
-|---|---|
-| `submit` (create) | `POST /v2/commands/submit-and-wait` with `CreateCommand` |
-| `submit` (exercise) | `POST /v2/commands/submit-and-wait` with `ExerciseCommand` |
-| `submit` (exerciseInterface) | `POST /v2/commands/submit-and-wait` with `ExerciseByInterfaceCommand` |
-| `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` 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:
-| 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 |
+### Matched-trade settlement probe
-### The headless DvP round-trip (`localnet:dvp-e2e`)
+[`testnet-v2registry-trade.ts`](../../scripts/testnet-v2registry-trade.ts)
+creates its own registry and instrument, mints to a sender, creates a
+one-instrument `MatchedTrade`, accepts both allocation sides, settles the batch,
+and verifies sender/receiver holdings. It is a direct ledger settlement probe;
+it does not exercise RFQ acceptance, order matching, AMM code, HTTP, or a
+wallet.
-`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:
+Required variables are `CANTON_LEDGER_URL`, `CANTON_LEDGER_TOKEN`,
+`CANTON_SYNCHRONIZER`, `CANTON_DEX_PACKAGE_ID`,
+`CANTON_ALLOC_REQUEST_PACKAGE_ID`, `CANTON_ALLOC_INSTR_PACKAGE_ID`,
+`CANTON_VENUE`, `CANTON_ADMIN`, `CANTON_ALICE`, and `CANTON_BOB`.
+`CANTON_USER_ID` is optional. The JWT must be allowed to act as all four
+configured parties.
```bash
-npm run localnet:dvp-e2e --prefix services/operator-backend
+cd services/operator-backend
+npm run live:matched-trade
```
-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.
+### Diagnosing and recovering from failures
+
+| Symptom | Likely cause | Recovery |
+|---|---|---|
+| `missing env: NAME` / `required env: NAME` | incomplete environment | export the named variable; no ledger command was sent before configuration finished |
+| HTTP 401 | expired JWT or missing party rights | issue a fresh token with the exact `actAs` set |
+| template/package not found | DAR absent or wrong package-id environment | upload the current DARs and correct the package ids |
+| requires authorizer / authorization failure | token cannot act as a submitted party | compare the script's documented party set with the JWT rights |
+| contract not found / duplicate fixture | stale CID, wrong observing party, or a rerun against shared state | use a new throwaway LocalNet or choose a dedicated pool; do not assume a failed run rolled back earlier transactions |
+
+The RFQ driver surfaces JSON API failures as `LedgerError.detail`; the raw
+scripts print their failing step and HTTP response body. Preserve that output
+and the run id before resetting a throwaway LocalNet. There is no generic
+cleanup command because a partial run can stop at many different contract
+states.
## What CI runs
`.github/workflows/ci.yml` gates every pull request on the offline layers: the
Daml build, in-script tests, and upgrade-compatibility check; backend typecheck
and tests; frontend typecheck, tests, and production build; the documentation
-site build; and a Docker build smoke. The live-ledger paths above are opt-in and
-not part of CI.
+site build; and a container build plus backend runtime smoke. The container
+check starts the read-only backend with an intentionally unreachable
+participant, asserts that `/v1/status` reports unsynchronized state, verifies
+an unauthenticated admin write returns 401, and checks the non-root runtime and
+SQLite binding. CI also runs `npm run typecheck:live-scripts` so the
+deployment/bootstrap and raw live-driver sources cannot silently drift. It does
+not connect to Canton or prove any live path.
## 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.
+- An automated remove-liquidity path through the authenticated operator HTTP
+ API and a real browser wallet. The self-contained raw JSON-API driver proves
+ the live ledger transition, not those application boundaries.
- The order-funding flow (`OrderFundingRequest` → trader-authored allocation →
`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.
+- One automated path through browser, real wallet transport, authenticated
+ backend HTTP, and live Canton.
+- A live external-registry HTTP round trip. The live RFQ integration test uses
+ a `FixedRegistryClient` whose methods are never called because RFQ acceptance
+ does not allocate or settle tokens. Offline tests prove the canonical
+ operation-specific request bodies and response validation; a live swap test
+ still needs a deployed registry endpoint, credentials, and factory contracts.
---
diff --git a/docs/tutorials/amm-first-walkthrough.md b/docs/tutorials/amm-first-walkthrough.md
new file mode 100644
index 00000000..55146a62
--- /dev/null
+++ b/docs/tutorials/amm-first-walkthrough.md
@@ -0,0 +1,362 @@
+# Trace one AMM swap from formula to Daml settlement
+
+This tutorial is for an AMM developer who knows `x*y=k` but is new to Canton
+and Daml. You will trace one exact-input swap through the repository, run three
+focused Daml tests, and learn what each test does—and does not—prove.
+
+This is a code-reading tutorial, not a live-network deployment. It uses the
+Daml Script runner so you can focus on contract state, authority, and value
+movement before adding a participant, wallet, or HTTP backend.
+
+## Before you begin
+
+Read the [Canton and Daml primer](../concepts/canton-daml-primer.md), then install
+the Daml prerequisites from [Getting started](../getting-started.md#additional-tools-for-daml-builds-tests-and-the-live-proof).
+
+From the repository root, build the trading DAR once:
+
+```bash
+dpm install 3.5.2
+bash scripts/build-trading-surface.sh
+```
+
+A successful build ends with:
+
+```text
+canton-dex-trading built successfully.
+```
+
+You will work with these files:
+
+| Question | File |
+|---|---|
+| Where is the constant-product formula? | [`PoolModel.daml`](../../trading/CantonDex/Dex/PoolModel.daml) |
+| Where are quote binding and swap settlement enforced? | [`PoolRules.daml`](../../trading/CantonDex/Dex/PoolRules.daml) |
+| What is pool configuration versus mutable state? | [`Pool.daml`](../../trading/CantonDex/Dex/Pool.daml), [`PoolState.daml`](../../trading/CantonDex/Dex/PoolState.daml) |
+| Where is reserve value represented? | [`PoolSlice.daml`](../../trading/CantonDex/Dex/PoolSlice.daml) and [`Registry/V2.daml`](../../trading/CantonDex/Registry/V2.daml) |
+| Which tests should I read first? | [`PoolRoundingTests.daml`](../../trading-tests/CantonDex/Tests/PoolRoundingTests.daml), [`PoolWorkflowTests.daml`](../../trading-tests/CantonDex/Tests/PoolWorkflowTests.daml), [`RealRegistryDvpTests.daml`](../../trading-tests/CantonDex/Tests/RealRegistryDvpTests.daml) |
+
+## 1. Start from the familiar formula
+
+For reserve-in `x`, reserve-out `y`, exact input `dx`, and fee `f`, the usual
+constant-product output is:
+
+```text
+dxAfterFee = dx × (1 - f)
+dy = y × dxAfterFee / (x + dxAfterFee)
+```
+
+The repository implements that in
+[`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)
+```
+
+Two details matter:
+
+- fees use basis points, so 30 means 0.30%;
+- multiplication and division round down on pool payouts so fixed-scale
+ decimal rounding cannot make the pool pay more than the exact result.
+
+The full input—not only `amountInAfterFee`—is later added to the input reserve.
+That is how the fee remains in the pool and accrues to LPs.
+
+### Run the arithmetic proof
+
+From `trading-tests/`:
+
+```bash
+cd trading-tests
+dpm test -p testSwapOutputRoundsDownToKeepConstantProduct
+```
+
+Expected result:
+
+```text
+testSwapOutputRoundsDownToKeepConstantProduct: ok
+```
+
+Read that test in
+[`PoolRoundingTests.daml`](../../trading-tests/CantonDex/Tests/PoolRoundingTests.daml).
+It creates a zero-fee 1000/1000 pool, swaps 7 units, and asserts that the
+post-swap product is not lower than the pre-swap product. Zero fees remove the
+usual fee cushion, exposing a one-unit-of-precision overpayment.
+
+This test proves arithmetic plus real Daml settlement in its fixture. It does
+not exercise the backend or browser.
+
+## 2. Replace one “pool contract” with four responsibilities
+
+An EVM AMM often places configuration, reserves, and swap functions on one pair
+contract. This reference separates them:
+
+```mermaid
+flowchart TD
+ Pool[Pool immutable instruments, parties, fee]
+ State[PoolState aggregate reserves, LP supply, status]
+ Rules[PoolRules request, validate, settle, pause]
+ Slices[PoolSlice set committed reserve inventory]
+ Holding[Token Standard Holding / Allocation actual value backing]
+
+ Pool --> State
+ Pool --> Rules
+ State -->|prices against totals| Rules
+ Slices -->|must sum to reserves| State
+ Slices --> Holding
+ Rules -->|settles and rolls forward| Slices
+```
+
+Open the files and identify these fields:
+
+- `Pool.poolId`, the two instrument IDs, `lpInstrumentId`, and `feeBps` are
+ stable configuration.
+- `PoolState.reserves`, `totalLpSupply`, and `status` are the small global state
+ every reserve-changing operation serializes through.
+- each `PoolSlice` names one side, amount, and committed allocation contract ID;
+- `PoolRules` is operator-signed and exposes nonconsuming choices. The rules
+ contract stays active while a swap archives and recreates state and slices.
+
+The accounting invariant is:
+
+```text
+PoolState.baseAmount = sum(active base PoolSlice.amount)
+PoolState.quoteAmount = sum(active quote PoolSlice.amount)
+```
+
+`PoolState` makes pricing efficient; slices connect those totals to reserved
+Token Standard value. A reserve number without matching slices would be only
+an operator assertion, not spendable inventory.
+
+## 3. See why quoting is not authorization
+
+The browser can compute or request a quote without moving funds. A settle needs
+an allocation specification that binds the trader to exact transfer-leg sides
+and one pool snapshot.
+
+The operator exercises `PoolRules_RequestSwap`. Its result contains:
+
+```daml
+data PoolRules_RequestSwapResult = PoolRules_RequestSwapResult with
+ settlement : V2.SettlementInfo
+ allocationSpec : V2.AllocationSpecification
+ quoteBinding : Optional SwapQuoteBinding
+```
+
+The `quoteBinding` records the state and slice contract IDs plus the trader's
+minimum output:
+
+```daml
+data SwapQuoteBinding = SwapQuoteBinding with
+ expectedPoolId : PoolId
+ poolStateCid : ContractId PoolState
+ inputSliceCid : ContractId PoolSlice
+ outputSliceCids : [ContractId PoolSlice]
+ minOutputAmount : Decimal
+```
+
+Contract IDs are part of the concurrency control. If another swap archives the
+bound `PoolState` or a bound slice first, the old quote cannot settle. The
+operator must produce a fresh request; it cannot reuse the trader's authority
+against different state.
+
+Inside `PoolRules_RequestSwap`, Daml builds the specification from the prepared
+input and output legs:
+
+```daml
+allocationSpec =
+ Utils.mkIteratedAllocationSpecification
+ pool.admin
+ swapperAccount
+ None
+ (prepared.preparedSwapInLeg :: prepared.preparedOutputDelivery.legs)
+ None
+ False
+```
+
+The operator prepares this specification, but the trader's wallet authors the
+allocation against it. Preparing terms and authorizing funds are separate
+actions.
+
+## 4. Follow authority, not HTTP calls
+
+The essential swap has three ledger steps:
+
+| Step | Daml action | Required authority | Result |
+|---|---|---|---|
+| Prepare | exercise `PoolRules_RequestSwap` | operator | exact settlement info, allocation spec, and quote binding |
+| Allocate | exercise `AllocationFactory_Allocate` | trader, plus any registry-required context/actors | trader's input value locked for those terms |
+| Settle | exercise `PoolRules_Swap` | operator | input and output settle atomically; state/slices roll forward |
+
+The dApp and backend orchestrate those steps, but neither changes who controls
+them. A frontend button cannot substitute operator authority, and an operator
+API token cannot substitute the trader's wallet authority on a self-custodial
+allocation.
+
+### Run the choreography proof
+
+```bash
+cd trading-tests
+dpm test -p testPoolSwapViaRequestSwap
+```
+
+Expected result:
+
+```text
+testPoolSwapViaRequestSwap: ok
+```
+
+Read the named test in
+[`PoolWorkflowTests.daml`](../../trading-tests/CantonDex/Tests/PoolWorkflowTests.daml).
+The most important three lines of the story are:
+
+```daml
+reqRes <- submit operator $ exerciseCmd rulesCid PoolRules_RequestSwap with ...
+bobInstr <- submit bob $ exerciseCmd factoryCid bobAllocateArg
+swapRes <- submit operator $ exerciseCmd rulesCid PoolRules_Swap with ...
+```
+
+This is excellent authority and choreography documentation: operator, then
+trader, then operator. Its `MockRegistry` fixture does not contain real
+holdings, so this particular test does **not** prove balance conservation. The
+file header says so explicitly.
+
+## 5. Read the atomic settlement boundary
+
+`PoolRules_Swap` recomputes the output from the bound pool snapshot and checks
+that every supplied contract ID and the minimum output match the quote binding.
+It then calls the registry's batch settlement factory:
+
+```daml
+settleResult <- exercise factoryCid V2.SettlementFactory_SettleBatch with
+ settlement
+ transferLegs = swapInLeg :: outDel.legs
+ allocations = swapperFinalized :: inputFinalized :: outDel.sliceFinalizeds
+ actors = [operator]
+ extraArgs
+```
+
+Because this is nested in one Daml transaction, settlement and the following
+state changes are atomic. After successful settlement the choice:
+
+1. rolls the input reserve allocation forward with the full input added;
+2. consumes enough output slices to pay the trader and recreates any leftover
+ boundary slice;
+3. asserts that slice deltas equal reserve deltas;
+4. archives the old `PoolState` and creates the successor reserves.
+
+If batch settlement fails, the state and slice updates do not commit. If a
+reserve/slice assertion fails, the value settlement does not commit either.
+
+## 6. Run the real-holding proof
+
+Now run the test whose fixture creates actual Token Standard holdings and uses
+an upstream context-requiring V2 registry:
+
+```bash
+cd trading-tests
+dpm test -p testRealRegistryDvpSwapSettles
+```
+
+Expected result:
+
+```text
+testRealRegistryDvpSwapSettles: ok
+```
+
+Read the named test in
+[`RealRegistryDvpTests.daml`](../../trading-tests/CantonDex/Tests/RealRegistryDvpTests.daml).
+It proves more than the choreography test:
+
+- the request's sender side is the exact input instrument and amount;
+- the receiver side is a positive amount of the output instrument;
+- changing the signed receiver amount by `0.0000000001` makes settlement fail;
+- the trader's input holdings back the allocation;
+- reserves move in the expected directions;
+- the trader receives an output `Holding`.
+
+It still runs in Daml Script. It does not prove package upload, JSON API
+serialization, wallet compatibility, network topology, or browser behavior.
+
+## 7. Promote the proof to a real Canton process
+
+Return to the repository root and run the default live proof:
+
+```bash
+bash scripts/run-dpm-sandbox-proof.sh
+```
+
+This starts the real Canton sandbox bundled with the pinned DPM SDK, uploads
+the Token Standard and DEX package closure, and drives add → swap → remove
+through the JSON Ledger API. The final checkpoint is:
+
+```text
+==> PASS: portable live-Canton proof completed
+ The throwaway sandbox is now stopping; no persistent ledger state remains.
+```
+
+You have now crossed two boundaries that Daml Script did not test: a Canton
+process started, and the JSON Ledger API accepted the package and value-flow
+commands. The script uses one unrestricted authentication-disabled sandbox
+user, but three Canton parties: operator/admin/LP registrar share the bootstrap
+party, while LP/trader and swapper are distinct counterparties. It still does
+not start the operator HTTP server, browser, or wallet. Those omissions are
+deliberate; see
+[Local Canton from a clean clone](../guides/localnet.md) for the proof matrix
+and the optional persistent environments.
+
+## 8. Connect the code to the UI without overstating it
+
+After the Daml tests pass, run the browser preview from
+[Getting started](../getting-started.md#mode-1-run-the-browser-preview). On the
+Trade page:
+
+1. change the BTC or USDC input and observe the quote;
+2. open browser developer tools and find the quote/request calls;
+3. connect Mock Wallet and inspect the wallet intent logged to the console;
+4. notice that its returned `#mock-…:0` value is not the allocation created in
+ the Daml test.
+
+The UI shows how a real integration is orchestrated. The Daml tests show what
+the contracts enforce. Only a live participant plus compatible wallet joins
+the browser orchestration and on-ledger settlement boundaries in one validation.
+
+## 9. Use the same reading pattern for other AMM flows
+
+You can now trace add and remove liquidity with the same questions:
+
+| Question | Add/remove liquidity answer |
+|---|---|
+| What computes the economic amounts? | pool ratio, LP supply, and conservative rounding in `PoolModel.daml` |
+| What records intent? | `LiquidityAllocationRequest` |
+| Who authorizes base/quote or LP value? | the liquidity provider through allocation factory choices |
+| Who executes? | operator and LP registrar on the liquidity rules choice |
+| What makes it atomic? | one settlement batch combines deposits/redemption with LP mint/burn |
+| Which real-value test should I read? | `testDvpAddLiquidity`, `testDvpRemoveDeliversToHolder`, and their negative cases in `PoolLiquidityRulesTests.daml` |
+
+Then read [Liquidity and custody](../concepts/liquidity-and-custody.md) for the
+full slice design and [LP tokens](../concepts/lp-tokens.md) for issuance and
+redemption.
+
+## Completion checklist
+
+You have completed this tutorial when you can point to:
+
+- the function that computes `amountOut`;
+- the contracts that separate pool configuration, aggregate state, and reserve
+ backing;
+- the choice that builds the trader's exact allocation specification;
+- the line where the trader—not the operator—authors the allocation;
+- the nested batch-settlement choice;
+- one mock-registry choreography test and one real-holding value test;
+- the final checkpoint of the DPM sandbox proof;
+- the reason passing the Daml and sandbox proofs is not yet a live browser and
+ external-wallet dApp.
+
+**Next canonical step:** [15-minute design tour](../concepts/design-tour.md).
+Use [Liquidity and custody](../concepts/liquidity-and-custody.md) and
+[Local Canton from a clean clone](../guides/localnet.md) as topic references.
diff --git a/docs/tutorials/make-your-first-amm-change.md b/docs/tutorials/make-your-first-amm-change.md
new file mode 100644
index 00000000..87d44446
--- /dev/null
+++ b/docs/tutorials/make-your-first-amm-change.md
@@ -0,0 +1,201 @@
+# Tutorial: make your first AMM code change
+
+This is Step 8 of the
+[canonical newcomer learning path](../README.md#canonical-newcomer-learning-path).
+Complete the workflow-design step first. Here you will make one small,
+behavior-preserving Daml refactor: give the swap-fee calculation a name, prove
+the new helper with a focused test, and then check every layer that could be
+affected.
+
+You will edit two files in your own checkout:
+
+- `trading/CantonDex/Dex/PoolModel.daml`, which owns the AMM arithmetic; and
+- `trading-tests/CantonDex/Tests/PoolRoundingTests.daml`, which proves the
+ arithmetic's conservative rounding.
+
+The finished change does **not** alter the formula, template fields, choices,
+HTTP API, or UI. That makes it a useful first contribution: the fail/pass loop
+is real, while the expected behavior remains stable.
+
+## Before you start
+
+From the repository root, confirm that the unmodified Daml surface is green:
+
+```bash
+bash scripts/run-local-daml-tests.sh
+```
+
+All Daml Script tests should report `ok`, and the command should exit with
+status 0. If the command cannot find Java, DPM, or SDK 3.5.2, return to
+[Getting started — prerequisites](../getting-started.md#prerequisites).
+
+Keep the repository root as the starting directory for every command below.
+
+## 1. Write the focused proof first
+
+Open
+[`trading-tests/CantonDex/Tests/PoolRoundingTests.daml`](../../trading-tests/CantonDex/Tests/PoolRoundingTests.daml)
+and find this existing declaration:
+
+```daml
+testSwapOutputRoundsDownToKeepConstantProduct : Script ()
+testSwapOutputRoundsDownToKeepConstantProduct = do
+```
+
+Immediately after the `= do` line, add these two assertions:
+
+```daml
+ PM.amountAfterSwapFee 30 1000.0 === 997.0
+ PM.amountAfterSwapFee 25 1000.0 === 997.5
+```
+
+They state the rule in basis points: a 30 bps fee leaves `997.0` of a
+`1000.0` input, and a 25 bps fee leaves `997.5`. The `PM` alias is already
+imported at the top of the test file.
+
+Run only that script:
+
+```bash
+(cd trading-tests && dpm test -p testSwapOutputRoundsDownToKeepConstantProduct)
+```
+
+### Expected failure
+
+The command should exit nonzero because `amountAfterSwapFee` does not exist
+yet. Depending on the SDK's diagnostic wording, the error will say that
+`PM.amountAfterSwapFee` is unknown, not in scope, or not exported. This failure
+is the red half of the red/green loop. If the test passes at this point, check
+that you saved the file and ran the command from this checkout.
+
+## 2. Extract the fee calculation
+
+Open
+[`trading/CantonDex/Dex/PoolModel.daml`](../../trading/CantonDex/Dex/PoolModel.daml).
+Find `floorDiv`, then add this helper immediately below it:
+
+```daml
+-- | Input remaining after the pool fee, rounded down so the pool never
+-- pays out from value it did not receive.
+amountAfterSwapFee : Int -> Decimal -> Decimal
+amountAfterSwapFee feeBps inputAmount =
+ floorDiv
+ (floorMul inputAmount (intToDecimal (10000 - feeBps)))
+ 10000.0
+```
+
+Next, find `constantProductOut` and replace only its definition with:
+
+```daml
+constantProductOut : Decimal -> Decimal -> Int -> Decimal -> Decimal
+constantProductOut reserveIn reserveOut feeBps inputAmount =
+ let amountInAfterFee = amountAfterSwapFee feeBps inputAmount
+ in floorDiv (floorMul amountInAfterFee reserveOut)
+ (reserveIn + amountInAfterFee)
+```
+
+The old inline expression and the new helper call are mathematically
+identical. `floorMul` and `floorDiv` still round in the pool's favor at the
+same points.
+
+## 3. Build, then make the focused proof green
+
+Build the trading DAR before compiling its test package:
+
+```bash
+bash scripts/build-trading-surface.sh
+(cd trading-tests && dpm test -p testSwapOutputRoundsDownToKeepConstantProduct)
+```
+
+The focused command should now exit 0 and report the named script as `ok`.
+If it still reports the missing helper, confirm that the helper is at module
+scope rather than nested inside `floorDiv`.
+
+## 4. Check which layers the change affects
+
+Use this table before expanding the change:
+
+| Layer | Impact of this tutorial's edit | Why |
+|---|---|---|
+| Daml implementation | **Changed** | `constantProductOut` now calls a named helper. |
+| Ledger schema and choices | **Unchanged** | No template, record, choice argument, or result type changed. |
+| Settlement behavior | **Unchanged by design** | The same fee and rounding expression runs before the same output calculation. |
+| Operator backend | **No edit required** | Its public API and expected quote shape did not change. |
+| React dApp / wallet handoff | **No edit required** | No request, response, or wallet-intent field changed. |
+
+This is impact analysis, not permission to ignore other layers for a real math
+change. If you later change the formula or rounding, inspect and update these
+consumers together:
+
+- `services/operator-backend/src/pool/index.ts` for backend quote math;
+- `services/operator-backend/src/dev-server.ts` for preview behavior;
+- `scripts/live-amm-roundtrip.ts` for the independent live-proof expectation;
+- the related backend tests and dApp tests for displayed quotes and limits.
+
+The UI can display a fee and proposed quote, but it does not authorize final
+settlement. The Daml choice must always recompute and validate executable
+amounts from the bound ledger state.
+
+## 5. Run the full local checks
+
+Now prove that the refactor did not disturb another workflow:
+
+```bash
+bash scripts/run-local-daml-tests.sh
+(cd services/operator-backend && npm run typecheck && npm test)
+(cd app/web && npm test && npm run build)
+```
+
+Expected results:
+
+- every Daml Script test reports `ok` and the script exits 0;
+- backend type-checking exits cleanly and TAP ends with `# fail 0`; and
+- Vitest reports all dApp tests passed, then Vite writes `app/web/dist/`.
+
+Run `npm ci` once in `services/operator-backend` and `app/web` if their
+dependencies are not installed.
+
+## 6. Prove the DAR on a real throwaway Canton process
+
+Run the repository's portable live-ledger proof:
+
+```bash
+bash scripts/run-dpm-sandbox-proof.sh
+```
+
+Near the end, expect:
+
+```text
+==> Running the live-Canton DvP proof
+==> PASS: portable live-Canton proof completed
+ The throwaway sandbox is now stopping; no persistent ledger state remains.
+```
+
+This proves package upload and add → quote-bound swap → partial-remove value
+movement through the JSON Ledger API on a real Canton process. It still does
+not prove browser, external-wallet, or operator-backend HTTP integration. Those
+boundaries require the separately configured environments described in
+[Getting started](../getting-started.md) and the
+[testing reference](../reference/testing.md).
+
+## 7. Review the change
+
+Check whitespace and inspect only the intended diff:
+
+```bash
+git diff --check
+git diff -- \
+ trading/CantonDex/Dex/PoolModel.daml \
+ trading-tests/CantonDex/Tests/PoolRoundingTests.daml
+```
+
+You are finished when:
+
+- the focused proof failed before the helper existed and passed afterward;
+- the complete Daml, backend, and dApp checks pass;
+- the live sandbox proof prints its `PASS` line;
+- the diff contains one helper, one call-site refactor, and two assertions; and
+- you can explain why no backend or UI source edit was needed.
+
+Continue to Step 9, the [Builder guide](../guides/builder-guide.md), to plan a
+behavior-changing extension and identify every affected boundary before you
+edit it.
diff --git a/scripts/e2e-smoke.sh b/scripts/backend-http-smoke.sh
old mode 100755
new mode 100644
similarity index 66%
rename from scripts/e2e-smoke.sh
rename to scripts/backend-http-smoke.sh
index fb7db14e..71a35f3c
--- a/scripts/e2e-smoke.sh
+++ b/scripts/backend-http-smoke.sh
@@ -1,47 +1,69 @@
#!/usr/bin/env bash
-# End-to-end smoke test. Starts the dev backend, hits every key endpoint,
-# verifies responses, then shuts down. Exits non-zero on any failure.
+# Backend HTTP smoke test. Starts the in-memory dev backend, checks a selected
+# set of read/quote endpoints plus the admin auth gate, then shuts down.
+# Exits non-zero on any failure.
#
-# Usage: ./scripts/e2e-smoke.sh
+# Usage: bash scripts/backend-http-smoke.sh
#
-# Requires: node, curl. Does not require a Canton participant (uses
-# InMemoryLedger).
+# Requires: bash, node, npm, curl, grep, and `npm ci` already run in
+# services/operator-backend. Does not require a Canton participant or dApp.
+# This is not a wallet, settlement, or full-stack browser test.
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
PORT="${PORT:-18080}"
BASE="http://localhost:${PORT}"
+SMOKE_TMP_DIR="$(mktemp -d "${TMPDIR:-/tmp}/canton-dex-http-smoke.XXXXXX")"
+BACKEND_LOG="$SMOKE_TMP_DIR/backend.log"
cleanup() {
+ local status=$?
if [[ -n "${BACKEND_PID:-}" ]]; then
kill "$BACKEND_PID" 2>/dev/null || true
wait "$BACKEND_PID" 2>/dev/null || true
fi
+ if [[ "$status" -eq 0 ]]; then
+ rm -rf "$SMOKE_TMP_DIR"
+ else
+ echo "backend log retained at: $BACKEND_LOG" >&2
+ fi
+ return "$status"
}
trap cleanup EXIT
+if curl -fsS "${BASE}/v1/status" >/dev/null 2>&1; then
+ echo "refusing to run: ${BASE} is already serving /v1/status; choose another PORT" >&2
+ exit 1
+fi
+
echo "==> Starting dev backend on :$PORT"
(
cd "$ROOT_DIR/services/operator-backend"
- PORT="$PORT" npm run dev >/tmp/e2e-smoke-backend.log 2>&1 &
- echo $! > /tmp/e2e-smoke-backend.pid
-)
-BACKEND_PID="$(cat /tmp/e2e-smoke-backend.pid)"
+ PORT="$PORT" exec npm run dev
+) >"$BACKEND_LOG" 2>&1 &
+BACKEND_PID="$!"
# Wait for the server to come up.
+READY=0
for i in {1..30}; do
if curl -fsS "${BASE}/v1/status" >/dev/null 2>&1; then
+ READY=1
break
fi
if ! kill -0 "$BACKEND_PID" 2>/dev/null; then
echo "backend died during startup; log:"
- cat /tmp/e2e-smoke-backend.log
+ cat "$BACKEND_LOG"
exit 1
fi
sleep 0.5
done
+if [[ "$READY" != "1" ]]; then
+ echo "backend did not become ready within 15 seconds; log:"
+ cat "$BACKEND_LOG"
+ exit 1
+fi
check_get_contains() {
local name="$1"
@@ -83,8 +105,9 @@ check_status() {
fi
}
-echo "==> Read endpoints"
-check_get_contains status "${BASE}/v1/status" '"synced":true'
+echo "==> Selected read endpoints"
+check_get_contains status-preview "${BASE}/v1/status" '"network":"preview:in-memory"'
+check_get_contains status-sync "${BASE}/v1/status" '"synced":true'
check_get_contains context "${BASE}/v1/context" '"operator"'
check_get_contains pools "${BASE}/v1/pools" 'BTC'
check_get_contains pairs "${BASE}/v1/pairs" 'BTC'
@@ -116,4 +139,4 @@ echo "==> Admin auth gate"
check_status admin-401 401 -X POST -H 'Content-Type: application/json' -d '{}' \
"${BASE}/v1/admin/pairs"
-echo "==> All smoke checks passed"
+echo "==> All backend HTTP smoke checks passed"
diff --git a/scripts/bootstrap-registry.ts b/scripts/bootstrap-registry.ts
index 5b9fec09..3d71cd16 100644
--- a/scripts/bootstrap-registry.ts
+++ b/scripts/bootstrap-registry.ts
@@ -13,15 +13,17 @@
// node --import tsx scripts/bootstrap-registry.ts
//
// Required env vars (see services/operator-backend/.env.example):
-// CANTON_LEDGER_URL, CANTON_LEDGER_TOKEN, CANTON_USER_ID,
+// CANTON_LEDGER_URL, CANTON_LEDGER_TOKEN, CANTON_DEX_PACKAGE_ID,
// CANTON_ADMIN, CANTON_LP_REGISTRAR, CANTON_OPERATOR.
//
// Optional:
// BOOTSTRAP_CONFIG path to a JSON config (default: scripts/bootstrap-registry.json)
// BOOTSTRAP_DRY_RUN "1" to print the plan without submitting
-// CANTON_DEX_PACKAGE_ID package hash prefix for template ids
+// CANTON_USER_ID JSON Ledger API user id (default: ledger-api-user)
import { readFileSync, existsSync } from "node:fs";
+import { dirname, resolve } from "node:path";
+import { fileURLToPath } from "node:url";
import { JsonApiLedger } from "../services/operator-backend/src/ledger/json-api.js";
import { rootLogger } from "../services/operator-backend/src/lib/logger.js";
@@ -75,7 +77,15 @@ function required(name: string): string {
}
function loadConfig(): BootstrapConfig {
- const path = process.env.BOOTSTRAP_CONFIG ?? "scripts/bootstrap-registry.json";
+ // The deploy script intentionally runs this module from
+ // services/operator-backend so `tsx` resolves from that package. Anchor the
+ // default beside this source file instead of silently changing behavior with
+ // the caller's working directory. Explicit relative overrides remain
+ // relative to the caller, as shell users expect.
+ const scriptDir = dirname(fileURLToPath(import.meta.url));
+ const path = process.env.BOOTSTRAP_CONFIG
+ ? resolve(process.cwd(), process.env.BOOTSTRAP_CONFIG)
+ : resolve(scriptDir, "bootstrap-registry.json");
if (!existsSync(path)) {
log.info("config file not found, using defaults", { path });
return DEFAULT_CONFIG;
@@ -175,6 +185,7 @@ async function main(): Promise {
const lpRegistrar = required("CANTON_LP_REGISTRAR");
// Lazy: only needed once there is a registry to create.
const operator = () => required("CANTON_OPERATOR");
+ const dexPackageId = required("CANTON_DEX_PACKAGE_ID");
const userId = process.env.CANTON_USER_ID ?? "ledger-api-user";
const dryRun = process.env.BOOTSTRAP_DRY_RUN === "1";
@@ -183,7 +194,7 @@ async function main(): Promise {
baseUrl,
token,
applicationId: userId,
- templateIdPrefix: process.env.CANTON_DEX_PACKAGE_ID,
+ templateIdPrefix: dexPackageId,
synchronizerId: process.env.CANTON_SYNCHRONIZER,
});
@@ -229,7 +240,13 @@ async function main(): Promise {
}
}
- log.info("bootstrap complete", { dryRun });
+ log.info("bootstrap complete", {
+ dryRun,
+ assetAdmin: admin,
+ assetRegistryCid: assetRegistryCid ?? null,
+ lpRegistrar,
+ lpRegistryCid: lpRegistryCid ?? null,
+ });
}
main().catch((e) => {
diff --git a/scripts/deploy-testnet.sh b/scripts/deploy-testnet.sh
index 011df69a..7bdeb9dc 100755
--- a/scripts/deploy-testnet.sh
+++ b/scripts/deploy-testnet.sh
@@ -1,130 +1,207 @@
#!/usr/bin/env bash
-# Canton testnet deployment.
+# Deterministic Canton testnet deployment.
#
-# Steps:
-# 1. Build all DARs from source.
-# 2. Upload DARs to the target Canton participant via JSON Ledger API.
-# 3. Allocate parties (operator, lpRegistrar, admin, demo trader).
-# 4. Run the registry bootstrap script (scripts/bootstrap-registry.ts).
-# 5. Seed initial pairs and pools via the operator backend admin API.
-# 6. Health check.
+# Default phases:
+# 1. Build the DEX DARs.
+# 2. Upload the current DEX DAR and its embedded dependency closure.
+# 3. Bootstrap Registry.V2 contracts and instrument configuration.
#
-# Required env vars (see services/operator-backend/.env.example):
-# CANTON_LEDGER_URL, CANTON_LEDGER_TOKEN
-# CANTON_OPERATOR, CANTON_LP_REGISTRAR, CANTON_ADMIN
-# OPERATOR_ADMIN_TOKEN (for admin API calls)
+# Optional market metadata phase (DEPLOY_SEED_MARKETS=1):
+# 4. Through an ALREADY-RUNNING operator backend, create a DexPair and an
+# unfunded Pool when they do not already exist.
#
-# Optional:
-# DEPLOY_SKIP_BUILD=1 skip `dpm build` (use existing DARs)
-# DEPLOY_SKIP_UPLOAD=1 skip DAR upload (already uploaded)
-# DEPLOY_SKIP_PARTIES=1 skip party allocation (already exist)
-# DEPLOY_SKIP_SEED=1 skip initial pair/pool seeding
+# Deliberate boundaries:
+# - This script does not allocate parties. CANTON_* party values must be the
+# exact allocated party ids, and the ledger JWT must have their rights.
+# - Creating a Pool does not fund it. Use seed-testnet-pool.ts or a wallet LP
+# flow after this script.
+# - A successful or partially failed run mutates the target ledger. Run only
+# against the intended participant and inspect the printed phase boundary.
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+usage() {
+ printf '%s\n' \
+ "Usage: bash scripts/deploy-testnet.sh" \
+ "" \
+ "Required:" \
+ " CANTON_LEDGER_URL CANTON_LEDGER_TOKEN" \
+ " CANTON_OPERATOR CANTON_LP_REGISTRAR CANTON_ADMIN" \
+ " CANTON_DEX_PACKAGE_ID" \
+ "" \
+ "Optional phase flags:" \
+ " DEPLOY_SKIP_BUILD=1 use existing DARs" \
+ " DEPLOY_SKIP_UPLOAD=1 packages are already uploaded/vetted" \
+ " DEPLOY_SKIP_BOOTSTRAP=1 registry already exists" \
+ " DEPLOY_SEED_MARKETS=1 create pair + unfunded pool via API" \
+ "" \
+ "Market phase variables:" \
+ " API_BASE (default http://localhost:8080)" \
+ " OPERATOR_ADMIN_TOKEN" \
+ " DEPLOY_BASE (default BTC), DEPLOY_QUOTE (default USDC)" \
+ " DEPLOY_LP_INSTRUMENT (default BTC-USDC-LP)" \
+ " DEPLOY_POOL_FEE_BPS (default 30)"
+}
+
+if [[ "${1:-}" == "--help" || "${1:-}" == "-h" ]]; then
+ usage
+ exit 0
+fi
+if [[ "$#" -ne 0 ]]; then
+ usage >&2
+ exit 2
+fi
+
require() {
if [[ -z "${!1:-}" ]]; then
- echo "[deploy-testnet] missing required env var: $1" >&2
- exit 1
+ printf '[deploy-testnet] missing required env var: %s\n' "$1" >&2
+ exit 2
fi
}
-require CANTON_LEDGER_URL
-require CANTON_LEDGER_TOKEN
-require CANTON_OPERATOR
-require CANTON_LP_REGISTRAR
-require CANTON_ADMIN
+for required_var in \
+ CANTON_LEDGER_URL CANTON_LEDGER_TOKEN \
+ CANTON_OPERATOR CANTON_LP_REGISTRAR CANTON_ADMIN \
+ CANTON_DEX_PACKAGE_ID; do
+ require "$required_var"
+done
AUTH="Authorization: Bearer ${CANTON_LEDGER_TOKEN}"
-# 1. Build DARs ----------------------------------------------------------
-
if [[ "${DEPLOY_SKIP_BUILD:-0}" != "1" ]]; then
- echo "==> Building DARs"
+ printf '%s\n' '==> [1/4] Building DEX DARs'
bash "$ROOT_DIR/scripts/build-trading-surface.sh"
- (cd "$ROOT_DIR/trading-tests" && dpm build)
else
- echo "==> Skipping DAR build (DEPLOY_SKIP_BUILD=1)"
+ printf '%s\n' '==> [1/4] Build skipped (DEPLOY_SKIP_BUILD=1)'
fi
-# 2. Upload DARs ---------------------------------------------------------
-
upload_dar() {
local dar="$1"
- echo " uploading: $dar"
+ printf ' upload %s\n' "${dar#"$ROOT_DIR"/}"
curl -fsS -X POST \
-H "$AUTH" \
-H "Content-Type: application/octet-stream" \
--data-binary "@$dar" \
- "${CANTON_LEDGER_URL}/v2/packages" >/dev/null
+ "${CANTON_LEDGER_URL%/}/v2/packages" >/dev/null
}
if [[ "${DEPLOY_SKIP_UPLOAD:-0}" != "1" ]]; then
- echo "==> Uploading DARs to $CANTON_LEDGER_URL"
- for dar in \
- "$ROOT_DIR"/vendor/splice/daml/splice-util-token-standard-wallet/.daml/dist/splice-util-token-standard-wallet-current.dar \
- "$ROOT_DIR"/trading/.daml/dist/*.dar \
- "$ROOT_DIR"/trading-tests/.daml/dist/*.dar; do
- [[ -f "$dar" ]] && upload_dar "$dar"
- done
+ printf '%s\n' '==> [2/4] Uploading package closure'
+ # A DAR already contains its transitive DALF dependency closure. Select the
+ # exact name/version declared in daml.yaml: globbing dist/*.dar can pick up
+ # stale builds whose old dependency hashes share a package name/version and
+ # Canton correctly rejects that ambiguous package-vetting request.
+ read -r dex_name dex_version < <(node -e '
+ const fs = require("node:fs");
+ const yaml = fs.readFileSync(process.argv[1], "utf8");
+ const field = (name) => yaml.match(new RegExp(`^${name}:\\s*(.+)$`, "m"))?.[1]?.trim();
+ const packageName = field("name");
+ const version = field("version");
+ if (!packageName || !version) process.exit(1);
+ process.stdout.write(`${packageName} ${version}\n`);
+ ' "$ROOT_DIR/trading/daml.yaml")
+ dex_dar="$ROOT_DIR/trading/.daml/dist/${dex_name}-${dex_version}.dar"
+ if [[ ! -f "$dex_dar" ]]; then
+ printf '[deploy-testnet] expected current DAR not found: %s\n' "$dex_dar" >&2
+ printf '%s\n' '[deploy-testnet] run without DEPLOY_SKIP_BUILD to create it' >&2
+ exit 1
+ fi
+ upload_dar "$dex_dar"
+ printf '%s\n' ' uploaded 1 DAR (including its Token Standard dependency closure)'
else
- echo "==> Skipping DAR upload (DEPLOY_SKIP_UPLOAD=1)"
+ printf '%s\n' '==> [2/4] Upload skipped (DEPLOY_SKIP_UPLOAD=1)'
fi
-# 3. Allocate parties ----------------------------------------------------
-
-allocate_party() {
- local hint="$1"
- echo " allocating party hint=$hint"
- curl -fsS -X POST \
- -H "$AUTH" \
- -H "Content-Type: application/json" \
- -d "{\"partyIdHint\":\"$hint\"}" \
- "${CANTON_LEDGER_URL}/v2/parties" >/dev/null || true
-}
-
-if [[ "${DEPLOY_SKIP_PARTIES:-0}" != "1" ]]; then
- echo "==> Allocating parties (idempotent; existing parties are no-op)"
- allocate_party "$CANTON_OPERATOR"
- allocate_party "$CANTON_LP_REGISTRAR"
- allocate_party "$CANTON_ADMIN"
- allocate_party "trader-demo"
+if [[ "${DEPLOY_SKIP_BOOTSTRAP:-0}" != "1" ]]; then
+ printf '%s\n' '==> [3/4] Bootstrapping Registry.V2 contracts'
+ # tsx is a backend dependency; running from this directory makes a clean
+ # clone work without a nonexistent root node_modules. Install the locked
+ # dependency tree only when it is not already present.
+ if [[ ! -x "$ROOT_DIR/services/operator-backend/node_modules/.bin/tsx" ]]; then
+ (cd "$ROOT_DIR/services/operator-backend" && npm ci)
+ fi
+ (cd "$ROOT_DIR/services/operator-backend" && \
+ node --import tsx ../../scripts/bootstrap-registry.ts)
else
- echo "==> Skipping party allocation (DEPLOY_SKIP_PARTIES=1)"
+ printf '%s\n' '==> [3/4] Bootstrap skipped (DEPLOY_SKIP_BOOTSTRAP=1)'
fi
-# 4. Registry bootstrap --------------------------------------------------
-
-echo "==> Running registry bootstrap"
-echo " (instrument configs, LP configs, credentials, and the lpRegistrar's"
-echo " Registry.V2 -- without which liquidity cannot be allocated)"
-(cd "$ROOT_DIR" && node --import tsx scripts/bootstrap-registry.ts)
-
-# 5. Seed initial pair/pool ---------------------------------------------
-
-if [[ "${DEPLOY_SKIP_SEED:-0}" != "1" && -n "${OPERATOR_ADMIN_TOKEN:-}" ]]; then
- echo "==> Seeding BTC/USDC pair (via operator admin API)"
+if [[ "${DEPLOY_SEED_MARKETS:-0}" == "1" ]]; then
+ require OPERATOR_ADMIN_TOKEN
API_BASE="${API_BASE:-http://localhost:8080}"
- curl -fsS -X POST \
- -H "Authorization: Bearer ${OPERATOR_ADMIN_TOKEN}" \
- -H "Content-Type: application/json" \
- -d '{"baseInstrumentId":"BTC","quoteInstrumentId":"USDC","feeModel":{"makerFeeBps":10,"takerFeeBps":30,"poolFeeBps":30},"tradingMode":"TM_Both"}' \
- "${API_BASE}/v1/admin/pairs" || echo " (pair may already exist; continuing)"
-else
- echo "==> Skipping initial pair/pool seed"
-fi
-
-# 6. Health check --------------------------------------------------------
+ BASE="${DEPLOY_BASE:-BTC}"
+ QUOTE="${DEPLOY_QUOTE:-USDC}"
+ LP_INSTRUMENT="${DEPLOY_LP_INSTRUMENT:-${BASE}-${QUOTE}-LP}"
+ POOL_FEE_BPS="${DEPLOY_POOL_FEE_BPS:-30}"
+
+ printf '%s\n' '==> [4/4] Creating pair and unfunded pool through operator API'
+ # This is a precondition, not an informational health check: market seeding
+ # cannot work until the fail-closed backend is running.
+ curl -fsS "${API_BASE%/}/v1/status" >/dev/null
+
+ pairs_json="$(curl -fsS "${API_BASE%/}/v1/pairs")"
+ pair_exists="$(PAIRS_JSON="$pairs_json" BASE="$BASE" QUOTE="$QUOTE" node -e '
+ const rows = JSON.parse(process.env.PAIRS_JSON || "[]");
+ process.stdout.write(rows.some((p) => p.baseInstrumentId === process.env.BASE && p.quoteInstrumentId === process.env.QUOTE) ? "1" : "0");
+ ')"
+ if [[ "$pair_exists" == "0" ]]; then
+ pair_payload="$(CANTON_ADMIN="$CANTON_ADMIN" BASE="$BASE" QUOTE="$QUOTE" \
+ POOL_FEE_BPS="$POOL_FEE_BPS" node -e '
+ process.stdout.write(JSON.stringify({
+ admin: process.env.CANTON_ADMIN,
+ baseInstrumentId: process.env.BASE,
+ quoteInstrumentId: process.env.QUOTE,
+ feeModel: {
+ makerFeeBps: 10,
+ takerFeeBps: 30,
+ poolFeeBps: Number(process.env.POOL_FEE_BPS),
+ },
+ tradingMode: "TM_Both",
+ active: true,
+ }));
+ ')"
+ curl -fsS -X POST \
+ -H "Authorization: Bearer ${OPERATOR_ADMIN_TOKEN}" \
+ -H "Content-Type: application/json" \
+ --data-binary "$pair_payload" \
+ "${API_BASE%/}/v1/admin/pairs" >/dev/null
+ printf ' created pair %s/%s\n' "$BASE" "$QUOTE"
+ else
+ printf ' pair %s/%s already exists\n' "$BASE" "$QUOTE"
+ fi
-echo "==> Health check"
-API_BASE="${API_BASE:-http://localhost:8080}"
-if curl -fsS "${API_BASE}/v1/status" >/dev/null 2>&1; then
- echo " operator backend reachable at ${API_BASE}"
+ pools_json="$(curl -fsS "${API_BASE%/}/v1/pools")"
+ pool_exists="$(POOLS_JSON="$pools_json" BASE="$BASE" QUOTE="$QUOTE" node -e '
+ const rows = JSON.parse(process.env.POOLS_JSON || "[]");
+ process.stdout.write(rows.some((p) => p.baseInstrumentId === process.env.BASE && p.quoteInstrumentId === process.env.QUOTE) ? "1" : "0");
+ ')"
+ if [[ "$pool_exists" == "0" ]]; then
+ pool_payload="$(CANTON_LP_REGISTRAR="$CANTON_LP_REGISTRAR" \
+ CANTON_ADMIN="$CANTON_ADMIN" BASE="$BASE" QUOTE="$QUOTE" \
+ LP_INSTRUMENT="$LP_INSTRUMENT" POOL_FEE_BPS="$POOL_FEE_BPS" node -e '
+ process.stdout.write(JSON.stringify({
+ lpRegistrar: process.env.CANTON_LP_REGISTRAR,
+ admin: process.env.CANTON_ADMIN,
+ baseInstrumentId: process.env.BASE,
+ quoteInstrumentId: process.env.QUOTE,
+ lpInstrumentId: process.env.LP_INSTRUMENT,
+ feeBps: Number(process.env.POOL_FEE_BPS),
+ }));
+ ')"
+ curl -fsS -X POST \
+ -H "Authorization: Bearer ${OPERATOR_ADMIN_TOKEN}" \
+ -H "Content-Type: application/json" \
+ --data-binary "$pool_payload" \
+ "${API_BASE%/}/v1/admin/pools" >/dev/null
+ printf ' created UNFUNDED pool %s/%s; fund it through an LP wallet flow\n' "$BASE" "$QUOTE"
+ else
+ printf ' pool %s/%s already exists\n' "$BASE" "$QUOTE"
+ fi
else
- echo " operator backend not reachable at ${API_BASE} (start it separately)"
+ printf '%s\n' '==> [4/4] Market metadata skipped (set DEPLOY_SEED_MARKETS=1 after backend startup)'
fi
-echo "==> Deployment complete"
+printf '%s\n' '==> Deployment phases completed without a suppressed error'
diff --git a/scripts/live-amm-roundtrip.ts b/scripts/live-amm-roundtrip.ts
new file mode 100644
index 00000000..179e14a3
--- /dev/null
+++ b/scripts/live-amm-roundtrip.ts
@@ -0,0 +1,913 @@
+// Headless AMM liquidity round trip against a live Canton participant.
+//
+// Stands in for the trader's wallet (the one piece a browser CIP-0103
+// wallet normally does): it authors the trader's allocations and drives a
+// self-contained add -> swap -> partial remove through the JSON Ledger API.
+//
+// It does NOT exercise the operator HTTP server, dApp, a real wallet transport,
+// or browser authentication. Those boundaries need separate tests.
+//
+// Self-contained: creates its own V2 Registry (admin == pool admin ==
+// lpRegistrar, the self-registry case), registers base/quote/LP
+// instruments, mints to the LP and swapper, creates the pool contracts, then:
+// 1. adds liquidity and asserts reserves + LP supply/holding;
+// 2. swaps quote -> base and asserts exact balances/reserves + x*y;
+// 3. redeems half the LP position and asserts returned balances, remaining
+// reserves/slices/supply, and reserve-per-LP invariants.
+//
+// STATE WARNING: a successful or partially failed run leaves contracts on the
+// participant. Use a throwaway LocalNet. The unique `dvp-` run id
+// printed at startup identifies the pool and command ids left by this run.
+//
+// Env:
+// CANTON_LEDGER_URL, CANTON_LEDGER_TOKEN,
+// CANTON_DEX_PACKAGE_ID (e.g. #canton-dex-trading),
+// CANTON_ALLOC_INSTR_PACKAGE_ID
+// (e.g. #splice-api-token-allocation-instruction-v2),
+// CANTON_USER_ID (default ledger-api-user),
+// CANTON_SYNCHRONIZER (optional; omit to let a single-synchronizer
+// participant route the submission),
+// CANTON_OPERATOR, CANTON_ADMIN, CANTON_TRADER,
+// CANTON_SWAPPER (optional; defaults to CANTON_TRADER)
+// (operator == venue; admin == instrument issuer == lpRegistrar;
+// trader == the LP). A full round trip requires swapper != operator because
+// a swap cannot contain self-transfer legs. The token must have actAs for
+// every distinct configured party.
+//
+// Run from services/operator-backend (which has tsx on its path):
+// npm run live:roundtrip # add -> swap -> partial remove
+// npm run live:add-liquidity # add only; still needs trader != operator
+
+import * as dec from "../services/operator-backend/src/pool/decimal.js";
+
+function req(name: string): string {
+ const v = process.env[name];
+ if (!v) { console.error(`missing env: ${name}`); process.exit(2); }
+ return v;
+}
+
+const trader = req("CANTON_TRADER");
+const cfg = {
+ baseUrl: req("CANTON_LEDGER_URL"),
+ token: req("CANTON_LEDGER_TOKEN"),
+ sync: process.env.CANTON_SYNCHRONIZER || undefined,
+ pkg: req("CANTON_DEX_PACKAGE_ID"),
+ userId: process.env.CANTON_USER_ID ?? "ledger-api-user",
+ operator: req("CANTON_OPERATOR"),
+ admin: req("CANTON_ADMIN"),
+ trader,
+ swapper: process.env.CANTON_SWAPPER ?? trader,
+ // AllocationFactory_Allocate is a token-standard INTERFACE choice; it must
+ // be exercised against the interface id (alloc-instruction-v2 package),
+ // not the concrete Registry template.
+ pkgAllocInstr: req("CANTON_ALLOC_INSTR_PACKAGE_ID"),
+};
+const lpRegistrar = cfg.admin; // self-registry: admin issues base/quote AND LP
+
+const RUN = `dvp-${Date.now()}`;
+const BASE = "BTC", QUOTE = "USDC", LP = `BTC-USDC-LP-${RUN}`;
+const ADD_BASE = "4.0", ADD_QUOTE = "12000.0";
+const SWAP_IN = "1000.0"; // USDC -> BTC
+const FEE_BPS = 30;
+const CAP = "1000000000.0";
+const ADD_ONLY = process.argv.includes("--add-only");
+const unknownArgs = process.argv.slice(2).filter((arg) => arg !== "--add-only");
+if (unknownArgs.length > 0) {
+ console.error(`unknown argument(s): ${unknownArgs.join(", ")}`);
+ process.exit(2);
+}
+if (cfg.trader === cfg.operator) {
+ console.error(
+ "liquidity flow requires CANTON_TRADER != CANTON_OPERATOR because a deposit cannot self-transfer",
+ );
+ process.exit(2);
+}
+if (!ADD_ONLY && cfg.swapper === cfg.operator) {
+ console.error(
+ "full round trip requires CANTON_SWAPPER != CANTON_OPERATOR; " +
+ "use a second sandbox party or pass --add-only",
+ );
+ process.exit(2);
+}
+const tid = (m: string) => `${cfg.pkg}:${m}`;
+const acct = (p: string) => ({ owner: p, provider: null, id: "" });
+const EXTRA = { context: { values: {} }, meta: { values: {} } };
+
+interface Created { contractId: string; templateId: string; createArgument: Record }
+interface Exercised { choice: string; exerciseResult: unknown }
+type Ev =
+ | { CreatedEvent: Created }
+ | { ArchivedEvent: { contractId: string } }
+ | { ExercisedEvent: Exercised };
+interface Tx { transaction: { updateId: string; events: Ev[] } }
+interface PoolStateArg {
+ poolId: string;
+ status: string;
+ reserves: { baseAmount: string; quoteAmount: string };
+ totalLpSupply: string;
+}
+interface SliceArg { poolId: string; operator: string; side: string; amount: string }
+interface HoldingArg {
+ admin: string; owner: string; instrumentId: string; amount: string; locked?: boolean;
+}
+interface RequestArg { allocations: unknown[]; settlement: unknown }
+interface PolicyArg { totalSupply: string; lpInstrumentId: { admin: string; id: string } }
+interface SwapRequestResult {
+ settlement: unknown;
+ allocationSpec: unknown;
+ quoteBinding: SwapQuoteBinding | null;
+}
+interface SwapQuoteBinding {
+ expectedPoolId: string;
+ poolStateCid: string;
+ inputSliceCid: string;
+ outputSliceCids: string[];
+ minOutputAmount: string;
+}
+
+const argOf = (created: Created): T => created.createArgument as unknown as T;
+
+function only(values: T[], what: string): T {
+ if (values.length !== 1) {
+ throw new Error(`expected exactly 1 ${what}, found ${values.length}`);
+ }
+ return values[0]!;
+}
+
+// Canton 3.x JSON API encodes Daml Int64 as a JSON string. Coerce every
+// integer-valued number before submission.
+function encInt(v: unknown): unknown {
+ if (typeof v === "number") return Number.isInteger(v) ? String(v) : v;
+ if (Array.isArray(v)) return v.map(encInt);
+ if (v !== null && typeof v === "object") {
+ const o: Record = {};
+ for (const [k, val] of Object.entries(v)) o[k] = encInt(val);
+ return o;
+ }
+ return v;
+}
+
+async function submit(
+ actAs: string[], cid: string, commands: unknown[], readAs: string[] = [],
+): Promise {
+ const uniqueActAs = [...new Set(actAs)];
+ const uniqueReadAs = [...new Set(readAs)].filter((party) => !uniqueActAs.includes(party));
+ const res = await fetch(`${cfg.baseUrl}/v2/commands/submit-and-wait-for-transaction`, {
+ method: "POST",
+ headers: { Authorization: `Bearer ${cfg.token}`, "Content-Type": "application/json" },
+ body: JSON.stringify({
+ commands: {
+ commandId: cid,
+ userId: cfg.userId,
+ actAs: uniqueActAs,
+ ...(uniqueReadAs.length > 0 ? { readAs: uniqueReadAs } : {}),
+ ...(cfg.sync ? { synchronizerId: cfg.sync } : {}),
+ commands: encInt(commands),
+ },
+ transactionShape: "TRANSACTION_SHAPE_ACS_DELTA",
+ }),
+ });
+ const t = await res.text();
+ if (!res.ok) throw new Error(`submit ${cid} -> HTTP ${res.status}: ${t}`);
+ return JSON.parse(t) as Tx;
+}
+function creates(tx: Tx, suffix: string): Created[] {
+ return tx.transaction.events
+ .filter((e): e is { CreatedEvent: Created } => "CreatedEvent" in e)
+ .map((e) => e.CreatedEvent)
+ .filter((c) => c.templateId.endsWith(suffix));
+}
+function exercisedResult(tx: Tx, choice: string): unknown {
+ for (const event of tx.transaction.events) {
+ if ("ExercisedEvent" in event && event.ExercisedEvent.choice === choice) {
+ return event.ExercisedEvent.exerciseResult;
+ }
+ }
+ return undefined;
+}
+async function treeExercisedResult(
+ updateId: string, party: string, choice: string,
+): Promise {
+ const url = new URL(
+ `/v2/updates/transaction-tree-by-id/${encodeURIComponent(updateId)}`,
+ cfg.baseUrl,
+ );
+ url.searchParams.append("parties", party);
+ const response = await fetch(url.toString(), {
+ headers: { Authorization: `Bearer ${cfg.token}` },
+ });
+ if (!response.ok) return undefined;
+ const body = (await response.json()) as {
+ transaction?: { eventsById?: Record };
+ };
+ for (const event of Object.values(body.transaction?.eventsById ?? {})) {
+ const exercised = event.ExercisedTreeEvent?.value;
+ if (exercised?.choice === choice) return exercised.exerciseResult;
+ }
+ return undefined;
+}
+async function ledgerEnd(): Promise {
+ const r = await fetch(`${cfg.baseUrl}/v2/state/ledger-end`, { headers: { Authorization: `Bearer ${cfg.token}` } });
+ if (!r.ok) throw new Error(`ledger end -> HTTP ${r.status}: ${await r.text()}`);
+ return ((await r.json()) as { offset: number }).offset;
+}
+async function acs(party: string, template: string): Promise {
+ const offset = await ledgerEnd();
+ const r = await fetch(`${cfg.baseUrl}/v2/state/active-contracts`, {
+ method: "POST",
+ headers: { Authorization: `Bearer ${cfg.token}`, "Content-Type": "application/json" },
+ body: JSON.stringify({
+ verbose: false, activeAtOffset: offset,
+ filter: { filtersByParty: { [party]: { cumulative: [
+ { identifierFilter: { TemplateFilter: { value: { templateId: tid(template), includeCreatedEventBlob: false } } } },
+ ] } } },
+ }),
+ });
+ if (!r.ok) throw new Error(`ACS ${template} -> HTTP ${r.status}: ${await r.text()}`);
+ const body = (await r.json()) as Array<{ contractEntry?: { JsActiveContract?: { createdEvent?: Created } } }>;
+ return body.map((e) => e.contractEntry?.JsActiveContract?.createdEvent).filter((x): x is Created => !!x);
+}
+async function step(name: string, fn: () => Promise): Promise {
+ const t0 = Date.now();
+ try { const out = await fn(); console.log(` ok ${name} (${Date.now() - t0}ms)`); return out; }
+ catch (e) { console.error(` FAIL ${name}: ${(e as Error).message}`); throw e; }
+}
+const eq = (a: unknown, b: unknown, m: string) => {
+ if (String(a) !== String(b)) throw new Error(`assert ${m}: expected ${b}, got ${a}`);
+};
+const eqDec = (a: bigint, b: bigint, m: string) => {
+ if (a !== b) {
+ throw new Error(
+ `assert ${m}: expected ${dec.formatDecimal(b)}, got ${dec.formatDecimal(a)}`,
+ );
+ }
+};
+const atLeastRaw = (a: bigint, b: bigint, m: string) => {
+ if (a < b) throw new Error(`assert ${m}: expected left side >= right side`);
+};
+
+const sum = (values: bigint[]): bigint => values.reduce((total, value) => total + value, 0n);
+
+function constantProductOut(
+ reserveIn: bigint, reserveOut: bigint, feeBps: number, inputAmount: bigint,
+): bigint {
+ const feeNumerator = dec.parseDecimal(String(10000 - feeBps));
+ const feeDenominator = dec.parseDecimal("10000");
+ const afterFee = dec.divFloor(
+ dec.mulFloor(inputAmount, feeNumerator),
+ feeDenominator,
+ );
+ return dec.divFloor(dec.mulFloor(afterFee, reserveOut), reserveIn + afterFee);
+}
+
+function coveringPlan(slices: Created[], target: bigint, side: string): {
+ cids: string[];
+ outs: string[];
+} {
+ let remaining = target;
+ const cids: string[] = [];
+ const outs: string[] = [];
+ for (const slice of slices) {
+ if (remaining <= 0n) break;
+ const amount = dec.parseDecimal(argOf(slice).amount);
+ const drawn = amount < remaining ? amount : remaining;
+ cids.push(slice.contractId);
+ outs.push(dec.formatDecimal(drawn));
+ remaining -= drawn;
+ }
+ if (remaining > 0n) {
+ throw new Error(
+ `${side} slices cannot cover ${dec.formatDecimal(target)}; short ${dec.formatDecimal(remaining)}`,
+ );
+ }
+ return { cids, outs };
+}
+
+// Author one allocation as the trader (the wallet's job): exercise
+// AllocationFactory_Allocate on the registry, locking inputHoldingCids.
+async function authorAlloc(
+ regCid: string,
+ party: string,
+ settlement: unknown,
+ allocation: unknown,
+ inputHoldingCids: string[],
+ label: string,
+): Promise {
+ const tx = await submit([party], `${RUN}-author-${label}`, [{
+ ExerciseCommand: {
+ templateId: `${cfg.pkgAllocInstr}:Splice.Api.Token.AllocationInstructionV2:AllocationFactory`,
+ contractId: regCid,
+ choice: "AllocationFactory_Allocate",
+ choiceArgument: {
+ settlement,
+ allocation,
+ requestedAt: new Date().toISOString(),
+ inputHoldingCids,
+ extraArgs: EXTRA,
+ actors: [party],
+ },
+ },
+ }]);
+ return only(
+ creates(tx, "CantonDex.Registry.V2:Allocation"),
+ `${label} allocation`,
+ ).contractId;
+}
+
+async function holdingsFor(
+ party: string,
+ instrumentId: string,
+): Promise> {
+ const holdings = await acs(party, "CantonDex.Registry.V2:Holding");
+ return holdings
+ .map((created) => ({ cid: created.contractId, arg: argOf(created) }))
+ .filter(
+ ({ arg }) =>
+ arg.owner === party &&
+ arg.admin === cfg.admin &&
+ arg.instrumentId === instrumentId &&
+ !arg.locked,
+ )
+ .map(({ cid, arg }) => ({ cid, amount: arg.amount }));
+}
+
+async function balance(party: string, instrumentId: string): Promise {
+ return sum((await holdingsFor(party, instrumentId)).map((holding) => dec.parseDecimal(holding.amount)));
+}
+
+async function poolSlices(poolId: string): Promise<{ base: Created[]; quote: Created[] }> {
+ const slices = (await acs(cfg.operator, "CantonDex.Dex.PoolSlice:PoolSlice"))
+ .filter((created) => {
+ const arg = argOf(created);
+ return arg.poolId === poolId && arg.operator === cfg.operator;
+ });
+ return {
+ base: slices.filter((created) => argOf(created).side === "BaseSide"),
+ quote: slices.filter((created) => argOf(created).side === "QuoteSide"),
+ };
+}
+
+function sliceTotal(slices: Created[]): bigint {
+ return sum(slices.map((created) => dec.parseDecimal(argOf(created).amount)));
+}
+
+async function reconcile(
+ rulesCid: string,
+ poolId: string,
+ poolCid: string,
+ poolStateCid: string,
+): Promise {
+ const slices = await poolSlices(poolId);
+ const sliceCids = [...slices.base, ...slices.quote].map((created) => created.contractId);
+ await submit([cfg.operator], `${RUN}-reconcile-${Date.now()}`, [{
+ ExerciseCommand: {
+ templateId: tid("CantonDex.Dex.PoolRules:PoolRules"),
+ contractId: rulesCid,
+ choice: "PoolRules_ReconcileState",
+ choiceArgument: { expectedPoolId: poolId, poolCid, poolStateCid, sliceCids },
+ },
+ }]);
+ return sliceCids.length;
+}
+
+async function main() {
+ console.log(`run ${RUN}`);
+ console.log(
+ `operator=${cfg.operator.slice(0, 20)}.. admin=${cfg.admin.slice(0, 20)}.. ` +
+ `trader=${cfg.trader.slice(0, 20)}.. swapper=${cfg.swapper.slice(0, 20)}..`,
+ );
+
+ // 1. Registry + instruments + trader holdings ---------------------------
+ const regCid = await step("create Registry.V2 (factory + settlement)", async () => {
+ const tx = await submit([cfg.admin], `${RUN}-reg`, [{
+ CreateCommand: {
+ templateId: tid("CantonDex.Registry.V2:Registry"),
+ createArguments: {
+ admin: cfg.admin,
+ users: [...new Set([cfg.operator, cfg.trader, cfg.swapper])],
+ },
+ },
+ }]);
+ return creates(tx, "CantonDex.Registry.V2:Registry")[0]!.contractId;
+ });
+ // RegisterInstrument returns an InstrumentConfig; Mint consumes the
+ // latest config (BumpSupply) and rotates it. Track per-instrument.
+ const configCid: Record = {};
+ for (const id of [BASE, QUOTE, LP]) {
+ await step(`register ${id}`, async () => {
+ const tx = await submit([cfg.admin], `${RUN}-reg-${id}`, [{
+ ExerciseCommand: {
+ templateId: tid("CantonDex.Registry.V2:Registry"), contractId: regCid,
+ choice: "Registry_RegisterInstrument",
+ choiceArgument: {
+ instrumentId: id, decimals: "10", supplyCap: CAP,
+ holderRequirements: [], issuerRequirements: [], isin: null, cusip: null,
+ },
+ },
+ }]);
+ configCid[id] = creates(tx, "CantonDex.Registry.V2:InstrumentConfig")[0]!.contractId;
+ });
+ }
+ const mint = (id: string, amt: string, owner: string) =>
+ step(`mint ${amt} ${id} -> ${owner === cfg.trader ? "trader" : owner.slice(0, 8)}`, async () => {
+ const tx = await submit([cfg.admin, owner], `${RUN}-mint-${id}-${owner.slice(0, 6)}-${Date.now()}`, [{
+ ExerciseCommand: {
+ templateId: tid("CantonDex.Registry.V2:Registry"), contractId: regCid,
+ choice: "Registry_Mint",
+ choiceArgument: { configCid: configCid[id], owner, amount: amt, issuerClaims: [] },
+ },
+ }]);
+ configCid[id] = creates(tx, "CantonDex.Registry.V2:InstrumentConfig")[0]!.contractId;
+ return creates(tx, "CantonDex.Registry.V2:Holding")[0]!.contractId;
+ });
+ // Snapshot unrelated unlocked inventory before this run mints anything.
+ // The driver is safe to repeat on a persistent LocalNet, so conservation
+ // must compare deltas instead of pretending the participant was pristine.
+ const valueParties = [...new Set([cfg.trader, cfg.swapper])];
+ const initialUnlockedBase = sum(
+ await Promise.all(valueParties.map((party) => balance(party, BASE))),
+ );
+ const initialUnlockedQuote = sum(
+ await Promise.all(valueParties.map((party) => balance(party, QUOTE))),
+ );
+ await mint(BASE, ADD_BASE, cfg.trader);
+ await mint(QUOTE, ADD_QUOTE, cfg.trader);
+ if (!ADD_ONLY) await mint(QUOTE, SWAP_IN, cfg.swapper);
+
+ // 2. Pool contracts (operator-authored), as the admin bootstrap does ----
+ // Unique poolId per run so we never collide with other pools the
+ // operator observes (which would make a poolId-based lookup ambiguous).
+ const poolId = `${BASE}-${QUOTE}-${RUN}`;
+ const lpInstrumentId = { admin: lpRegistrar, id: LP };
+ const poolCid = await step("create Pool", async () => {
+ const tx = await submit([cfg.operator], `${RUN}-pool`, [{
+ CreateCommand: {
+ templateId: tid("CantonDex.Dex.Pool:Pool"),
+ createArguments: {
+ poolId, operator: cfg.operator, lpRegistrar, admin: cfg.admin,
+ baseInstrumentId: BASE, quoteInstrumentId: QUOTE, lpInstrumentId,
+ feeBps: "30",
+ },
+ },
+ }]);
+ return creates(tx, "CantonDex.Dex.Pool:Pool")[0]!.contractId;
+ });
+ let stateCid = await step("create PoolState (Unfunded)", async () => {
+ const tx = await submit([cfg.operator], `${RUN}-state`, [{
+ CreateCommand: {
+ templateId: tid("CantonDex.Dex.PoolState:PoolState"),
+ createArguments: {
+ poolId, operator: cfg.operator, lpRegistrar, status: "PS_Unfunded",
+ reserves: { baseAmount: "0.0", quoteAmount: "0.0" }, totalLpSupply: "0.0", publicReaders: [],
+ },
+ },
+ }]);
+ return creates(tx, "CantonDex.Dex.PoolState:PoolState")[0]!.contractId;
+ });
+ const rulesCid = await step("create PoolRules", async () => {
+ const tx = await submit([cfg.operator], `${RUN}-rules`, [{
+ CreateCommand: { templateId: tid("CantonDex.Dex.PoolRules:PoolRules"), createArguments: { operator: cfg.operator } },
+ }]);
+ return only(creates(tx, "CantonDex.Dex.PoolRules:PoolRules"), "PoolRules").contractId;
+ });
+ const dvpCid = await step("create PoolLiquidityRules", async () => {
+ const tx = await submit([cfg.operator, lpRegistrar], `${RUN}-dvp`, [{
+ CreateCommand: {
+ templateId: tid("CantonDex.Dex.PoolLiquidityRules:PoolLiquidityRules"),
+ createArguments: { operator: cfg.operator, lpRegistrar },
+ },
+ }]);
+ return creates(tx, "CantonDex.Dex.PoolLiquidityRules:PoolLiquidityRules")[0]!.contractId;
+ });
+ let policyCid = await step("create LPTokenPolicy", async () => {
+ const tx = await submit([lpRegistrar], `${RUN}-policy`, [{
+ CreateCommand: {
+ templateId: tid("CantonDex.Lp.Policy:LPTokenPolicy"),
+ createArguments: { lpRegistrar, operator: cfg.operator, lpInstrumentId, totalSupply: "0.0", active: true },
+ },
+ }]);
+ return creates(tx, "CantonDex.Lp.Policy:LPTokenPolicy")[0]!.contractId;
+ });
+
+ // 3. DvP ADD: request -> author 3 allocations -> settle -----------------
+ console.log("\n== ADD LIQUIDITY ==");
+ const reqAdd = await step("PoolLiquidityRules_RequestAddLiquidity", async () => {
+ const lpAmount = dec.formatDecimal(
+ dec.sqrt(dec.mul(dec.parseDecimal(ADD_BASE), dec.parseDecimal(ADD_QUOTE))),
+ );
+ const tx = await submit([cfg.operator], `${RUN}-add-req`, [{
+ ExerciseCommand: {
+ templateId: tid("CantonDex.Dex.PoolLiquidityRules:PoolLiquidityRules"), contractId: dvpCid,
+ choice: "PoolLiquidityRules_RequestAddLiquidity",
+ choiceArgument: {
+ poolCid, recipient: cfg.trader, baseAmount: ADD_BASE, quoteAmount: ADD_QUOTE,
+ lpAmount, requestedAt: new Date().toISOString(), settleAt: null,
+ },
+ },
+ }]);
+ const r = creates(tx, "CantonDex.Dex.LiquidityAllocationRequest:LiquidityAllocationRequest")[0]!;
+ return { cid: r.contractId, arg: r.createArgument as { allocations: unknown[]; settlement: unknown } };
+ });
+ const addBaseH = (await holdingsFor(cfg.trader, BASE))
+ .find((holding) => dec.parseDecimal(holding.amount) === dec.parseDecimal(ADD_BASE));
+ const addQuoteH = (await holdingsFor(cfg.trader, QUOTE))
+ .find((holding) => dec.parseDecimal(holding.amount) === dec.parseDecimal(ADD_QUOTE));
+ if (!addBaseH || !addQuoteH) throw new Error("trader add-liquidity holdings were not found");
+ const settlement = reqAdd.arg.settlement;
+ const [baseSpec, quoteSpec, receiptSpec] = reqAdd.arg.allocations;
+ if (!baseSpec || !quoteSpec || !receiptSpec) throw new Error("add request did not return 3 allocation specs");
+ const baseDep = await step("trader authors base deposit", () =>
+ authorAlloc(regCid, cfg.trader, settlement, baseSpec, [addBaseH.cid], "add-base"));
+ const quoteDep = await step("trader authors quote deposit", () =>
+ authorAlloc(regCid, cfg.trader, settlement, quoteSpec, [addQuoteH.cid], "add-quote"));
+ const receipt = await step("trader authors LP receipt", () =>
+ authorAlloc(regCid, cfg.trader, settlement, receiptSpec, [], "add-receipt"));
+ const addRes = await step("PoolLiquidityRules_SettleAddLiquidity", async () => {
+ const tx = await submit([cfg.operator, lpRegistrar], `${RUN}-add-settle`, [{
+ ExerciseCommand: {
+ templateId: tid("CantonDex.Dex.PoolLiquidityRules:PoolLiquidityRules"), contractId: dvpCid,
+ choice: "PoolLiquidityRules_SettleAddLiquidity",
+ choiceArgument: {
+ expectedPoolId: poolId, poolCid, poolStateCid: stateCid, lpPolicyCid: policyCid,
+ requestCid: reqAdd.cid, acceptanceCid: null, recipient: cfg.trader,
+ lpBaseDepositCid: baseDep, lpQuoteDepositCid: quoteDep, lpReceiptCid: receipt,
+ baseFactoryCid: regCid, quoteFactoryCid: regCid, lpFactoryCid: regCid,
+ baseQuoteSettleCid: regCid, lpSettleCid: regCid,
+ baseAmount: ADD_BASE, quoteAmount: ADD_QUOTE, minLpTokens: "0.0", knownTotalLpSupply: "0.0",
+ requestedAt: new Date().toISOString(), poolAdminExtraArgs: EXTRA, lpRegistrarExtraArgs: EXTRA,
+ },
+ },
+ }]);
+ // This settle tx creates exactly one PoolState (for THIS pool); match
+ // it by poolId to be unambiguous even if [0] ordering ever changes.
+ const ps = creates(tx, "CantonDex.Dex.PoolState:PoolState")
+ .find((c) => (c.createArgument as { poolId: string }).poolId === poolId)!;
+ const policy = only(
+ creates(tx, "CantonDex.Lp.Policy:LPTokenPolicy")
+ .filter((created) => argOf(created).lpInstrumentId.id === LP),
+ "post-add LPTokenPolicy",
+ );
+ stateCid = ps.contractId;
+ policyCid = policy.contractId;
+ return argOf(ps);
+ });
+ const expectLp = dec.sqrt(dec.mul(dec.parseDecimal(ADD_BASE), dec.parseDecimal(ADD_QUOTE)));
+ eq(addRes.status, "PS_Active", "pool active after add");
+ eqDec(dec.parseDecimal(addRes.reserves.baseAmount), dec.parseDecimal(ADD_BASE), "base reserve");
+ eqDec(dec.parseDecimal(addRes.reserves.quoteAmount), dec.parseDecimal(ADD_QUOTE), "quote reserve");
+ eqDec(dec.parseDecimal(addRes.totalLpSupply), expectLp, "LP minted = sqrt(base*quote)");
+ console.log(` reserves ${addRes.reserves.baseAmount}/${addRes.reserves.quoteAmount}, LP ${addRes.totalLpSupply} (= sqrt(${ADD_BASE}*${ADD_QUOTE}))`);
+ // Confirm the trader actually received the LP holding (DvP, not just supply bump).
+ const lpHeld = (await acs(cfg.trader, "CantonDex.Registry.V2:Holding"))
+ .map((c) => c.createArgument as { owner: string; instrumentId: string; amount: string; locked?: boolean })
+ .filter((p) => p.owner === cfg.trader && p.instrumentId === LP && !p.locked);
+ eq(lpHeld.length >= 1, true, "trader holds an LP holding");
+ eqDec(
+ sum(lpHeld.map((holding) => dec.parseDecimal(holding.amount))),
+ expectLp,
+ "trader LP balance = minted",
+ );
+ console.log(` trader LP holding: ${lpHeld.map((h) => h.amount).join("+")}`);
+
+ const addSliceCount = await step("reconcile reserves against pool slices", () =>
+ reconcile(rulesCid, poolId, poolCid, stateCid));
+ console.log(` ${addSliceCount} pool slices reconcile exactly with reserves`);
+
+ if (ADD_ONLY) {
+ console.log("\n== live-ledger add-liquidity probe complete ==");
+ console.log("PASS: add-liquidity DvP (trader authored all 3 allocations; operator+lpRegistrar settled)");
+ console.log(`created ledger state: run=${RUN}, registry=${regCid}, pool=${poolId}`);
+ console.log("persistence is controlled by the enclosing environment; the DPM proof wrapper removes its throwaway sandbox");
+ return;
+ }
+
+ // 4. SWAP: quote snapshot -> wallet allocation -> atomic settle ---------
+ console.log("\n== SWAP QUOTE -> BASE ==");
+ const beforeSwapSlices = await step("read active pool slices", () => poolSlices(poolId));
+ const inputSlice = beforeSwapSlices.quote[0];
+ if (!inputSlice) throw new Error("pool has no quote slice to receive swap input");
+
+ const swapIn = dec.parseDecimal(SWAP_IN);
+ const oldBase = dec.parseDecimal(addRes.reserves.baseAmount);
+ const oldQuote = dec.parseDecimal(addRes.reserves.quoteAmount);
+ const expectedOut = constantProductOut(oldQuote, oldBase, FEE_BPS, swapIn);
+ if (expectedOut <= 0n || expectedOut >= oldBase) {
+ throw new Error(`invalid quoted output ${dec.formatDecimal(expectedOut)} ${BASE}`);
+ }
+ const outputPlan = coveringPlan(beforeSwapSlices.base, expectedOut, "base");
+ const quoteBinding: SwapQuoteBinding = {
+ expectedPoolId: poolId,
+ poolStateCid: stateCid,
+ inputSliceCid: inputSlice.contractId,
+ outputSliceCids: outputPlan.cids,
+ minOutputAmount: dec.formatDecimal(expectedOut),
+ };
+
+ const swapHolding = (await holdingsFor(cfg.swapper, QUOTE))
+ .find((holding) => dec.parseDecimal(holding.amount) === swapIn);
+ if (!swapHolding) throw new Error(`swapper has no unlocked ${SWAP_IN} ${QUOTE} holding`);
+ const swapperQuoteBefore = await balance(cfg.swapper, QUOTE);
+ const swapperBaseBefore = await balance(cfg.swapper, BASE);
+
+ const swapRequest = await step("PoolRules_RequestSwap", async () => {
+ const tx = await submit([cfg.operator], `${RUN}-swap-request`, [{
+ ExerciseCommand: {
+ templateId: tid("CantonDex.Dex.PoolRules:PoolRules"),
+ contractId: rulesCid,
+ choice: "PoolRules_RequestSwap",
+ choiceArgument: {
+ poolCid,
+ swapper: cfg.swapper,
+ inputInstrumentId: QUOTE,
+ inputAmount: SWAP_IN,
+ quoteBinding,
+ },
+ },
+ }]);
+ const result = exercisedResult(tx, "PoolRules_RequestSwap")
+ ?? await treeExercisedResult(tx.transaction.updateId, cfg.operator, "PoolRules_RequestSwap");
+ if (!result) {
+ throw new Error(
+ "participant did not expose the PoolRules_RequestSwap result in the transaction or transaction tree",
+ );
+ }
+ return result as SwapRequestResult;
+ });
+ if (!swapRequest.quoteBinding) throw new Error("swap request returned no quote binding");
+ eq(swapRequest.quoteBinding.poolStateCid, stateCid, "request is bound to current PoolState");
+ eq(
+ swapRequest.quoteBinding.minOutputAmount,
+ quoteBinding.minOutputAmount,
+ "request preserves quoted minimum",
+ );
+
+ const swapAllocationCid = await step("swapper authors the exact swap allocation", () =>
+ authorAlloc(
+ regCid,
+ cfg.swapper,
+ swapRequest.settlement,
+ swapRequest.allocationSpec,
+ [swapHolding.cid],
+ "swap-input",
+ ));
+
+ const swapRes = await step("PoolRules_Swap", async () => {
+ const tx = await submit([cfg.operator], `${RUN}-swap-settle`, [{
+ ExerciseCommand: {
+ templateId: tid("CantonDex.Dex.PoolRules:PoolRules"),
+ contractId: rulesCid,
+ choice: "PoolRules_Swap",
+ choiceArgument: {
+ expectedPoolId: poolId,
+ poolCid,
+ poolStateCid: stateCid,
+ swapperAccount: acct(cfg.swapper),
+ inputInstrumentId: QUOTE,
+ inputAmount: SWAP_IN,
+ minOutputAmount: quoteBinding.minOutputAmount,
+ swapperAllocationCid: swapAllocationCid,
+ inputSliceCid: quoteBinding.inputSliceCid,
+ outputSliceCids: quoteBinding.outputSliceCids,
+ factoryCid: regCid,
+ extraArgs: EXTRA,
+ quoteBinding,
+ },
+ },
+ }], [cfg.swapper]);
+ const state = only(
+ creates(tx, "CantonDex.Dex.PoolState:PoolState")
+ .filter((created) => argOf(created).poolId === poolId),
+ "post-swap PoolState",
+ );
+ stateCid = state.contractId;
+ return argOf(state);
+ });
+
+ const postSwapBase = dec.parseDecimal(swapRes.reserves.baseAmount);
+ const postSwapQuote = dec.parseDecimal(swapRes.reserves.quoteAmount);
+ eq(swapRes.status, "PS_Active", "pool active after swap");
+ eqDec(postSwapBase, oldBase - expectedOut, "base reserve after swap");
+ eqDec(postSwapQuote, oldQuote + swapIn, "quote reserve after swap");
+ eqDec(
+ dec.parseDecimal(swapRes.totalLpSupply),
+ dec.parseDecimal(addRes.totalLpSupply),
+ "swap does not change LP supply",
+ );
+ atLeastRaw(postSwapBase * postSwapQuote, oldBase * oldQuote, "x*y does not decrease");
+ eqDec(
+ swapperQuoteBefore - await balance(cfg.swapper, QUOTE),
+ swapIn,
+ "swapper quote balance paid",
+ );
+ eqDec(
+ await balance(cfg.swapper, BASE) - swapperBaseBefore,
+ expectedOut,
+ "swapper base balance received",
+ );
+ const postSwapSlices = await poolSlices(poolId);
+ eqDec(sliceTotal(postSwapSlices.base), postSwapBase, "base slices equal base reserve after swap");
+ eqDec(sliceTotal(postSwapSlices.quote), postSwapQuote, "quote slices equal quote reserve after swap");
+ const swapSliceCount = await step("reconcile post-swap reserves and slices", () =>
+ reconcile(rulesCid, poolId, poolCid, stateCid));
+ console.log(
+ ` ${SWAP_IN} ${QUOTE} -> ${dec.formatDecimal(expectedOut)} ${BASE}; ` +
+ `reserves ${swapRes.reserves.baseAmount}/${swapRes.reserves.quoteAmount}; ` +
+ `x*y non-decreasing; ${swapSliceCount} slices reconciled`,
+ );
+
+ // 5. REMOVE: request -> wallet allocations -> redeem half the LP --------
+ console.log("\n== REMOVE HALF THE LP POSITION ==");
+ const supplyBeforeRemove = dec.parseDecimal(swapRes.totalLpSupply);
+ const redeemAmount = dec.divFloor(supplyBeforeRemove, dec.parseDecimal("2.0"));
+ if (redeemAmount <= 0n) throw new Error("half-position redemption rounded to zero");
+ const share = dec.divFloor(redeemAmount, supplyBeforeRemove);
+ const baseOut = dec.mulFloor(postSwapBase, share);
+ const quoteOut = dec.mulFloor(postSwapQuote, share);
+ const removeSlices = await poolSlices(poolId);
+ const basePlan = coveringPlan(removeSlices.base, baseOut, "base");
+ const quotePlan = coveringPlan(removeSlices.quote, quoteOut, "quote");
+
+ const lpHoldings = await holdingsFor(cfg.trader, LP);
+ const lpInputCids: string[] = [];
+ let lpCovered = 0n;
+ for (const holding of lpHoldings) {
+ lpInputCids.push(holding.cid);
+ lpCovered += dec.parseDecimal(holding.amount);
+ if (lpCovered >= redeemAmount) break;
+ }
+ if (lpCovered < redeemAmount) {
+ throw new Error(
+ `LP holdings cover ${dec.formatDecimal(lpCovered)}, need ${dec.formatDecimal(redeemAmount)}`,
+ );
+ }
+ const traderBaseBeforeRemove = await balance(cfg.trader, BASE);
+ const traderQuoteBeforeRemove = await balance(cfg.trader, QUOTE);
+ const traderLpBeforeRemove = await balance(cfg.trader, LP);
+
+ const removeRequest = await step("PoolLiquidityRules_RequestRemoveLiquidity", async () => {
+ const tx = await submit([cfg.operator], `${RUN}-remove-request`, [{
+ ExerciseCommand: {
+ templateId: tid("CantonDex.Dex.PoolLiquidityRules:PoolLiquidityRules"),
+ contractId: dvpCid,
+ choice: "PoolLiquidityRules_RequestRemoveLiquidity",
+ choiceArgument: {
+ poolCid,
+ holder: cfg.trader,
+ baseOuts: basePlan.outs,
+ quoteOuts: quotePlan.outs,
+ lpBurnAmount: dec.formatDecimal(redeemAmount),
+ requestedAt: new Date().toISOString(),
+ settleAt: null,
+ },
+ },
+ }]);
+ const request = only(
+ creates(tx, "CantonDex.Dex.LiquidityAllocationRequest:LiquidityAllocationRequest"),
+ "remove LiquidityAllocationRequest",
+ );
+ return { cid: request.contractId, arg: argOf(request) };
+ });
+ const [baseReceiptSpec, quoteReceiptSpec, burnSpec] = removeRequest.arg.allocations;
+ if (!baseReceiptSpec || !quoteReceiptSpec || !burnSpec) {
+ throw new Error("remove request did not return 3 allocation specs");
+ }
+ const holderBaseReceiptCid = await step("trader authors base receipt", () =>
+ authorAlloc(
+ regCid,
+ cfg.trader,
+ removeRequest.arg.settlement,
+ baseReceiptSpec,
+ [],
+ "remove-base-receipt",
+ ));
+ const holderQuoteReceiptCid = await step("trader authors quote receipt", () =>
+ authorAlloc(
+ regCid,
+ cfg.trader,
+ removeRequest.arg.settlement,
+ quoteReceiptSpec,
+ [],
+ "remove-quote-receipt",
+ ));
+ const holderBurnSenderCid = await step("trader authors LP burn sender", () =>
+ authorAlloc(
+ regCid,
+ cfg.trader,
+ removeRequest.arg.settlement,
+ burnSpec,
+ lpInputCids,
+ "remove-lp-burn",
+ ));
+
+ const removeRes = await step("PoolLiquidityRules_SettleRemoveLiquidity", async () => {
+ const tx = await submit([cfg.operator, lpRegistrar], `${RUN}-remove-settle`, [{
+ ExerciseCommand: {
+ templateId: tid("CantonDex.Dex.PoolLiquidityRules:PoolLiquidityRules"),
+ contractId: dvpCid,
+ choice: "PoolLiquidityRules_SettleRemoveLiquidity",
+ choiceArgument: {
+ expectedPoolId: poolId,
+ poolCid,
+ poolStateCid: stateCid,
+ lpPolicyCid: policyCid,
+ requestCid: removeRequest.cid,
+ acceptanceCid: null,
+ holder: cfg.trader,
+ lpTokensToRedeem: dec.formatDecimal(redeemAmount),
+ knownTotalLpSupply: dec.formatDecimal(supplyBeforeRemove),
+ minBaseOut: dec.formatDecimal(baseOut),
+ minQuoteOut: dec.formatDecimal(quoteOut),
+ baseSliceCids: basePlan.cids,
+ quoteSliceCids: quotePlan.cids,
+ holderBaseReceiptCid,
+ holderQuoteReceiptCid,
+ holderBurnSenderCid,
+ baseFactoryCid: regCid,
+ quoteFactoryCid: regCid,
+ lpFactoryCid: regCid,
+ baseQuoteSettleCid: regCid,
+ lpSettleCid: regCid,
+ requestedAt: new Date().toISOString(),
+ poolAdminExtraArgs: EXTRA,
+ lpRegistrarExtraArgs: EXTRA,
+ },
+ },
+ }]);
+ const state = only(
+ creates(tx, "CantonDex.Dex.PoolState:PoolState")
+ .filter((created) => argOf(created).poolId === poolId),
+ "post-remove PoolState",
+ );
+ const policy = only(
+ creates(tx, "CantonDex.Lp.Policy:LPTokenPolicy")
+ .filter((created) => argOf(created).lpInstrumentId.id === LP),
+ "post-remove LPTokenPolicy",
+ );
+ stateCid = state.contractId;
+ policyCid = policy.contractId;
+ return { state: argOf(state), policy: argOf(policy) };
+ });
+
+ const finalBase = dec.parseDecimal(removeRes.state.reserves.baseAmount);
+ const finalQuote = dec.parseDecimal(removeRes.state.reserves.quoteAmount);
+ const finalSupply = dec.parseDecimal(removeRes.state.totalLpSupply);
+ eq(removeRes.state.status, "PS_Active", "partially redeemed pool remains active");
+ eqDec(finalBase, postSwapBase - baseOut, "base reserve after remove");
+ eqDec(finalQuote, postSwapQuote - quoteOut, "quote reserve after remove");
+ eqDec(finalSupply, supplyBeforeRemove - redeemAmount, "LP supply after burn");
+ eqDec(dec.parseDecimal(removeRes.policy.totalSupply), finalSupply, "policy supply equals PoolState supply");
+ eqDec(
+ await balance(cfg.trader, BASE) - traderBaseBeforeRemove,
+ baseOut,
+ "LP received base payout",
+ );
+ eqDec(
+ await balance(cfg.trader, QUOTE) - traderQuoteBeforeRemove,
+ quoteOut,
+ "LP received quote payout",
+ );
+ eqDec(
+ traderLpBeforeRemove - await balance(cfg.trader, LP),
+ redeemAmount,
+ "LP holding burned",
+ );
+ atLeastRaw(finalBase * supplyBeforeRemove, postSwapBase * finalSupply, "base per LP does not decrease");
+ atLeastRaw(finalQuote * supplyBeforeRemove, postSwapQuote * finalSupply, "quote per LP does not decrease");
+
+ const finalSlices = await poolSlices(poolId);
+ eqDec(sliceTotal(finalSlices.base), finalBase, "final base slices equal reserve");
+ eqDec(sliceTotal(finalSlices.quote), finalQuote, "final quote slices equal reserve");
+ const finalSliceCount = await step("reconcile final reserves and slices", () =>
+ reconcile(rulesCid, poolId, poolCid, stateCid));
+
+ const finalUnlockedBase = sum(await Promise.all(valueParties.map((party) => balance(party, BASE))));
+ const finalUnlockedQuote = sum(await Promise.all(valueParties.map((party) => balance(party, QUOTE))));
+ eqDec(
+ finalBase + finalUnlockedBase,
+ initialUnlockedBase + dec.parseDecimal(ADD_BASE),
+ "base value conserved",
+ );
+ eqDec(
+ finalQuote + finalUnlockedQuote,
+ initialUnlockedQuote + dec.parseDecimal(ADD_QUOTE) + dec.parseDecimal(SWAP_IN),
+ "quote value conserved",
+ );
+
+ console.log(
+ ` burned ${dec.formatDecimal(redeemAmount)} ${LP}; returned ` +
+ `${dec.formatDecimal(baseOut)} ${BASE} + ${dec.formatDecimal(quoteOut)} ${QUOTE}`,
+ );
+ console.log(
+ ` final reserves ${removeRes.state.reserves.baseAmount}/${removeRes.state.reserves.quoteAmount}; ` +
+ `LP supply ${removeRes.state.totalLpSupply}; ${finalSliceCount} slices reconciled`,
+ );
+ console.log("\n== live-ledger AMM round trip complete ==");
+ console.log(
+ "PASS: add -> swap -> partial remove settled real holdings; balances, reserves, " +
+ "slice totals, LP supply, x*y, reserve-per-LP, and value conservation all hold",
+ );
+ console.log(`created ledger state: run=${RUN}, registry=${regCid}, pool=${poolId}`);
+ console.log("persistence is controlled by the enclosing environment; the DPM proof wrapper removes its throwaway sandbox");
+}
+
+main().catch((e) => { console.error("FATAL", (e as Error).message); process.exit(1); });
diff --git a/scripts/localnet-dvp-e2e.ts b/scripts/localnet-dvp-e2e.ts
deleted file mode 100644
index 3df0a5d0..00000000
--- a/scripts/localnet-dvp-e2e.ts
+++ /dev/null
@@ -1,322 +0,0 @@
-// Headless DvP liquidity end-to-end against a live Canton participant.
-//
-// Stands in for the trader's wallet (the one piece a browser CIP-0103
-// wallet normally does): it authors the trader's 3 allocations for each
-// DvP add/remove, then settles. Exercises the full operator two-call
-// flow (request -> wallet authors allocations -> settle) plus a swap,
-// on a real ledger -- the seam that can't be driven through the UI
-// without a human approving in the wallet popup.
-//
-// Self-contained: creates its own V2 Registry (admin == pool admin ==
-// lpRegistrar, the self-registry case), registers base/quote/LP
-// instruments, mints to the trader, creates the pool contracts, then
-// runs add -> swap -> remove and asserts the on-ledger reserves/LP.
-//
-// Env (all from the LocalNet bring-up):
-// CANTON_LEDGER_URL, CANTON_LEDGER_TOKEN, CANTON_SYNCHRONIZER,
-// CANTON_DEX_PACKAGE_ID (e.g. #canton-dex-trading),
-// CANTON_USER_ID (default ledger-api-user),
-// CANTON_OPERATOR, CANTON_ADMIN, CANTON_TRADER
-// (operator == venue; admin == instrument issuer == lpRegistrar;
-// trader == the LP/swapper). The user token must have actAs for all
-// three parties (a single ledger-api-user with granted rights works).
-//
-// Run (from services/operator-backend, which has tsx on its path):
-// npm run localnet:dvp-e2e
-// with the CANTON_* env above exported.
-
-function req(name: string): string {
- const v = process.env[name];
- if (!v) { console.error(`missing env: ${name}`); process.exit(2); }
- return v;
-}
-
-const cfg = {
- baseUrl: req("CANTON_LEDGER_URL"),
- token: req("CANTON_LEDGER_TOKEN"),
- sync: req("CANTON_SYNCHRONIZER"),
- pkg: req("CANTON_DEX_PACKAGE_ID"),
- userId: process.env.CANTON_USER_ID ?? "ledger-api-user",
- operator: req("CANTON_OPERATOR"),
- admin: req("CANTON_ADMIN"),
- trader: req("CANTON_TRADER"),
- // AllocationFactory_Allocate is a token-standard INTERFACE choice; it must
- // be exercised against the interface id (alloc-instruction-v2 package),
- // not the concrete Registry template.
- pkgAllocInstr: req("CANTON_ALLOC_INSTR_PACKAGE_ID"),
-};
-const lpRegistrar = cfg.admin; // self-registry: admin issues base/quote AND LP
-
-const BASE = "BTC", QUOTE = "USDC", LP = "BTC-USDC-LP";
-const ADD_BASE = "4.0", ADD_QUOTE = "12000.0";
-const SWAP_IN = "1000.0"; // USDC -> BTC
-const CAP = "1000000000.0";
-const RUN = `dvp-${Date.now()}`;
-const tid = (m: string) => `${cfg.pkg}:${m}`;
-const acct = (p: string) => ({ owner: p, provider: null, id: "" });
-const EXTRA = { context: { values: {} }, meta: { values: {} } };
-
-interface Created { contractId: string; templateId: string; createArgument: Record }
-type Ev = { CreatedEvent: Created } | { ArchivedEvent: { contractId: string } };
-interface Tx { transaction: { updateId: string; events: Ev[] } }
-
-// Canton 3.x JSON API encodes Daml Int64 as a JSON string. Coerce every
-// integer-valued number before submission.
-function encInt(v: unknown): unknown {
- if (typeof v === "number") return Number.isInteger(v) ? String(v) : v;
- if (Array.isArray(v)) return v.map(encInt);
- if (v !== null && typeof v === "object") {
- const o: Record = {};
- for (const [k, val] of Object.entries(v)) o[k] = encInt(val);
- return o;
- }
- return v;
-}
-
-async function submit(actAs: string[], cid: string, commands: unknown[]): Promise {
- const res = await fetch(`${cfg.baseUrl}/v2/commands/submit-and-wait-for-transaction`, {
- method: "POST",
- headers: { Authorization: `Bearer ${cfg.token}`, "Content-Type": "application/json" },
- body: JSON.stringify({
- commands: { commandId: cid, userId: cfg.userId, actAs, synchronizerId: cfg.sync, commands: encInt(commands) },
- transactionShape: "TRANSACTION_SHAPE_ACS_DELTA",
- }),
- });
- const t = await res.text();
- if (!res.ok) throw new Error(`submit ${cid} -> HTTP ${res.status}: ${t}`);
- return JSON.parse(t) as Tx;
-}
-function creates(tx: Tx, suffix: string): Created[] {
- return tx.transaction.events
- .filter((e): e is { CreatedEvent: Created } => "CreatedEvent" in e)
- .map((e) => e.CreatedEvent)
- .filter((c) => c.templateId.endsWith(suffix));
-}
-async function ledgerEnd(): Promise {
- const r = await fetch(`${cfg.baseUrl}/v2/state/ledger-end`, { headers: { Authorization: `Bearer ${cfg.token}` } });
- return ((await r.json()) as { offset: number }).offset;
-}
-async function acs(party: string, template: string): Promise {
- const offset = await ledgerEnd();
- const r = await fetch(`${cfg.baseUrl}/v2/state/active-contracts`, {
- method: "POST",
- headers: { Authorization: `Bearer ${cfg.token}`, "Content-Type": "application/json" },
- body: JSON.stringify({
- verbose: false, activeAtOffset: offset,
- filter: { filtersByParty: { [party]: { cumulative: [
- { identifierFilter: { TemplateFilter: { value: { templateId: tid(template), includeCreatedEventBlob: false } } } },
- ] } } },
- }),
- });
- const body = (await r.json()) as Array<{ contractEntry?: { JsActiveContract?: { createdEvent?: Created } } }>;
- return body.map((e) => e.contractEntry?.JsActiveContract?.createdEvent).filter((x): x is Created => !!x);
-}
-async function step(name: string, fn: () => Promise): Promise {
- const t0 = Date.now();
- try { const out = await fn(); console.log(` ok ${name} (${Date.now() - t0}ms)`); return out; }
- catch (e) { console.error(` FAIL ${name}: ${(e as Error).message}`); throw e; }
-}
-const eq = (a: unknown, b: unknown, m: string) => {
- if (String(a) !== String(b)) throw new Error(`assert ${m}: expected ${b}, got ${a}`);
-};
-
-// Author one allocation as the trader (the wallet's job): exercise
-// AllocationFactory_Allocate on the registry, locking inputHoldingCids.
-async function authorAlloc(
- regCid: string, spec: unknown, inputHoldingCids: string[], label: string,
-): Promise {
- const tx = await submit([cfg.trader], `${RUN}-author-${label}`, [{
- ExerciseCommand: {
- templateId: `${cfg.pkgAllocInstr}:Splice.Api.Token.AllocationInstructionV2:AllocationFactory`,
- contractId: regCid,
- choice: "AllocationFactory_Allocate",
- choiceArgument: {
- settlement: (spec as { __settlement: unknown }).__settlement,
- allocation: (spec as { __alloc: unknown }).__alloc,
- requestedAt: new Date().toISOString(),
- inputHoldingCids,
- extraArgs: EXTRA,
- actors: [cfg.trader],
- },
- },
- }]);
- return creates(tx, "CantonDex.Registry.V2:Allocation")[0]!.contractId;
-}
-
-async function main() {
- console.log(`run ${RUN}`);
- console.log(`operator=${cfg.operator.slice(0, 20)}.. admin=${cfg.admin.slice(0, 20)}.. trader=${cfg.trader.slice(0, 20)}..`);
-
- // 1. Registry + instruments + trader holdings ---------------------------
- const regCid = await step("create Registry.V2 (factory + settlement)", async () => {
- const tx = await submit([cfg.admin], `${RUN}-reg`, [{
- CreateCommand: {
- templateId: tid("CantonDex.Registry.V2:Registry"),
- createArguments: { admin: cfg.admin, users: [cfg.operator, cfg.trader] },
- },
- }]);
- return creates(tx, "CantonDex.Registry.V2:Registry")[0]!.contractId;
- });
- // RegisterInstrument returns an InstrumentConfig; Mint consumes the
- // latest config (BumpSupply) and rotates it. Track per-instrument.
- const configCid: Record = {};
- for (const id of [BASE, QUOTE, LP]) {
- await step(`register ${id}`, async () => {
- const tx = await submit([cfg.admin], `${RUN}-reg-${id}`, [{
- ExerciseCommand: {
- templateId: tid("CantonDex.Registry.V2:Registry"), contractId: regCid,
- choice: "Registry_RegisterInstrument",
- choiceArgument: {
- instrumentId: id, decimals: "10", supplyCap: CAP,
- holderRequirements: [], issuerRequirements: [], isin: null, cusip: null,
- },
- },
- }]);
- configCid[id] = creates(tx, "CantonDex.Registry.V2:InstrumentConfig")[0]!.contractId;
- });
- }
- const mint = (id: string, amt: string, owner: string) =>
- step(`mint ${amt} ${id} -> ${owner === cfg.trader ? "trader" : owner.slice(0, 8)}`, async () => {
- const tx = await submit([cfg.admin, owner], `${RUN}-mint-${id}-${owner.slice(0, 6)}-${Date.now()}`, [{
- ExerciseCommand: {
- templateId: tid("CantonDex.Registry.V2:Registry"), contractId: regCid,
- choice: "Registry_Mint",
- choiceArgument: { configCid: configCid[id], owner, amount: amt, issuerClaims: [] },
- },
- }]);
- configCid[id] = creates(tx, "CantonDex.Registry.V2:InstrumentConfig")[0]!.contractId;
- return creates(tx, "CantonDex.Registry.V2:Holding")[0]!.contractId;
- });
- await mint(BASE, ADD_BASE, cfg.trader);
- await mint(QUOTE, ADD_QUOTE, cfg.trader);
- await mint(QUOTE, SWAP_IN, cfg.trader); // separate holding for the swap input
-
- // 2. Pool contracts (operator-authored), as the admin bootstrap does ----
- // Unique poolId per run so we never collide with other pools the
- // operator observes (which would make a poolId-based lookup ambiguous).
- const poolId = `${BASE}-${QUOTE}-${RUN}`;
- const lpInstrumentId = { admin: lpRegistrar, id: LP };
- const poolCid = await step("create Pool", async () => {
- const tx = await submit([cfg.operator], `${RUN}-pool`, [{
- CreateCommand: {
- templateId: tid("CantonDex.Dex.Pool:Pool"),
- createArguments: {
- poolId, operator: cfg.operator, lpRegistrar, admin: cfg.admin,
- baseInstrumentId: BASE, quoteInstrumentId: QUOTE, lpInstrumentId,
- feeBps: "30",
- },
- },
- }]);
- return creates(tx, "CantonDex.Dex.Pool:Pool")[0]!.contractId;
- });
- let stateCid = await step("create PoolState (Unfunded)", async () => {
- const tx = await submit([cfg.operator], `${RUN}-state`, [{
- CreateCommand: {
- templateId: tid("CantonDex.Dex.PoolState:PoolState"),
- createArguments: {
- poolId, operator: cfg.operator, lpRegistrar, status: "PS_Unfunded",
- reserves: { baseAmount: "0.0", quoteAmount: "0.0" }, totalLpSupply: "0.0", publicReaders: [],
- },
- },
- }]);
- return creates(tx, "CantonDex.Dex.PoolState:PoolState")[0]!.contractId;
- });
- await step("create PoolRules", async () => {
- await submit([cfg.operator], `${RUN}-rules`, [{
- CreateCommand: { templateId: tid("CantonDex.Dex.PoolRules:PoolRules"), createArguments: { operator: cfg.operator } },
- }]);
- });
- const dvpCid = await step("create PoolLiquidityRules", async () => {
- const tx = await submit([cfg.operator, lpRegistrar], `${RUN}-dvp`, [{
- CreateCommand: {
- templateId: tid("CantonDex.Dex.PoolLiquidityRules:PoolLiquidityRules"),
- createArguments: { operator: cfg.operator, lpRegistrar },
- },
- }]);
- return creates(tx, "CantonDex.Dex.PoolLiquidityRules:PoolLiquidityRules")[0]!.contractId;
- });
- let policyCid = await step("create LPTokenPolicy", async () => {
- const tx = await submit([lpRegistrar], `${RUN}-policy`, [{
- CreateCommand: {
- templateId: tid("CantonDex.Lp.Policy:LPTokenPolicy"),
- createArguments: { lpRegistrar, operator: cfg.operator, lpInstrumentId, totalSupply: "0.0", active: true },
- },
- }]);
- return creates(tx, "CantonDex.Lp.Policy:LPTokenPolicy")[0]!.contractId;
- });
-
- const holdingsFor = async (id: string): Promise<{ cid: string; amount: string }[]> => {
- const hs = await acs(cfg.trader, "CantonDex.Registry.V2:Holding");
- return hs
- .map((c) => ({ cid: c.contractId, p: c.createArgument as { owner: string; instrumentId: string; amount: string; locked?: boolean } }))
- .filter((x) => x.p.owner === cfg.trader && x.p.instrumentId === id && !x.p.locked)
- .map((x) => ({ cid: x.cid, amount: x.p.amount }));
- };
-
- // 3. DvP ADD: request -> author 3 allocations -> settle -----------------
- console.log("\n== ADD LIQUIDITY ==");
- const reqAdd = await step("PoolLiquidityRules_RequestAddLiquidity", async () => {
- const lpAmount = Math.sqrt(parseFloat(ADD_BASE) * parseFloat(ADD_QUOTE)).toFixed(10);
- const tx = await submit([cfg.operator], `${RUN}-add-req`, [{
- ExerciseCommand: {
- templateId: tid("CantonDex.Dex.PoolLiquidityRules:PoolLiquidityRules"), contractId: dvpCid,
- choice: "PoolLiquidityRules_RequestAddLiquidity",
- choiceArgument: {
- poolCid, recipient: cfg.trader, baseAmount: ADD_BASE, quoteAmount: ADD_QUOTE,
- lpAmount, requestedAt: new Date().toISOString(), settleAt: null,
- },
- },
- }]);
- const r = creates(tx, "CantonDex.Dex.LiquidityAllocationRequest:LiquidityAllocationRequest")[0]!;
- return { cid: r.contractId, arg: r.createArgument as { allocations: unknown[]; settlement: unknown } };
- });
- const addBaseH = (await holdingsFor(BASE)).find((h) => h.amount === `${ADD_BASE}000000000` || parseFloat(h.amount) === parseFloat(ADD_BASE))!;
- const addQuoteH = (await holdingsFor(QUOTE)).find((h) => parseFloat(h.amount) === parseFloat(ADD_QUOTE))!;
- const settlement = reqAdd.arg.settlement;
- const [baseSpec, quoteSpec, receiptSpec] = reqAdd.arg.allocations;
- const wrap = (a: unknown) => ({ __settlement: settlement, __alloc: a });
- const baseDep = await step("trader authors base deposit", () => authorAlloc(regCid, wrap(baseSpec), [addBaseH.cid], "add-base"));
- const quoteDep = await step("trader authors quote deposit", () => authorAlloc(regCid, wrap(quoteSpec), [addQuoteH.cid], "add-quote"));
- const receipt = await step("trader authors LP receipt", () => authorAlloc(regCid, wrap(receiptSpec), [], "add-receipt"));
- const addRes = await step("PoolLiquidityRules_SettleAddLiquidity", async () => {
- const tx = await submit([cfg.operator, lpRegistrar], `${RUN}-add-settle`, [{
- ExerciseCommand: {
- templateId: tid("CantonDex.Dex.PoolLiquidityRules:PoolLiquidityRules"), contractId: dvpCid,
- choice: "PoolLiquidityRules_SettleAddLiquidity",
- choiceArgument: {
- expectedPoolId: poolId, poolCid, poolStateCid: stateCid, lpPolicyCid: policyCid,
- requestCid: reqAdd.cid, recipient: cfg.trader,
- lpBaseDepositCid: baseDep, lpQuoteDepositCid: quoteDep, lpReceiptCid: receipt,
- baseFactoryCid: regCid, quoteFactoryCid: regCid, lpFactoryCid: regCid,
- baseQuoteSettleCid: regCid, lpSettleCid: regCid,
- baseAmount: ADD_BASE, quoteAmount: ADD_QUOTE, minLpTokens: "0.0", knownTotalLpSupply: "0.0",
- requestedAt: new Date().toISOString(), poolAdminExtraArgs: EXTRA, lpRegistrarExtraArgs: EXTRA,
- },
- },
- }]);
- // This settle tx creates exactly one PoolState (for THIS pool); match
- // it by poolId to be unambiguous even if [0] ordering ever changes.
- const ps = creates(tx, "CantonDex.Dex.PoolState:PoolState")
- .find((c) => (c.createArgument as { poolId: string }).poolId === poolId)!;
- stateCid = ps.contractId;
- return ps.createArgument as { status: string; reserves: { baseAmount: string; quoteAmount: string }; totalLpSupply: string };
- });
- const expectLp = Math.sqrt(parseFloat(ADD_BASE) * parseFloat(ADD_QUOTE)).toFixed(10);
- eq(addRes.status, "PS_Active", "pool active after add");
- eq(parseFloat(addRes.reserves.baseAmount), parseFloat(ADD_BASE), "base reserve");
- eq(parseFloat(addRes.reserves.quoteAmount), parseFloat(ADD_QUOTE), "quote reserve");
- eq(parseFloat(addRes.totalLpSupply).toFixed(6), parseFloat(expectLp).toFixed(6), "LP minted = sqrt(base*quote)");
- console.log(` reserves ${addRes.reserves.baseAmount}/${addRes.reserves.quoteAmount}, LP ${addRes.totalLpSupply} (= sqrt(${ADD_BASE}*${ADD_QUOTE}))`);
- // Confirm the trader actually received the LP holding (DvP, not just supply bump).
- const lpHeld = (await acs(cfg.trader, "CantonDex.Registry.V2:Holding"))
- .map((c) => c.createArgument as { owner: string; instrumentId: string; amount: string; locked?: boolean })
- .filter((p) => p.owner === cfg.trader && p.instrumentId === LP && !p.locked);
- eq(lpHeld.length >= 1, true, "trader holds an LP holding");
- eq(parseFloat(lpHeld.reduce((s, h) => s + parseFloat(h.amount), 0).toFixed(6)), parseFloat(expectLp).toFixed(6), "trader LP balance = minted");
- console.log(` trader LP holding: ${lpHeld.map((h) => h.amount).join("+")}`);
-
- console.log("\n== DvP add settled end-to-end via the wallet-authored allocation path ==");
- console.log("PASS: add-liquidity DvP (trader authored all 3 allocations; operator+lpRegistrar settled)");
-}
-
-main().catch((e) => { console.error("FATAL", (e as Error).message); process.exit(1); });
diff --git a/scripts/run-dpm-sandbox-proof.sh b/scripts/run-dpm-sandbox-proof.sh
new file mode 100755
index 00000000..228d5b19
--- /dev/null
+++ b/scripts/run-dpm-sandbox-proof.sh
@@ -0,0 +1,186 @@
+#!/usr/bin/env bash
+
+# Portable live-Canton proof using only the DPM SDK pinned by this repository.
+#
+# This script builds the DAR, starts a throwaway `dpm sandbox` on dynamic ports,
+# creates three parties plus one unrestricted LOCAL sandbox user, uploads the
+# package closure, runs the live DvP driver, and stops Canton. It does not
+# require canton-devkit, Splice LocalNet, a browser, or a production JWT.
+
+set -euo pipefail
+
+ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+RUN_DIR="$(mktemp -d "${TMPDIR:-/tmp}/canton-dex-sandbox.XXXXXX")"
+PORT_FILE="$RUN_DIR/ports.json"
+LOG_FILE="$RUN_DIR/canton.log"
+STDOUT_FILE="$RUN_DIR/canton.stdout.log"
+SANDBOX_PID=""
+
+cleanup() {
+ local status=$?
+ trap - EXIT INT TERM
+ if [[ -n "$SANDBOX_PID" ]] && kill -0 "$SANDBOX_PID" 2>/dev/null; then
+ kill -INT "$SANDBOX_PID" 2>/dev/null || true
+ wait "$SANDBOX_PID" 2>/dev/null || true
+ fi
+ if [[ "$status" -eq 0 ]]; then
+ case "$RUN_DIR" in
+ "${TMPDIR:-/tmp}"/canton-dex-sandbox.*) rm -rf "$RUN_DIR" ;;
+ *) printf 'refusing to remove unexpected temp path: %s\n' "$RUN_DIR" >&2 ;;
+ esac
+ else
+ printf 'proof failed; Canton logs preserved at %s\n' "$RUN_DIR" >&2
+ fi
+ exit "$status"
+}
+trap cleanup EXIT INT TERM
+
+for tool in dpm java node npm curl; do
+ if ! command -v "$tool" >/dev/null 2>&1; then
+ printf 'missing prerequisite: %s\n' "$tool" >&2
+ exit 2
+ fi
+done
+
+printf '%s\n' '==> Installing the pinned SDK and building the DEX'
+SDK_VERSION="$(node -e '
+ const fs = require("node:fs");
+ const yaml = fs.readFileSync(process.argv[1], "utf8");
+ const version = yaml.match(/^sdk-version:\s*(.+)$/m)?.[1]?.trim();
+ if (!version) process.exit(1);
+ process.stdout.write(version);
+' "$ROOT_DIR/trading/daml.yaml")"
+dpm install "$SDK_VERSION"
+bash "$ROOT_DIR/scripts/build-trading-surface.sh"
+if [[ ! -x "$ROOT_DIR/services/operator-backend/node_modules/.bin/tsx" ]]; then
+ (cd "$ROOT_DIR/services/operator-backend" && npm ci)
+fi
+
+# Canton 3.5 cannot internally reconnect when its own ports are configured as
+# zero. Reserve all six sandbox ports, release them together, and pass
+# the concrete values immediately. The tiny release/start race is detected by
+# the readiness check and produces preserved logs rather than a false PASS.
+read -r LEDGER_PORT ADMIN_PORT JSON_PORT SEQUENCER_PORT SEQUENCER_ADMIN_PORT MEDIATOR_ADMIN_PORT < <(node -e '
+ const net = require("node:net");
+ const servers = [];
+ const open = () => new Promise((resolve, reject) => {
+ const s = net.createServer();
+ servers.push(s);
+ s.once("error", reject);
+ s.listen(0, "127.0.0.1", () => resolve(s.address().port));
+ });
+ Promise.all([open(), open(), open(), open(), open(), open()]).then((ports) => {
+ for (const s of servers) s.close();
+ process.stdout.write(`${ports.join(" ")}\n`);
+ }).catch((e) => { console.error(e.message); process.exit(1); });
+')
+
+printf '%s\n' '==> Starting throwaway Canton sandbox on reserved loopback ports'
+dpm sandbox \
+ --ledger-api-port "$LEDGER_PORT" \
+ --admin-api-port "$ADMIN_PORT" \
+ --json-api-port "$JSON_PORT" \
+ --sequencer-public-port "$SEQUENCER_PORT" \
+ --sequencer-admin-port "$SEQUENCER_ADMIN_PORT" \
+ --mediator-admin-port "$MEDIATOR_ADMIN_PORT" \
+ --canton-port-file "$PORT_FILE" \
+ --log-file-name "$LOG_FILE" \
+ --log-file-appender flat \
+ >"$STDOUT_FILE" 2>&1 &
+SANDBOX_PID=$!
+
+JSON_PORT=""
+for _ in $(seq 1 120); do
+ if ! kill -0 "$SANDBOX_PID" 2>/dev/null; then
+ printf '%s\n' 'Canton exited before becoming ready' >&2
+ tail -n 80 "$STDOUT_FILE" >&2 || true
+ exit 1
+ fi
+ if [[ -s "$PORT_FILE" ]]; then
+ JSON_PORT="$(node -e '
+ const fs = require("node:fs");
+ const body = JSON.parse(fs.readFileSync(process.argv[1], "utf8"));
+ if (body.sandbox?.jsonApi) process.stdout.write(String(body.sandbox.jsonApi));
+ ' "$PORT_FILE")"
+ if [[ -n "$JSON_PORT" ]] && \
+ curl -fsS "http://127.0.0.1:${JSON_PORT}/v2/state/ledger-end" >/dev/null 2>&1; then
+ break
+ fi
+ fi
+ sleep 1
+done
+if [[ -z "$JSON_PORT" ]] || \
+ ! curl -fsS "http://127.0.0.1:${JSON_PORT}/v2/state/ledger-end" >/dev/null 2>&1; then
+ printf '%s\n' 'Canton did not become ready within 120 seconds' >&2
+ tail -n 80 "$STDOUT_FILE" >&2 || true
+ exit 1
+fi
+
+export CANTON_LEDGER_URL="http://127.0.0.1:${JSON_PORT}"
+export CANTON_LEDGER_TOKEN="sandbox-auth-disabled"
+export CANTON_USER_ID="ledger-api-user"
+
+parties_json="$(curl -fsS "${CANTON_LEDGER_URL}/v2/parties")"
+primary_party="$(DEX_PARTIES_JSON="$parties_json" node -e '
+ const body = JSON.parse(process.env.DEX_PARTIES_JSON || "{}");
+ const party = body.partyDetails?.find((p) => p.isLocal)?.party;
+ if (!party) process.exit(1);
+ process.stdout.write(party);
+')"
+
+# The sandbox has authentication disabled, but command submission still names a
+# ledger user. Give this throwaway user unrestricted rights inside this process
+# only. Never copy this user policy to a shared or production participant.
+curl -fsS -X POST \
+ -H "Content-Type: application/json" \
+ -d "{\"user\":{\"id\":\"${CANTON_USER_ID}\",\"primaryParty\":\"${primary_party}\",\"isDeactivated\":false,\"identityProviderId\":\"\",\"metadata\":{\"resourceVersion\":\"\",\"annotations\":{}}},\"rights\":[{\"kind\":{\"CanExecuteAsAnyParty\":{\"value\":{}}}},{\"kind\":{\"CanReadAsAnyParty\":{\"value\":{}}}},{\"kind\":{\"ParticipantAdmin\":{\"value\":{}}}}]}" \
+ "${CANTON_LEDGER_URL}/v2/users" >/dev/null
+
+# A liquidity deposit and a swap both move value between the operator and a
+# counterparty, so neither counterparty can be the operator itself. Allocate a
+# distinct LP/trader and swapper. The sandbox user's CanExecuteAsAnyParty right
+# is deliberately scoped to this throwaway process, so no production-style
+# permission is implied here.
+allocate_party() {
+ local hint="$1"
+ local response
+ response="$(curl -fsS -X POST \
+ -H "Content-Type: application/json" \
+ -d "{\"partyIdHint\":\"${hint}\",\"userId\":\"${CANTON_USER_ID}\"}" \
+ "${CANTON_LEDGER_URL}/v2/parties")"
+ DEX_ALLOCATED_PARTY_JSON="$response" node -e '
+ const body = JSON.parse(process.env.DEX_ALLOCATED_PARTY_JSON || "{}");
+ const party = body.partyDetails?.party;
+ if (!party) process.exit(1);
+ process.stdout.write(party);
+'
+}
+trader_party="$(allocate_party "dex-lp-${RANDOM}")"
+swapper_party="$(allocate_party "dex-swapper-${RANDOM}")"
+
+export CANTON_OPERATOR="$primary_party"
+export CANTON_ADMIN="$primary_party"
+export CANTON_LP_REGISTRAR="$primary_party"
+export CANTON_TRADER="$trader_party"
+export CANTON_SWAPPER="$swapper_party"
+export CANTON_DEX_PACKAGE_ID="#canton-dex-trading"
+export CANTON_ALLOC_INSTR_PACKAGE_ID="#splice-api-token-allocation-instruction-v2"
+unset CANTON_SYNCHRONIZER
+
+printf ' JSON Ledger API: %s\n' "$CANTON_LEDGER_URL"
+printf '%s\n' \
+ ' Auth model: local sandbox only; unrestricted throwaway ledger user' \
+ ' Roles: operator/admin share the bootstrap party; LP/trader and swapper are distinct'
+
+printf '%s\n' '==> Uploading the package closure'
+DEPLOY_SKIP_BUILD=1 \
+DEPLOY_SKIP_BOOTSTRAP=1 \
+DEPLOY_SEED_MARKETS=0 \
+ bash "$ROOT_DIR/scripts/deploy-testnet.sh"
+
+printf '%s\n' '==> Running the live-Canton DvP proof'
+(cd "$ROOT_DIR/services/operator-backend" && npm run live:roundtrip)
+
+printf '%s\n' \
+ '==> PASS: portable live-Canton proof completed' \
+ ' The throwaway sandbox is now stopping; no persistent ledger state remains.'
diff --git a/scripts/run-localnet-roundtrip.sh b/scripts/run-localnet-roundtrip.sh
new file mode 100755
index 00000000..867f9428
--- /dev/null
+++ b/scripts/run-localnet-roundtrip.sh
@@ -0,0 +1,142 @@
+#!/usr/bin/env bash
+
+# Clean-clone Canton LocalNet proof for this reference implementation.
+#
+# Starts (or reuses) a named canton-devkit LocalNet, resolves its JSON Ledger
+# API and dev credential without printing the JWT, builds/uploads the package
+# closure, and runs the repository's live DvP driver. The instance is left
+# running for inspection; the final output prints the exact non-destructive
+# `down` command.
+
+set -euo pipefail
+set +x # never shell-trace the LocalNet JWT
+
+ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+INSTANCE="${1:-canton-dex}"
+VERSION="${CANTON_LOCALNET_VERSION:-0.6.12}"
+
+if [[ "$INSTANCE" == "--help" || "$INSTANCE" == "-h" ]]; then
+ printf '%s\n' \
+ 'Usage: bash scripts/run-localnet-roundtrip.sh [instance-name]' \
+ '' \
+ 'Optional environment:' \
+ ' CANTON_LOCALNET_VERSION=0.6.12 pinned Splice LocalNet version' \
+ ' LOCALNET_SKIP_DEPLOY=1 reuse already-uploaded DARs' \
+ ' DEX_LOCALNET_OPERATOR= use separate pre-authorized roles' \
+ ' DEX_LOCALNET_ADMIN=' \
+ ' DEX_LOCALNET_TRADER=' \
+ ' DEX_LOCALNET_SWAPPER='
+ exit 0
+fi
+if [[ ! "$INSTANCE" =~ ^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$ ]]; then
+ printf 'invalid LocalNet instance name: %s\n' "$INSTANCE" >&2
+ exit 2
+fi
+
+for tool in canton-devkit dpm curl node npm; do
+ if ! command -v "$tool" >/dev/null 2>&1; then
+ printf 'missing prerequisite: %s\n' "$tool" >&2
+ exit 2
+ fi
+done
+
+printf '%s\n' '==> Checking LocalNet host prerequisites'
+canton-devkit localnet doctor
+
+printf '==> Starting/reusing LocalNet %s (Splice %s)\n' "$INSTANCE" "$VERSION"
+canton-devkit localnet up --name "$INSTANCE" --version "$VERSION"
+
+# DevKit shell output is eval-safe. --include-jwt is intentionally scoped to
+# this process; the token is never printed by this script.
+eval "$(canton-devkit localnet env "$INSTANCE" --format shell --include-jwt)"
+
+if [[ -z "${CANTON_PARTICIPANT_JSON_APP_PROVIDER_PORT:-}" || \
+ -z "${CANTON_APP_PROVIDER_JWT:-}" || \
+ -z "${CANTON_APP_PROVIDER_USER:-}" ]]; then
+ printf '%s\n' 'LocalNet did not expose the app-provider JSON API credential' >&2
+ exit 1
+fi
+
+export CANTON_LEDGER_URL="http://127.0.0.1:${CANTON_PARTICIPANT_JSON_APP_PROVIDER_PORT}"
+export CANTON_LEDGER_TOKEN="$CANTON_APP_PROVIDER_JWT"
+export CANTON_USER_ID="$CANTON_APP_PROVIDER_USER"
+
+user_json="$(curl -fsS \
+ -H "Authorization: Bearer ${CANTON_LEDGER_TOKEN}" \
+ "${CANTON_LEDGER_URL}/v2/users/${CANTON_USER_ID}")"
+primary_party="$(DEX_USER_JSON="$user_json" node -e '
+ const body = JSON.parse(process.env.DEX_USER_JSON || "{}");
+ const party = body.user?.primaryParty;
+ if (!party) process.exit(1);
+ process.stdout.write(party);
+')"
+
+# DevKit is only the network lifecycle/credential adapter here; it is not a DEX
+# runtime dependency. Allocate missing counterparty roles through the standard
+# JSON Ledger API and grant them to the already-authenticated app-provider user.
+# Explicit overrides let an integrator exercise pre-provisioned parties instead.
+allocate_party() {
+ local hint="$1"
+ local response
+ response="$(curl -fsS -X POST \
+ -H "Authorization: Bearer ${CANTON_LEDGER_TOKEN}" \
+ -H "Content-Type: application/json" \
+ -d "{\"partyIdHint\":\"${hint}\",\"userId\":\"${CANTON_USER_ID}\"}" \
+ "${CANTON_LEDGER_URL}/v2/parties")"
+ DEX_ALLOCATED_PARTY_JSON="$response" node -e '
+ const body = JSON.parse(process.env.DEX_ALLOCATED_PARTY_JSON || "{}");
+ const party = body.partyDetails?.party;
+ if (!party) process.exit(1);
+ process.stdout.write(party);
+'
+}
+export CANTON_OPERATOR="${DEX_LOCALNET_OPERATOR:-$primary_party}"
+export CANTON_ADMIN="${DEX_LOCALNET_ADMIN:-$primary_party}"
+export CANTON_LP_REGISTRAR="$CANTON_ADMIN"
+export CANTON_TRADER="${DEX_LOCALNET_TRADER:-$(allocate_party "dex-lp-${RANDOM}")}"
+export CANTON_SWAPPER="${DEX_LOCALNET_SWAPPER:-$(allocate_party "dex-swapper-${RANDOM}")}"
+export CANTON_DEX_PACKAGE_ID="${CANTON_DEX_PACKAGE_ID:-#canton-dex-trading}"
+export CANTON_ALLOC_INSTR_PACKAGE_ID="${CANTON_ALLOC_INSTR_PACKAGE_ID:-#splice-api-token-allocation-instruction-v2}"
+
+printf ' JSON Ledger API: %s\n' "$CANTON_LEDGER_URL"
+printf ' Ledger user: %s\n' "$CANTON_USER_ID"
+printf '%s\n' \
+ ' Proof roles: operator/admin use app-provider primary party;' \
+ ' LP/trader and swapper are separately allocated unless overridden'
+
+printf '%s\n' '==> Installing pinned Daml SDK and Node runner dependencies'
+SDK_VERSION="$(node -e '
+ const fs = require("node:fs");
+ const yaml = fs.readFileSync(process.argv[1], "utf8");
+ const version = yaml.match(/^sdk-version:\s*(.+)$/m)?.[1]?.trim();
+ if (!version) process.exit(1);
+ process.stdout.write(version);
+' "$ROOT_DIR/trading/daml.yaml")"
+dpm install "$SDK_VERSION"
+if [[ ! -x "$ROOT_DIR/services/operator-backend/node_modules/.bin/tsx" ]]; then
+ (cd "$ROOT_DIR/services/operator-backend" && npm ci)
+fi
+
+if [[ "${LOCALNET_SKIP_DEPLOY:-0}" != "1" ]]; then
+ printf '%s\n' '==> Building and uploading the package closure'
+ DEPLOY_SKIP_BOOTSTRAP=1 \
+ DEPLOY_SEED_MARKETS=0 \
+ bash "$ROOT_DIR/scripts/deploy-testnet.sh"
+else
+ printf '%s\n' '==> Package deployment skipped (LOCALNET_SKIP_DEPLOY=1)'
+fi
+
+printf '%s\n' '==> Running the live-ledger DvP proof'
+(cd "$ROOT_DIR/services/operator-backend" && npm run live:roundtrip)
+
+printf '%s\n' \
+ '' \
+ 'LocalNet remains running so you can inspect the created contracts:' \
+ " canton-devkit localnet status --name $INSTANCE" \
+ " canton-devkit localnet contracts --help" \
+ '' \
+ 'Stop containers while preserving ledger volumes:' \
+ " canton-devkit localnet down --name $INSTANCE" \
+ '' \
+ 'Destructive cleanup (removes this instance and its ledger state):' \
+ " canton-devkit localnet remove --name $INSTANCE"
diff --git a/scripts/seed-testnet-pool.ts b/scripts/seed-testnet-pool.ts
index 95d0e326..13d37aac 100644
--- a/scripts/seed-testnet-pool.ts
+++ b/scripts/seed-testnet-pool.ts
@@ -13,7 +13,7 @@
// Registry_Mint on the existing registry
// 2. add -- the wallet-authored DvP add (request -> the LP authors its
// three allocations -> settle), the flow proven headlessly in
-// scripts/localnet-dvp-e2e.ts
+// scripts/live-amm-roundtrip.ts
// 3. swap -- PoolRules_RequestSwap -> the swapper authors its input
// allocation -> PoolRules_Swap, then asserts against the
// ledger that the reserves moved by exactly the
@@ -24,6 +24,11 @@
//
// Every assertion is fatal: a failure exits non-zero.
//
+// STATE WARNING: mint/add/swap transactions permanently change the selected
+// pool and participant holdings, and an interrupted run can leave partial
+// state. Use a dedicated test pool, retain the printed run id, and do not point
+// this script at production liquidity.
+//
// Env (ledger + parties):
// CANTON_LEDGER_URL, CANTON_LEDGER_TOKEN, CANTON_SYNCHRONIZER,
// CANTON_DEX_PACKAGE_ID (e.g. #canton-dex-trading),
@@ -126,7 +131,18 @@ interface InstrumentConfigArg { admin: string; instrumentId: string }
interface RulesArg { operator: string }
interface LiquidityRulesArg { operator: string; lpRegistrar: string }
interface RequestArg { allocations: unknown[]; settlement: unknown }
-interface SwapRequestResult { settlement: unknown; allocationSpec: unknown }
+interface SwapQuoteBinding {
+ expectedPoolId: string;
+ poolStateCid: string;
+ inputSliceCid: string;
+ outputSliceCids: string[];
+ minOutputAmount: string;
+}
+interface SwapRequestResult {
+ settlement: unknown;
+ allocationSpec: unknown;
+ quoteBinding: SwapQuoteBinding | null;
+}
const argOf = (c: Created): T => c.createArgument as unknown as T;
@@ -242,9 +258,14 @@ async function retrying(what: string, fn: (attempt: number) => Promise, at
// The Daml floors the result to 10dp afterwards, which is a no-op on a value
// that already carries 10 decimals.
function constantProductOut(reserveIn: bigint, reserveOut: bigint, feeBps: number, inputAmount: bigint): bigint {
- const feeMultiplier = dec.div(dec.parseDecimal(String(10000 - feeBps)), dec.parseDecimal("10000"));
- const amountInAfterFee = dec.mul(inputAmount, feeMultiplier);
- return dec.div(dec.mul(amountInAfterFee, reserveOut), reserveIn + amountInAfterFee);
+ const amountInAfterFee = dec.divFloor(
+ dec.mulFloor(inputAmount, dec.parseDecimal(String(10000 - feeBps))),
+ dec.parseDecimal("10000"),
+ );
+ return dec.divFloor(
+ dec.mulFloor(amountInAfterFee, reserveOut),
+ reserveIn + amountInAfterFee,
+ );
}
// The LP entitlement PoolLiquidityRules_SettleAddLiquidity bounds the receipt
@@ -305,8 +326,8 @@ async function authorAlloc(
return only(creates(tx, "CantonDex.Registry.V2:Allocation"), `${label} allocation`).contractId;
}
-// The Registry is `signatory admin, observer users`, so a party outside `users`
-// -- every faucet-created tester -- cannot see the factory it must exercise.
+// The Registry is `signatory admin, observer users`, so a separately allocated
+// test party outside `users` cannot see the factory it must exercise.
// Explicit contract disclosure is the mechanism for exactly this: fetch the
// contract's createdEventBlob as someone who CAN see it (the admin) and attach
// it to the submitter's command. This is what a registry's off-ledger API
@@ -553,6 +574,13 @@ async function main() {
if (covered < expectedOut) {
throw new Error(`${outputId} slices cover ${dec.formatDecimal(covered)}, need ${dec.formatDecimal(expectedOut)}`);
}
+ const quoteBinding: SwapQuoteBinding = {
+ expectedPoolId: pool.poolId,
+ poolStateCid: addState.cid,
+ inputSliceCid: headInput.contractId,
+ outputSliceCids,
+ minOutputAmount: dec.formatDecimal(expectedOut),
+ };
const inBefore = await balance(swapper, pool.admin, inputId);
const outBefore = await balance(swapper, pool.admin, outputId);
@@ -565,30 +593,26 @@ async function main() {
choiceArgument: {
poolCid: ctx.poolC.contractId, swapper,
inputInstrumentId: inputId, inputAmount: cfg.swapIn,
+ quoteBinding,
},
},
}]);
const result = exercisedResult(tx, "PoolRules_RequestSwap")
?? (await treeExercisedResult(tx.transaction.updateId, cfg.operator, "PoolRules_RequestSwap"));
- if (result) return result as SwapRequestResult;
- // The participant served neither the choice result nor a tree, so rebuild
- // what the choice returns (PoolModel.poolSettlement +
- // Utils.mkPrefundedAllocationSpecification). Nothing is taken on trust:
- // the registry re-checks the funding at allocate and PoolRules_Swap
- // re-checks every leg at settle, so a wrong spec aborts the swap.
- console.log(" .. choice result unavailable, rebuilding the allocation spec locally");
- return {
- settlement: { executors: [cfg.operator], id: "DexPool", cid: ctx.poolC.contractId, meta: { values: {} } },
- allocationSpec: {
- admin: pool.admin, authorizer: acct(swapper), transferLegSides: [],
- settlementDeadline: null, nextIterationFunding: { [inputId]: cfg.swapIn },
- committed: false, meta: { values: {} },
- },
- } satisfies SwapRequestResult;
+ if (!result) {
+ throw new Error(
+ "participant did not expose the PoolRules_RequestSwap result in the transaction or transaction tree",
+ );
+ }
+ const request = result as SwapRequestResult;
+ if (!request.quoteBinding) throw new Error("PoolRules_RequestSwap returned no quote binding");
+ eq(request.quoteBinding.poolStateCid, quoteBinding.poolStateCid, "swap request state binding");
+ eq(request.quoteBinding.minOutputAmount, quoteBinding.minOutputAmount, "swap request minimum binding");
+ return request;
});
- // The swapper is an arbitrary party (a faucet tester in the real flow), so it
- // is not an observer of the asset registry. Disclose the registry to it.
+ // The swapper is an arbitrary, separately allocated party, so it is not an
+ // observer of the asset registry. Disclose the registry to it.
const registryDisclosure = await step("disclose registry to the swapper", () =>
discloseRegistry(pool.admin, ctx.registryCid));
@@ -612,6 +636,7 @@ async function main() {
swapperAllocationCid: swapAlloc,
inputSliceCid: headInput.contractId, outputSliceCids,
factoryCid: ctx.registryCid, extraArgs: EXTRA,
+ quoteBinding,
},
},
}], [swapper]);
@@ -661,6 +686,7 @@ async function main() {
console.log(`swapper balances: ${inputId} ${dec.formatDecimal(inBefore)} -> ${dec.formatDecimal(inAfter)}, ${outputId} ${dec.formatDecimal(outBefore)} -> ${dec.formatDecimal(outAfter)}`);
console.log(`LP supply ${swapState.arg.totalLpSupply}`);
console.log("PASS: existing pool seeded via the wallet-authored DvP add, and a swap settled and asserted against it");
+ console.log(`state changed on participant: run=${RUN}, pool=${pool.poolId}`);
}
/** Unlocked balance of one instrument, as issued by `admin`. */
diff --git a/scripts/testnet-v2registry-trade.ts b/scripts/testnet-v2registry-trade.ts
index c05d1245..bd5f30f7 100644
--- a/scripts/testnet-v2registry-trade.ts
+++ b/scripts/testnet-v2registry-trade.ts
@@ -2,6 +2,12 @@
// V2 Registry as AllocationFactory + SettlementFactory + TransferFactory.
// Registers an instrument, mints to alice, posts a MatchedTrade, runs
// the V2 allocation accept on both sides, settles via SettleBatch.
+//
+// STATE WARNING: every run creates a registry, instrument, holdings, and trade
+// contracts. Use dedicated parties or a throwaway participant and retain the
+// printed run id for cleanup/audit.
+
+export {};
function required(name: string): string {
const v = process.env[name];
@@ -123,7 +129,7 @@ async function queryHoldings(party: string, instrumentId: string) {
async function main() {
console.log(`run id: ${RUN_ID}`);
- console.log(`registry package: canton-dex-trading v0.0.3 (${cfg.pkgDex.slice(0, 12)}…)`);
+ console.log(`registry package: ${cfg.pkgDex.slice(0, 12)}…`);
console.log(`venue: ${cfg.venue}`);
console.log(`admin: ${cfg.admin} (instrument issuer)`);
console.log(`alice: ${cfg.alice} (sender)`);
diff --git a/services/operator-backend/.env.example b/services/operator-backend/.env.example
index 73236ff1..8157947f 100644
--- a/services/operator-backend/.env.example
+++ b/services/operator-backend/.env.example
@@ -31,31 +31,70 @@ CANTON_NETWORK=canton:devnet
# Synchronizer id, e.g. global-domain::1220...
CANTON_SYNCHRONIZER=
-# Daml package hash or prefix for template ids.
+# Daml package hash or package-name prefix for template ids (required).
CANTON_DEX_PACKAGE_ID=
# --- Factory Contract IDs ---
-# AllocationFactory contract id (from registry bootstrap).
+# Asset-admin AllocationFactory contract id from registry bootstrap (required
+# in full mode).
CANTON_ALLOC_FACTORY_CID=
-# SettlementFactory contract id (from registry bootstrap).
+# Asset-admin SettlementFactory contract id from registry bootstrap (required
+# in full mode).
CANTON_SETTLE_FACTORY_CID=
+# When CANTON_LP_REGISTRAR differs from CANTON_ADMIN, set these to the LP
+# registrar's Registry.V2 cid (the same cid implements both interfaces).
+# They are required in full mode only for distinct registrars.
+CANTON_LP_ALLOC_FACTORY_CID=
+CANTON_LP_SETTLE_FACTORY_CID=
+
# --- Server ---
# HTTP server port (default: 8080).
PORT=8080
+# Bind address (default: 127.0.0.1 for direct runs). Containers override this
+# to 0.0.0.0 so nginx/the published port can reach the process.
+HOST=127.0.0.1
+
# SQLite database path for the indexer (default: ./data/operator.db).
DB_PATH=./data/operator.db
# Indexer polling interval in milliseconds (default: 5000).
INDEXER_INTERVAL_MS=5000
-# Admin auth token for /v1/admin/* routes. If unset, admin routes are unprotected.
+# Full-mode testnet startup requires both write tokens below. Generate separate,
+# high-entropy values. The server never sends them to the browser automatically.
+# The Admin screen can hold short-lived copies in per-tab sessionStorage for a
+# validator/operator run; public deployments should use an authenticated BFF.
+
+# Admin auth token for /v1/admin/* writes. Unset fails closed.
OPERATOR_ADMIN_TOKEN=
-# CORS allowed origins (comma-separated). If unset, allows all origins.
+# Operator auth token for every other state-changing HTTP route. Unset fails
+# closed. The testnet server also refuses to start in full mode without it.
+DEX_OPERATOR_API_TOKEN=
+
+# Set to 1 only for an intentionally read-only testnet server. In this mode the
+# server starts without the two tokens above and all state-changing routes
+# return 401. Read-only computation such as POST /v1/swaps/quote still works.
+DEX_READ_ONLY=0
+
+# Optional per-caller binding. When set, party-scoped reads and trader-subject
+# writes require an X-Caller-Token HS256 JWT with sub= and an exp claim.
+# Set an audience as an additional replay boundary when your issuer provides one.
+DEX_CALLER_JWT_SECRET=
+DEX_CALLER_JWT_AUDIENCE=
+
+# Trusted hosted-RFQ authority relay. Disabled by default. Enabling it requires
+# DEX_CALLER_JWT_SECRET and a participant JWT with actAs rights for each hosted
+# trader. Prefer wallet-authored RFQ commands for self-custody.
+DEX_HOSTED_RFQ_RELAY=0
+
+# CORS allowed origins (comma-separated). If unset, browsers receive no
+# Access-Control-Allow-Origin header (default-deny). Same-origin nginx traffic
+# does not need CORS; include Vite/preview origins for direct local browser use.
# Include the preview port you use for local UI testing.
ALLOWED_ORIGINS=http://localhost:5173,http://localhost:4173,http://127.0.0.1:18081
diff --git a/services/operator-backend/package-lock.json b/services/operator-backend/package-lock.json
index 2258d1d9..03254599 100644
--- a/services/operator-backend/package-lock.json
+++ b/services/operator-backend/package-lock.json
@@ -32,9 +32,9 @@
"link": true
},
"node_modules/@esbuild/aix-ppc64": {
- "version": "0.27.7",
- "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz",
- "integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==",
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz",
+ "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==",
"cpu": [
"ppc64"
],
@@ -49,9 +49,9 @@
}
},
"node_modules/@esbuild/android-arm": {
- "version": "0.27.7",
- "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz",
- "integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==",
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz",
+ "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==",
"cpu": [
"arm"
],
@@ -66,9 +66,9 @@
}
},
"node_modules/@esbuild/android-arm64": {
- "version": "0.27.7",
- "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz",
- "integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==",
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz",
+ "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==",
"cpu": [
"arm64"
],
@@ -83,9 +83,9 @@
}
},
"node_modules/@esbuild/android-x64": {
- "version": "0.27.7",
- "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz",
- "integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==",
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz",
+ "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==",
"cpu": [
"x64"
],
@@ -100,9 +100,9 @@
}
},
"node_modules/@esbuild/darwin-arm64": {
- "version": "0.27.7",
- "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz",
- "integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==",
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz",
+ "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==",
"cpu": [
"arm64"
],
@@ -117,9 +117,9 @@
}
},
"node_modules/@esbuild/darwin-x64": {
- "version": "0.27.7",
- "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz",
- "integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==",
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz",
+ "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==",
"cpu": [
"x64"
],
@@ -134,9 +134,9 @@
}
},
"node_modules/@esbuild/freebsd-arm64": {
- "version": "0.27.7",
- "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz",
- "integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==",
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz",
+ "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==",
"cpu": [
"arm64"
],
@@ -151,9 +151,9 @@
}
},
"node_modules/@esbuild/freebsd-x64": {
- "version": "0.27.7",
- "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz",
- "integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==",
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz",
+ "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==",
"cpu": [
"x64"
],
@@ -168,9 +168,9 @@
}
},
"node_modules/@esbuild/linux-arm": {
- "version": "0.27.7",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz",
- "integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==",
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz",
+ "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==",
"cpu": [
"arm"
],
@@ -185,9 +185,9 @@
}
},
"node_modules/@esbuild/linux-arm64": {
- "version": "0.27.7",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz",
- "integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==",
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz",
+ "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==",
"cpu": [
"arm64"
],
@@ -202,9 +202,9 @@
}
},
"node_modules/@esbuild/linux-ia32": {
- "version": "0.27.7",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz",
- "integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==",
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz",
+ "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==",
"cpu": [
"ia32"
],
@@ -219,9 +219,9 @@
}
},
"node_modules/@esbuild/linux-loong64": {
- "version": "0.27.7",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz",
- "integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==",
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz",
+ "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==",
"cpu": [
"loong64"
],
@@ -236,9 +236,9 @@
}
},
"node_modules/@esbuild/linux-mips64el": {
- "version": "0.27.7",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz",
- "integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==",
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz",
+ "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==",
"cpu": [
"mips64el"
],
@@ -253,9 +253,9 @@
}
},
"node_modules/@esbuild/linux-ppc64": {
- "version": "0.27.7",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz",
- "integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==",
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz",
+ "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==",
"cpu": [
"ppc64"
],
@@ -270,9 +270,9 @@
}
},
"node_modules/@esbuild/linux-riscv64": {
- "version": "0.27.7",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz",
- "integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==",
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz",
+ "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==",
"cpu": [
"riscv64"
],
@@ -287,9 +287,9 @@
}
},
"node_modules/@esbuild/linux-s390x": {
- "version": "0.27.7",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz",
- "integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==",
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz",
+ "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==",
"cpu": [
"s390x"
],
@@ -304,9 +304,9 @@
}
},
"node_modules/@esbuild/linux-x64": {
- "version": "0.27.7",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz",
- "integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==",
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz",
+ "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==",
"cpu": [
"x64"
],
@@ -321,9 +321,9 @@
}
},
"node_modules/@esbuild/netbsd-arm64": {
- "version": "0.27.7",
- "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz",
- "integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==",
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz",
+ "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==",
"cpu": [
"arm64"
],
@@ -338,9 +338,9 @@
}
},
"node_modules/@esbuild/netbsd-x64": {
- "version": "0.27.7",
- "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz",
- "integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==",
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz",
+ "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==",
"cpu": [
"x64"
],
@@ -355,9 +355,9 @@
}
},
"node_modules/@esbuild/openbsd-arm64": {
- "version": "0.27.7",
- "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz",
- "integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==",
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz",
+ "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==",
"cpu": [
"arm64"
],
@@ -372,9 +372,9 @@
}
},
"node_modules/@esbuild/openbsd-x64": {
- "version": "0.27.7",
- "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz",
- "integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==",
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz",
+ "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==",
"cpu": [
"x64"
],
@@ -389,9 +389,9 @@
}
},
"node_modules/@esbuild/openharmony-arm64": {
- "version": "0.27.7",
- "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz",
- "integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==",
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz",
+ "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==",
"cpu": [
"arm64"
],
@@ -406,9 +406,9 @@
}
},
"node_modules/@esbuild/sunos-x64": {
- "version": "0.27.7",
- "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz",
- "integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==",
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz",
+ "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==",
"cpu": [
"x64"
],
@@ -423,9 +423,9 @@
}
},
"node_modules/@esbuild/win32-arm64": {
- "version": "0.27.7",
- "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz",
- "integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==",
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz",
+ "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==",
"cpu": [
"arm64"
],
@@ -440,9 +440,9 @@
}
},
"node_modules/@esbuild/win32-ia32": {
- "version": "0.27.7",
- "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz",
- "integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==",
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz",
+ "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==",
"cpu": [
"ia32"
],
@@ -457,9 +457,9 @@
}
},
"node_modules/@esbuild/win32-x64": {
- "version": "0.27.7",
- "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz",
- "integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==",
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz",
+ "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==",
"cpu": [
"x64"
],
@@ -620,9 +620,9 @@
}
},
"node_modules/esbuild": {
- "version": "0.27.7",
- "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz",
- "integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==",
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz",
+ "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
@@ -633,32 +633,32 @@
"node": ">=18"
},
"optionalDependencies": {
- "@esbuild/aix-ppc64": "0.27.7",
- "@esbuild/android-arm": "0.27.7",
- "@esbuild/android-arm64": "0.27.7",
- "@esbuild/android-x64": "0.27.7",
- "@esbuild/darwin-arm64": "0.27.7",
- "@esbuild/darwin-x64": "0.27.7",
- "@esbuild/freebsd-arm64": "0.27.7",
- "@esbuild/freebsd-x64": "0.27.7",
- "@esbuild/linux-arm": "0.27.7",
- "@esbuild/linux-arm64": "0.27.7",
- "@esbuild/linux-ia32": "0.27.7",
- "@esbuild/linux-loong64": "0.27.7",
- "@esbuild/linux-mips64el": "0.27.7",
- "@esbuild/linux-ppc64": "0.27.7",
- "@esbuild/linux-riscv64": "0.27.7",
- "@esbuild/linux-s390x": "0.27.7",
- "@esbuild/linux-x64": "0.27.7",
- "@esbuild/netbsd-arm64": "0.27.7",
- "@esbuild/netbsd-x64": "0.27.7",
- "@esbuild/openbsd-arm64": "0.27.7",
- "@esbuild/openbsd-x64": "0.27.7",
- "@esbuild/openharmony-arm64": "0.27.7",
- "@esbuild/sunos-x64": "0.27.7",
- "@esbuild/win32-arm64": "0.27.7",
- "@esbuild/win32-ia32": "0.27.7",
- "@esbuild/win32-x64": "0.27.7"
+ "@esbuild/aix-ppc64": "0.28.2",
+ "@esbuild/android-arm": "0.28.2",
+ "@esbuild/android-arm64": "0.28.2",
+ "@esbuild/android-x64": "0.28.2",
+ "@esbuild/darwin-arm64": "0.28.2",
+ "@esbuild/darwin-x64": "0.28.2",
+ "@esbuild/freebsd-arm64": "0.28.2",
+ "@esbuild/freebsd-x64": "0.28.2",
+ "@esbuild/linux-arm": "0.28.2",
+ "@esbuild/linux-arm64": "0.28.2",
+ "@esbuild/linux-ia32": "0.28.2",
+ "@esbuild/linux-loong64": "0.28.2",
+ "@esbuild/linux-mips64el": "0.28.2",
+ "@esbuild/linux-ppc64": "0.28.2",
+ "@esbuild/linux-riscv64": "0.28.2",
+ "@esbuild/linux-s390x": "0.28.2",
+ "@esbuild/linux-x64": "0.28.2",
+ "@esbuild/netbsd-arm64": "0.28.2",
+ "@esbuild/netbsd-x64": "0.28.2",
+ "@esbuild/openbsd-arm64": "0.28.2",
+ "@esbuild/openbsd-x64": "0.28.2",
+ "@esbuild/openharmony-arm64": "0.28.2",
+ "@esbuild/sunos-x64": "0.28.2",
+ "@esbuild/win32-arm64": "0.28.2",
+ "@esbuild/win32-ia32": "0.28.2",
+ "@esbuild/win32-x64": "0.28.2"
}
},
"node_modules/expand-template": {
diff --git a/services/operator-backend/package.json b/services/operator-backend/package.json
index 4a907297..75c05e15 100644
--- a/services/operator-backend/package.json
+++ b/services/operator-backend/package.json
@@ -5,7 +5,7 @@
"license": "Apache-2.0",
"repository": {
"type": "git",
- "url": "https://github.com/canton-foundation/canton-dex",
+ "url": "https://github.com/srikanth-bitdynamics/Canton-Dex-Reference-Implementation.git",
"directory": "services/operator-backend"
},
"private": true,
@@ -19,17 +19,25 @@
},
"scripts": {
"typecheck": "tsc --noEmit",
+ "typecheck:live-scripts": "tsc -p tsconfig.live-scripts.json",
"test": "node --import tsx --test test/*.test.ts",
+ "test:live:rfq": "node --import tsx --test test/live/canton-live-rfq.test.ts",
"dev": "node --import tsx src/dev-server.ts",
"testnet": "node --import tsx src/testnet-server.ts",
"start": "node --import tsx src/testnet-server.ts",
- "localnet:dvp-e2e": "node --import tsx ../../scripts/localnet-dvp-e2e.ts",
- "testnet:seed-pool": "node --import tsx ../../scripts/seed-testnet-pool.ts"
+ "live:roundtrip": "node --import tsx ../../scripts/live-amm-roundtrip.ts",
+ "live:add-liquidity": "node --import tsx ../../scripts/live-amm-roundtrip.ts --add-only",
+ "localnet:amm-roundtrip": "node --import tsx ../../scripts/live-amm-roundtrip.ts",
+ "testnet:seed-pool": "node --import tsx ../../scripts/seed-testnet-pool.ts",
+ "live:matched-trade": "node --import tsx ../../scripts/testnet-v2registry-trade.ts"
},
"dependencies": {
"@canton-dex/registry-client": "file:../registry-client",
"better-sqlite3": "^12.10.0"
},
+ "overrides": {
+ "esbuild": "^0.28.2"
+ },
"devDependencies": {
"@types/better-sqlite3": "^7.6.13",
"@types/node": "^20",
diff --git a/services/operator-backend/src/dealers/index.ts b/services/operator-backend/src/dealers/index.ts
index 94ce6337..05245991 100644
--- a/services/operator-backend/src/dealers/index.ts
+++ b/services/operator-backend/src/dealers/index.ts
@@ -1,7 +1,7 @@
-// Dealer registry. The operator owns the list of RFQ counterparties:
-// who can quote, whose quotes get the "trusted" tier bump in the
-// matching policy, and per-dealer telemetry (latency, fill rate) that
-// the UI surfaces during compose.
+// Dealer directory. The operator curates who the hosted UI offers as an RFQ
+// counterparty and the telemetry shown during compose. On-ledger RfqQuote.tier
+// is dealer-declared; the operator endorses the considered quotes only when it
+// co-authorizes Rfq_Accept.
//
// Backed by the SQLite indexer DB. Read-only consumers query
// `list()`; admin endpoints call `upsert()` / `remove()` behind the
diff --git a/services/operator-backend/src/dev-server.ts b/services/operator-backend/src/dev-server.ts
index 63619447..923766a2 100644
--- a/services/operator-backend/src/dev-server.ts
+++ b/services/operator-backend/src/dev-server.ts
@@ -11,14 +11,15 @@
// Order_Fund/Cancel, OrderFundingRequest_Bind, Rfq_Accept,
// MatchedTrade_* are stubbed minimally; admin/* re-use built-in create).
//
-// This is NOT a production server. It is the smallest amount of
-// scaffolding that lets the UI demo end-to-end without a Canton
-// participant. Production swaps in JsonApiLedger + a real registry.
+// This is NOT a production server. It is the smallest amount of scaffolding
+// needed to exercise the UI-to-HTTP loop without a Canton participant. It does
+// not prove Daml execution or value movement. Live mode swaps in JsonApiLedger
+// and a real registry.
import { InMemoryLedger } from "./ledger/in-memory.js";
import { OperatorBackend } from "./index.js";
import { startHttpServer } from "./http/index.js";
-import { RegistryClient } from "@canton-dex/registry-client";
+import { FixedRegistryClient } from "@canton-dex/registry-client";
import type {
ContractId,
Decimal,
@@ -26,26 +27,15 @@ import type {
Pool,
PoolSlice,
} from "./types.js";
-import type { ChoiceContextRef } from "@canton-dex/registry-client";
// Stub registry that returns canned factory CIDs for any admin party.
-class StubRegistry extends RegistryClient {
+class StubRegistry extends FixedRegistryClient {
constructor() {
- super({ baseUrl: "http://stub-registry" });
- }
- override async getFactories(): Promise<{
- allocationFactoryCid: ContractId<"AllocationFactory">;
- settlementFactoryCid: ContractId<"SettlementFactory">;
- disclosure: never[];
- }> {
- return {
+ super(() => ({
allocationFactoryCid: "#alloc-fac:0" as ContractId<"AllocationFactory">,
settlementFactoryCid: "#settle-fac:0" as ContractId<"SettlementFactory">,
disclosure: [],
- };
- }
- override async getChoiceContext(): Promise {
- return { context: { values: {} }, disclosure: [] };
+ }));
}
}
@@ -396,11 +386,9 @@ async function main(): Promise {
operator,
lpRegistrar,
admin,
- allocationFactoryCid: "#alloc-fac:0",
- settlementFactoryCid: "#settle-fac:0",
- allocationFactoryExtraArgs: { context: { values: {} }, meta: { values: {} } },
- allocationFactoryDisclosure: [],
- network: process.env.CANTON_NETWORK ?? "canton:devnet",
+ // A sentinel consumed by the dApp shell so the seeded preview can never
+ // look like a synchronized Canton environment.
+ network: "preview:in-memory",
},
// Operator-write auth: the dev server has no token, so default to the
// explicit dev-open bypass unless an operator token is supplied.
@@ -412,6 +400,9 @@ async function main(): Promise {
.split(",")
.map((s) => s.trim())
.filter(Boolean),
+ // The in-memory demo owns its seeded trader authority. Real-Canton
+ // testnet-server keeps this trusted relay disabled by default.
+ hostedRfqEnabled: true,
});
// eslint-disable-next-line no-console
console.log(`[operator-backend] dev server listening at ${url}`);
diff --git a/services/operator-backend/src/http/auth.ts b/services/operator-backend/src/http/auth.ts
index b64a8b2e..70e5e85f 100644
--- a/services/operator-backend/src/http/auth.ts
+++ b/services/operator-backend/src/http/auth.ts
@@ -77,6 +77,7 @@ const OPERATOR_WRITE_EXACT = new Set([
// (It is additionally gated by walletRelayEnabled + a party allowlist in
// the handler, but the auth gate is the first line of defence.)
"/v1/wallet/submit",
+ "/v1/registry/allocation-factory",
"/v1/pools/swap",
"/v1/pools/swap/request",
"/v1/pools/add-liquidity/request",
diff --git a/services/operator-backend/src/http/caller-auth.ts b/services/operator-backend/src/http/caller-auth.ts
index 9029d2d6..b0772f66 100644
--- a/services/operator-backend/src/http/caller-auth.ts
+++ b/services/operator-backend/src/http/caller-auth.ts
@@ -1,4 +1,4 @@
-// Per-caller party binding for operator-authority write routes.
+// Per-caller party binding for private reads and operator-authority writes.
//
// The operator bearer token (checkOperatorAuth) authenticates the *backend
// client* — but on its own it lets any holder name an arbitrary party as the
@@ -207,3 +207,36 @@ export function checkCallerBinding(
}
return { ok: true };
}
+
+/**
+ * Bind a party-scoped read (for example `?owner=` or `?trader=`) to the
+ * verified caller. Admin callers are handled by the HTTP layer before this
+ * function. Like write binding, this is a no-op when no caller secret is
+ * configured and fail-closed when it is configured.
+ */
+export function checkCallerRead(
+ req: IncomingMessage,
+ cfg: CallerAuthConfig,
+ subject: string,
+): AuthCheck {
+ if (!cfg.callerJwtSecret) return { ok: true };
+ const caller = callerPartyFromRequest(req, cfg);
+ if (!caller) {
+ return {
+ ok: false,
+ status: 401,
+ code: "unauthorized",
+ message:
+ "this private read requires a valid X-Caller-Token (per-caller party JWT)",
+ };
+ }
+ if (caller !== subject) {
+ return {
+ ok: false,
+ status: 403,
+ code: "forbidden",
+ message: "caller may only read records for its own party",
+ };
+ }
+ return { ok: true };
+}
diff --git a/services/operator-backend/src/http/index.ts b/services/operator-backend/src/http/index.ts
index a97a559c..302bbb8f 100644
--- a/services/operator-backend/src/http/index.ts
+++ b/services/operator-backend/src/http/index.ts
@@ -1,8 +1,9 @@
// HTTP surface over the operator backend services.
//
-// Runs on Node's built-in http server (no framework dependency). Not
-// production-grade auth; production should put this behind an auth
-// proxy that validates the trader's session.
+// Runs on Node's built-in http server (no framework dependency). Bearer-token
+// gates protect operator/admin writes, and an optional caller JWT binds private
+// reads and trader-subject actions to the caller's Canton party. A hosted
+// deployment should issue those credentials through its authenticated BFF.
//
// Endpoints (single-source list; matches `app/web/src/services/ledger.ts`):
//
@@ -47,9 +48,15 @@ import { mergeDisclosures } from "../ledger/disclosure.js";
import * as dec from "../pool/decimal.js";
import { DealersService } from "../dealers/index.js";
import { checkAdminAuth, checkOperatorAuth, bearerMatches } from "./auth.js";
-import { checkCallerBinding, callerPartyFromRequest, type CallerAuthConfig } from "./caller-auth.js";
+import {
+ checkCallerBinding,
+ checkCallerRead,
+ callerPartyFromRequest,
+ type CallerAuthConfig,
+} from "./caller-auth.js";
import { validateWriteBody, ValidationError } from "./validate.js";
import { RfqAuthError } from "../rfq/index.js";
+import { OrderAuthError } from "../order/index.js";
import { rootLogger } from "../lib/logger.js";
const httpLog = rootLogger.child({ component: "http" });
@@ -105,29 +112,21 @@ function expectField(o: unknown, field: string): T {
}
/**
- * Static context the dApp needs to build trader-authority intents. The
- * dApp does not derive these from queries — it would have to guess
- * which admin governs which instrument, which factory CID to use, etc.
- * Surfacing them here keeps that knowledge on the operator's side.
+ * Static venue context. Factory CIDs and choice contexts are discovered per
+ * operation from the relevant V2 registry after exact choice arguments exist.
*/
export interface DexContext {
operator: Party;
lpRegistrar: Party;
admin: Party;
- allocationFactoryCid: string;
- settlementFactoryCid: string;
- allocationFactoryExtraArgs: {
- context: { values: Record };
- meta: { values: Record };
- };
- allocationFactoryDisclosure: DisclosedContract[];
network: string;
}
export interface DexStatus {
network: string;
- /** Monotonic counter while this process runs. Stand-in for a real participant offset. */
+ /** Latest participant ledger-end offset, or a dev-only local counter. */
slot: number;
+ /** Whether the most recent configured participant probe succeeded. */
synced: boolean;
/** ISO timestamp the server cut this snapshot. */
serverTime: string;
@@ -152,10 +151,15 @@ export interface HttpServerConfig {
/** Allowlist of actAs parties the wallet relay may forward for. */
walletRelayParties?: string[];
/**
- * HS256 secret for per-caller party binding. When set, write
+ * Trusted hosted-RFQ relay. These routes submit with trader authority and
+ * therefore require deployment-specific trader rights. Testnet/production
+ * entrypoints should leave this false unless caller binding is mandatory.
+ */
+ hostedRfqEnabled?: boolean;
+ /**
+ * HS256 secret for per-caller party binding. When set, party-scoped reads and
* routes that act on behalf of a trader require an X-Caller-Token JWT whose
- * `sub` is the caller's party, and reject any request whose subject party is
- * not the caller's own. Unset = binding disabled (single trusted backend).
+ * `sub` is the caller's party. Unset = binding disabled (single trusted backend).
*/
callerJwtSecret?: string;
/**
@@ -191,6 +195,24 @@ function pairParams(url: URL): { base: string; quote: string } | undefined {
return base && quote ? { base, quote } : undefined;
}
+function boundedPositiveInt(
+ url: URL,
+ name: string,
+ fallback: number,
+ maximum: number,
+): number {
+ const raw = url.searchParams.get(name);
+ if (raw === null) return fallback;
+ if (!/^\d+$/.test(raw)) {
+ throw new HttpError(400, "bad_request", `${name} must be a positive integer`);
+ }
+ const value = Number(raw);
+ if (!Number.isSafeInteger(value) || value < 1) {
+ throw new HttpError(400, "bad_request", `${name} must be a positive integer`);
+ }
+ return Math.min(value, maximum);
+}
+
export interface HttpServerHandle {
close: () => Promise;
/** Base URL carrying the port actually bound, so `port: 0` is usable. */
@@ -202,48 +224,38 @@ export interface HttpServerHandle {
export function startHttpServer(
cfg: HttpServerConfig,
): Promise {
- // Slot is the ledger's latest offset (ACS pruning watermark). We poll
- // the participant every 2s and cache the result. Falls back to a local
- // counter if the participant query fails so the UI's pill still moves.
+ // Poll the participant ledger end every 2s. A configured participant that
+ // cannot be reached must report synced=false; manufacturing a moving local
+ // slot here would make a broken testnet deployment look healthy. The local
+ // counter is used only by the in-memory dev server, which supplies no ledger
+ // URL/token at all.
let slot = 0;
- let lastPolledOk = false;
const slotUrl = (cfg.ledgerUrl ?? "").replace(/\/$/, "");
const slotToken = cfg.ledgerToken;
+ const hasParticipantProbe = Boolean(slotUrl && slotToken);
+ let lastPollSucceeded = !hasParticipantProbe;
async function pollSlot(): Promise {
- if (!slotUrl || !slotToken) {
+ if (!hasParticipantProbe || !slotUrl || !slotToken) {
slot += 1;
+ lastPollSucceeded = true;
return;
}
try {
const res = await fetch(
- `${slotUrl}/v2/state/latest-pruned-offsets`,
+ `${slotUrl}/v2/state/ledger-end`,
{ headers: { Authorization: `Bearer ${slotToken}` } },
);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
- const body = (await res.json()) as {
- participantPrunedUpToInclusive?: number;
- };
- const offset = body.participantPrunedUpToInclusive;
- if (typeof offset === "number" && offset > 0) {
- slot = offset;
- lastPolledOk = true;
- } else {
- // Pruned offset is 0 (nothing pruned yet) — fall back to ACS end.
- const ledgerEndRes = await fetch(
- `${slotUrl}/v2/state/ledger-end`,
- { headers: { Authorization: `Bearer ${slotToken}` } },
- );
- if (ledgerEndRes.ok) {
- const end = (await ledgerEndRes.json()) as { offset?: number };
- if (typeof end.offset === "number") {
- slot = end.offset;
- lastPolledOk = true;
- }
- }
+ const body = (await res.json()) as { offset?: number };
+ if (typeof body.offset !== "number") {
+ throw new Error("ledger-end response has no numeric offset");
}
+ slot = body.offset;
+ lastPollSucceeded = true;
} catch {
- // Quiet on transient errors; keep the last good value or tick.
- if (!lastPolledOk) slot += 1;
+ // Quiet on transient errors and keep the last genuine ledger offset, but
+ // expose the failed probe through /v1/status.
+ lastPollSucceeded = false;
}
}
void pollSlot();
@@ -263,6 +275,7 @@ export function startHttpServer(
cfg,
cfg.context,
() => slot,
+ () => lastPollSucceeded,
cfg.db,
allowedOrigins,
req,
@@ -286,6 +299,11 @@ export function startHttpServer(
respondJson(res, 403, { error: e.message, code: "forbidden", requestId });
return;
}
+ if (e instanceof OrderAuthError) {
+ reqLog.warn("request rejected", { status: 403, code: "forbidden", error: e.message });
+ respondJson(res, 403, { error: e.message, code: "forbidden", requestId });
+ return;
+ }
if (e instanceof LedgerError && e.kind === "validation") {
// A precondition/input failure surfaced by a service or the ledger —
// a client error, not a server fault.
@@ -336,6 +354,7 @@ async function routeRequest(
cfg: HttpServerConfig,
context: DexContext,
getSlot: () => number,
+ getSynced: () => boolean,
db: Db | undefined,
allowedOrigins: string[],
req: IncomingMessage,
@@ -361,7 +380,10 @@ async function routeRequest(
if (corsOrigin) res.setHeader("Access-Control-Allow-Origin", corsOrigin);
res.setHeader("Vary", "Origin");
res.setHeader("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS");
- res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization, X-Request-Id");
+ res.setHeader(
+ "Access-Control-Allow-Headers",
+ "Content-Type, Authorization, X-Caller-Token, X-Request-Id",
+ );
res.setHeader("Access-Control-Expose-Headers", "X-Request-Id");
if (method === "OPTIONS") {
res.statusCode = 204;
@@ -391,22 +413,35 @@ async function routeRequest(
// === read endpoints ====================================================
if (method === "GET" && path === "/v1/context") {
- const [factories, choiceContext] = await Promise.all([
- backend.registry.getFactories(context.admin),
- backend.registry.getChoiceContext(context.admin),
- ]);
+ respondJson(res, 200, context);
+ return;
+ }
+
+ // Canonical Token Standard V2 allocation-factory discovery. The caller
+ // supplies the exact Daml JSON AllocationFactory_Allocate argument.
+ if (method === "POST" && path === "/v1/registry/allocation-factory") {
+ const body = await readJson<{
+ admin: Party;
+ choiceArguments: Record;
+ }>(req);
+ if (typeof body.admin !== "string" || body.admin.length === 0) {
+ throw new HttpError(400, "bad_request", "admin is required");
+ }
+ if (
+ typeof body.choiceArguments !== "object" ||
+ body.choiceArguments === null ||
+ Array.isArray(body.choiceArguments)
+ ) {
+ throw new HttpError(400, "bad_request", "choiceArguments must be an object");
+ }
+ const found = await backend.registry.getAllocationFactory(
+ body.admin,
+ body.choiceArguments,
+ );
respondJson(res, 200, {
- ...context,
- allocationFactoryCid: factories.allocationFactoryCid,
- settlementFactoryCid: factories.settlementFactoryCid,
- allocationFactoryExtraArgs: {
- context: choiceContext.context,
- meta: { values: {} },
- },
- allocationFactoryDisclosure: mergeDisclosures(
- factories.disclosure,
- choiceContext.disclosure,
- ),
+ factoryCid: found.factoryCid,
+ extraArgs: { context: found.context, meta: { values: {} } },
+ disclosure: found.disclosure,
});
return;
}
@@ -415,7 +450,7 @@ async function routeRequest(
const body: DexStatus = {
network: context.network,
slot: getSlot(),
- synced: true,
+ synced: getSynced(),
serverTime: new Date().toISOString(),
};
respondJson(res, 200, body);
@@ -444,6 +479,7 @@ async function routeRequest(
if (!trader) {
throw new HttpError(400, "bad_request", "missing ?trader= query parameter");
}
+ requireCallerForPrivateRead(req, callerAuth, trader, adminToken);
const all = await backend.order.listOpen();
respondJson(
res,
@@ -596,6 +632,7 @@ async function routeRequest(
if (!owner) {
throw new HttpError(400, "bad_request", "missing ?owner= query parameter");
}
+ requireCallerForPrivateRead(req, callerAuth, owner, adminToken);
// Per-contract (UTXO-style) rows. For a summed balance, use /v1/balances.
respondJson(res, 200, await loadHoldings(backend, owner));
return;
@@ -609,6 +646,7 @@ async function routeRequest(
if (!owner) {
throw new HttpError(400, "bad_request", "missing ?owner= query parameter");
}
+ requireCallerForPrivateRead(req, callerAuth, owner, adminToken);
const holdings = await loadHoldings(backend, owner);
const byInstrument = new Map();
for (const h of holdings) {
@@ -675,10 +713,7 @@ async function routeRequest(
respondJson(res, 400, { error: "missing ?pair=BASE/QUOTE" });
return;
}
- const hours = Math.max(
- 1,
- Math.min(24 * 30, parseInt(url.searchParams.get("hours") ?? "24", 10)),
- );
+ const hours = boundedPositiveInt(url, "hours", 24, 24 * 30);
// `ts` is in milliseconds, so the bound must be too.
const since = Date.now() - hours * 3600 * 1000;
const rows = db
@@ -973,11 +1008,9 @@ async function routeRequest(
"missing ?trader= query parameter; the unfiltered view requires the admin token",
);
}
+ if (trader) requireCallerForPrivateRead(req, callerAuth, trader, adminToken);
const pair = url.searchParams.get("pair");
- const limit = Math.min(
- parseInt(url.searchParams.get("limit") ?? "50", 10),
- 500,
- );
+ const limit = boundedPositiveInt(url, "limit", 50, 500);
const where: string[] = [];
const args: unknown[] = [];
if (trader) {
@@ -1017,10 +1050,7 @@ async function routeRequest(
// Default to swaps only: LP moves and pause/resume rotate the state too,
// and existing callers pass no kind and expect the trade feed alone.
const kind = kindParam ?? "swap";
- const limit = Math.min(
- parseInt(url.searchParams.get("limit") ?? "50", 10),
- 500,
- );
+ const limit = boundedPositiveInt(url, "limit", 50, 500);
const sql = pair
? `SELECT * FROM swaps WHERE kind = ? AND pair = ? ORDER BY ts DESC LIMIT ${limit}`
: `SELECT * FROM swaps WHERE kind = ? ORDER BY ts DESC LIMIT ${limit}`;
@@ -1117,10 +1147,8 @@ async function routeRequest(
"missing ?trader= query parameter; the unfiltered view requires the admin token",
);
}
- const limit = Math.min(
- parseInt(url.searchParams.get("limit") ?? "100", 10),
- 500,
- );
+ if (trader) requireCallerForPrivateRead(req, callerAuth, trader, adminToken);
+ const limit = boundedPositiveInt(url, "limit", 100, 500);
const sql = trader
? `SELECT * FROM rfq_history WHERE trader = ? ORDER BY ts DESC LIMIT ${limit}`
: `SELECT * FROM rfq_history ORDER BY ts DESC LIMIT ${limit}`;
@@ -1183,6 +1211,7 @@ async function routeRequest(
respondJson(res, 200, await backend.rfq.list());
return;
}
+ requireCallerForPrivateRead(req, callerAuth, owner, adminToken);
const { rfqs, quotes } = await backend.rfq.list();
respondJson(res, 200, {
rfqs: rfqs.filter((r) => r.trader === owner || r.whitelist.includes(owner)),
@@ -1192,6 +1221,13 @@ async function routeRequest(
}
if (method === "POST" && path === "/v1/rfq") {
+ if (cfg.hostedRfqEnabled === false) {
+ respondJson(res, 404, {
+ error: "hosted RFQ relay disabled; use a trader-authorized wallet flow",
+ code: "not_found",
+ });
+ return;
+ }
const body = await readValidatedJson[0]>(req, "POST /v1/rfq", callerAuth);
const result = await backend.rfq.create(body);
respondJson(res, 200, result);
@@ -1201,6 +1237,13 @@ async function routeRequest(
// /v1/rfq/:cid/cancel
const rfqCancelMatch = path.match(/^\/v1\/rfq\/([^/]+)\/cancel$/);
if (method === "POST" && rfqCancelMatch) {
+ if (cfg.hostedRfqEnabled === false) {
+ respondJson(res, 404, {
+ error: "hosted RFQ relay disabled; use a trader-authorized wallet flow",
+ code: "not_found",
+ });
+ return;
+ }
const rfqCid = decodeURIComponent(rfqCancelMatch[1]!);
// Per-caller binding: cancel acts as the fetched
// RFQ's trader, so the body-map binding can't cover it. Resolve the caller
@@ -1213,6 +1256,13 @@ async function routeRequest(
}
if (method === "POST" && path === "/v1/rfq/accept") {
+ if (cfg.hostedRfqEnabled === false) {
+ respondJson(res, 404, {
+ error: "hosted RFQ relay disabled; use a trader-authorized wallet flow",
+ code: "not_found",
+ });
+ return;
+ }
const body = await readValidatedJson[0]>(req, "POST /v1/rfq/accept", callerAuth);
// Same fetch-based binding as cancel: accept acts as the RFQ's trader, so
// an operator-token holder must not accept a quote on a trader's behalf.
@@ -1224,14 +1274,24 @@ async function routeRequest(
if (method === "POST" && path === "/v1/orders/bind") {
const body = await readValidatedJson[0]>(req, "POST /v1/orders/bind", callerAuth);
- const result = await backend.order.bind(body);
+ const requireTrader = requireCallerForFetchBoundRoute(
+ req,
+ callerAuth,
+ "binding an order request",
+ );
+ const result = await backend.order.bind({ ...body, requireTrader });
respondJson(res, 200, result);
return;
}
if (method === "POST" && path === "/v1/orders/fund") {
const body = await readValidatedJson[0]>(req, "POST /v1/orders/fund", callerAuth);
- const result = await backend.order.fund(body);
+ const requireTrader = requireCallerForFetchBoundRoute(
+ req,
+ callerAuth,
+ "funding an order",
+ );
+ const result = await backend.order.fund({ ...body, requireTrader });
respondJson(res, 200, result);
return;
}
@@ -1240,7 +1300,12 @@ async function routeRequest(
const cancelMatch = path.match(/^\/v1\/orders\/([^/]+)\/cancel$/);
if (method === "POST" && cancelMatch) {
const orderCid = decodeURIComponent(cancelMatch[1]!);
- await backend.order.cancel(orderCid as never);
+ const requireTrader = requireCallerForFetchBoundRoute(
+ req,
+ callerAuth,
+ "cancelling an order",
+ );
+ await backend.order.cancel(orderCid as never, requireTrader);
respondJson(res, 204, {});
return;
}
@@ -1467,20 +1532,35 @@ async function loadHoldings(
Array<{ owner: string; instrumentId: string; amount: string; locked: boolean }>
> {
type H = { owner: string; instrumentId: string; amount: string; locked: boolean };
- const load = async (templateId: string): Promise => {
- try {
- return await backend.ledger.query({
- templateId,
- observingParty: owner as never,
- });
- } catch {
- return [];
- }
- };
- const holdings = await load("CantonDex.Registry.V2:Holding");
+ let holdings: H[];
+ try {
+ holdings = await backend.ledger.query({
+ templateId: "CantonDex.Registry.V2:Holding",
+ observingParty: owner as never,
+ });
+ } catch {
+ throw new HttpError(
+ 503,
+ "ledger_unavailable",
+ "unable to load holdings from the ledger",
+ );
+ }
return holdings.filter((h) => h.owner === owner);
}
+function requireCallerForPrivateRead(
+ req: IncomingMessage,
+ callerAuth: CallerAuthConfig,
+ subject: string,
+ adminToken: string | undefined,
+): void {
+ if (adminToken && bearerMatches(req.headers["authorization"], adminToken)) return;
+ const binding = checkCallerRead(req, callerAuth, subject);
+ if (!binding.ok) {
+ throw new HttpError(binding.status, binding.code, binding.message);
+ }
+}
+
async function readValidatedJson(
req: IncomingMessage,
routeKey: string,
diff --git a/services/operator-backend/src/index.ts b/services/operator-backend/src/index.ts
index 71657735..1f6d900e 100644
--- a/services/operator-backend/src/index.ts
+++ b/services/operator-backend/src/index.ts
@@ -27,7 +27,7 @@
// templates -- that's a guardrail violation.
import type { LedgerSubmitter } from "./ledger/index.js";
-import type { RegistryClient } from "@canton-dex/registry-client";
+import type { RegistryDiscovery } from "@canton-dex/registry-client";
import { AdminService } from "./admin/index.js";
import { OrderService } from "./order/index.js";
@@ -44,7 +44,7 @@ import type { Party } from "./types.js";
export interface OperatorBackendConfig {
ledger: LedgerSubmitter;
- registry: RegistryClient;
+ registry: RegistryDiscovery;
operatorParty: Party;
}
@@ -59,7 +59,7 @@ export class OperatorBackend {
// that need to drive raw ledger commands. Production callers should
// prefer the typed flow modules.
readonly ledger: LedgerSubmitter;
- readonly registry: RegistryClient;
+ readonly registry: RegistryDiscovery;
readonly operatorParty: Party;
constructor(cfg: OperatorBackendConfig) {
diff --git a/services/operator-backend/src/ledger/choice-context.ts b/services/operator-backend/src/ledger/choice-context.ts
index 17a2cdfb..b0221a02 100644
--- a/services/operator-backend/src/ledger/choice-context.ts
+++ b/services/operator-backend/src/ledger/choice-context.ts
@@ -1,11 +1,8 @@
-// Shared off-ledger choice-context fetch: wraps the registry's enriched
-// context + disclosures into the extraArgs shape the token-standard choices
-// take. Used by the pool, order, and matched-trade services.
+// Convert one operation-specific registry response into the ExtraArgs shape
+// expected by Token Standard choices. Discovery itself stays at the call site
+// so a context cannot be fetched without the exact operation arguments.
-import type { DisclosedContract } from "@canton-dex/registry-client";
-import { RegistryClient } from "@canton-dex/registry-client";
-
-import type { Party } from "../types.js";
+import type { ChoiceContextRef, DisclosedContract } from "@canton-dex/registry-client";
export interface ChoiceContext {
extraArgs: {
@@ -15,13 +12,14 @@ export interface ChoiceContext {
disclosure: DisclosedContract[];
}
-export async function fetchChoiceContext(
- registry: RegistryClient,
- admin: Party,
-): Promise {
- const ctx = await registry.getChoiceContext(admin);
+export function asChoiceContext(ctx: ChoiceContextRef): ChoiceContext {
return {
extraArgs: { context: ctx.context, meta: { values: {} } },
disclosure: ctx.disclosure,
};
}
+
+export const emptyExtraArgs = {
+ context: { values: {} },
+ meta: { values: {} },
+};
diff --git a/services/operator-backend/src/ledger/json-api.ts b/services/operator-backend/src/ledger/json-api.ts
index 774d14d8..1747d91b 100644
--- a/services/operator-backend/src/ledger/json-api.ts
+++ b/services/operator-backend/src/ledger/json-api.ts
@@ -1,9 +1,9 @@
// JsonApiLedger -- LedgerSubmitter implementation that talks to a
// real Canton participant via the JSON Ledger API.
//
-// This is the production driver. Tests can use it to drive against a
-// live `daml start` (or a deployed Canton participant); the in-memory
-// driver in `in-memory.ts` is the fast unit-test path.
+// This is the live-participant driver. Tests can use it against the repository's
+// default `dpm sandbox` proof or a separately operated Canton participant; the
+// in-memory driver in `in-memory.ts` is the fast unit-test path.
//
// JSON API reference:
// https://docs.daml.com/json-api/ (general)
diff --git a/services/operator-backend/src/matched-trade/index.ts b/services/operator-backend/src/matched-trade/index.ts
index a91b7c1d..c208acab 100644
--- a/services/operator-backend/src/matched-trade/index.ts
+++ b/services/operator-backend/src/matched-trade/index.ts
@@ -1,9 +1,9 @@
// MatchedTrade flow.
import type { ContractId, DisclosedContract } from "@canton-dex/registry-client";
-import { RegistryClient } from "@canton-dex/registry-client";
+import type { RegistryDiscovery } from "@canton-dex/registry-client";
-import { fetchChoiceContext, type ChoiceContext } from "../ledger/choice-context.js";
+import { asChoiceContext } from "../ledger/choice-context.js";
import { mergeDisclosures } from "../ledger/disclosure.js";
import { LedgerSubmitter } from "../ledger/index.js";
import { retryOnContention } from "../ledger/submit-with-retry.js";
@@ -49,14 +49,10 @@ export interface SettlementBatchV2 {
export class MatchedTradeService {
constructor(
private readonly ledger: LedgerSubmitter,
- private readonly registry: RegistryClient,
+ private readonly registry: RegistryDiscovery,
private readonly operatorParty: Party,
) {}
- private choiceContext(admin: Party): Promise {
- return fetchChoiceContext(this.registry, admin);
- }
-
async requestAllocations(
input: MatchedTradeRequestAllocationsInput,
): Promise[]> {
@@ -76,6 +72,31 @@ export class MatchedTradeService {
}
async settle(input: MatchedTradeSettleInput): Promise {
+ const plansByAdmin = [...input.batchesByAdmin].map(([admin, batch]) => [
+ admin,
+ {
+ transferLegs: batch.transferLegs,
+ allocations: batch.allocationCids.map((allocationCid) => ({
+ allocationCid,
+ extraTransferLegSides: [],
+ nextIterationFunding: null,
+ })),
+ },
+ ]);
+ const preview = await retryOnContention(() =>
+ this.ledger.submit]>>({
+ actAs: [this.operatorParty],
+ commandId: `mt-settle-preview:${input.tradeCid}`,
+ command: {
+ kind: "exercise",
+ templateId: "CantonDex.Dex.MatchedTrade:MatchedTrade",
+ contractId: input.tradeCid,
+ choice: "MatchedTrade_PreviewSettlement",
+ argument: { plansByAdmin },
+ },
+ }),
+ );
+ const argumentsByAdmin = new Map(preview);
const adminEntries: Array<{
admin: Party;
batch: SettlementBatchV2;
@@ -87,16 +108,18 @@ export class MatchedTradeService {
disclosure: DisclosedContract[];
}> = [];
for (const [admin, batch] of input.batchesByAdmin) {
- const [factories, ctx] = await Promise.all([
- this.registry.getFactories(admin),
- this.choiceContext(admin),
- ]);
+ const choiceArguments = argumentsByAdmin.get(admin);
+ if (!choiceArguments) {
+ throw new Error(`matched trade preview omitted registry admin ${admin}`);
+ }
+ const factory = await this.registry.getSettlementFactory(admin, choiceArguments);
+ const ctx = asChoiceContext(factory);
adminEntries.push({
admin,
batch,
- factoryCid: factories.settlementFactoryCid,
+ factoryCid: factory.factoryCid as ContractId<"SettlementFactory">,
extraArgs: ctx.extraArgs,
- disclosure: mergeDisclosures(factories.disclosure, ctx.disclosure),
+ disclosure: ctx.disclosure,
});
}
@@ -156,10 +179,15 @@ export class MatchedTradeService {
>;
}> = [];
for (const [admin, allocationCids] of input.allocationsByAdmin) {
- const ctx = await this.choiceContext(admin);
+ const contexts = await Promise.all(
+ allocationCids.map((cid) => this.registry.getAllocationCancelContext(admin, cid)),
+ );
adminEntries.push({
- disclosure: ctx.disclosure,
- allocationsToCancel: allocationCids.map((cid) => [cid, ctx.extraArgs]),
+ disclosure: mergeDisclosures(...contexts.map((ctx) => ctx.disclosure)),
+ allocationsToCancel: allocationCids.map((cid, index) => [
+ cid,
+ asChoiceContext(contexts[index]!).extraArgs,
+ ]),
});
}
diff --git a/services/operator-backend/src/order/index.ts b/services/operator-backend/src/order/index.ts
index d8fa95a2..038c34d7 100644
--- a/services/operator-backend/src/order/index.ts
+++ b/services/operator-backend/src/order/index.ts
@@ -2,9 +2,9 @@
// order, attach the trader-authored funding allocation, then match or cancel.
import type { ContractId } from "@canton-dex/registry-client";
-import { RegistryClient } from "@canton-dex/registry-client";
+import type { RegistryDiscovery } from "@canton-dex/registry-client";
-import { fetchChoiceContext, type ChoiceContext } from "../ledger/choice-context.js";
+import { asChoiceContext } from "../ledger/choice-context.js";
import { mergeDisclosures } from "../ledger/disclosure.js";
import { LedgerSubmitter, type SubmitRequest } from "../ledger/index.js";
import {
@@ -35,6 +35,8 @@ export interface OrderBindInput {
// transaction tree.
updateId?: string | null;
settlementRef: string;
+ /** Verified caller party when per-caller binding is enabled. */
+ requireTrader?: Party;
}
export interface OrderBindResult {
@@ -54,6 +56,16 @@ export interface OrderFundInput {
// The OrderAllocationRequest created at bind. Order_Fund consumes it together
// with the pending order after validating the allocation specification.
allocationRequestCid?: ContractId<"OrderAllocationRequest"> | null;
+ /** Verified caller party when per-caller binding is enabled. */
+ requireTrader?: Party;
+}
+
+/** Thrown when a caller tries to mutate another trader's order workflow. */
+export class OrderAuthError extends Error {
+ constructor(message: string) {
+ super(message);
+ this.name = "OrderAuthError";
+ }
}
export interface OrderCancelResult {
@@ -90,17 +102,17 @@ interface LiveOrder {
allocationCid: ContractId<"Allocation"> | null;
}
+function basicAccount(owner: Party): V2Account {
+ return { owner, provider: null, id: "" };
+}
+
export class OrderService {
constructor(
private readonly ledger: LedgerSubmitter,
- private readonly registry: RegistryClient,
+ private readonly registry: RegistryDiscovery,
private readonly operatorParty: Party,
) {}
- private choiceContext(admin: Party): Promise {
- return fetchChoiceContext(this.registry, admin);
- }
-
async bind(input: OrderBindInput): Promise {
// Recover the created request from the transaction tree when a wallet
// returns an update id instead of contract ids.
@@ -117,6 +129,17 @@ export class OrderService {
"order bind: supply fundingRequestCid or an updateId to recover it",
);
}
+ if (input.requireTrader !== undefined) {
+ const requests = await this.ledger.query<{ contractId: string; trader: Party }>({
+ templateId: "CantonDex.Dex.OrderFundingRequest:OrderFundingRequest",
+ observingParty: this.operatorParty,
+ });
+ const request = requests.find((row) => row.contractId === fundingRequestCid);
+ if (!request) throw new Error(`Order funding request ${fundingRequestCid} not found`);
+ if (request.trader !== input.requireTrader) {
+ throw new OrderAuthError("caller may only bind its own order request");
+ }
+ }
const result = await retryOnContention(() =>
this.ledger.submit({
actAs: [this.operatorParty],
@@ -152,6 +175,13 @@ export class OrderService {
if (!allocationCid) {
throw new Error("order fund: supply allocationCid or an updateId to recover it");
}
+ if (input.requireTrader !== undefined) {
+ const order = (await this.listOpen()).find((row) => row.contractId === input.orderCid);
+ if (!order) throw new Error(`Order ${input.orderCid} not found`);
+ if (order.trader !== input.requireTrader) {
+ throw new OrderAuthError("caller may only fund its own order");
+ }
+ }
return retryOnContention(() =>
this.ledger.submit<{ orderCid: ContractId<"Order"> }>({
actAs: [this.operatorParty],
@@ -171,19 +201,25 @@ export class OrderService {
);
}
- async cancel(orderCid: ContractId<"Order">): Promise {
+ async cancel(
+ orderCid: ContractId<"Order">,
+ requireTrader?: Party,
+ ): Promise {
const order = (await this.listOpen()).find((o) => o.contractId === orderCid);
if (!order) throw new Error(`Order ${orderCid} not found`);
- const [factories, ctx] = await Promise.all([
- this.registry.getFactories(order.admin),
- this.choiceContext(order.admin),
- ]);
+ if (requireTrader !== undefined && order.trader !== requireTrader) {
+ throw new OrderAuthError("caller may only cancel its own order");
+ }
+ const discovered = order.allocationCid
+ ? await this.registry.getAllocationCancelContext(order.admin, order.allocationCid)
+ : { context: { values: {} }, disclosure: [] };
+ const ctx = asChoiceContext(discovered);
const req: SubmitRequest = {
actAs: [this.operatorParty],
// Cancellation may release holdings visible only to the owner and
// registry admin, so include the registry's choice context and disclosure.
commandId: `order-cancel:${orderCid}`,
- disclosure: mergeDisclosures(factories.disclosure, ctx.disclosure),
+ disclosure: mergeDisclosures(ctx.disclosure),
command: {
kind: "exercise",
templateId: "CantonDex.Dex.Order:Order",
@@ -250,13 +286,14 @@ export class OrderService {
/**
* Discover crossing orders for a pair and settle each one atomically via
- * `OrderMatchExecution_Execute`: a single submission that re-checks the fill
+ * `OrderMatchExecution_Execute`: one value-moving submission that re-checks the fill
* against both orders' own terms, builds the base/quote transfer legs, runs
* the settle batch that consumes both funding allocations, rolls each order
* onto the allocation that batch minted, and records the settled trade.
*
- * One submission per match, so there is no window in which the funds have
- * moved but an order still points at the allocation the settle archived.
+ * A read-only Daml preview first supplies the registry with the exact batch.
+ * The subsequent execute still moves funds, rolls orders, and records the
+ * trade atomically.
*
* Each match is settled independently; one failure doesn't abort the
* rest of the run.
@@ -268,10 +305,6 @@ export class OrderService {
}): Promise {
const matches = await this.findMatches(input);
if (matches.length === 0) return [];
- const [factories, ctx] = await Promise.all([
- this.registry.getFactories(input.admin),
- this.choiceContext(input.admin),
- ]);
const out: MatchRunResult[] = [];
// One order can fill against several counterparties in a single run, and
// each fill archives it and consumes its allocation. Track what the
@@ -309,52 +342,58 @@ export class OrderService {
if (!buy.allocationCid || !sell.allocationCid) {
throw new Error(`match ${matchId}: a matched order has no funding allocation`);
}
- const acct = (owner: Party): V2Account => ({
- owner,
- provider: null,
- id: "",
- });
+ const executionArgument = {
+ operator: this.operatorParty,
+ matchId,
+ match: {
+ buyerAccount: basicAccount(m.buy.trader),
+ sellerAccount: basicAccount(m.sell.trader),
+ baseInstrumentId: m.buy.baseInstrumentId,
+ quoteInstrumentId: m.buy.quoteInstrumentId,
+ fillQty: m.quantity,
+ fillPrice: m.price,
+ },
+ buyOrderCid: buy.cid,
+ sellOrderCid: sell.cid,
+ buyerAllocationCid: buy.allocationCid,
+ sellerAllocationCid: sell.allocationCid,
+ buyerCommittedFunding: {},
+ sellerCommittedFunding: {},
+ };
+ const settlementArguments = await retryOnContention(() =>
+ this.ledger.submit>({
+ actAs: [this.operatorParty],
+ readAs: [input.admin],
+ commandId: `order-match-preview:${matchId}`,
+ command: {
+ kind: "createAndExercise",
+ templateId:
+ "CantonDex.Dex.OrderMatchExecution:OrderMatchExecution",
+ argument: executionArgument,
+ choice: "OrderMatchExecution_PreviewSettlement",
+ choiceArgument: {},
+ },
+ }),
+ );
+ const factory = await this.registry.getSettlementFactory(
+ input.admin,
+ settlementArguments,
+ );
+ const ctx = asChoiceContext(factory);
const executed = await retryOnContention(() =>
this.ledger.submit({
actAs: [this.operatorParty],
- // The settle fetches each order's funding allocation and the
- // holdings it locked -- `signatory admin, owner`, which the
- // operator is not a stakeholder of. readAs the instrument admin so
- // it can see them; without it the settle fails CONTRACT_NOT_FOUND
- // on the funding it is trying to move. Both orders share an admin
- // (asserted by the choice), so one entry covers both sides.
readAs: [input.admin],
commandId: `order-match:${matchId}`,
- // Factory + choice-context disclosure for the registry's own
- // contracts.
- disclosure: mergeDisclosures(factories.disclosure, ctx.disclosure),
+ disclosure: ctx.disclosure,
command: {
kind: "createAndExercise",
templateId:
"CantonDex.Dex.OrderMatchExecution:OrderMatchExecution",
- argument: {
- operator: this.operatorParty,
- matchId,
- match: {
- buyerAccount: acct(m.buy.trader),
- sellerAccount: acct(m.sell.trader),
- baseInstrumentId: m.buy.baseInstrumentId,
- quoteInstrumentId: m.buy.quoteInstrumentId,
- fillQty: m.quantity,
- fillPrice: m.price,
- },
- buyOrderCid: buy.cid,
- sellOrderCid: sell.cid,
- buyerAllocationCid: buy.allocationCid,
- sellerAllocationCid: sell.allocationCid,
- // [COMPAT] These fields are retained for package lineage and
- // ignored by the choice. Each budget comes from its allocation.
- buyerCommittedFunding: {},
- sellerCommittedFunding: {},
- },
+ argument: executionArgument,
choice: "OrderMatchExecution_Execute",
choiceArgument: {
- factoryCid: factories.settlementFactoryCid,
+ factoryCid: factory.factoryCid,
extraArgs: ctx.extraArgs,
},
},
@@ -366,13 +405,19 @@ export class OrderService {
const buyRemainderCid = executed.buyRemainderCid ?? null;
advance(
m.buy,
- buyRemainderCid && { cid: buyRemainderCid, allocationCid: buyNext },
+ buyRemainderCid && {
+ cid: buyRemainderCid,
+ allocationCid: buyNext,
+ },
);
const sellNext = executed.sellerNextAllocationCid ?? null;
const sellRemainderCid = executed.sellRemainderCid ?? null;
advance(
m.sell,
- sellRemainderCid && { cid: sellRemainderCid, allocationCid: sellNext },
+ sellRemainderCid && {
+ cid: sellRemainderCid,
+ allocationCid: sellNext,
+ },
);
out.push({
buyCid: m.buy.contractId,
diff --git a/services/operator-backend/src/policy/index.ts b/services/operator-backend/src/policy/index.ts
index 48b4ab9e..944a0515 100644
--- a/services/operator-backend/src/policy/index.ts
+++ b/services/operator-backend/src/policy/index.ts
@@ -3,14 +3,12 @@
import { createHash, createHmac } from "node:crypto";
-import { parseDecimal } from "../pool/decimal.js";
import type {
Decimal,
Party,
PolicyReceipt,
RankedDealer,
RfqQuote,
- RfqSide,
Time,
} from "../types.js";
@@ -19,24 +17,14 @@ import type {
export const POLICY_VERSION = "v2.0";
export const POLICY_HASH = "sha256:rfq-policy-v2.0";
-// Compare two Daml Decimal strings exactly (10dp, no IEEE-754) so the
-// ranking agrees with the on-ledger Decimal ordering in
-// trading/CantonDex/Dex/Rfq.daml. Returns -1 / 0 / 1.
-export function compareDecimal(a: string, b: string): number {
- const da = parseDecimal(a);
- const db = parseDecimal(b);
- return da < db ? -1 : da > db ? 1 : 0;
-}
-
export function rankQuotes(
- side: RfqSide,
quotes: RfqQuote[],
now: Time,
): RfqQuote[] {
const valid = quotes.filter(
(q) => Date.parse(q.expiresAt) > Date.parse(now),
);
- // Reproduces `policyCmp` (Rfq.daml:241-256) exactly: trusted tier first,
+ // Reproduces `policyCmp` in Rfq.daml exactly: trusted tier first,
// then LATER expiresAt (more time to act), then EARLIER postedAt
// (first-mover), then a deterministic dealer-party tie-break.
//
@@ -70,7 +58,6 @@ export function rankedDealersOf(ranked: RfqQuote[]): RankedDealer[] {
export function buildReceipt(args: {
rfqId: string;
- side: RfqSide;
quotes: RfqQuote[];
acceptedDealer: Party;
signedBy: Party;
@@ -78,7 +65,7 @@ export function buildReceipt(args: {
now?: Time;
}): PolicyReceipt {
const now = args.now ?? args.signedAt;
- const ranked = rankQuotes(args.side, args.quotes, now);
+ const ranked = rankQuotes(args.quotes, now);
const rankedDealers = rankedDealersOf(ranked);
const idx = rankedDealers.findIndex((d) => d.party === args.acceptedDealer);
if (idx < 0) {
diff --git a/services/operator-backend/src/pool/index.ts b/services/operator-backend/src/pool/index.ts
index 3c8743c0..69b769a9 100644
--- a/services/operator-backend/src/pool/index.ts
+++ b/services/operator-backend/src/pool/index.ts
@@ -3,10 +3,10 @@
import { createHash } from "node:crypto";
import { LedgerError } from "../ledger/index.js";
-import type { ContractId, DisclosedContract } from "@canton-dex/registry-client";
-import { RegistryClient } from "@canton-dex/registry-client";
+import type { ContractId } from "@canton-dex/registry-client";
+import type { RegistryDiscovery } from "@canton-dex/registry-client";
-import { fetchChoiceContext, type ChoiceContext } from "../ledger/choice-context.js";
+import { asChoiceContext } from "../ledger/choice-context.js";
import { mergeDisclosures } from "../ledger/disclosure.js";
import { LedgerSubmitter } from "../ledger/index.js";
import { recoverCreatedAllocations } from "../ledger/recover.js";
@@ -31,9 +31,52 @@ import type {
V2SettlementInfo,
} from "../types.js";
-interface RegistryExtraArgs {
- context: { values: Record };
- meta: { values: Record };
+type ChoiceArguments = Record;
+
+interface AllocationInstructionResult {
+ output?: {
+ tag?: string;
+ value?: { allocationCid?: string };
+ };
+}
+
+interface AddLiquidityAllocationPlan {
+ baseReceiver: ChoiceArguments;
+ quoteReceiver: ChoiceArguments;
+ lpMintSender: ChoiceArguments;
+}
+
+interface AddLiquiditySettlementPlan {
+ baseQuoteBatch: ChoiceArguments;
+ lpBatch: ChoiceArguments;
+}
+
+interface RemoveLiquidityAllocationPlan {
+ lpBurnReceiver: ChoiceArguments;
+}
+
+interface RemoveLiquiditySettlementPlan {
+ baseQuoteBatch: ChoiceArguments;
+ lpBatch: ChoiceArguments;
+}
+
+function completedAllocationCid(
+ result: AllocationInstructionResult,
+ operation: string,
+): ContractId<"Allocation"> {
+ const tag = result.output?.tag;
+ const allocationCid = result.output?.value?.allocationCid;
+ if (
+ (tag !== "AllocationInstructionResult_Completed" && tag !== "Completed") ||
+ !allocationCid
+ ) {
+ throw new LedgerError(
+ "unsupported",
+ `${operation}: registry did not complete allocation creation synchronously`,
+ false,
+ );
+ }
+ return allocationCid as ContractId<"Allocation">;
}
export interface PoolSwapInput {
@@ -140,10 +183,6 @@ export interface PoolRequestSwapResult {
allocationSpec: V2AllocationSpecification;
settlement: V2SettlementInfo;
quoteBinding: PoolSwapQuoteBinding;
- // The pool-admin allocation factory the swapper allocates under.
- factoryCid: ContractId<"AllocationFactory">;
- allocationFactoryExtraArgs: RegistryExtraArgs;
- allocationFactoryDisclosure: DisclosedContract[];
}
// === DvP liquidity ==========================================
@@ -187,13 +226,6 @@ export interface PoolRequestAddLiquidityResult extends LiquidityMatch {
// The on-ledger specs the wallet authors, in canonical order.
allocations: V2AllocationSpecification[];
settlement: V2SettlementInfo;
- // Distinct factories for pool-admin vs lpRegistrar allocations.
- depositFactoryCid: ContractId<"AllocationFactory">;
- lpFactoryCid: ContractId<"AllocationFactory">;
- depositFactoryExtraArgs: RegistryExtraArgs;
- lpFactoryExtraArgs: RegistryExtraArgs;
- depositFactoryDisclosure: DisclosedContract[];
- lpFactoryDisclosure: DisclosedContract[];
}
export interface PoolSettleAddLiquidityInput {
@@ -240,12 +272,6 @@ export interface PoolRequestRemoveLiquidityResult {
// The on-ledger specs the holder authors.
allocations: V2AllocationSpecification[];
settlement: V2SettlementInfo;
- depositFactoryCid: ContractId<"AllocationFactory">;
- lpFactoryCid: ContractId<"AllocationFactory">;
- depositFactoryExtraArgs: RegistryExtraArgs;
- lpFactoryExtraArgs: RegistryExtraArgs;
- depositFactoryDisclosure: DisclosedContract[];
- lpFactoryDisclosure: DisclosedContract[];
}
export interface PoolSettleRemoveLiquidityInput {
@@ -302,14 +328,10 @@ function normalizePoolStatus(raw: unknown): Pool["status"] {
export class PoolService {
constructor(
private readonly ledger: LedgerSubmitter,
- private readonly registry: RegistryClient,
+ private readonly registry: RegistryDiscovery,
private readonly operatorParty: Party,
) {}
- private choiceContext(admin: Party): Promise {
- return fetchChoiceContext(this.registry, admin);
- }
-
private async rulesCid(): Promise> {
const rules = await this.ledger.query({
templateId: "CantonDex.Dex.PoolRules:PoolRules",
@@ -534,8 +556,6 @@ export class PoolService {
false,
);
}
- const factories = await this.registry.getFactories(pool.admin);
- const ctx = await this.choiceContext(pool.admin);
const inputIsBase = input.inputInstrumentId === pool.baseInstrumentId;
const inputSlices = inputIsBase ? pool.baseSlices : pool.quoteSlices;
const outputSlices = inputIsBase ? pool.quoteSlices : pool.baseSlices;
@@ -564,31 +584,53 @@ export class PoolService {
updateId: input.updateId ?? null,
});
const commandId = `pool-swap:${input.poolCid}:${swapKey}`;
+ const swapArgument = {
+ expectedPoolId: pool.poolId,
+ poolCid: input.poolCid,
+ poolStateCid: binding.poolStateCid,
+ swapperAccount: input.swapperAccount,
+ inputInstrumentId: input.inputInstrumentId,
+ inputAmount: input.inputAmount,
+ minOutputAmount: input.minOutputAmount,
+ swapperAllocationCid,
+ inputSliceCid: binding.inputSliceCid,
+ outputSliceCids: binding.outputSliceCids,
+ quoteBinding: binding,
+ };
+ const settlementArguments = await retryOnContention(() =>
+ this.ledger.submit>({
+ actAs: [this.operatorParty],
+ readAs: input.swapperAccount.owner ? [input.swapperAccount.owner] : [],
+ commandId: `${commandId}:preview`,
+ command: {
+ kind: "exercise",
+ templateId: "CantonDex.Dex.PoolRules:PoolRules",
+ contractId: pool.rulesCid,
+ choice: "PoolRules_PreviewSwapSettlement",
+ argument: swapArgument,
+ },
+ }),
+ );
+ const settlementFactory = await this.registry.getSettlementFactory(
+ pool.admin,
+ settlementArguments,
+ );
+ const settlementContext = asChoiceContext(settlementFactory);
return retryOnContention(() =>
this.ledger.submit({
actAs: [this.operatorParty],
readAs: input.swapperAccount.owner ? [input.swapperAccount.owner] : [],
commandId,
- disclosure: mergeDisclosures(factories.disclosure, ctx.disclosure),
+ disclosure: settlementContext.disclosure,
command: {
kind: "exercise",
templateId: "CantonDex.Dex.PoolRules:PoolRules",
contractId: pool.rulesCid,
choice: "PoolRules_Swap",
argument: {
- expectedPoolId: pool.poolId,
- poolCid: input.poolCid,
- poolStateCid: binding.poolStateCid,
- swapperAccount: input.swapperAccount,
- inputInstrumentId: input.inputInstrumentId,
- inputAmount: input.inputAmount,
- minOutputAmount: input.minOutputAmount,
- swapperAllocationCid,
- inputSliceCid: binding.inputSliceCid,
- outputSliceCids: binding.outputSliceCids,
- factoryCid: factories.settlementFactoryCid,
- extraArgs: ctx.extraArgs,
- quoteBinding: binding,
+ ...swapArgument,
+ factoryCid: settlementFactory.factoryCid,
+ extraArgs: settlementContext.extraArgs,
},
},
}),
@@ -626,10 +668,6 @@ export class PoolService {
outputSliceCids: selectCoveringPrefix(outputSlices, amountOut),
minOutputAmount: input.minOutputAmount,
};
- const [factories, ctx] = await Promise.all([
- this.registry.getFactories(pool.admin),
- this.choiceContext(pool.admin),
- ]);
const result = await this.ledger.submit<{
settlement: V2SettlementInfo;
allocationSpec: V2AllocationSpecification;
@@ -664,12 +702,6 @@ export class PoolService {
allocationSpec: result.allocationSpec,
settlement: result.settlement,
quoteBinding: result.quoteBinding,
- factoryCid: factories.allocationFactoryCid,
- allocationFactoryExtraArgs: ctx.extraArgs,
- allocationFactoryDisclosure: mergeDisclosures(
- factories.disclosure,
- ctx.disclosure,
- ),
};
}
@@ -689,22 +721,42 @@ export class PoolService {
return { pool, liquidityRulesCid: this.requirePoolLiquidityRules(pool) };
}
- private async loadLiquidityFactories(pool: Pool) {
- const [depositFactories, lpFactories] = await Promise.all([
- this.registry.getFactories(pool.admin),
- this.registry.getFactories(pool.lpRegistrar),
- ]);
- return { depositFactories, lpFactories };
- }
-
- private async loadLiquiditySurface(pool: Pool) {
- const [{ depositFactories, lpFactories }, depositContext, lpContext] =
- await Promise.all([
- this.loadLiquidityFactories(pool),
- this.choiceContext(pool.admin),
- this.choiceContext(pool.lpRegistrar),
- ]);
- return { depositFactories, lpFactories, depositContext, lpContext };
+ private async createRegistryAllocation(
+ admin: Party,
+ choiceArguments: ChoiceArguments,
+ commandId: string,
+ actAs: Party[],
+ ): Promise<{
+ allocationCid: ContractId<"Allocation">;
+ factoryCid: ContractId<"AllocationFactory">;
+ }> {
+ const discovered = await this.registry.getAllocationFactory(
+ admin,
+ choiceArguments,
+ );
+ const context = asChoiceContext(discovered);
+ const result = await retryOnContention(() =>
+ this.ledger.submit({
+ actAs,
+ commandId,
+ disclosure: context.disclosure,
+ command: {
+ kind: "exerciseInterface",
+ interfaceId:
+ "Splice.Api.Token.AllocationInstructionV2:AllocationFactory",
+ contractId: discovered.factoryCid,
+ choice: "AllocationFactory_Allocate",
+ argument: {
+ ...choiceArguments,
+ extraArgs: context.extraArgs,
+ },
+ },
+ }),
+ );
+ return {
+ allocationCid: completedAllocationCid(result, commandId),
+ factoryCid: discovered.factoryCid,
+ };
}
/** Read back a newly-created liquidity request. */
@@ -848,8 +900,6 @@ export class PoolService {
}),
);
const req = await this.fetchRequest(requestCid);
- const { depositFactories, lpFactories, depositContext, lpContext } =
- await this.loadLiquiditySurface(pool);
return {
...match,
requestCid,
@@ -858,18 +908,6 @@ export class PoolService {
quoteAmount: input.quoteAmount,
allocations: req.allocations,
settlement: req.settlement,
- depositFactoryCid: depositFactories.allocationFactoryCid,
- lpFactoryCid: lpFactories.allocationFactoryCid,
- depositFactoryExtraArgs: depositContext.extraArgs,
- lpFactoryExtraArgs: lpContext.extraArgs,
- depositFactoryDisclosure: mergeDisclosures(
- depositFactories.disclosure,
- depositContext.disclosure,
- ),
- lpFactoryDisclosure: mergeDisclosures(
- lpFactories.disclosure,
- lpContext.disclosure,
- ),
};
}
@@ -877,8 +915,6 @@ export class PoolService {
async settleAddLiquidity(input: PoolSettleAddLiquidityInput): Promise {
const { pool, liquidityRulesCid } = await this.fetchLiquidityPool(input.poolCid);
const lpPolicyCid = await this.fetchLpAssetPolicy(pool);
- const { depositFactories, lpFactories, depositContext, lpContext } =
- await this.loadLiquiditySurface(pool);
// Resolve the three created allocation cids + the binding. On the
// operator-discovery path (updateId-only wallet, e.g. PartyLayer) the
@@ -896,23 +932,92 @@ export class PoolService {
"settleAddLiquidity: supply the 3 allocation cids or an updateId to recover them",
);
}
+ const flowKey = requestCid ?? acceptanceCid ?? input.updateId;
+ if (!flowKey) {
+ throw new Error("settleAddLiquidity: request, acceptance, or update id is required");
+ }
+ const preparation = {
+ expectedPoolId: pool.poolId,
+ poolCid: input.poolCid,
+ poolStateCid: pool.poolStateCid,
+ lpPolicyCid,
+ requestCid: requestCid ?? null,
+ acceptanceCid: acceptanceCid ?? null,
+ recipient: input.recipient,
+ lpBaseDepositCid,
+ lpQuoteDepositCid,
+ lpReceiptCid,
+ baseAmount: input.baseAmount,
+ quoteAmount: input.quoteAmount,
+ minLpTokens: input.minLpTokens,
+ knownTotalLpSupply: input.knownTotalLpSupply,
+ };
+ const allocationPlan = await retryOnContention(() =>
+ this.ledger.submit({
+ actAs: [this.operatorParty, pool.lpRegistrar],
+ commandId: `lp-add-preview-allocations:${flowKey}`,
+ command: {
+ kind: "exercise",
+ templateId: "CantonDex.Dex.PoolLiquidityRules:PoolLiquidityRules",
+ contractId: liquidityRulesCid,
+ choice: "PoolLiquidityRules_PreviewAddAllocations",
+ argument: { preparation, requestedAt: input.requestedAt },
+ },
+ }),
+ );
+ const [operatorBaseReceiver, operatorQuoteReceiver, registrarMint] =
+ await Promise.all([
+ this.createRegistryAllocation(
+ pool.admin,
+ allocationPlan.baseReceiver,
+ `lp-add-base-receiver:${flowKey}`,
+ [this.operatorParty],
+ ),
+ this.createRegistryAllocation(
+ pool.admin,
+ allocationPlan.quoteReceiver,
+ `lp-add-quote-receiver:${flowKey}`,
+ [this.operatorParty],
+ ),
+ this.createRegistryAllocation(
+ pool.lpRegistrar,
+ allocationPlan.lpMintSender,
+ `lp-add-mint-sender:${flowKey}`,
+ [pool.lpRegistrar],
+ ),
+ ]);
+ const settlementPlan = await retryOnContention(() =>
+ this.ledger.submit({
+ actAs: [this.operatorParty, pool.lpRegistrar],
+ commandId: `lp-add-preview-settlement:${flowKey}`,
+ command: {
+ kind: "exercise",
+ templateId: "CantonDex.Dex.PoolLiquidityRules:PoolLiquidityRules",
+ contractId: liquidityRulesCid,
+ choice: "PoolLiquidityRules_PreviewAddSettlement",
+ argument: {
+ preparation,
+ operatorBaseReceiverCid: operatorBaseReceiver.allocationCid,
+ operatorQuoteReceiverCid: operatorQuoteReceiver.allocationCid,
+ registrarMintCid: registrarMint.allocationCid,
+ },
+ },
+ }),
+ );
+ const [poolSettlementFactory, lpSettlementFactory] = await Promise.all([
+ this.registry.getSettlementFactory(pool.admin, settlementPlan.baseQuoteBatch),
+ this.registry.getSettlementFactory(pool.lpRegistrar, settlementPlan.lpBatch),
+ ]);
+ const poolSettlementContext = asChoiceContext(poolSettlementFactory);
+ const lpSettlementContext = asChoiceContext(lpSettlementFactory);
- // Split-admin DvP: the base/quote batch settles under pool.admin and the
- // LP-mint batch under pool.lpRegistrar, so each carries its own registry
- // choice context. For the self-registry both contexts are empty.
- //
- // The LP's deposit holdings are `signatory admin, owner`, so the operator
- // cannot see them. Registry discovery supplies the transaction-wide
- // disclosures needed to validate the nested choices.
return retryOnContention(() =>
this.ledger.submit({
actAs: [this.operatorParty, pool.lpRegistrar],
- commandId: `lp-add-settle:${requestCid ?? acceptanceCid ?? input.updateId}`,
+ commandId: `lp-add-settle:${flowKey}`,
disclosure: mergeDisclosures(
- depositFactories.disclosure,
- lpFactories.disclosure,
- depositContext.disclosure,
- lpContext.disclosure,
+ poolSettlementContext.disclosure,
+ lpSettlementContext.disclosure,
),
command: {
kind: "exercise",
@@ -920,28 +1025,18 @@ export class PoolService {
contractId: liquidityRulesCid,
choice: "PoolLiquidityRules_SettleAddLiquidity",
argument: {
- expectedPoolId: pool.poolId,
- poolCid: input.poolCid,
- poolStateCid: pool.poolStateCid,
- lpPolicyCid,
- requestCid: requestCid ?? null,
- acceptanceCid: acceptanceCid ?? null,
- recipient: input.recipient,
- lpBaseDepositCid,
- lpQuoteDepositCid,
- lpReceiptCid,
- baseFactoryCid: depositFactories.allocationFactoryCid,
- quoteFactoryCid: depositFactories.allocationFactoryCid,
- lpFactoryCid: lpFactories.allocationFactoryCid,
- baseQuoteSettleCid: depositFactories.settlementFactoryCid,
- lpSettleCid: lpFactories.settlementFactoryCid,
- baseAmount: input.baseAmount,
- quoteAmount: input.quoteAmount,
- minLpTokens: input.minLpTokens,
- knownTotalLpSupply: input.knownTotalLpSupply,
+ ...preparation,
+ baseFactoryCid: operatorBaseReceiver.factoryCid,
+ quoteFactoryCid: operatorQuoteReceiver.factoryCid,
+ lpFactoryCid: registrarMint.factoryCid,
+ baseQuoteSettleCid: poolSettlementFactory.factoryCid,
+ lpSettleCid: lpSettlementFactory.factoryCid,
requestedAt: input.requestedAt,
- poolAdminExtraArgs: depositContext.extraArgs,
- lpRegistrarExtraArgs: lpContext.extraArgs,
+ poolAdminExtraArgs: poolSettlementContext.extraArgs,
+ lpRegistrarExtraArgs: lpSettlementContext.extraArgs,
+ operatorBaseReceiverCid: operatorBaseReceiver.allocationCid,
+ operatorQuoteReceiverCid: operatorQuoteReceiver.allocationCid,
+ registrarMintCid: registrarMint.allocationCid,
},
},
}),
@@ -1012,8 +1107,6 @@ export class PoolService {
}),
);
const req = await this.fetchRequest(requestCid);
- const { depositFactories, lpFactories, depositContext, lpContext } =
- await this.loadLiquiditySurface(pool);
return {
requestCid,
knownTotalLpSupply: pool.totalLpSupply,
@@ -1023,18 +1116,6 @@ export class PoolService {
quoteOuts: plan.quote.outs,
allocations: req.allocations,
settlement: req.settlement,
- depositFactoryCid: depositFactories.allocationFactoryCid,
- lpFactoryCid: lpFactories.allocationFactoryCid,
- depositFactoryExtraArgs: depositContext.extraArgs,
- lpFactoryExtraArgs: lpContext.extraArgs,
- depositFactoryDisclosure: mergeDisclosures(
- depositFactories.disclosure,
- depositContext.disclosure,
- ),
- lpFactoryDisclosure: mergeDisclosures(
- lpFactories.disclosure,
- lpContext.disclosure,
- ),
};
}
@@ -1044,8 +1125,6 @@ export class PoolService {
// Re-derive from current state; drift since /request aborts at settle.
const plan = this.deriveRemovePlan(pool, input.lpTokensToRedeem, input.knownTotalLpSupply);
const lpPolicyCid = await this.fetchLpAssetPolicy(pool);
- const { depositFactories, lpFactories, depositContext, lpContext } =
- await this.loadLiquiditySurface(pool);
// Operator-discovery path (updateId-only wallet): recover the 3 created
// allocation cids [base receipt, quote receipt, burn-sender] + acceptance.
@@ -1061,23 +1140,77 @@ export class PoolService {
"settleRemoveLiquidity: supply the 3 allocation cids or an updateId to recover them",
);
}
+ const flowKey = requestCid ?? acceptanceCid ?? input.updateId;
+ if (!flowKey) {
+ throw new Error("settleRemoveLiquidity: request, acceptance, or update id is required");
+ }
+ const preparation = {
+ expectedPoolId: pool.poolId,
+ poolCid: input.poolCid,
+ poolStateCid: pool.poolStateCid,
+ lpPolicyCid,
+ requestCid: requestCid ?? null,
+ acceptanceCid: acceptanceCid ?? null,
+ holder: input.holder,
+ lpTokensToRedeem: input.lpTokensToRedeem,
+ knownTotalLpSupply: input.knownTotalLpSupply,
+ minBaseOut: input.minBaseOut,
+ minQuoteOut: input.minQuoteOut,
+ baseSliceCids: plan.base.sliceCids,
+ quoteSliceCids: plan.quote.sliceCids,
+ holderBaseReceiptCid,
+ holderQuoteReceiptCid,
+ holderBurnSenderCid,
+ };
+ const allocationPlan = await retryOnContention(() =>
+ this.ledger.submit({
+ actAs: [this.operatorParty, pool.lpRegistrar],
+ commandId: `lp-remove-preview-allocations:${flowKey}`,
+ command: {
+ kind: "exercise",
+ templateId: "CantonDex.Dex.PoolLiquidityRules:PoolLiquidityRules",
+ contractId: liquidityRulesCid,
+ choice: "PoolLiquidityRules_PreviewRemoveAllocations",
+ argument: { preparation, requestedAt: input.requestedAt },
+ },
+ }),
+ );
+ const registrarBurnReceiver = await this.createRegistryAllocation(
+ pool.lpRegistrar,
+ allocationPlan.lpBurnReceiver,
+ `lp-remove-burn-receiver:${flowKey}`,
+ [pool.lpRegistrar],
+ );
+ const settlementPlan = await retryOnContention(() =>
+ this.ledger.submit({
+ actAs: [this.operatorParty, pool.lpRegistrar],
+ commandId: `lp-remove-preview-settlement:${flowKey}`,
+ command: {
+ kind: "exercise",
+ templateId: "CantonDex.Dex.PoolLiquidityRules:PoolLiquidityRules",
+ contractId: liquidityRulesCid,
+ choice: "PoolLiquidityRules_PreviewRemoveSettlement",
+ argument: {
+ preparation,
+ registrarBurnReceiverCid: registrarBurnReceiver.allocationCid,
+ },
+ },
+ }),
+ );
+ const [poolSettlementFactory, lpSettlementFactory] = await Promise.all([
+ this.registry.getSettlementFactory(pool.admin, settlementPlan.baseQuoteBatch),
+ this.registry.getSettlementFactory(pool.lpRegistrar, settlementPlan.lpBatch),
+ ]);
+ const poolSettlementContext = asChoiceContext(poolSettlementFactory);
+ const lpSettlementContext = asChoiceContext(lpSettlementFactory);
- // Split-admin DvP: base/quote batch under pool.admin, LP-burn batch under
- // pool.lpRegistrar — each carries its own registry choice context.
- // For the self-registry both contexts are empty.
- //
- // The holder's allocations can reference contracts the operator cannot
- // see. Registry discovery supplies the transaction-wide disclosures needed
- // to validate the nested choices.
return retryOnContention(() =>
this.ledger.submit({
actAs: [this.operatorParty, pool.lpRegistrar],
- commandId: `lp-remove-settle:${requestCid ?? acceptanceCid ?? input.updateId}`,
+ commandId: `lp-remove-settle:${flowKey}`,
disclosure: mergeDisclosures(
- depositFactories.disclosure,
- lpFactories.disclosure,
- depositContext.disclosure,
- lpContext.disclosure,
+ poolSettlementContext.disclosure,
+ lpSettlementContext.disclosure,
),
command: {
kind: "exercise",
@@ -1085,30 +1218,18 @@ export class PoolService {
contractId: liquidityRulesCid,
choice: "PoolLiquidityRules_SettleRemoveLiquidity",
argument: {
- expectedPoolId: pool.poolId,
- poolCid: input.poolCid,
- poolStateCid: pool.poolStateCid,
- lpPolicyCid,
- requestCid: requestCid ?? null,
- acceptanceCid: acceptanceCid ?? null,
- holder: input.holder,
- lpTokensToRedeem: input.lpTokensToRedeem,
- knownTotalLpSupply: input.knownTotalLpSupply,
- minBaseOut: input.minBaseOut,
- minQuoteOut: input.minQuoteOut,
- baseSliceCids: plan.base.sliceCids,
- quoteSliceCids: plan.quote.sliceCids,
- holderBaseReceiptCid,
- holderQuoteReceiptCid,
- holderBurnSenderCid,
- baseFactoryCid: depositFactories.allocationFactoryCid,
- quoteFactoryCid: depositFactories.allocationFactoryCid,
- lpFactoryCid: lpFactories.allocationFactoryCid,
- baseQuoteSettleCid: depositFactories.settlementFactoryCid,
- lpSettleCid: lpFactories.settlementFactoryCid,
+ ...preparation,
+ // These two fields are retained for the deployed choice shape.
+ // Remove-liquidity does not create base/quote allocations here.
+ baseFactoryCid: registrarBurnReceiver.factoryCid,
+ quoteFactoryCid: registrarBurnReceiver.factoryCid,
+ lpFactoryCid: registrarBurnReceiver.factoryCid,
+ baseQuoteSettleCid: poolSettlementFactory.factoryCid,
+ lpSettleCid: lpSettlementFactory.factoryCid,
requestedAt: input.requestedAt,
- poolAdminExtraArgs: depositContext.extraArgs,
- lpRegistrarExtraArgs: lpContext.extraArgs,
+ poolAdminExtraArgs: poolSettlementContext.extraArgs,
+ lpRegistrarExtraArgs: lpSettlementContext.extraArgs,
+ registrarBurnReceiverCid: registrarBurnReceiver.allocationCid,
},
},
}),
diff --git a/services/operator-backend/src/rfq/index.ts b/services/operator-backend/src/rfq/index.ts
index 7b65d56e..f6607fbc 100644
--- a/services/operator-backend/src/rfq/index.ts
+++ b/services/operator-backend/src/rfq/index.ts
@@ -124,10 +124,11 @@ export class RfqService {
}
/**
- * Create an RFQ on the trader's behalf. The Rfq template is signatory
- * trader, so this submission carries the trader's authority — in
- * production the trader's wallet does this, but the operator backend
- * accepts the call here so the dApp can drive the live demo path.
+ * Trusted-custodial exception: create an RFQ with the trader's authority.
+ * The public HTTP route is disabled unless DEX_HOSTED_RFQ_RELAY and caller
+ * binding are configured, and the participant user must already have actAs
+ * rights for that trader. A self-custody deployment hands this command to the
+ * trader's wallet instead.
*/
async create(input: RfqCreateInput): Promise<{ rfqCid: ContractId<"Rfq"> }> {
const rfqCid = await retryOnContention(() =>
@@ -186,7 +187,7 @@ export class RfqService {
throw new RfqAuthError("caller may only accept its own RFQ");
}
const quotes = await this.fetchQuotes(input.consideredQuoteCids);
- const ranked = rankQuotes(rfq.side, quotes, input.now);
+ const ranked = rankQuotes(quotes, input.now);
const accepted = quotes.find(
(q) => q.contractId === input.acceptedQuoteCid,
);
@@ -208,7 +209,6 @@ export class RfqService {
// Rfq_Accept choice computes its own copy from the same inputs.
const receipt = buildReceipt({
rfqId: rfq.rfqId,
- side: rfq.side,
quotes,
acceptedDealer: accepted.dealer,
signedBy: this.operatorParty,
diff --git a/services/operator-backend/src/testnet-server.ts b/services/operator-backend/src/testnet-server.ts
index 9d2a4ca9..c032924c 100644
--- a/services/operator-backend/src/testnet-server.ts
+++ b/services/operator-backend/src/testnet-server.ts
@@ -1,6 +1,6 @@
-// Testnet server. Same HTTP shim as dev-server.ts, but pointed at a
-// real Canton participant via JsonApiLedger. Used for the smoke-test
-// path against the deployed DEX on a remote testnet.
+// Remote-participant runtime entrypoint. It uses the same HTTP API surface as
+// dev-server.ts, but JsonApiLedger submits to a real Canton participant and a
+// DEX package that the operator has deployed to a controlled testnet.
//
// Required env vars:
// CANTON_LEDGER_URL Base URL of the JSON Ledger API.
@@ -8,14 +8,31 @@
// CANTON_OPERATOR Operator party (DEX market venue).
// CANTON_LP_REGISTRAR LP registrar party.
// CANTON_ADMIN Asset admin party.
+// CANTON_DEX_PACKAGE_ID Hash (or `#canton-dex-trading`) for template ids.
+//
+// Defaulted / optional:
// CANTON_USER_ID JSON Ledger API user id (default: ledger-api-user).
// CANTON_NETWORK Display label, e.g. canton:devnet.
// CANTON_SYNCHRONIZER Synchronizer id, e.g. global-domain::1220...
-// CANTON_DEX_PACKAGE_ID Hash (or `#canton-dex-trading`) for template ids.
//
-// Optional:
-// CANTON_ALLOC_FACTORY_CID AllocationFactory contract id.
-// CANTON_SETTLE_FACTORY_CID SettlementFactory contract id.
+// Required in full/write mode (optional only with DEX_READ_ONLY=1):
+// DEX_OPERATOR_API_TOKEN Bearer token for non-admin state-changing routes.
+// OPERATOR_ADMIN_TOKEN Bearer token for /v1/admin/* writes.
+// CANTON_ALLOC_FACTORY_CID Asset-admin AllocationFactory contract id.
+// CANTON_SETTLE_FACTORY_CID Asset-admin SettlementFactory contract id.
+// CANTON_LP_ALLOC_FACTORY_CID LP-registry AllocationFactory contract id
+// when lpRegistrar != admin.
+// CANTON_LP_SETTLE_FACTORY_CID LP-registry SettlementFactory contract id
+// when lpRegistrar != admin.
+// DEX_READ_ONLY=1 Start without API write tokens; state-changing
+// routes fail closed, while reads/read-only quotes
+// remain usable.
+//
+// Optional trusted relay (disabled by default):
+// DEX_HOSTED_RFQ_RELAY=1 Allow the HTTP RFQ create/cancel/accept routes to
+// submit with trader authority. Requires
+// DEX_CALLER_JWT_SECRET and participant rights for
+// every hosted trader. This is not self-custody.
//
// Why this lives next to dev-server.ts and not in place of it: the
// in-memory dev server is the fast local path for UI development. The
@@ -29,8 +46,12 @@ import { openDb } from "./indexer/db.js";
import { Indexer } from "./indexer/index.js";
import { IdempotentLedger } from "./indexer/idempotency.js";
import { DealersService } from "./dealers/index.js";
-import { RegistryClient } from "@canton-dex/registry-client";
-import type { ChoiceContextRef, ContractId } from "@canton-dex/registry-client";
+import { FixedRegistryClient, RegistryError } from "@canton-dex/registry-client";
+import type {
+ ContractId,
+ FactoryRefs,
+ Party,
+} from "@canton-dex/registry-client";
import { rootLogger } from "./lib/logger.js";
const log = rootLogger.child({ component: "testnet-server" });
@@ -44,24 +65,24 @@ function required(name: string): string {
return v;
}
-// Lightweight registry client: returns the configured factory CIDs for
-// every admin. Production deployments use a real registry index.
-class FixedRegistry extends RegistryClient {
- constructor(
- private readonly allocCid: ContractId<"AllocationFactory">,
- private readonly settleCid: ContractId<"SettlementFactory">,
- ) {
- super({ baseUrl: "http://fixed-registry" });
- }
- override async getFactories() {
- return {
- allocationFactoryCid: this.allocCid,
- settlementFactoryCid: this.settleCid,
- disclosure: [] as never[],
- };
- }
- override async getChoiceContext(): Promise {
- return { context: { values: {} }, disclosure: [] };
+// Lightweight registry client for the two reference registrars. It is
+// intentionally explicit per admin: returning one registry CID for every
+// party breaks LP issuance as soon as asset governance and LP custody are
+// separated. Deployments that list arbitrary third-party assets should replace
+// this map with the registry HTTP discovery client.
+class ConfiguredRegistry extends FixedRegistryClient {
+ constructor(factoriesByAdmin: ReadonlyMap) {
+ super((admin) => {
+ const factories = factoriesByAdmin.get(admin);
+ if (!factories) {
+ throw new RegistryError(
+ "factory-stale",
+ `no configured factory mapping for admin=${admin}`,
+ false,
+ );
+ }
+ return factories;
+ });
}
}
@@ -71,18 +92,68 @@ async function main(): Promise {
const operator = required("CANTON_OPERATOR");
const lpRegistrar = required("CANTON_LP_REGISTRAR");
const admin = required("CANTON_ADMIN");
+ const dexPackageId = required("CANTON_DEX_PACKAGE_ID");
const userId = process.env.CANTON_USER_ID ?? "ledger-api-user";
const network = process.env.CANTON_NETWORK ?? "canton:devnet";
- const allocCid = (process.env.CANTON_ALLOC_FACTORY_CID ??
- "PENDING_ALLOC_FACTORY") as ContractId<"AllocationFactory">;
- const settleCid = (process.env.CANTON_SETTLE_FACTORY_CID ??
- "PENDING_SETTLE_FACTORY") as ContractId<"SettlementFactory">;
+ const readOnly = process.env.DEX_READ_ONLY === "1";
+ const hostedRfqEnabled = process.env.DEX_HOSTED_RFQ_RELAY === "1";
+ const callerJwtSecret = process.env.DEX_CALLER_JWT_SECRET || undefined;
+ if (readOnly && hostedRfqEnabled) {
+ log.error("invalid mode: DEX_HOSTED_RFQ_RELAY cannot be enabled with DEX_READ_ONLY");
+ process.exit(1);
+ }
+ if (hostedRfqEnabled && !callerJwtSecret) {
+ required("DEX_CALLER_JWT_SECRET");
+ }
+ // Fail at startup instead of presenting a deceptively healthy but unusable
+ // full-mode server. Read-only operation must be chosen explicitly.
+ const operatorToken = readOnly
+ ? undefined
+ : required("DEX_OPERATOR_API_TOKEN");
+ const adminToken = readOnly
+ ? undefined
+ : required("OPERATOR_ADMIN_TOKEN");
+ const allocCid = (readOnly
+ ? process.env.CANTON_ALLOC_FACTORY_CID || "PENDING_ALLOC_FACTORY"
+ : required("CANTON_ALLOC_FACTORY_CID")) as ContractId<"AllocationFactory">;
+ const settleCid = (readOnly
+ ? process.env.CANTON_SETTLE_FACTORY_CID || "PENDING_SETTLE_FACTORY"
+ : required("CANTON_SETTLE_FACTORY_CID")) as ContractId<"SettlementFactory">;
+ const lpAllocCid = (lpRegistrar === admin
+ ? allocCid
+ : readOnly
+ ? process.env.CANTON_LP_ALLOC_FACTORY_CID || "PENDING_LP_ALLOC_FACTORY"
+ : required("CANTON_LP_ALLOC_FACTORY_CID")) as ContractId<"AllocationFactory">;
+ const lpSettleCid = (lpRegistrar === admin
+ ? settleCid
+ : readOnly
+ ? process.env.CANTON_LP_SETTLE_FACTORY_CID || "PENDING_LP_SETTLE_FACTORY"
+ : required("CANTON_LP_SETTLE_FACTORY_CID")) as ContractId<"SettlementFactory">;
+
+ const factoriesByAdmin = new Map([
+ [
+ admin,
+ {
+ allocationFactoryCid: allocCid,
+ settlementFactoryCid: settleCid,
+ disclosure: [],
+ },
+ ],
+ [
+ lpRegistrar,
+ {
+ allocationFactoryCid: lpAllocCid,
+ settlementFactoryCid: lpSettleCid,
+ disclosure: [],
+ },
+ ],
+ ]);
const rawLedger = new JsonApiLedger({
baseUrl,
token,
applicationId: userId,
- templateIdPrefix: process.env.CANTON_DEX_PACKAGE_ID,
+ templateIdPrefix: dexPackageId,
synchronizerId: process.env.CANTON_SYNCHRONIZER,
});
@@ -98,7 +169,7 @@ async function main(): Promise {
const backend = new OperatorBackend({
ledger,
- registry: new FixedRegistry(allocCid, settleCid),
+ registry: new ConfiguredRegistry(factoriesByAdmin),
operatorParty: operator,
});
@@ -141,27 +212,26 @@ async function main(): Promise {
operator,
lpRegistrar,
admin,
- allocationFactoryCid: allocCid,
- settlementFactoryCid: settleCid,
- allocationFactoryExtraArgs: { context: { values: {} }, meta: { values: {} } },
- allocationFactoryDisclosure: [],
network,
},
db,
- adminToken: process.env.OPERATOR_ADMIN_TOKEN,
+ adminToken,
// Operator token gates all non-admin writes; fail-closed on testnet
// (no DEX_DEV_OPEN bypass here).
- operatorToken: process.env.DEX_OPERATOR_API_TOKEN,
- devOpen: process.env.DEX_DEV_OPEN === "1",
- // Wallet relay OFF unless explicitly enabled, with a party allowlist.
- walletRelayEnabled: process.env.DEX_DEV_WALLET_RELAY === "1",
- walletRelayParties: (process.env.DEX_DEV_RELAY_PARTIES ?? "")
- .split(",")
- .map((s) => s.trim())
- .filter(Boolean),
- // Per-caller party binding: when set, trader-subject write routes
- // require an X-Caller-Token JWT whose `sub` is the caller's party.
- callerJwtSecret: process.env.DEX_CALLER_JWT_SECRET || undefined,
+ operatorToken,
+ // The in-memory dev server is the only entrypoint allowed to honor
+ // DEX_DEV_OPEN. A stray deployment environment variable must never bypass
+ // the testnet/production write gate (including explicit read-only mode).
+ devOpen: false,
+ // The arbitrary-command wallet relay is confined to dev-server.ts. A
+ // deployment must use a real wallet/BFF boundary; testnet-server never
+ // honors DEX_DEV_WALLET_RELAY even if it leaks into the environment.
+ walletRelayEnabled: false,
+ walletRelayParties: [],
+ hostedRfqEnabled,
+ // Per-caller party binding: when set, party-scoped reads and trader-subject
+ // writes require an X-Caller-Token JWT whose `sub` is the caller's party.
+ callerJwtSecret,
// Optional `aud` claim the caller JWT must carry (defence against a token
// minted for another service being replayed here).
callerJwtAudience: process.env.DEX_CALLER_JWT_AUDIENCE || undefined,
@@ -177,6 +247,9 @@ async function main(): Promise {
network,
db: dbPath,
indexerIntervalMs: Number(process.env.INDEXER_INTERVAL_MS ?? 5000),
+ mode: readOnly ? "read-only" : "full",
+ registryAdmins: Array.from(factoriesByAdmin.keys()),
+ hostedRfqEnabled,
});
// Graceful shutdown: drain HTTP requests, stop indexer, flush DB.
diff --git a/services/operator-backend/test/auth.test.ts b/services/operator-backend/test/auth.test.ts
index fac8a90a..b9ffb638 100644
--- a/services/operator-backend/test/auth.test.ts
+++ b/services/operator-backend/test/auth.test.ts
@@ -17,24 +17,7 @@ import {
isOperatorWrite,
checkOperatorAuth,
} from "../src/http/auth.js";
-import { RegistryClient } from "@canton-dex/registry-client";
-import type { ChoiceContextRef, ContractId } from "@canton-dex/registry-client";
-
-class StubRegistry extends RegistryClient {
- constructor() {
- super({ baseUrl: "http://stub" });
- }
- override async getFactories() {
- return {
- allocationFactoryCid: "#alloc:0" as ContractId<"AllocationFactory">,
- settlementFactoryCid: "#settle:0" as ContractId<"SettlementFactory">,
- disclosure: [] as never[],
- };
- }
- override async getChoiceContext(): Promise {
- return { context: { values: {} }, disclosure: [] };
- }
-}
+import { StubRegistry } from "./stub-registry.js";
function startServer(
extra: Partial,
@@ -54,10 +37,6 @@ function startServer(
operator: "op" as never,
lpRegistrar: "lp" as never,
admin: "ad" as never,
- allocationFactoryCid: "#alloc:0",
- settlementFactoryCid: "#settle:0",
- allocationFactoryExtraArgs: { context: { values: {} }, meta: { values: {} } },
- allocationFactoryDisclosure: [],
network: "canton:test",
},
...extra,
@@ -257,4 +236,50 @@ describe("wallet relay + CORS", () => {
await close();
}
});
+
+ it("CORS preflight permits the per-caller JWT header", async () => {
+ const { url, close } = await startServer({ devOpen: true });
+ try {
+ const res = await fetch(`${url}/v1/pools/swap`, {
+ method: "OPTIONS",
+ headers: {
+ Origin: "http://localhost:5173",
+ "Access-Control-Request-Method": "POST",
+ "Access-Control-Request-Headers": "X-Caller-Token",
+ },
+ });
+ await res.text();
+ assert.equal(res.status, 204);
+ assert.match(
+ res.headers.get("access-control-allow-headers") ?? "",
+ /X-Caller-Token/i,
+ );
+ } finally {
+ await close();
+ }
+ });
+});
+
+describe("hosted RFQ relay", () => {
+ it("returns 404 when a deployment disables trader-authority relay", async () => {
+ const { url, close } = await startServer({
+ devOpen: true,
+ hostedRfqEnabled: false,
+ });
+ try {
+ const status = await post(url, "/v1/rfq", {
+ trader: "trader",
+ rfqId: "rfq-disabled",
+ pair: "BTC/USDC",
+ side: "RFQ_Buy",
+ size: "1.0",
+ expiresAt: "2030-01-01T00:00:00Z",
+ whitelist: [],
+ createdAt: "2026-01-01T00:00:00Z",
+ });
+ assert.equal(status, 404);
+ } finally {
+ await close();
+ }
+ });
});
diff --git a/services/operator-backend/test/caller-auth.test.ts b/services/operator-backend/test/caller-auth.test.ts
index 72e66887..55759df1 100644
--- a/services/operator-backend/test/caller-auth.test.ts
+++ b/services/operator-backend/test/caller-auth.test.ts
@@ -9,6 +9,7 @@ import type { IncomingMessage } from "node:http";
import {
checkCallerBinding,
+ checkCallerRead,
routeBindsCaller,
verifyHs256,
} from "../src/http/caller-auth.js";
@@ -182,3 +183,28 @@ describe("checkCallerBinding", () => {
assert.equal(r.ok, true);
});
});
+
+describe("checkCallerRead", () => {
+ const cfg = { callerJwtSecret: SECRET };
+
+ it("is disabled when no caller secret is configured", () => {
+ assert.equal(
+ checkCallerRead(reqWith(), { callerJwtSecret: undefined }, BOB).ok,
+ true,
+ );
+ });
+
+ it("requires a valid caller token when enabled", () => {
+ const result = checkCallerRead(reqWith(), cfg, ALICE);
+ assert.equal(result.ok, false);
+ assert.equal((result as { status: number }).status, 401);
+ });
+
+ it("allows only the caller's own party", () => {
+ const req = reqWith(signHs256({ sub: ALICE }));
+ assert.equal(checkCallerRead(req, cfg, ALICE).ok, true);
+ const denied = checkCallerRead(req, cfg, BOB);
+ assert.equal(denied.ok, false);
+ assert.equal((denied as { status: number }).status, 403);
+ });
+});
diff --git a/services/operator-backend/test/canton-e2e.test.ts b/services/operator-backend/test/canton-e2e.test.ts
deleted file mode 100644
index 1053f1c2..00000000
--- a/services/operator-backend/test/canton-e2e.test.ts
+++ /dev/null
@@ -1,242 +0,0 @@
-// Canton-backed end-to-end test for the operator backend.
-//
-// This test drives the SAME flow code as the InMemoryLedger test
-// (`rfq.test.ts`) but against a real Canton participant via the
-// JSON Ledger API. It is gated on the `CANTON_E2E` env var so it
-// doesn't run in CI by default; running it requires a Canton
-// sandbox with the canton-dex DARs uploaded and the operator party
-// allocated.
-//
-// How to run:
-//
-// 1. Boot a sandbox with the DEX DARs:
-// $ cd trading && daml build
-// $ cd .. && daml sandbox \
-// --port 6865 \
-// --json-api-port 7575 \
-// --dar trading/.daml/dist/canton-dex-trading-0.0.1.dar
-//
-// OR use `daml start` from a project that depends on the DAR.
-//
-// 2. Allocate parties + get a JWT:
-// $ daml ledger allocate-parties operator alice orca jump galaxy
-// $ daml-helper request-token --party operator > /tmp/operator.jwt
-//
-// 3. Run the test:
-// $ CANTON_E2E=1 \
-// CANTON_JSON_API_URL=http://localhost:7575 \
-// CANTON_JSON_API_TOKEN=$(cat /tmp/operator.jwt) \
-// CANTON_OPERATOR_PARTY=operator \
-// npm test
-//
-// What it verifies:
-// - The JsonApiLedger driver successfully submits an Rfq + RfqQuote
-// creates and an Rfq_Accept exercise.
-// - The receipt the operator backend computes off-chain matches the
-// receipt the on-chain Rfq_Accept choice produces.
-// - The MatchedTrade carries the policy receipt in
-// SettlementInfo.meta exactly as PolicyReceipt.daml encodes it.
-
-import assert from "node:assert/strict";
-import { test, before } from "node:test";
-
-import {
- JsonApiLedger,
- OperatorBackend,
- POLICY_VERSION,
- verifyReceipt,
-} from "../src/index.ts";
-import type { ContractId, Party, Rfq, RfqQuote } from "../src/types.ts";
-import { RegistryClient } from "@canton-dex/registry-client";
-
-const e2eEnabled = process.env.CANTON_E2E === "1";
-
-// Skip the entire suite when not enabled. node:test supports per-test
-// `skip` but we want a single skip message at suite level.
-if (!e2eEnabled) {
- test("Canton E2E (skipped: set CANTON_E2E=1 to enable)", { skip: true }, () => {});
-}
-
-if (e2eEnabled) {
- const baseUrl = required("CANTON_JSON_API_URL");
- const token = required("CANTON_JSON_API_TOKEN");
- const operator = required("CANTON_OPERATOR_PARTY") as Party;
- const trader = required("CANTON_TRADER_PARTY") as Party;
- const dealerJump = required("CANTON_DEALER_JUMP") as Party;
- const dealerOrca = required("CANTON_DEALER_ORCA") as Party;
-
- const ledger = new JsonApiLedger({
- baseUrl,
- token,
- applicationId: "canton-dex-e2e",
- });
-
- // The integration test only needs the registry client for the
- // factories endpoint. For the RFQ flow we don't actually settle the
- // resulting MatchedTrade so the factories aren't read; a stub is
- // sufficient.
- // Inline-defined stub (avoid forward reference to a class declared
- // later in the file).
- const registry = new (class extends RegistryClient {
- constructor() {
- super({ baseUrl });
- }
- override async getFactories(): Promise<{
- allocationFactoryCid: ContractId<"AllocationFactory">;
- settlementFactoryCid: ContractId<"SettlementFactory">;
- disclosure: never[];
- }> {
- return {
- allocationFactoryCid:
- "stub-not-used-in-rfq" as ContractId<"AllocationFactory">,
- settlementFactoryCid:
- "stub-not-used-in-rfq" as ContractId<"SettlementFactory">,
- disclosure: [],
- };
- }
- override async getChoiceContext() {
- return { context: { values: {} }, disclosure: [] };
- }
- })();
-
- const backend = new OperatorBackend({
- ledger,
- registry,
- operatorParty: operator,
- });
-
- test("Canton E2E: RFQ accept produces MatchedTrade with PolicyReceipt", async () => {
- const now = new Date().toISOString();
- const expiresIn1h = new Date(Date.now() + 60 * 60 * 1000).toISOString();
- const expiresIn30s = new Date(Date.now() + 30 * 1000).toISOString();
- const rfqId = `rfq-e2e-${Date.now()}`;
-
- // 1. Trader creates the Rfq.
- const rfqCid = (await ledger.submit>({
- actAs: [trader],
- commandId: `seed-rfq-${rfqId}`,
- command: {
- kind: "create",
- templateId: "CantonDex.Dex.Rfq:Rfq",
- argument: {
- trader,
- operator,
- rfqId,
- pair: "BTC/USDC",
- side: "RFQ_Buy",
- size: "5.0",
- expiresAt: expiresIn1h,
- whitelist: [dealerOrca, dealerJump],
- createdAt: now,
- },
- },
- })) as ContractId<"Rfq">;
-
- // 2. Two dealers post quotes.
- const quoteJump = await ledger.submit>({
- actAs: [dealerJump],
- commandId: `quote-jump-${rfqId}`,
- command: {
- kind: "create",
- templateId: "CantonDex.Dex.Rfq:RfqQuote",
- argument: {
- dealer: dealerJump,
- trader,
- operator,
- rfqId,
- price: "60510.00",
- expiresAt: expiresIn30s,
- postedAt: now,
- tier: "TierTrusted",
- },
- },
- });
- const quoteOrca = await ledger.submit>({
- actAs: [dealerOrca],
- commandId: `quote-orca-${rfqId}`,
- command: {
- kind: "create",
- templateId: "CantonDex.Dex.Rfq:RfqQuote",
- argument: {
- dealer: dealerOrca,
- trader,
- operator,
- rfqId,
- price: "60530.00",
- expiresAt: expiresIn30s,
- postedAt: now,
- tier: "TierTrusted",
- },
- },
- });
-
- // 3. Operator backend drives Rfq_Accept (joint trader+operator).
- const result = await backend.rfq.accept({
- rfqCid,
- acceptedQuoteCid: quoteJump,
- consideredQuoteCids: [quoteJump, quoteOrca],
- admin: required("CANTON_BTC_ADMIN") as Party,
- now,
- });
-
- assert.equal(
- result.receipt.acceptedDealer,
- dealerJump,
- "Jump should be accepted as the policy-ranked quote",
- );
- assert.equal(result.receipt.acceptedRank, 1);
- assert.equal(result.receipt.consideredCount, 2);
- assert.equal(result.receipt.policyVersion, POLICY_VERSION);
- assert.equal(verifyReceipt(result.receipt), true, "receipt verifies");
- // The cid format from JSON API is implementation-defined; just
- // sanity-check it exists.
- assert.ok(typeof result.tradeCid === "string");
- assert.ok((result.tradeCid as string).length > 0);
- });
-
- test("Canton E2E: rfq.list returns visible RFQs and quotes", async () => {
- const list = await backend.rfq.list();
- assert.ok(Array.isArray(list.rfqs));
- assert.ok(Array.isArray(list.quotes));
- });
-
- test("Canton E2E: rfq.cancel archives an open Rfq", async () => {
- const now = new Date().toISOString();
- const expiresIn1h = new Date(Date.now() + 60 * 60 * 1000).toISOString();
- const rfqId = `rfq-cancel-${Date.now()}`;
-
- const rfqCid = (await ledger.submit>({
- actAs: [trader],
- commandId: `seed-cancel-${rfqId}`,
- command: {
- kind: "create",
- templateId: "CantonDex.Dex.Rfq:Rfq",
- argument: {
- trader,
- operator,
- rfqId,
- pair: "BTC/USDC",
- side: "RFQ_Buy",
- size: "1.0",
- expiresAt: expiresIn1h,
- whitelist: [dealerOrca],
- createdAt: now,
- },
- },
- })) as ContractId<"Rfq">;
-
- await backend.rfq.cancel({ rfqCid });
-
- const after = await backend.rfq.list();
- const stillThere = after.rfqs.find(
- (r: Rfq) => r.contractId === rfqCid,
- );
- assert.equal(stillThere, undefined, "cancelled Rfq should be archived");
- });
-}
-
-function required(name: string): string {
- const v = process.env[name];
- if (!v) throw new Error(`required env: ${name}`);
- return v;
-}
diff --git a/services/operator-backend/test/decimal-money.test.ts b/services/operator-backend/test/decimal-money.test.ts
index 91dfcb79..01ff8e2f 100644
--- a/services/operator-backend/test/decimal-money.test.ts
+++ b/services/operator-backend/test/decimal-money.test.ts
@@ -1,13 +1,11 @@
-// On-ledger amounts must go through the BigInt decimal module, not
-// IEEE-754. Pins (1) the matching-engine quote-leg amount = price*quantity at
-// 10dp round-half-even, and (2) rankQuotes price ordering via exact decimal
-// comparison.
+// On-ledger amounts must go through the BigInt decimal module, not IEEE-754.
+// The RFQ cases also pin the exact non-price policy ordering used on-ledger.
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import * as dec from "../src/pool/decimal.js";
-import { rankQuotes, compareDecimal } from "../src/policy/index.js";
+import { rankQuotes } from "../src/policy/index.js";
import type { RfqQuote } from "../src/types.js";
describe("quote-leg amount via decimal module", () => {
@@ -71,16 +69,6 @@ describe("floored decimal ops mirror the pool's payout rounding", () => {
});
});
-describe("compareDecimal is exact", () => {
- it("orders by decimal value, not float", () => {
- assert.equal(compareDecimal("60510.00", "60530.00"), -1);
- assert.equal(compareDecimal("60530.00", "60510.00"), 1);
- assert.equal(compareDecimal("1.0", "1.0000000000"), 0);
- // A pair where float subtraction could lose precision but decimal must not.
- assert.equal(compareDecimal("0.1000000001", "0.1000000002"), -1);
- });
-});
-
function mkQuote(o: {
dealer: string;
price?: string;
@@ -117,7 +105,7 @@ describe("rankQuotes reproduces the on-ledger policyCmp (v2.0)", () => {
mkQuote({ dealer: "mid", expiresAt: "2026-01-01T05:00:00Z" }),
];
assert.deepEqual(
- rankQuotes("RFQ_Buy", quotes, now).map((q) => q.dealer),
+ rankQuotes(quotes, now).map((q) => q.dealer),
["latest", "mid", "soon"],
);
});
@@ -129,13 +117,11 @@ describe("rankQuotes reproduces the on-ledger policyCmp (v2.0)", () => {
];
// Same expiry and postedAt, so the dealer tie-break decides -- price does
// not enter the comparison at all, and the side does not change it.
- for (const side of ["RFQ_Buy", "RFQ_Sell"] as const) {
- assert.deepEqual(
- rankQuotes(side, quotes, now).map((q) => q.dealer),
- ["cheap", "dear"],
- `${side}: ordered by dealer tie-break, not price`,
- );
- }
+ assert.deepEqual(
+ rankQuotes(quotes, now).map((q) => q.dealer),
+ ["cheap", "dear"],
+ "ordered by dealer tie-break, not price",
+ );
});
it("trusted tier ranks ahead of whitelist regardless of expiry", () => {
@@ -151,7 +137,7 @@ describe("rankQuotes reproduces the on-ledger policyCmp (v2.0)", () => {
expiresAt: "2026-01-01T02:00:00Z",
}),
];
- assert.equal(rankQuotes("RFQ_Buy", quotes, now)[0]?.dealer, "sooner-trusted");
+ assert.equal(rankQuotes(quotes, now)[0]?.dealer, "sooner-trusted");
});
it("breaks an expiry tie by earlier postedAt, then by dealer", () => {
@@ -161,7 +147,7 @@ describe("rankQuotes reproduces the on-ledger policyCmp (v2.0)", () => {
mkQuote({ dealer: "c-late", postedAt: "2026-01-01T00:00:05Z" }),
];
assert.deepEqual(
- rankQuotes("RFQ_Buy", quotes, now).map((q) => q.dealer),
+ rankQuotes(quotes, now).map((q) => q.dealer),
["a-early", "b-late", "c-late"],
);
});
@@ -172,7 +158,7 @@ describe("rankQuotes reproduces the on-ledger policyCmp (v2.0)", () => {
mkQuote({ dealer: "lapsed", expiresAt: "2025-12-31T23:00:00Z" }),
];
assert.deepEqual(
- rankQuotes("RFQ_Buy", quotes, now).map((q) => q.dealer),
+ rankQuotes(quotes, now).map((q) => q.dealer),
["live"],
);
});
diff --git a/services/operator-backend/test/deployment-wiring.test.ts b/services/operator-backend/test/deployment-wiring.test.ts
new file mode 100644
index 00000000..d72d94bd
--- /dev/null
+++ b/services/operator-backend/test/deployment-wiring.test.ts
@@ -0,0 +1,42 @@
+// Static deployment guards for defects that can survive TypeScript and unit
+// tests: wrong working-directory defaults, missing template qualification,
+// public container ports, skipped native install scripts, and root runtimes.
+
+import { describe, it } from "node:test";
+import assert from "node:assert/strict";
+import { readFileSync } from "node:fs";
+import { join } from "node:path";
+
+const ROOT = join(import.meta.dirname, "..", "..", "..");
+const read = (path: string) => readFileSync(join(ROOT, path), "utf8");
+
+describe("deployment wiring", () => {
+ it("requires a DEX package prefix before registry bootstrap", () => {
+ const deploy = read("scripts/deploy-testnet.sh");
+ const bootstrap = read("scripts/bootstrap-registry.ts");
+ assert.match(deploy, /CANTON_DEX_PACKAGE_ID; do/);
+ assert.match(bootstrap, /required\("CANTON_DEX_PACKAGE_ID"\)/);
+ assert.match(bootstrap, /templateIdPrefix:\s*dexPackageId/);
+ });
+
+ it("anchors the default bootstrap config beside the script", () => {
+ const bootstrap = read("scripts/bootstrap-registry.ts");
+ assert.match(bootstrap, /fileURLToPath\(import\.meta\.url\)/);
+ assert.match(bootstrap, /resolve\(scriptDir,\s*"bootstrap-registry\.json"\)/);
+ });
+
+ it("keeps the Compose backend private behind nginx", () => {
+ const compose = read("docker-compose.yml");
+ const backend = compose.split(/^ frontend:/m)[0] ?? compose;
+ assert.match(backend, /^ expose:/m);
+ assert.doesNotMatch(backend, /^ ports:/m);
+ assert.match(backend, /CANTON_LP_ALLOC_FACTORY_CID/);
+ assert.match(backend, /CANTON_LP_SETTLE_FACTORY_CID/);
+ });
+
+ it("installs the native SQLite binding and runs the backend as non-root", () => {
+ const dockerfile = read("Dockerfile.backend");
+ assert.match(dockerfile, /WORKDIR \/app\/services\/operator-backend\s+RUN npm ci\s/m);
+ assert.match(dockerfile, /^USER node$/m);
+ });
+});
diff --git a/services/operator-backend/test/docs-harness.ts b/services/operator-backend/test/docs-harness.ts
index 1297ce12..afd6ca17 100644
--- a/services/operator-backend/test/docs-harness.ts
+++ b/services/operator-backend/test/docs-harness.ts
@@ -34,9 +34,12 @@ function walk(dir: string): string[] {
return out;
}
-/** Every markdown file the guards read: docs/** plus the top-level README. */
+/** Canonical docs plus every top-level project Markdown file. */
export function docFiles(): string[] {
- return [...walk(join(ROOT, "docs")), join(ROOT, "README.md")];
+ const topLevel = readdirSync(ROOT, { withFileTypes: true })
+ .filter((entry) => entry.isFile() && entry.name.endsWith(".md"))
+ .map((entry) => join(ROOT, entry.name));
+ return [...walk(join(ROOT, "docs")), ...topLevel];
}
/** Drop bold/italic markers: `does **not** define` must match `does not define`. */
diff --git a/services/operator-backend/test/docs-hosted-scope.test.ts b/services/operator-backend/test/docs-hosted-scope.test.ts
new file mode 100644
index 00000000..edd9569c
--- /dev/null
+++ b/services/operator-backend/test/docs-hosted-scope.test.ts
@@ -0,0 +1,77 @@
+// Keep historical deployment feedback separate from the API this repository
+// actually implements. A past external report referenced a public hostname,
+// faucet, and /v1/testnet wrapper that are not present in this tree.
+
+import { describe, it } from "node:test";
+import assert from "node:assert/strict";
+import { readFileSync } from "node:fs";
+import { join, relative } from "node:path";
+
+import { ROOT, docFiles } from "./docs-harness.ts";
+
+describe("hosted deployment scope", () => {
+ it("does not advertise the retired external hostname", () => {
+ const hits = docFiles().filter((file) =>
+ /testnet-dex\.bitdynamics\.cc/i.test(readFileSync(file, "utf8")),
+ );
+ assert.deepEqual(
+ hits.map((file) => relative(ROOT, file)),
+ [],
+ "The old hosted endpoint is not provisioned by this repository. " +
+ "Keep historical reports as provenance, not current setup instructions.",
+ );
+ });
+
+ it("does not present this repository as a current public deployment", () => {
+ const security = readFileSync(join(ROOT, "SECURITY.md"), "utf8");
+ assert.doesNotMatch(
+ security,
+ /package version on the public testnet\s+is the deployed surface/i,
+ "SECURITY.md must describe source support without inventing a hosted service.",
+ );
+ assert.match(
+ security,
+ /does not provision or promise a public testnet deployment/i,
+ );
+ });
+
+ it("has no hidden /v1/testnet route implementation", () => {
+ const server = readFileSync(
+ join(ROOT, "services/operator-backend/src/http/index.ts"),
+ "utf8",
+ );
+ assert.doesNotMatch(
+ server,
+ /["'`]\/v1\/testnet(?:\/|["'`])/,
+ "A /v1/testnet route was added. Document and secure it explicitly, or " +
+ "keep deployment wrappers outside the reference API.",
+ );
+ });
+
+ it("states the current repository boundary in the canonical docs", () => {
+ const api = readFileSync(join(ROOT, "docs/reference/http-api.md"), "utf8");
+ const nonGoals = readFileSync(join(ROOT, "docs/concepts/non-goals.md"), "utf8");
+ const feedback = readFileSync(
+ join(ROOT, "docs/reference/ecosystem-feedback.md"),
+ "utf8",
+ );
+
+ assert.match(api, /has no `\/v1\/testnet\/\*` namespace, party faucet/i);
+ assert.match(nonGoals, /does not create parties, mint faucet assets/i);
+ assert.match(feedback, /does \*\*not\*\* provision a public hostname/i);
+ });
+
+ it("keeps npm package metadata on this reference repository", () => {
+ const expected =
+ "https://github.com/srikanth-bitdynamics/Canton-Dex-Reference-Implementation.git";
+ for (const packagePath of [
+ "app/web/package.json",
+ "services/operator-backend/package.json",
+ ]) {
+ const manifest = JSON.parse(readFileSync(join(ROOT, packagePath), "utf8")) as {
+ repository?: { url?: string };
+ };
+ assert.equal(manifest.repository?.url, expected, packagePath);
+ }
+ });
+});
diff --git a/services/operator-backend/test/docs-references.test.ts b/services/operator-backend/test/docs-references.test.ts
index fb8f5021..7dbc66d5 100644
--- a/services/operator-backend/test/docs-references.test.ts
+++ b/services/operator-backend/test/docs-references.test.ts
@@ -1,7 +1,7 @@
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import { existsSync, readFileSync, readdirSync } from "node:fs";
-import { dirname, join, relative } from "node:path";
+import { basename, dirname, join, relative } from "node:path";
import { ROOT, docFiles, sentences } from "./docs-harness.ts";
@@ -101,6 +101,101 @@ describe("documentation references", () => {
assert.deepEqual(missing, []);
});
+ it("every line-linked Daml test points at its declaration", () => {
+ const stale: string[] = [];
+ for (const file of docFiles()) {
+ const source = readFileSync(file, "utf8");
+ for (const match of source.matchAll(
+ /\[`?(test[A-Z]\w*)`?\]\(([^)#]+\.daml)#L(\d+)\)/g,
+ )) {
+ const [, testName, rawPath, rawLine] = match;
+ const target = join(dirname(file), decodeURIComponent(rawPath!));
+ if (!existsSync(target)) continue;
+ const lineNumber = Number(rawLine);
+ const lines = readFileSync(target, "utf8").split("\n");
+ const line = lines[lineNumber - 1] ?? "";
+ const nextLine = lines[lineNumber] ?? "";
+ const declaration = new RegExp(`^${testName}\\s*:\\s*Script\\b`);
+ const pointsToDeclaration = declaration.test(line);
+ const pointsToInvariant = /^-- \| Proves\b/.test(line) && declaration.test(nextLine);
+ if (!pointsToDeclaration && !pointsToInvariant) {
+ stale.push(
+ `${relative(ROOT, file)} -> ${rawPath}#L${lineNumber} ` +
+ `(expected ${testName} declaration, found ${JSON.stringify(line.trim())})`,
+ );
+ }
+ }
+ }
+ assert.deepEqual(stale, []);
+ });
+
+ it("every line-linked Daml source symbol points at its declaration", () => {
+ const stale: string[] = [];
+ for (const file of docFiles()) {
+ const source = readFileSync(file, "utf8");
+ for (const match of source.matchAll(
+ /\[`([^`]+)`\]\(([^)#]+\.daml)#L(\d+)\)/g,
+ )) {
+ const [, symbol, rawPath, rawLine] = match;
+ const target = join(dirname(file), decodeURIComponent(rawPath!));
+ if (!existsSync(target) || !relative(ROOT, target).startsWith("trading/")) continue;
+ const lineNumber = Number(rawLine);
+ const line = readFileSync(target, "utf8").split("\n")[lineNumber - 1] ?? "";
+ const escaped = symbol!.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
+ const declaration = new RegExp(
+ `(?:template\\s+${escaped}\\b|(?:nonconsuming\\s+)?choice\\s+${escaped}\\b|` +
+ `${escaped}\\s*:\\s|interface instance\\s+(?:\\w+\\.)?${escaped}\\b)`,
+ );
+ if (!declaration.test(line)) {
+ stale.push(
+ `${relative(ROOT, file)} -> ${rawPath}#L${lineNumber} ` +
+ `(expected ${symbol} declaration, found ${JSON.stringify(line.trim())})`,
+ );
+ }
+ }
+ }
+ assert.deepEqual(stale, []);
+ });
+
+ it("the testing matrix accounts for every Daml Script declaration", () => {
+ const testFiles = filesBelow(
+ join(ROOT, "trading-tests", "CantonDex", "Tests"),
+ ".daml",
+ );
+ const actualByFile = new Map();
+ for (const file of testFiles) {
+ const declarations = [
+ ...readFileSync(file, "utf8").matchAll(/^\s*test[A-Z]\w*\s*:\s*Script\b/gm),
+ ].length;
+ if (declarations > 0) {
+ actualByFile.set(basename(file), declarations);
+ }
+ }
+
+ const matrix = readFileSync(join(ROOT, "docs/reference/testing.md"), "utf8");
+ const documentedByFile = new Map();
+ for (const match of matrix.matchAll(
+ /\[`([^`/]+Tests\.daml)`\]\([^)]*\)\s*\|\s*(\d+)\s*\|/g,
+ )) {
+ documentedByFile.set(match[1]!, Number(match[2]));
+ }
+
+ assert.deepEqual(
+ Object.fromEntries([...documentedByFile].sort()),
+ Object.fromEntries([...actualByFile].sort()),
+ );
+
+ const total = [...actualByFile.values()].reduce((sum, count) => sum + count, 0);
+ for (const relativePath of ["README.md", "docs/getting-started.md"]) {
+ const source = readFileSync(join(ROOT, relativePath), "utf8");
+ assert.match(
+ source,
+ new RegExp(`\\b${total}\\s+(?:Daml Script )?test`),
+ `${relativePath} does not advertise the actual ${total}-script total`,
+ );
+ }
+ });
+
it("every documented Daml choice identifier is still declared", () => {
const damlFiles = [
...filesBelow(join(ROOT, "trading"), ".daml"),
diff --git a/services/operator-backend/test/indexer-order-book-fill.test.ts b/services/operator-backend/test/indexer-order-book-fill.test.ts
index baf51990..3e9ac1b4 100644
--- a/services/operator-backend/test/indexer-order-book-fill.test.ts
+++ b/services/operator-backend/test/indexer-order-book-fill.test.ts
@@ -17,29 +17,12 @@ import { Indexer } from "../src/indexer/index.js";
import { OrderService } from "../src/order/index.js";
import { MatchingLedger } from "./matching-ledger.js";
import type { Order } from "../src/types.js";
-import { RegistryClient } from "@canton-dex/registry-client";
-import type { ChoiceContextRef, ContractId } from "@canton-dex/registry-client";
+import { StubRegistry } from "./stub-registry.js";
const OPERATOR = "operator::1220ab";
const BUYER = "alice::1220ab";
const SELLER = "bob::1220ab";
-class StubRegistry extends RegistryClient {
- constructor() {
- super({ baseUrl: "http://stub" });
- }
- override async getFactories() {
- return {
- allocationFactoryCid: "#alloc-fac:0" as ContractId<"AllocationFactory">,
- settlementFactoryCid: "#settle-fac:0" as ContractId<"SettlementFactory">,
- disclosure: [] as never[],
- };
- }
- override async getChoiceContext(): Promise {
- return { context: { values: {} }, disclosure: [] };
- }
-}
-
function mkOrder(
contractId: string,
trader: string,
diff --git a/services/operator-backend/test/indexer-projection-exactness.test.ts b/services/operator-backend/test/indexer-projection-exactness.test.ts
index 1ed5afb1..ca95c54a 100644
--- a/services/operator-backend/test/indexer-projection-exactness.test.ts
+++ b/services/operator-backend/test/indexer-projection-exactness.test.ts
@@ -15,24 +15,7 @@ import { openDb, type Db } from "../src/indexer/db.js";
import { InMemoryLedger } from "../src/ledger/in-memory.js";
import { OperatorBackend } from "../src/index.js";
import { startHttpServer } from "../src/http/index.js";
-import { RegistryClient } from "@canton-dex/registry-client";
-import type { ChoiceContextRef, ContractId } from "@canton-dex/registry-client";
-
-class StubRegistry extends RegistryClient {
- constructor() {
- super({ baseUrl: "http://stub" });
- }
- override async getFactories() {
- return {
- allocationFactoryCid: "#alloc:0" as ContractId<"AllocationFactory">,
- settlementFactoryCid: "#settle:0" as ContractId<"SettlementFactory">,
- disclosure: [] as never[],
- };
- }
- override async getChoiceContext(): Promise {
- return { context: { values: {} }, disclosure: [] };
- }
-}
+import { StubRegistry } from "./stub-registry.js";
// Ten-decimal values whose trailing zeros a float would drop, and one whose
// last digit float subtraction would move.
@@ -68,10 +51,6 @@ before(async () => {
operator: "op" as never,
lpRegistrar: "lp" as never,
admin: "ad" as never,
- allocationFactoryCid: "#alloc:0",
- settlementFactoryCid: "#settle:0",
- allocationFactoryExtraArgs: { context: { values: {} }, meta: { values: {} } },
- allocationFactoryDisclosure: [],
network: "canton:test",
},
devOpen: true,
diff --git a/services/operator-backend/test/instruments-route.test.ts b/services/operator-backend/test/instruments-route.test.ts
index af29e546..5dab6149 100644
--- a/services/operator-backend/test/instruments-route.test.ts
+++ b/services/operator-backend/test/instruments-route.test.ts
@@ -8,24 +8,7 @@ import { InMemoryLedger } from "../src/ledger/in-memory.js";
import type { SubscriptionFilter } from "../src/ledger/index.js";
import { OperatorBackend } from "../src/index.js";
import { startHttpServer } from "../src/http/index.js";
-import { RegistryClient } from "@canton-dex/registry-client";
-import type { ChoiceContextRef, ContractId } from "@canton-dex/registry-client";
-
-class StubRegistry extends RegistryClient {
- constructor() {
- super({ baseUrl: "http://stub" });
- }
- override async getFactories() {
- return {
- allocationFactoryCid: "#alloc:0" as ContractId<"AllocationFactory">,
- settlementFactoryCid: "#settle:0" as ContractId<"SettlementFactory">,
- disclosure: [] as never[],
- };
- }
- override async getChoiceContext(): Promise {
- return { context: { values: {} }, disclosure: [] };
- }
-}
+import { StubRegistry } from "./stub-registry.js";
// Returns what a participant returns: Int64 as a string, Optional Text as null.
class ConfigLedger extends InMemoryLedger {
@@ -56,10 +39,6 @@ before(async () => {
operator: "op" as never,
lpRegistrar: "lp" as never,
admin: "ad" as never,
- allocationFactoryCid: "#alloc:0",
- settlementFactoryCid: "#settle:0",
- allocationFactoryExtraArgs: { context: { values: {} }, meta: { values: {} } },
- allocationFactoryDisclosure: [],
network: "canton:test",
},
devOpen: true,
diff --git a/services/operator-backend/test/live/canton-live-rfq.test.ts b/services/operator-backend/test/live/canton-live-rfq.test.ts
new file mode 100644
index 00000000..935360db
--- /dev/null
+++ b/services/operator-backend/test/live/canton-live-rfq.test.ts
@@ -0,0 +1,315 @@
+// Canton-backed RFQ service integration test.
+//
+// This drives the same RfqService code as `rfq.test.ts`, but through
+// JsonApiLedger against an already-running Canton participant. It does not
+// start the HTTP server, dApp, or a wallet, and it does not fund or settle the
+// MatchedTrade. CANTON_LIVE_RFQ gates all live submissions.
+//
+// Prerequisites:
+// - the current canton-dex trading DAR and dependencies are uploaded;
+// - the five configured parties exist;
+// - the JWT has actAs rights for operator, trader, and both dealers.
+// CANTON_BTC_ADMIN is data on the resulting trade, not an authorizer here.
+//
+// Run from services/operator-backend:
+// $ CANTON_LIVE_RFQ=1 \
+// CANTON_JSON_API_URL=... CANTON_JSON_API_TOKEN=... \
+// CANTON_OPERATOR_PARTY=... CANTON_TRADER_PARTY=... \
+// CANTON_DEALER_JUMP=... CANTON_DEALER_ORCA=... \
+// CANTON_BTC_ADMIN=... npm run test:live:rfq
+//
+// What it verifies:
+// - real Rfq/RfqQuote creates and Rfq_Accept/cancel exercises;
+// - exact CIDs returned by RfqService.list;
+// - the choice result's receipt verifies and equals the PolicyReceipt stored
+// on the queried MatchedTrade.
+//
+// STATE WARNING: the accept case leaves one MatchedTrade. Use a throwaway
+// LocalNet or dedicated test parties. The RFQ id printed by node:test identifies
+// the run if manual cleanup is needed.
+
+import assert from "node:assert/strict";
+import { test } from "node:test";
+
+import {
+ JsonApiLedger,
+ OperatorBackend,
+ POLICY_VERSION,
+ verifyReceipt,
+} from "../../src/index.ts";
+import type {
+ ContractId,
+ Party,
+ PolicyReceipt,
+ Rfq,
+ RfqQuote,
+} from "../../src/types.ts";
+import { FixedRegistryClient } from "@canton-dex/registry-client";
+
+const liveEnabled = process.env.CANTON_LIVE_RFQ === "1";
+
+// Skip the entire suite when not enabled. node:test supports per-test
+// `skip` but we want a single skip message at suite level.
+if (!liveEnabled) {
+ test(
+ "Canton live RFQ (skipped: set CANTON_LIVE_RFQ=1 to enable)",
+ { skip: true },
+ () => {},
+ );
+}
+
+if (liveEnabled) {
+ const baseUrl = required("CANTON_JSON_API_URL");
+ const token = required("CANTON_JSON_API_TOKEN");
+ const operator = required("CANTON_OPERATOR_PARTY") as Party;
+ const trader = required("CANTON_TRADER_PARTY") as Party;
+ const dealerJump = required("CANTON_DEALER_JUMP") as Party;
+ const dealerOrca = required("CANTON_DEALER_ORCA") as Party;
+ const btcAdmin = required("CANTON_BTC_ADMIN") as Party;
+ const runId = `${Date.now()}-${process.pid}`;
+ console.info(`[canton-rfq-live] run id: ${runId}`);
+
+ interface MatchedTradeContract {
+ contractId: ContractId<"MatchedTrade">;
+ venue: Party;
+ admin: Party;
+ policyReceipt: PolicyReceipt | null;
+ }
+
+ const ledger = new JsonApiLedger({
+ baseUrl,
+ token,
+ applicationId: "canton-dex-live-rfq",
+ });
+
+ // The integration test only needs the registry client for the
+ // factories endpoint. For the RFQ flow we don't actually settle the
+ // resulting MatchedTrade so the factories aren't read; a stub is
+ // sufficient.
+ // Inline-defined stub (avoid forward reference to a class declared
+ // later in the file).
+ const registry = new FixedRegistryClient(() => ({
+ allocationFactoryCid:
+ "stub-not-used-in-rfq" as ContractId<"AllocationFactory">,
+ settlementFactoryCid:
+ "stub-not-used-in-rfq" as ContractId<"SettlementFactory">,
+ disclosure: [],
+ }));
+
+ const backend = new OperatorBackend({
+ ledger,
+ registry,
+ operatorParty: operator,
+ });
+
+ test("Canton live RFQ: accept produces MatchedTrade with PolicyReceipt", async () => {
+ const now = new Date().toISOString();
+ const expiresIn1h = new Date(Date.now() + 60 * 60 * 1000).toISOString();
+ const expiresIn15m = new Date(Date.now() + 15 * 60 * 1000).toISOString();
+ const rfqId = `rfq-live-${runId}`;
+
+ // 1. Trader creates the Rfq.
+ const rfqCid = (await ledger.submit>({
+ actAs: [trader],
+ commandId: `seed-rfq-${rfqId}`,
+ command: {
+ kind: "create",
+ templateId: "CantonDex.Dex.Rfq:Rfq",
+ argument: {
+ trader,
+ operator,
+ rfqId,
+ pair: "BTC/USDC",
+ side: "RFQ_Buy",
+ size: "5.0",
+ expiresAt: expiresIn1h,
+ whitelist: [dealerOrca, dealerJump],
+ createdAt: now,
+ },
+ },
+ })) as ContractId<"Rfq">;
+
+ // 2. Two dealers post quotes.
+ const quoteJump = await ledger.submit>({
+ actAs: [dealerJump],
+ commandId: `quote-jump-${rfqId}`,
+ command: {
+ kind: "create",
+ templateId: "CantonDex.Dex.Rfq:RfqQuote",
+ argument: {
+ dealer: dealerJump,
+ trader,
+ operator,
+ rfqId,
+ price: "60510.00",
+ expiresAt: expiresIn15m,
+ postedAt: now,
+ tier: "TierTrusted",
+ },
+ },
+ });
+ const quoteOrca = await ledger.submit>({
+ actAs: [dealerOrca],
+ commandId: `quote-orca-${rfqId}`,
+ command: {
+ kind: "create",
+ templateId: "CantonDex.Dex.Rfq:RfqQuote",
+ argument: {
+ dealer: dealerOrca,
+ trader,
+ operator,
+ rfqId,
+ price: "60530.00",
+ expiresAt: expiresIn15m,
+ postedAt: now,
+ tier: "TierTrusted",
+ },
+ },
+ });
+
+ // 3. Operator backend drives Rfq_Accept (joint trader+operator).
+ const result = await backend.rfq.accept({
+ rfqCid,
+ acceptedQuoteCid: quoteJump,
+ consideredQuoteCids: [quoteJump, quoteOrca],
+ admin: btcAdmin,
+ now,
+ });
+
+ assert.equal(
+ result.receipt.acceptedDealer,
+ dealerJump,
+ "Jump should be accepted as the policy-ranked quote",
+ );
+ assert.equal(result.receipt.acceptedRank, 1);
+ assert.equal(result.receipt.consideredCount, 2);
+ assert.equal(result.receipt.policyVersion, POLICY_VERSION);
+ assert.equal(verifyReceipt(result.receipt), true, "receipt verifies");
+ assert.ok(typeof result.tradeCid === "string");
+ assert.ok((result.tradeCid as string).length > 0);
+
+ const trades = await ledger.query({
+ templateId: "CantonDex.Dex.MatchedTrade:MatchedTrade",
+ observingParty: operator,
+ });
+ const trade = trades.find((candidate) => candidate.contractId === result.tradeCid);
+ assert.ok(trade, "Rfq_Accept result CID must identify a visible MatchedTrade");
+ assert.equal(trade.venue, operator);
+ assert.equal(trade.admin, btcAdmin);
+ assert.deepEqual(
+ trade.policyReceipt,
+ result.receipt,
+ "queried MatchedTrade must store the choice result's PolicyReceipt",
+ );
+ });
+
+ test("Canton live RFQ: list returns the exact visible RFQ and quote CIDs", async () => {
+ const now = new Date().toISOString();
+ const expiresAt = new Date(Date.now() + 60 * 60 * 1000).toISOString();
+ const rfqId = `rfq-list-${runId}`;
+ const { rfqCid } = await backend.rfq.create({
+ trader,
+ rfqId,
+ pair: "BTC/USDC",
+ side: "RFQ_Buy",
+ size: "2.0",
+ expiresAt,
+ whitelist: [dealerOrca],
+ createdAt: now,
+ });
+ const quoteCid = await ledger.submit>({
+ actAs: [dealerOrca],
+ commandId: `quote-list-${runId}`,
+ command: {
+ kind: "create",
+ templateId: "CantonDex.Dex.Rfq:RfqQuote",
+ argument: {
+ dealer: dealerOrca,
+ trader,
+ operator,
+ rfqId,
+ price: "60520.00",
+ expiresAt,
+ postedAt: now,
+ tier: "TierTrusted",
+ },
+ },
+ });
+
+ try {
+ const list = await backend.rfq.list();
+ assert.equal(
+ list.rfqs.find((rfq) => rfq.contractId === rfqCid)?.rfqId,
+ rfqId,
+ "list must include the RFQ created by this case",
+ );
+ assert.equal(
+ list.quotes.find((quote) => quote.contractId === quoteCid)?.rfqId,
+ rfqId,
+ "list must include the quote created by this case",
+ );
+ } finally {
+ await Promise.all([
+ backend.rfq.cancel({ rfqCid }),
+ ledger.submit({
+ actAs: [dealerOrca],
+ commandId: `withdraw-list-quote-${runId}`,
+ command: {
+ kind: "exercise",
+ templateId: "CantonDex.Dex.Rfq:RfqQuote",
+ contractId: quoteCid,
+ choice: "RfqQuote_Withdraw",
+ argument: {},
+ },
+ }),
+ ]);
+ }
+ });
+
+ test("Canton live RFQ: cancel archives an open Rfq", async () => {
+ const now = new Date().toISOString();
+ const expiresIn1h = new Date(Date.now() + 60 * 60 * 1000).toISOString();
+ const rfqId = `rfq-cancel-${runId}`;
+
+ const rfqCid = (await ledger.submit>({
+ actAs: [trader],
+ commandId: `seed-cancel-${rfqId}`,
+ command: {
+ kind: "create",
+ templateId: "CantonDex.Dex.Rfq:Rfq",
+ argument: {
+ trader,
+ operator,
+ rfqId,
+ pair: "BTC/USDC",
+ side: "RFQ_Buy",
+ size: "1.0",
+ expiresAt: expiresIn1h,
+ whitelist: [dealerOrca],
+ createdAt: now,
+ },
+ },
+ })) as ContractId<"Rfq">;
+
+ const beforeCancel = await backend.rfq.list();
+ assert.equal(
+ beforeCancel.rfqs.find((rfq) => rfq.contractId === rfqCid)?.rfqId,
+ rfqId,
+ "created RFQ must be visible before cancellation",
+ );
+
+ await backend.rfq.cancel({ rfqCid });
+
+ const after = await backend.rfq.list();
+ const stillThere = after.rfqs.find(
+ (r: Rfq) => r.contractId === rfqCid,
+ );
+ assert.equal(stillThere, undefined, "cancelled Rfq should be archived");
+ });
+}
+
+function required(name: string): string {
+ const v = process.env[name];
+ if (!v) throw new Error(`required env: ${name}`);
+ return v;
+}
diff --git a/services/operator-backend/test/match-leg-shape.test.ts b/services/operator-backend/test/match-leg-shape.test.ts
index c1c520e6..2cbd8725 100644
--- a/services/operator-backend/test/match-leg-shape.test.ts
+++ b/services/operator-backend/test/match-leg-shape.test.ts
@@ -12,21 +12,32 @@ import { join } from "node:path";
import { OrderService } from "../src/order/index.js";
import { InMemoryLedger } from "../src/ledger/in-memory.js";
-import { RegistryClient } from "@canton-dex/registry-client";
-import type { ChoiceContextRef, ContractId } from "@canton-dex/registry-client";
+import { FixedRegistryClient } from "@canton-dex/registry-client";
+import type {
+ ChoiceArguments,
+ ContractId,
+ FactoryChoiceContextRef,
+ Party,
+} from "@canton-dex/registry-client";
import type { Order } from "../src/types.js";
-class StubRegistry extends RegistryClient {
- constructor() { super({ baseUrl: "http://stub" }); }
- override async getFactories() {
- return {
+class StubRegistry extends FixedRegistryClient {
+ settlementArguments: ChoiceArguments | null = null;
+
+ constructor() {
+ super(() => ({
allocationFactoryCid: "#alloc:0" as ContractId<"AllocationFactory">,
settlementFactoryCid: "#settle:0" as ContractId<"SettlementFactory">,
disclosure: [] as never[],
- };
+ }));
}
- override async getChoiceContext(): Promise {
- return { context: { values: {} }, disclosure: [] };
+
+ override async getSettlementFactory(
+ admin: Party,
+ choiceArguments: ChoiceArguments,
+ ): Promise {
+ this.settlementArguments = choiceArguments;
+ return super.getSettlementFactory(admin, choiceArguments);
}
}
@@ -36,9 +47,22 @@ class CapturingLedger extends InMemoryLedger {
override async submit(req: any): Promise {
this.captured.push(req.command);
if (req.command.kind === "createAndExercise") {
+ if (req.command.choice === "OrderMatchExecution_PreviewSettlement") {
+ return {
+ settlement: {
+ executors: ["op"], id: "preview-match", cid: null, meta: { values: {} },
+ },
+ transferLegs: [],
+ allocations: [],
+ actors: ["op"],
+ extraArgs: { context: { values: {} }, meta: { values: {} } },
+ } as R;
+ }
return {
buyerNextAllocationCid: null,
sellerNextAllocationCid: null,
+ buyRemainderCid: null,
+ sellRemainderCid: null,
} as R;
}
return null as R;
@@ -62,14 +86,28 @@ class CapturingLedger extends InMemoryLedger {
describe("match execution argument", () => {
it("carries an Account-shaped pair the settle factory can settle", async () => {
const ledger = new CapturingLedger();
- const svc = new OrderService(ledger, new StubRegistry(), "op" as never);
+ const registry = new StubRegistry();
+ const svc = new OrderService(ledger, registry, "op" as never);
await svc.runMatching({
baseInstrumentId: "dBTC", quoteInstrumentId: "dUSD", admin: "ad" as never,
});
- const exec = ledger.captured.find((c) => c?.kind === "createAndExercise");
+ const previewIndex = ledger.captured.findIndex(
+ (c) => c?.choice === "OrderMatchExecution_PreviewSettlement",
+ );
+ const executeIndex = ledger.captured.findIndex(
+ (c) => c?.choice === "OrderMatchExecution_Execute",
+ );
+ const exec = ledger.captured[executeIndex];
assert.ok(exec, "no OrderMatchExecution was submitted");
+ assert.ok(previewIndex >= 0, "the exact settlement argument was not previewed");
+ assert.ok(previewIndex < executeIndex, "registry discovery must happen before execution");
assert.equal(exec.choice, "OrderMatchExecution_Execute");
assert.equal(exec.choiceArgument.factoryCid, "#settle:0");
+ assert.equal(
+ (registry.settlementArguments?.settlement as { id?: string })?.id,
+ "preview-match",
+ "the registry receives the exact Daml preview result",
+ );
const match = exec.argument.match;
for (const side of ["buyerAccount", "sellerAccount"] as const) {
diff --git a/services/operator-backend/test/matched-trade.test.ts b/services/operator-backend/test/matched-trade.test.ts
index 80c0d097..a86d8cac 100644
--- a/services/operator-backend/test/matched-trade.test.ts
+++ b/services/operator-backend/test/matched-trade.test.ts
@@ -3,10 +3,10 @@ import assert from "node:assert/strict";
import { RegistryClient } from "@canton-dex/registry-client";
import type {
- ChoiceContextRef,
+ ChoiceArguments,
ContractId,
DisclosedContract,
- FactoryRefs,
+ FactoryChoiceContextRef,
Party,
} from "@canton-dex/registry-client";
@@ -23,9 +23,43 @@ import type {
class CapturingLedger implements LedgerSubmitter {
lastSubmit: SubmitRequest | null = null;
+ readonly submissions: SubmitRequest[] = [];
async submit(req: SubmitRequest): Promise {
this.lastSubmit = req;
+ this.submissions.push(req);
+ const command = req.command as {
+ choice?: string;
+ argument?: {
+ plansByAdmin?: Array<[
+ Party,
+ {
+ transferLegs: V2TransferLeg[];
+ allocations: unknown[];
+ },
+ ]>;
+ };
+ };
+ if (command.choice === "MatchedTrade_PreviewSettlement") {
+ return (command.argument?.plansByAdmin ?? []).map(([admin, plan]) => [
+ admin,
+ {
+ settlement: {
+ executors: ["operator"],
+ id: `matched-trade:${admin}`,
+ cid: null,
+ meta: { values: {} },
+ },
+ transferLegs: plan.transferLegs,
+ allocations: plan.allocations,
+ actors: ["operator"],
+ extraArgs: {
+ context: { values: {} },
+ meta: { values: {} },
+ },
+ },
+ ]) as R;
+ }
return "#result:0" as R;
}
@@ -47,22 +81,39 @@ function disclosed(tag: string): DisclosedContract {
}
class ContextRegistry extends RegistryClient {
+ readonly settlementLookups: Array<{
+ admin: Party;
+ choiceArguments: ChoiceArguments;
+ }> = [];
+ readonly cancelLookups: Array<{ admin: Party; allocationId: string }> = [];
+
constructor() {
super({ baseUrl: "http://stub" });
}
- override async getFactories(admin: Party): Promise {
+ override async getSettlementFactory(
+ admin: Party,
+ choiceArguments: ChoiceArguments,
+ ): Promise {
+ this.settlementLookups.push({ admin, choiceArguments });
return {
- allocationFactoryCid: `#alloc:${admin}` as ContractId<"AllocationFactory">,
- settlementFactoryCid: `#settle:${admin}` as ContractId<"SettlementFactory">,
- disclosure: [disclosed(`factory-${admin}`)],
+ factoryCid: `#settle:${admin}` as ContractId<"TokenStandardFactory">,
+ context: { values: { [`ctx.${admin}`]: true } },
+ disclosure: [
+ disclosed(`factory-${admin}`),
+ disclosed(`context-${admin}`),
+ ],
};
}
- override async getChoiceContext(admin: Party): Promise {
+ override async getAllocationCancelContext(
+ admin: Party,
+ allocationId: string,
+ ) {
+ this.cancelLookups.push({ admin, allocationId });
return {
- context: { values: { [`ctx.${admin}`]: true } },
- disclosure: [disclosed(`context-${admin}`)],
+ context: { values: { [`ctx.${admin}.${allocationId}`]: true } },
+ disclosure: [disclosed(`cancel-${admin}-${allocationId}`)],
};
}
}
@@ -81,9 +132,10 @@ function leg(id: string, instrumentId: string): V2TransferLeg {
describe("MatchedTradeService", () => {
it("settle threads per-admin choice context and legs into each SettlementBatchV2", async () => {
const ledger = new CapturingLedger();
+ const registry = new ContextRegistry();
const svc = new MatchedTradeService(
ledger,
- new ContextRegistry(),
+ registry,
"operator" as Party,
);
@@ -177,6 +229,22 @@ describe("MatchedTradeService", () => {
assert.deepEqual(adminABatch!.extraArgs.context.values, { "ctx.adminA": true });
assert.deepEqual(adminBBatch!.extraArgs.context.values, { "ctx.adminB": true });
+
+ const preview = ledger.submissions.find(
+ (s) => (s.command as { choice?: string }).choice === "MatchedTrade_PreviewSettlement",
+ );
+ assert.ok(preview, "settlement runs the on-ledger preview first");
+ assert.deepEqual(
+ registry.settlementLookups.map(({ admin }) => admin),
+ ["adminA", "adminB"],
+ );
+ for (const { admin, choiceArguments } of registry.settlementLookups) {
+ assert.equal(
+ (choiceArguments.settlement as { id: string }).id,
+ `matched-trade:${admin}`,
+ "the exact preview result is sent to that admin's settlement endpoint",
+ );
+ }
const disclosureBlobs = submit.disclosure!.map((d) => d.createdEventBlob);
assert.deepEqual(new Set(disclosureBlobs), new Set([
"factory-adminA",
@@ -189,9 +257,10 @@ describe("MatchedTradeService", () => {
it("cancel threads the matching admin context for each allocation group", async () => {
const ledger = new CapturingLedger();
+ const registry = new ContextRegistry();
const svc = new MatchedTradeService(
ledger,
- new ContextRegistry(),
+ registry,
"operator" as Party,
);
@@ -216,13 +285,22 @@ describe("MatchedTradeService", () => {
};
assert.equal(cmd.choice, "MatchedTrade_Cancel");
assert.deepEqual(cmd.argument.allocationsToCancel, [
- ["#a:0", { context: { values: { "ctx.adminA": true } }, meta: { values: {} } }],
- ["#a:1", { context: { values: { "ctx.adminA": true } }, meta: { values: {} } }],
- ["#b:0", { context: { values: { "ctx.adminB": true } }, meta: { values: {} } }],
+ ["#a:0", { context: { values: { "ctx.adminA.#a:0": true } }, meta: { values: {} } }],
+ ["#a:1", { context: { values: { "ctx.adminA.#a:1": true } }, meta: { values: {} } }],
+ ["#b:0", { context: { values: { "ctx.adminB.#b:0": true } }, meta: { values: {} } }],
+ ]);
+ assert.deepEqual(registry.cancelLookups, [
+ { admin: "adminA", allocationId: "#a:0" },
+ { admin: "adminA", allocationId: "#a:1" },
+ { admin: "adminB", allocationId: "#b:0" },
]);
assert.deepEqual(
new Set(submit.disclosure?.map((d) => d.createdEventBlob)),
- new Set(["context-adminA", "context-adminB"]),
+ new Set([
+ "cancel-adminA-#a:0",
+ "cancel-adminA-#a:1",
+ "cancel-adminB-#b:0",
+ ]),
);
});
});
diff --git a/services/operator-backend/test/matching-ledger.ts b/services/operator-backend/test/matching-ledger.ts
index 6c63ec2f..2d61c85b 100644
--- a/services/operator-backend/test/matching-ledger.ts
+++ b/services/operator-backend/test/matching-ledger.ts
@@ -139,7 +139,8 @@ export class MatchingLedger implements LedgerSubmitter {
get executes(): CreateAndExerciseCommand[] {
return this.commands.filter(
- (c): c is CreateAndExerciseCommand => c.kind === "createAndExercise",
+ (c): c is CreateAndExerciseCommand =>
+ c.kind === "createAndExercise" && c.choice === "OrderMatchExecution_Execute",
);
}
@@ -158,6 +159,14 @@ export class MatchingLedger implements LedgerSubmitter {
if (cmd.kind !== "createAndExercise") {
throw new Error(`unexpected ${cmd.kind} submission`);
}
+ if (cmd.choice === "OrderMatchExecution_PreviewSettlement") {
+ const arg = cmd.argument as ExecuteArgument;
+ return {
+ previewFor: arg.matchId,
+ actors: [arg.operator],
+ extraArgs: { context: { values: {} }, meta: { values: {} } },
+ } as R;
+ }
return this.execute(cmd.argument as ExecuteArgument) as R;
}
diff --git a/services/operator-backend/test/order-fill-recording.test.ts b/services/operator-backend/test/order-fill-recording.test.ts
index e6bd5384..7ff25d63 100644
--- a/services/operator-backend/test/order-fill-recording.test.ts
+++ b/services/operator-backend/test/order-fill-recording.test.ts
@@ -5,24 +5,7 @@ import { OrderService } from "../src/order/index.js";
import type { LedgerSubmitter, SubmitRequest } from "../src/ledger/index.js";
import type { Order } from "../src/types.js";
import { MatchingLedger, type ExecuteArgument } from "./matching-ledger.js";
-import { RegistryClient } from "@canton-dex/registry-client";
-import type { ChoiceContextRef, ContractId } from "@canton-dex/registry-client";
-
-class StubRegistry extends RegistryClient {
- constructor() {
- super({ baseUrl: "http://stub" });
- }
- override async getFactories() {
- return {
- allocationFactoryCid: "#alloc-fac:0" as ContractId<"AllocationFactory">,
- settlementFactoryCid: "#settle-fac:0" as ContractId<"SettlementFactory">,
- disclosure: [] as never[],
- };
- }
- override async getChoiceContext(): Promise {
- return { context: { values: {} }, disclosure: [] };
- }
-}
+import { StubRegistry } from "./stub-registry.js";
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function mkOrder(o: Record): Order {
@@ -79,15 +62,20 @@ const bid = (o: Record): Order =>
});
describe("OrderService.runMatching settlement", () => {
- it("settles a match in exactly one submission", async () => {
+ it("settles a match in one value-moving submission after a read-only preview", async () => {
const ledger = new MatchingLedger([ask({}), bid({})]);
const results = await service(ledger).runMatching(RUN);
assert.equal(results.length, 1);
assert.equal(results[0]!.error, undefined);
- // Settlement, both order transitions, and the trade record are atomic.
- assert.equal(ledger.submissions.length, 1);
+ // Discovery gets an exact on-ledger preview first. Settlement, both order
+ // transitions, and the trade record then remain one atomic submission.
+ assert.equal(ledger.submissions.length, 2);
+ assert.equal(
+ (ledger.submissions[0]!.command as { choice?: string }).choice,
+ "OrderMatchExecution_PreviewSettlement",
+ );
assert.equal(
ledger.executes[0]!.templateId,
"CantonDex.Dex.OrderMatchExecution:OrderMatchExecution",
@@ -97,7 +85,7 @@ describe("OrderService.runMatching settlement", () => {
// without readAs the admin the operator cannot see them and the settle
// fails CONTRACT_NOT_FOUND on a real ledger.
assert.ok(
- (ledger.submissions[0]!.readAs ?? []).includes(RUN.admin),
+ (ledger.submissions[1]!.readAs ?? []).includes(RUN.admin),
"the settle must readAs the instrument admin",
);
assert.equal(
@@ -109,11 +97,12 @@ describe("OrderService.runMatching settlement", () => {
it("records a partial-fill remainder in the settlement transaction", async () => {
const ledger = new MatchingLedger([ask({}), bid({ remainingQty: "3" })]);
- // The ledger rejects any second submission. A correct match still succeeds
- // because it records its funded remainder in the settlement transaction.
+ // The ledger allows preview + execute but rejects any third submission. A
+ // correct match succeeds because it records its funded remainder inside
+ // the value-moving settlement transaction.
const flaky: LedgerSubmitter = {
submit: async (req: SubmitRequest): Promise => {
- if (ledger.submissions.length > 0) throw new Error("ledger unavailable");
+ if (ledger.submissions.length > 1) throw new Error("ledger unavailable");
return ledger.submit(req);
},
subscribe: ledger.subscribe.bind(ledger),
@@ -332,7 +321,7 @@ describe("OrderService.runMatching settlement", () => {
assert.equal(results[0]!.buyRemainderCid, null, "the bid did not close out");
assert.equal(results.length, 1);
- assert.equal(ledger.submissions.length, 1);
+ assert.equal(ledger.submissions.length, 2, "preview + one value-moving execute");
assert.deepEqual(
ledger.executes.map((e) => (e.argument as ExecuteArgument).buyOrderCid),
["#bid:1"],
diff --git a/services/operator-backend/test/order-route-pair-param.test.ts b/services/operator-backend/test/order-route-pair-param.test.ts
index dc036666..c84b0eac 100644
--- a/services/operator-backend/test/order-route-pair-param.test.ts
+++ b/services/operator-backend/test/order-route-pair-param.test.ts
@@ -8,24 +8,7 @@ import assert from "node:assert/strict";
import { InMemoryLedger } from "../src/ledger/in-memory.js";
import { OperatorBackend } from "../src/index.js";
import { startHttpServer } from "../src/http/index.js";
-import { RegistryClient } from "@canton-dex/registry-client";
-import type { ChoiceContextRef, ContractId } from "@canton-dex/registry-client";
-
-class StubRegistry extends RegistryClient {
- constructor() {
- super({ baseUrl: "http://stub" });
- }
- override async getFactories() {
- return {
- allocationFactoryCid: "#alloc:0" as ContractId<"AllocationFactory">,
- settlementFactoryCid: "#settle:0" as ContractId<"SettlementFactory">,
- disclosure: [] as never[],
- };
- }
- override async getChoiceContext(): Promise {
- return { context: { values: {} }, disclosure: [] };
- }
-}
+import { StubRegistry } from "./stub-registry.js";
let baseUrl: string;
let close: () => Promise;
@@ -44,10 +27,6 @@ before(async () => {
operator: "op" as never,
lpRegistrar: "lp" as never,
admin: "ad" as never,
- allocationFactoryCid: "#alloc:0",
- settlementFactoryCid: "#settle:0",
- allocationFactoryExtraArgs: { context: { values: {} }, meta: { values: {} } },
- allocationFactoryDisclosure: [],
network: "canton:test",
},
devOpen: true,
diff --git a/services/operator-backend/test/order.test.ts b/services/operator-backend/test/order.test.ts
index bbeb5398..08ce5d27 100644
--- a/services/operator-backend/test/order.test.ts
+++ b/services/operator-backend/test/order.test.ts
@@ -7,21 +7,15 @@
import { describe, it } from "node:test";
import assert from "node:assert/strict";
-import { OrderService } from "../src/order/index.js";
+import { OrderAuthError, OrderService } from "../src/order/index.js";
import type {
LedgerSubmitter,
SubmitRequest,
SubscriptionFilter,
LedgerEvent,
} from "../src/ledger/index.js";
-import { RegistryClient } from "@canton-dex/registry-client";
import type { ContractId } from "@canton-dex/registry-client";
-
-class StubRegistry extends RegistryClient {
- constructor() {
- super({ baseUrl: "http://stub" });
- }
-}
+import { StubRegistry } from "./stub-registry.js";
const FUNDING_TEMPLATE =
"abcdef:CantonDex.Dex.OrderFundingRequest:OrderFundingRequest";
@@ -54,6 +48,7 @@ const BIND_SPEC = {
class CapturingLedger implements LedgerSubmitter {
lastSubmit: SubmitRequest | null = null;
treeEvents: Array<{ contractId: string; templateId: string }> = [];
+ queryRows: unknown[] = [];
async submit(req: SubmitRequest): Promise {
this.lastSubmit = req;
return {
@@ -72,7 +67,7 @@ class CapturingLedger implements LedgerSubmitter {
// no streaming in this stub
}
async query(_f: SubscriptionFilter): Promise {
- return [];
+ return this.queryRows as T[];
}
}
@@ -136,3 +131,53 @@ describe("OrderService.bind", () => {
);
});
});
+
+describe("OrderService caller binding", () => {
+ it("binds only the funding request owned by the verified caller", async () => {
+ const ledger = new CapturingLedger();
+ ledger.queryRows = [{ contractId: "00abc", trader: "alice" }];
+ const svc = new OrderService(ledger, new StubRegistry(), "op" as never);
+
+ await assert.rejects(
+ () => svc.bind({
+ fundingRequestCid: "00abc" as ContractId<"OrderFundingRequest">,
+ settlementRef: "ref-auth",
+ requireTrader: "mallory" as never,
+ }),
+ OrderAuthError,
+ );
+ assert.equal(ledger.lastSubmit, null);
+
+ await svc.bind({
+ fundingRequestCid: "00abc" as ContractId<"OrderFundingRequest">,
+ settlementRef: "ref-auth",
+ requireTrader: "alice" as never,
+ });
+ assert.equal(commandOf(ledger).contractId, "00abc");
+ });
+
+ it("fund and cancel reject another trader's order", async () => {
+ const ledger = new CapturingLedger();
+ ledger.queryRows = [{
+ contractId: "00order",
+ trader: "alice",
+ status: "Pending",
+ allocationCid: null,
+ }];
+ const svc = new OrderService(ledger, new StubRegistry(), "op" as never);
+
+ await assert.rejects(
+ () => svc.fund({
+ orderCid: "00order" as ContractId<"Order">,
+ allocationCid: "00alloc" as ContractId<"Allocation">,
+ requireTrader: "mallory" as never,
+ }),
+ OrderAuthError,
+ );
+ await assert.rejects(
+ () => svc.cancel("00order" as ContractId<"Order">, "mallory" as never),
+ OrderAuthError,
+ );
+ assert.equal(ledger.lastSubmit, null);
+ });
+});
diff --git a/services/operator-backend/test/pool-status-normalisation.test.ts b/services/operator-backend/test/pool-status-normalisation.test.ts
index 0363ea9d..bdf3d7e7 100644
--- a/services/operator-backend/test/pool-status-normalisation.test.ts
+++ b/services/operator-backend/test/pool-status-normalisation.test.ts
@@ -6,8 +6,7 @@ import { describe, it } from "node:test";
import assert from "node:assert/strict";
import { PoolService } from "../src/pool/index.ts";
-import { RegistryClient } from "@canton-dex/registry-client";
-import type { ChoiceContextRef } from "@canton-dex/registry-client";
+import { StubRegistry } from "./stub-registry.js";
import type {
LedgerEvent,
LedgerSubmitter,
@@ -18,22 +17,6 @@ import type { Party } from "../src/types.ts";
const OPERATOR = "operator::test" as Party;
-class StubRegistry extends RegistryClient {
- constructor() {
- super({ baseUrl: "http://stub" });
- }
- override async getFactories() {
- return {
- allocationFactoryCid: "#f:0" as never,
- settlementFactoryCid: "#f:0" as never,
- disclosure: [],
- };
- }
- override async getChoiceContext(): Promise {
- return { context: { values: {} }, disclosure: [] };
- }
-}
-
/** Serves one pool whose PoolState carries whatever status the test sets. */
function ledgerServing(status: string): LedgerSubmitter {
return {
diff --git a/services/operator-backend/test/pool.test.ts b/services/operator-backend/test/pool.test.ts
index e1513548..4cb26016 100644
--- a/services/operator-backend/test/pool.test.ts
+++ b/services/operator-backend/test/pool.test.ts
@@ -15,12 +15,13 @@ import type {
SubscriptionFilter,
LedgerEvent,
} from "../src/ledger/index.js";
-import { RegistryClient } from "@canton-dex/registry-client";
+import {
+ FixedRegistryClient,
+ RegistryClient,
+} from "@canton-dex/registry-client";
import type {
- ChoiceContextRef,
ContractId,
DisclosedContract,
- FactoryRefs,
} from "@canton-dex/registry-client";
import type {
LPTokenPolicy,
@@ -29,19 +30,13 @@ import type {
Party,
} from "../src/types.js";
-class StubRegistry extends RegistryClient {
+class StubRegistry extends FixedRegistryClient {
constructor() {
- super({ baseUrl: "http://stub" });
- }
- override async getFactories(_admin: Party): Promise {
- return {
+ super(() => ({
allocationFactoryCid: "#alloc:0" as ContractId<"AllocationFactory">,
settlementFactoryCid: "#settle:0" as ContractId<"SettlementFactory">,
disclosure: [],
- };
- }
- override async getChoiceContext(_admin: Party): Promise {
- return { context: { values: {} }, disclosure: [] };
+ }));
}
}
@@ -53,20 +48,13 @@ function disclosed(contractId: string): DisclosedContract {
};
}
-class PerAdminRegistry extends StubRegistry {
- override async getFactories(admin: Party): Promise {
- return {
+class PerAdminRegistry extends FixedRegistryClient {
+ constructor() {
+ super((admin: Party) => ({
allocationFactoryCid: `#alloc:${admin}` as ContractId<"AllocationFactory">,
settlementFactoryCid: `#settle:${admin}` as ContractId<"SettlementFactory">,
disclosure: [disclosed("#shared-rules"), disclosed(`#factory:${admin}`)],
- };
- }
-
- override async getChoiceContext(admin: Party): Promise {
- return {
- context: { values: { [`ctx.${admin}`]: true } },
- disclosure: [disclosed("#shared-rules"), disclosed(`#context:${admin}`)],
- };
+ }));
}
}
@@ -80,6 +68,7 @@ class CapturingLedger implements LedgerSubmitter {
servePolicy = true;
acceptances: LiquidityAllocationAcceptanceContract[] = [];
treeEvents: Array<{ contractId: string; templateId: string }> = [];
+ private allocationCounter = 0;
private readonly policies: LPTokenPolicy[];
constructor(private readonly pool: Pool, policyOrPolicies: LPTokenPolicy | LPTokenPolicy[]) {
this.policies = Array.isArray(policyOrPolicies)
@@ -88,6 +77,32 @@ class CapturingLedger implements LedgerSubmitter {
}
async submit(req: SubmitRequest): Promise {
this.lastSubmit = req;
+ const choice = (req.command as { choice?: string }).choice;
+ if (choice === "PoolLiquidityRules_PreviewAddAllocations") {
+ return {
+ baseReceiver: {},
+ quoteReceiver: {},
+ lpMintSender: {},
+ } as R;
+ }
+ if (choice === "PoolLiquidityRules_PreviewRemoveAllocations") {
+ return { lpBurnReceiver: {} } as R;
+ }
+ if (
+ choice === "PoolLiquidityRules_PreviewAddSettlement" ||
+ choice === "PoolLiquidityRules_PreviewRemoveSettlement"
+ ) {
+ return { baseQuoteBatch: {}, lpBatch: {} } as R;
+ }
+ if (choice === "AllocationFactory_Allocate") {
+ const allocationCid = `#created-allocation:${this.allocationCounter++}`;
+ return {
+ output: {
+ tag: "AllocationInstructionResult_Completed",
+ value: { allocationCid },
+ },
+ } as R;
+ }
return "#result:0" as R;
}
async treeCreatedEvents() {
@@ -471,7 +486,7 @@ describe("PoolService DvP liquidity", () => {
assert.equal(ledger.lastSubmit, null);
});
- it("settleAddLiquidity is co-signed and threads requestCid + both registries' factories + per-admin contexts", async () => {
+ it("settleAddLiquidity is co-signed and threads requestCid + both self-registry factory sets", async () => {
const pool = mkPool(0, 0);
const ledger = new CapturingLedger(pool, mkLpPolicy());
const svc = new PoolService(ledger, new PerAdminRegistry(), "op" as never);
@@ -508,13 +523,14 @@ describe("PoolService DvP liquidity", () => {
assert.equal(cmd.argument.lpFactoryCid, "#alloc:lp");
assert.equal(cmd.argument.baseQuoteSettleCid, "#settle:ad");
assert.equal(cmd.argument.lpSettleCid, "#settle:lp");
- // Split-admin contexts threaded separately, not collapsed.
+ // The fixed self-registry requires no operation-specific context. The two
+ // admin slots still remain separate and must never collapse to one field.
assert.deepEqual(cmd.argument.poolAdminExtraArgs, {
- context: { values: { "ctx.ad": true } },
+ context: { values: {} },
meta: { values: {} },
});
assert.deepEqual(cmd.argument.lpRegistrarExtraArgs, {
- context: { values: { "ctx.lp": true } },
+ context: { values: {} },
meta: { values: {} },
});
assert.equal(cmd.argument.extraArgs, undefined, "no collapsed single extraArgs");
@@ -523,12 +539,45 @@ describe("PoolService DvP liquidity", () => {
"#shared-rules",
"#factory:ad",
"#factory:lp",
- "#context:ad",
- "#context:lp",
]));
assert.equal(disclosureIds.length, new Set(disclosureIds).size);
});
+ it("stops before allocation when operation-specific registry discovery fails", async () => {
+ const pool = mkPool(0, 0);
+ const ledger = new CapturingLedger(pool, mkLpPolicy());
+ const svc = new PoolService(
+ ledger,
+ new RegistryClient({
+ baseUrl: "https://registry.example",
+ fetchImpl: async () => new Response(null, { status: 404 }),
+ }),
+ "op" as never,
+ );
+
+ await assert.rejects(
+ svc.settleAddLiquidity({
+ poolCid: pool.contractId,
+ requestCid: "#req:unsupported" as never,
+ recipient: "lp" as never,
+ lpBaseDepositCid: "#b:unsupported" as never,
+ lpQuoteDepositCid: "#q:unsupported" as never,
+ lpReceiptCid: "#r:unsupported" as never,
+ baseAmount: "10.0",
+ quoteAmount: "200000.0",
+ minLpTokens: "0.0",
+ knownTotalLpSupply: "0.0",
+ requestedAt,
+ }),
+ /registry: not-found: \/registry\/allocation-instruction\/v2\/allocation-factory/,
+ );
+ assert.equal(
+ (ledger.lastSubmit!.command as { choice?: string }).choice,
+ "PoolLiquidityRules_PreviewAddAllocations",
+ "only the read-only plan may run before registry discovery fails",
+ );
+ });
+
it("settleAddLiquidity binds to acceptance evidence when no live request is supplied", async () => {
const pool = mkPool(0, 0);
const ledger = new CapturingLedger(pool, mkLpPolicy());
@@ -730,13 +779,13 @@ describe("PoolService DvP liquidity", () => {
assert.deepEqual(ledger.lastSubmit!.actAs, ["op", "lp"]);
assert.equal(cmd.argument.requestCid, "#req:1");
assert.equal(cmd.argument.holderBurnSenderCid, "#burn:0");
- // Split-admin contexts threaded separately, not collapsed.
+ // Fixed self-registry contexts are empty but remain separate per admin.
assert.deepEqual(cmd.argument.poolAdminExtraArgs, {
- context: { values: { "ctx.ad": true } },
+ context: { values: {} },
meta: { values: {} },
});
assert.deepEqual(cmd.argument.lpRegistrarExtraArgs, {
- context: { values: { "ctx.lp": true } },
+ context: { values: {} },
meta: { values: {} },
});
assert.equal(cmd.argument.extraArgs, undefined, "no collapsed single extraArgs");
@@ -745,8 +794,6 @@ describe("PoolService DvP liquidity", () => {
"#shared-rules",
"#factory:ad",
"#factory:lp",
- "#context:ad",
- "#context:lp",
]));
assert.equal(disclosureIds.length, new Set(disclosureIds).size);
});
diff --git a/services/operator-backend/test/read-exposure.test.ts b/services/operator-backend/test/read-exposure.test.ts
index a9f64dd4..050b2925 100644
--- a/services/operator-backend/test/read-exposure.test.ts
+++ b/services/operator-backend/test/read-exposure.test.ts
@@ -5,30 +5,32 @@ import assert from "node:assert/strict";
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
+import { createHmac } from "node:crypto";
import { openDb, type Db } from "../src/indexer/db.js";
import { InMemoryLedger } from "../src/ledger/in-memory.js";
+import type { SubscriptionFilter } from "../src/ledger/index.js";
import { OperatorBackend } from "../src/index.js";
import { startHttpServer } from "../src/http/index.js";
import { aggregateBook } from "../src/order/matching.js";
-import { RegistryClient } from "@canton-dex/registry-client";
-import type { ChoiceContextRef, ContractId } from "@canton-dex/registry-client";
+import { StubRegistry } from "./stub-registry.js";
import type { Order } from "../src/types.js";
const ADMIN_TOKEN = "admin-secret";
-
-class StubRegistry extends RegistryClient {
- constructor() { super({ baseUrl: "http://stub" }); }
- override async getFactories() {
- return {
- allocationFactoryCid: "#alloc:0" as ContractId<"AllocationFactory">,
- settlementFactoryCid: "#settle:0" as ContractId<"SettlementFactory">,
- disclosure: [] as never[],
- };
- }
- override async getChoiceContext(): Promise {
- return { context: { values: {} }, disclosure: [] };
- }
+const CALLER_SECRET = "caller-secret";
+
+function callerToken(sub: string): string {
+ const encode = (value: string | Buffer) =>
+ Buffer.from(value).toString("base64url");
+ const header = encode(JSON.stringify({ alg: "HS256", typ: "JWT" }));
+ const payload = encode(JSON.stringify({
+ sub,
+ exp: Math.floor(Date.now() / 1000) + 3600,
+ }));
+ const signature = encode(
+ createHmac("sha256", CALLER_SECRET).update(`${header}.${payload}`).digest(),
+ );
+ return `${header}.${payload}.${signature}`;
}
let baseUrl: string;
@@ -54,11 +56,10 @@ before(async () => {
port: 0,
host: "127.0.0.1",
adminToken: ADMIN_TOKEN,
+ callerJwtSecret: CALLER_SECRET,
context: {
operator: "op" as never, lpRegistrar: "lp" as never, admin: "ad" as never,
- allocationFactoryCid: "#alloc:0", settlementFactoryCid: "#settle:0",
- allocationFactoryExtraArgs: { context: { values: {} }, meta: { values: {} } },
- allocationFactoryDisclosure: [], network: "canton:test",
+ network: "canton:test",
},
devOpen: true,
});
@@ -71,9 +72,12 @@ after(async () => {
rmSync(dir, { recursive: true, force: true });
});
-const get = async (p: string, token?: string) => {
+const get = async (p: string, token?: string, caller?: string) => {
const r = await fetch(`${baseUrl}${p}`, {
- headers: token ? { authorization: `Bearer ${token}` } : {},
+ headers: {
+ ...(token ? { authorization: `Bearer ${token}` } : {}),
+ ...(caller ? { "x-caller-token": callerToken(caller) } : {}),
+ },
});
return { status: r.status, body: (await r.json().catch(() => ({}))) as any };
};
@@ -86,11 +90,19 @@ describe("GET /v1/trades scoping", () => {
});
it("serves a scoped read", async () => {
- const r = await get("/v1/trades?trader=alice");
+ const r = await get("/v1/trades?trader=alice", undefined, "alice");
assert.equal(r.status, 200);
assert.equal(r.body.length, 1);
});
+ it("rejects a missing or mismatched caller on a scoped read", async () => {
+ assert.equal((await get("/v1/trades?trader=alice")).status, 401);
+ assert.equal(
+ (await get("/v1/trades?trader=alice", undefined, "mallory")).status,
+ 403,
+ );
+ });
+
it("the admin token still gets the unfiltered view", async () => {
const r = await get("/v1/trades", ADMIN_TOKEN);
assert.equal(r.status, 200);
@@ -98,6 +110,21 @@ describe("GET /v1/trades scoping", () => {
});
});
+describe("party-scoped ACS reads", () => {
+ for (const path of [
+ "/v1/orders?trader=alice",
+ "/v1/holdings?owner=alice",
+ "/v1/balances?owner=alice",
+ ]) {
+ it(`${path} binds the query party to the caller`, async () => {
+ assert.equal((await get(path)).status, 401);
+ assert.equal((await get(path, undefined, "mallory")).status, 403);
+ assert.equal((await get(path, undefined, "alice")).status, 200);
+ assert.equal((await get(path, ADMIN_TOKEN)).status, 200);
+ });
+ }
+});
+
describe("GET /v1/orders/matches", () => {
it("serves only the terms, not the whole orders", async () => {
const r = await get("/v1/orders/matches?pair=dBTC/dUSD");
@@ -129,3 +156,61 @@ describe("aggregateBook", () => {
assert.equal(bids[0]!.size, "0.0000000001", "float renders this as 1e-10");
});
});
+
+describe("bounded history queries", () => {
+ it("rejects malformed or non-positive limits", async () => {
+ assert.equal(
+ (await get("/v1/trades?trader=alice&limit=-1", undefined, "alice")).status,
+ 400,
+ );
+ assert.equal((await get("/v1/swaps?limit=not-a-number")).status, 400);
+ });
+
+ it("clamps oversized limits instead of emitting an unbounded query", async () => {
+ assert.equal(
+ (await get("/v1/trades?trader=alice&limit=999999", undefined, "alice")).status,
+ 200,
+ );
+ });
+});
+
+describe("holding query failures", () => {
+ it("returns an error instead of presenting a ledger failure as a zero balance", async () => {
+ class FailingHoldingLedger extends InMemoryLedger {
+ override async query(filter: SubscriptionFilter): Promise {
+ if (filter.templateId?.endsWith("Registry.V2:Holding")) {
+ throw new Error("participant unavailable");
+ }
+ return [];
+ }
+ }
+
+ const handle = await startHttpServer({
+ backend: new OperatorBackend({
+ ledger: new FailingHoldingLedger(),
+ registry: new StubRegistry(),
+ operatorParty: "op" as never,
+ }),
+ port: 0,
+ host: "127.0.0.1",
+ callerJwtSecret: CALLER_SECRET,
+ context: {
+ operator: "op" as never,
+ lpRegistrar: "lp" as never,
+ admin: "ad" as never,
+ network: "canton:test",
+ },
+ devOpen: true,
+ });
+ try {
+ const response = await fetch(`${handle.url}/v1/balances?owner=alice`, {
+ headers: { "x-caller-token": callerToken("alice") },
+ });
+ assert.equal(response.status, 503);
+ const body = await response.json() as { error?: string };
+ assert.equal(body.error, "unable to load holdings from the ledger");
+ } finally {
+ await handle.close();
+ }
+ });
+});
diff --git a/services/operator-backend/test/registry-client.test.ts b/services/operator-backend/test/registry-client.test.ts
index bdb19f8f..932f1b19 100644
--- a/services/operator-backend/test/registry-client.test.ts
+++ b/services/operator-backend/test/registry-client.test.ts
@@ -3,54 +3,156 @@ import { describe, it } from "node:test";
import {
RegistryClient,
- type ChoiceContextRef,
- type ContractId,
+ RegistryError,
+ type ChoiceArguments,
} from "@canton-dex/registry-client";
-describe("RegistryClient.getChoiceContext", () => {
- it("fetches and caches the registry-supplied context + disclosure", async () => {
- let calls = 0;
- const expected: ChoiceContextRef = {
- context: { values: { "dex.choiceContext": true } },
- disclosure: [
- {
- contractId: "#registry:0" as ContractId<"Registry">,
- templateId: "CantonDex.Registry.V2:Registry",
- createdEventBlob: "payload",
- },
- ],
+const disclosed = {
+ contractId: "#registry-rules:0",
+ templateId: "Registry:Rules",
+ contractKeyHash: "key-hash",
+ createdEventBlob: "created-event-base64",
+ synchronizerId: "domain::id",
+};
+
+function factoryWire(factoryId: string, marker: string) {
+ return {
+ factoryId,
+ choiceContext: {
+ choiceContextData: { values: { marker } },
+ disclosedContracts: [disclosed],
+ },
+ };
+}
+
+function json(value: unknown, status = 200): Response {
+ return new Response(JSON.stringify(value), {
+ status,
+ headers: { "Content-Type": "application/json" },
+ });
+}
+
+describe("RegistryClient operation-specific Token Standard V2 discovery", () => {
+ it("POSTs the exact allocation choice argument and never reuses its response", async () => {
+ const choiceArguments: ChoiceArguments = {
+ expectedAdmin: "admin-a",
+ allocation: { settlement: { id: "swap-42" } },
};
+ let calls = 0;
const client = new RegistryClient({
- baseUrl: "https://registry.example",
- fetchImpl: async (input) => {
+ baseUrl: "https://registry.example/base/",
+ authToken: "registry-token",
+ fetchImpl: async (input, init) => {
calls += 1;
assert.equal(
input.toString(),
- "https://registry.example/registry/choice-context/admin-a",
+ "https://registry.example/registry/allocation-instruction/v2/allocation-factory",
+ );
+ assert.equal(init?.method, "POST");
+ assert.equal(
+ (init?.headers as Record).Authorization,
+ "Bearer registry-token",
);
- return new Response(JSON.stringify(expected), {
- status: 200,
- headers: { "Content-Type": "application/json" },
+ assert.deepEqual(JSON.parse(String(init?.body)), { choiceArguments });
+ return json(factoryWire("#allocation-factory:0", `call-${calls}`));
+ },
+ });
+
+ const first = await client.getAllocationFactory("admin-a", choiceArguments);
+ const second = await client.getAllocationFactory("admin-a", choiceArguments);
+
+ assert.equal(first.factoryCid, "#allocation-factory:0");
+ assert.deepEqual(first.context.values, { marker: "call-1" });
+ assert.deepEqual(first.disclosure, [disclosed]);
+ assert.deepEqual(second.context.values, { marker: "call-2" });
+ assert.equal(calls, 2, "choice context may be specific to one exercise");
+ });
+
+ it("resolves each admin's settlement endpoint and sends the exact preview", async () => {
+ const preview = {
+ settlement: { executors: ["operator"], id: "match-7", cid: null },
+ allocations: [{ allocationCid: "#allocation:7" }],
+ };
+ const client = new RegistryClient({
+ baseUrl: (admin) => `https://${admin}.registry.example/`,
+ fetchImpl: async (input, init) => {
+ assert.equal(
+ input.toString(),
+ "https://admin-b.registry.example/registry/allocation/v2/settlement-factory",
+ );
+ assert.deepEqual(JSON.parse(String(init?.body)), {
+ choiceArguments: preview,
});
+ return json(factoryWire("#settlement-factory:b", "settle-b"));
},
});
- const first = await client.getChoiceContext("admin-a");
- const second = await client.getChoiceContext("admin-a");
+ const got = await client.getSettlementFactory("admin-b", preview);
- assert.deepEqual(first, expected);
- assert.deepEqual(second, expected);
- assert.equal(calls, 1);
+ assert.equal(got.factoryCid, "#settlement-factory:b");
+ assert.deepEqual(got.context.values, { marker: "settle-b" });
});
- it("falls back to empty context when the registry has no endpoint", async () => {
+ it("uses allocation-specific cancel and withdraw context endpoints", async () => {
+ const seen: Array<{ url: string; body: unknown }> = [];
const client = new RegistryClient({
baseUrl: "https://registry.example",
- fetchImpl: async () => new Response(null, { status: 404 }),
+ fetchImpl: async (input, init) => {
+ seen.push({
+ url: input.toString(),
+ body: JSON.parse(String(init?.body)),
+ });
+ return json({
+ choiceContextData: { values: { operation: seen.length } },
+ disclosedContracts: [],
+ });
+ },
});
- const ctx = await client.getChoiceContext("admin-b");
+ const cancel = await client.getAllocationCancelContext(
+ "admin-a",
+ "#allocation/with spaces",
+ { reason: "user-request" },
+ );
+ const withdraw = await client.getAllocationWithdrawContext(
+ "admin-a",
+ "#allocation/with spaces",
+ );
- assert.deepEqual(ctx, { context: { values: {} }, disclosure: [] });
+ assert.deepEqual(seen, [
+ {
+ url:
+ "https://registry.example/registry/allocations/v2/%23allocation%2Fwith%20spaces/choice-contexts/cancel",
+ body: { meta: { reason: "user-request" } },
+ },
+ {
+ url:
+ "https://registry.example/registry/allocations/v2/%23allocation%2Fwith%20spaces/choice-contexts/withdraw",
+ body: { meta: {} },
+ },
+ ]);
+ assert.deepEqual(cancel.context.values, { operation: 1 });
+ assert.deepEqual(withdraw.context.values, { operation: 2 });
});
+
+ it("fails closed for missing or malformed canonical responses", async () => {
+ const missing = new RegistryClient({
+ baseUrl: "https://registry.example",
+ fetchImpl: async () => new Response(null, { status: 404 }),
+ });
+ await assert.rejects(
+ missing.getAllocationFactory("admin-a", { allocation: "exact" }),
+ (error) => error instanceof RegistryError && error.kind === "not-found",
+ );
+
+ const malformed = new RegistryClient({
+ baseUrl: "https://registry.example",
+ fetchImpl: async () => json({ factoryId: "#factory:0" }),
+ });
+ await assert.rejects(
+ malformed.getSettlementFactory("admin-a", { settlement: "exact" }),
+ (error) => error instanceof RegistryError && error.kind === "malformed",
+ );
+ });
+
});
diff --git a/services/operator-backend/test/rfq-read-scoping.test.ts b/services/operator-backend/test/rfq-read-scoping.test.ts
index f2a3875f..cf7526e8 100644
--- a/services/operator-backend/test/rfq-read-scoping.test.ts
+++ b/services/operator-backend/test/rfq-read-scoping.test.ts
@@ -5,33 +5,32 @@
import { describe, it, before, after } from "node:test";
import assert from "node:assert/strict";
+import { createHmac } from "node:crypto";
import { InMemoryLedger } from "../src/ledger/in-memory.js";
import type { SubscriptionFilter } from "../src/ledger/index.js";
import { OperatorBackend } from "../src/index.js";
import { startHttpServer } from "../src/http/index.js";
-import { RegistryClient } from "@canton-dex/registry-client";
-import type { ChoiceContextRef, ContractId } from "@canton-dex/registry-client";
+import { StubRegistry } from "./stub-registry.js";
const ALICE = "alice";
const BOB = "bob";
const DEALER = "northwind";
const ADMIN_TOKEN = "admin-secret";
+const CALLER_SECRET = "caller-secret";
-class StubRegistry extends RegistryClient {
- constructor() {
- super({ baseUrl: "http://stub" });
- }
- override async getFactories() {
- return {
- allocationFactoryCid: "#alloc:0" as ContractId<"AllocationFactory">,
- settlementFactoryCid: "#settle:0" as ContractId<"SettlementFactory">,
- disclosure: [] as never[],
- };
- }
- override async getChoiceContext(): Promise {
- return { context: { values: {} }, disclosure: [] };
- }
+function callerToken(sub: string): string {
+ const encode = (value: string | Buffer) =>
+ Buffer.from(value).toString("base64url");
+ const header = encode(JSON.stringify({ alg: "HS256", typ: "JWT" }));
+ const payload = encode(JSON.stringify({
+ sub,
+ exp: Math.floor(Date.now() / 1000) + 3600,
+ }));
+ const signature = encode(
+ createHmac("sha256", CALLER_SECRET).update(`${header}.${payload}`).digest(),
+ );
+ return `${header}.${payload}.${signature}`;
}
class RfqLedger extends InMemoryLedger {
@@ -66,14 +65,11 @@ before(async () => {
port: 0,
host: "127.0.0.1",
adminToken: ADMIN_TOKEN,
+ callerJwtSecret: CALLER_SECRET,
context: {
operator: "op" as never,
lpRegistrar: "lp" as never,
admin: "ad" as never,
- allocationFactoryCid: "#alloc:0",
- settlementFactoryCid: "#settle:0",
- allocationFactoryExtraArgs: { context: { values: {} }, meta: { values: {} } },
- allocationFactoryDisclosure: [],
network: "canton:test",
},
devOpen: true,
@@ -86,9 +82,12 @@ after(async () => {
await close();
});
-const get = async (path: string, token?: string) => {
+const get = async (path: string, token?: string, caller?: string) => {
const res = await fetch(`${baseUrl}${path}`, {
- headers: token ? { authorization: `Bearer ${token}` } : {},
+ headers: {
+ ...(token ? { authorization: `Bearer ${token}` } : {}),
+ ...(caller ? { "x-caller-token": callerToken(caller) } : {}),
+ },
});
return { status: res.status, body: await res.json().catch(() => ({})) as any };
};
@@ -101,14 +100,14 @@ describe("GET /v1/rfq scoping", () => {
});
it("a trader sees only their own RFQs and quotes", async () => {
- const r = await get(`/v1/rfq?owner=${ALICE}`);
+ const r = await get(`/v1/rfq?owner=${ALICE}`, undefined, ALICE);
assert.equal(r.status, 200);
assert.deepEqual(r.body.rfqs.map((x: any) => x.rfqId), ["a1"]);
assert.deepEqual(r.body.quotes.map((x: any) => x.rfqId), ["a1"]);
});
it("one trader cannot see another's size or the prices quoted to them", async () => {
- const r = await get(`/v1/rfq?owner=${ALICE}`);
+ const r = await get(`/v1/rfq?owner=${ALICE}`, undefined, ALICE);
const leaked = JSON.stringify(r.body);
assert.ok(!leaked.includes("b1"), "bob's RFQ id leaked");
assert.ok(!leaked.includes("50.0"), "bob's size leaked");
@@ -116,11 +115,19 @@ describe("GET /v1/rfq scoping", () => {
});
it("a whitelisted dealer sees the RFQ and its own quotes", async () => {
- const r = await get(`/v1/rfq?owner=${DEALER}`);
+ const r = await get(`/v1/rfq?owner=${DEALER}`, undefined, DEALER);
assert.deepEqual(r.body.rfqs.map((x: any) => x.rfqId), ["a1"], "whitelisted on a1 only");
assert.equal(r.body.quotes.length, 2, "its own quotes on both");
});
+ it("rejects a missing or mismatched caller on a scoped RFQ read", async () => {
+ assert.equal((await get(`/v1/rfq?owner=${ALICE}`)).status, 401);
+ assert.equal(
+ (await get(`/v1/rfq?owner=${ALICE}`, undefined, BOB)).status,
+ 403,
+ );
+ });
+
it("refuses an unscoped /v1/rfq/history without the admin token", async () => {
// Same exposure as /v1/rfq: each settled row names the trader, the pair,
// the winning dealer and its rank.
diff --git a/services/operator-backend/test/rfq.test.ts b/services/operator-backend/test/rfq.test.ts
index 926fd3b6..e439c1f3 100644
--- a/services/operator-backend/test/rfq.test.ts
+++ b/services/operator-backend/test/rfq.test.ts
@@ -1,4 +1,4 @@
-// End-to-end test for the operator backend's RFQ accept flow.
+// Service-level integration test for the operator backend's RFQ accept flow.
// Drives the InMemoryLedger with handlers that mimic Daml choice
// semantics, then exercises RfqService.accept and asserts on the
// resulting MatchedTrade + PolicyReceipt.
@@ -21,26 +21,15 @@ import type {
PolicyReceipt,
} from "../src/types.ts";
import { RfqAuthError } from "../src/rfq/index.ts";
-import { RegistryClient } from "@canton-dex/registry-client";
-import type { ChoiceContextRef } from "@canton-dex/registry-client";
+import { FixedRegistryClient } from "@canton-dex/registry-client";
-class StubRegistry extends RegistryClient {
+class StubRegistry extends FixedRegistryClient {
constructor() {
- super({ baseUrl: "http://stub" });
- }
- override async getFactories(): Promise<{
- allocationFactoryCid: ContractId<"AllocationFactory">;
- settlementFactoryCid: ContractId<"SettlementFactory">;
- disclosure: never[];
- }> {
- return {
+ super(() => ({
allocationFactoryCid: "#alloc-fac:0" as ContractId<"AllocationFactory">,
settlementFactoryCid: "#settle-fac:0" as ContractId<"SettlementFactory">,
disclosure: [],
- };
- }
- override async getChoiceContext(): Promise {
- return { context: { values: {} }, disclosure: [] };
+ }));
}
}
@@ -161,7 +150,7 @@ function setupLedger(): InMemoryLedger {
return ledger;
}
-test("RFQ accept end-to-end through operator backend", async () => {
+test("RFQ accept across the operator service boundary", async () => {
const ledger = setupLedger();
const registry = new StubRegistry();
const operator: Party = "operator::test";
diff --git a/services/operator-backend/test/server-port.test.ts b/services/operator-backend/test/server-port.test.ts
index cdf188e1..b70d35af 100644
--- a/services/operator-backend/test/server-port.test.ts
+++ b/services/operator-backend/test/server-port.test.ts
@@ -28,4 +28,54 @@ describe("server entrypoints", () => {
);
});
}
+
+ it("testnet-server never enables the development write bypass", () => {
+ const source = readFileSync(join(SRC, "testnet-server.ts"), "utf8");
+ assert.match(source, /devOpen:\s*false/);
+ assert.doesNotMatch(
+ source,
+ /devOpen:\s*process\.env\.DEX_DEV_OPEN/,
+ "testnet-server must not honor the in-memory server's auth bypass",
+ );
+ });
+
+ it("testnet-server never enables the arbitrary-command wallet relay", () => {
+ const source = readFileSync(join(SRC, "testnet-server.ts"), "utf8");
+ assert.match(source, /walletRelayEnabled:\s*false/);
+ assert.doesNotMatch(
+ source,
+ /walletRelayEnabled:\s*process\.env\.DEX_DEV_WALLET_RELAY/,
+ "testnet-server must not forward wallet commands under its participant JWT",
+ );
+ });
+
+ it("testnet-server makes hosted trader-authority RFQ relay opt-in", () => {
+ const source = readFileSync(join(SRC, "testnet-server.ts"), "utf8");
+ assert.match(
+ source,
+ /hostedRfqEnabled\s*=\s*process\.env\.DEX_HOSTED_RFQ_RELAY\s*===\s*"1"/,
+ );
+ assert.match(source, /hostedRfqEnabled\s*&&\s*!callerJwtSecret/);
+ assert.match(source, /readOnly\s*&&\s*hostedRfqEnabled/);
+ });
+
+ it("testnet-server uses the per-admin fixed self-registry adapter", () => {
+ const source = readFileSync(join(SRC, "testnet-server.ts"), "utf8");
+ assert.match(source, /class ConfiguredRegistry extends FixedRegistryClient/);
+ assert.match(source, /super\(\(admin\)\s*=>/);
+ assert.match(source, /factoriesByAdmin\.get\(admin\)/);
+ assert.match(source, /registry:\s*new ConfiguredRegistry\(factoriesByAdmin\)/);
+ assert.match(source, /required\("CANTON_LP_ALLOC_FACTORY_CID"\)/);
+ assert.match(source, /required\("CANTON_LP_SETTLE_FACTORY_CID"\)/);
+ });
+
+ it("dev-server identifies seeded state as an in-memory preview", () => {
+ const source = readFileSync(join(SRC, "dev-server.ts"), "utf8");
+ assert.match(source, /network:\s*"preview:in-memory"/);
+ assert.doesNotMatch(
+ source,
+ /network:\s*process\.env\.CANTON_NETWORK/,
+ "The seeded server must not masquerade as a Canton network via an env label",
+ );
+ });
});
diff --git a/services/operator-backend/test/status-sync.test.ts b/services/operator-backend/test/status-sync.test.ts
new file mode 100644
index 00000000..0918ac31
--- /dev/null
+++ b/services/operator-backend/test/status-sync.test.ts
@@ -0,0 +1,51 @@
+// /v1/status must distinguish a healthy in-memory demo from a configured
+// participant that cannot be reached. A fake moving slot would let deployment
+// smoke checks pass while Canton is offline.
+
+import { after, before, describe, it } from "node:test";
+import assert from "node:assert/strict";
+
+import { StubRegistry } from "./stub-registry.js";
+import { startHttpServer } from "../src/http/index.js";
+import { OperatorBackend } from "../src/index.js";
+import { InMemoryLedger } from "../src/ledger/in-memory.js";
+
+let baseUrl = "";
+let close: () => Promise;
+
+before(async () => {
+ const backend = new OperatorBackend({
+ ledger: new InMemoryLedger(),
+ registry: new StubRegistry(),
+ operatorParty: "op" as never,
+ });
+ const handle = await startHttpServer({
+ backend,
+ port: 0,
+ host: "127.0.0.1",
+ context: {
+ operator: "op" as never,
+ lpRegistrar: "lp" as never,
+ admin: "ad" as never,
+ network: "canton:test",
+ },
+ // Deliberately unreachable. Merely configuring a participant must switch
+ // status out of the in-memory dev-counter behavior.
+ ledgerUrl: "http://127.0.0.1:1",
+ ledgerToken: "test-token",
+ });
+ baseUrl = handle.url;
+ close = handle.close;
+});
+
+after(async () => close());
+
+describe("participant sync status", () => {
+ it("reports unsynced instead of inventing a live slot", async () => {
+ const res = await fetch(`${baseUrl}/v1/status`);
+ assert.equal(res.status, 200);
+ const body = (await res.json()) as { slot: number; synced: boolean };
+ assert.equal(body.synced, false);
+ assert.equal(body.slot, 0);
+ });
+});
diff --git a/services/operator-backend/test/stub-registry.ts b/services/operator-backend/test/stub-registry.ts
new file mode 100644
index 00000000..b5f97352
--- /dev/null
+++ b/services/operator-backend/test/stub-registry.ts
@@ -0,0 +1,19 @@
+import {
+ FixedRegistryClient,
+ type ContractId,
+ type FactoryRefs,
+ type Party,
+} from "@canton-dex/registry-client";
+
+/** Fixed self-registry used by backend tests that do not exercise discovery. */
+export class StubRegistry extends FixedRegistryClient {
+ constructor(
+ factoriesForAdmin: (admin: Party) => FactoryRefs = () => ({
+ allocationFactoryCid: "#alloc:0" as ContractId<"AllocationFactory">,
+ settlementFactoryCid: "#settle:0" as ContractId<"SettlementFactory">,
+ disclosure: [],
+ }),
+ ) {
+ super(factoriesForAdmin);
+ }
+}
diff --git a/services/operator-backend/test/swaps-kind-filter.test.ts b/services/operator-backend/test/swaps-kind-filter.test.ts
index 246d1e22..41fa05b2 100644
--- a/services/operator-backend/test/swaps-kind-filter.test.ts
+++ b/services/operator-backend/test/swaps-kind-filter.test.ts
@@ -12,24 +12,7 @@ import { openDb, type Db } from "../src/indexer/db.js";
import { InMemoryLedger } from "../src/ledger/in-memory.js";
import { OperatorBackend } from "../src/index.js";
import { startHttpServer } from "../src/http/index.js";
-import { RegistryClient } from "@canton-dex/registry-client";
-import type { ChoiceContextRef, ContractId } from "@canton-dex/registry-client";
-
-class StubRegistry extends RegistryClient {
- constructor() {
- super({ baseUrl: "http://stub" });
- }
- override async getFactories() {
- return {
- allocationFactoryCid: "#alloc:0" as ContractId<"AllocationFactory">,
- settlementFactoryCid: "#settle:0" as ContractId<"SettlementFactory">,
- disclosure: [] as never[],
- };
- }
- override async getChoiceContext(): Promise {
- return { context: { values: {} }, disclosure: [] };
- }
-}
+import { StubRegistry } from "./stub-registry.js";
let baseUrl: string;
let close: () => Promise;
@@ -76,10 +59,6 @@ before(async () => {
operator: "op" as never,
lpRegistrar: "lp" as never,
admin: "ad" as never,
- allocationFactoryCid: "#alloc:0",
- settlementFactoryCid: "#settle:0",
- allocationFactoryExtraArgs: { context: { values: {} }, meta: { values: {} } },
- allocationFactoryDisclosure: [],
network: "canton:test",
},
devOpen: true,
diff --git a/services/operator-backend/test/test-taxonomy-boundaries.test.ts b/services/operator-backend/test/test-taxonomy-boundaries.test.ts
new file mode 100644
index 00000000..842cbb8a
--- /dev/null
+++ b/services/operator-backend/test/test-taxonomy-boundaries.test.ts
@@ -0,0 +1,169 @@
+import assert from "node:assert/strict";
+import { describe, it } from "node:test";
+import { existsSync, readFileSync, readdirSync } from "node:fs";
+import { join, resolve } from "node:path";
+
+const ROOT = resolve(import.meta.dirname, "..", "..", "..");
+
+function textFilesBelow(dir: string): string[] {
+ const files: string[] = [];
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
+ if (entry.name === "node_modules" || entry.name === "dist" || entry.name.startsWith(".")) {
+ continue;
+ }
+ const path = join(dir, entry.name);
+ if (entry.isDirectory()) files.push(...textFilesBelow(path));
+ else if (/\.(?:daml|json|md|mjs|sh|ts|tsx|yml)$/.test(entry.name)) files.push(path);
+ }
+ return files;
+}
+
+describe("test taxonomy boundaries", () => {
+ it("keeps opt-in Canton tests outside the ordinary offline test glob", () => {
+ const packageJson = JSON.parse(
+ readFileSync(join(ROOT, "services/operator-backend/package.json"), "utf8"),
+ ) as { scripts: Record };
+
+ assert.equal(packageJson.scripts.test, "node --import tsx --test test/*.test.ts");
+ assert.equal(
+ packageJson.scripts["test:live:rfq"],
+ "node --import tsx --test test/live/canton-live-rfq.test.ts",
+ );
+ assert.ok(
+ existsSync(join(ROOT, "services/operator-backend/test/live/canton-live-rfq.test.ts")),
+ );
+ assert.ok(!existsSync(join(ROOT, "services/operator-backend/test/canton-live-rfq.test.ts")));
+ });
+
+ it("does not restore the misleading legacy E2E aliases", () => {
+ assert.ok(!existsSync(join(ROOT, "scripts/e2e-smoke.sh")));
+ assert.ok(!existsSync(join(ROOT, "scripts/localnet-dvp-e2e.ts")));
+
+ const canonicalFiles = [
+ join(ROOT, "README.md"),
+ ...textFilesBelow(join(ROOT, "docs")),
+ ...textFilesBelow(join(ROOT, "scripts")),
+ ...textFilesBelow(join(ROOT, "services")),
+ ...textFilesBelow(join(ROOT, "app")),
+ ...textFilesBelow(join(ROOT, "trading-tests")),
+ ...textFilesBelow(join(ROOT, ".github")),
+ ];
+ const stale = canonicalFiles
+ .filter(
+ (file) =>
+ file !== join(ROOT, "services/operator-backend/test/test-taxonomy-boundaries.test.ts"),
+ )
+ .flatMap((file) => {
+ const match = readFileSync(file, "utf8").match(
+ /CANTON_E2E|e2e-smoke|localnet-dvp-e2e|localnet:dvp-e2e/,
+ );
+ return match ? [`${file}: ${match[0]}`] : [];
+ });
+ assert.deepEqual(stale, []);
+ });
+
+ it("keeps the mock-registry workflow proofs split into readable modules", () => {
+ const testsDir = join(ROOT, "trading-tests/CantonDex/Tests");
+ assert.ok(!existsSync(join(testsDir, "WorkflowIntegrationTests.daml")));
+
+ const modules = readdirSync(testsDir)
+ .filter((name) => /WorkflowTests\.daml$/.test(name))
+ .sort();
+ assert.deepEqual(modules, [
+ "ChoiceContextWorkflowTests.daml",
+ "OrderWorkflowTests.daml",
+ "PoolWorkflowTests.daml",
+ "TradeWorkflowTests.daml",
+ ]);
+
+ let declarations = 0;
+ for (const module of modules) {
+ const source = readFileSync(join(testsDir, module), "utf8");
+ const lines = source.split("\n").length;
+ assert.ok(lines <= 600, `${module} has ${lines} lines; split it again`);
+ declarations += [...source.matchAll(/^test[A-Z]\w*\s*:\s*Script\b/gm)].length;
+ }
+ assert.equal(declarations, 19);
+
+ const fixtures = readFileSync(join(testsDir, "WorkflowTestFixtures.daml"), "utf8");
+ assert.ok(fixtures.split("\n").length <= 300, "workflow fixtures became a new monolith");
+ });
+
+ it("documents the live gate and the container runtime boundary", () => {
+ const testing = readFileSync(join(ROOT, "docs/reference/testing.md"), "utf8");
+ assert.match(testing, /CANTON_LIVE_RFQ=1 npm run test:live:rfq/);
+
+ const ci = readFileSync(join(ROOT, ".github/workflows/ci.yml"), "utf8");
+ assert.match(ci, /name: Container build \+ backend runtime smoke/);
+ });
+
+ it("keeps one canonical newcomer curriculum in both entry points", () => {
+ const expected = [
+ "concepts/canton-daml-primer.md",
+ "concepts/overview.md",
+ "getting-started.md",
+ "tutorials/amm-first-walkthrough.md",
+ "concepts/design-tour.md",
+ "concepts/architecture.md",
+ "concepts/workflows.md",
+ "tutorials/make-your-first-amm-change.md",
+ "guides/builder-guide.md",
+ ];
+
+ const index = readFileSync(join(ROOT, "docs/README.md"), "utf8");
+ const indexSection = index
+ .split("## Canonical newcomer learning path", 2)[1]!
+ .split("\n## ", 1)[0]!;
+ const indexPaths = [...indexSection.matchAll(/^\|\s*\d+\s*\|\s*\[[^\]]+\]\(([^)]+)\)/gm)]
+ .map((match) => match[1]!);
+ assert.deepEqual(indexPaths, expected);
+
+ const readme = readFileSync(join(ROOT, "README.md"), "utf8");
+ const readmeSection = readme
+ .split("## New To Canton Or Daml?", 2)[1]!
+ .split("\n## ", 1)[0]!;
+ const readmePaths = [...readmeSection.matchAll(/^\d+\.\s*\[[^\]]+\]\(([^)]+)\)/gm)]
+ .map((match) => match[1]!.replace(/^docs\//, ""));
+ assert.deepEqual(readmePaths, expected);
+
+ const website = readFileSync(join(ROOT, "website/astro.config.mjs"), "utf8");
+ const websiteSection = website
+ .split("label: 'Newcomer learning path'", 2)[1]!
+ .split("label: 'Concepts'", 1)[0]!;
+ const websitePaths = [...websiteSection.matchAll(/slug:\s*'([^']+)'/g)]
+ .map((match) => `${match[1]}.md`);
+ assert.deepEqual(websitePaths, expected);
+ });
+
+ it("uses the live official archive for SDK 3.5 learning links", () => {
+ const docs = [
+ readFileSync(join(ROOT, "README.md"), "utf8"),
+ readFileSync(join(ROOT, "docs/getting-started.md"), "utf8"),
+ readFileSync(join(ROOT, "docs/concepts/canton-daml-primer.md"), "utf8"),
+ ].join("\n");
+ assert.doesNotMatch(docs, /https:\/\/docs\.digitalasset\.com\/build\/3\.5/);
+ assert.match(
+ docs,
+ /https:\/\/archived\.docs\.digitalasset\.com\/build\/3\.5\/dpm\/manual-install\.html/,
+ );
+ });
+
+ it("keeps registry documentation operation-specific and fail-closed", () => {
+ const guide = readFileSync(join(ROOT, "docs/guides/choice-context.md"), "utf8");
+ const obsolete = [
+ ["get", "Factories"],
+ ["get", "ChoiceContext"],
+ ["choiceContext", "TtlMs"],
+ ].map((parts) => parts.join(""));
+ for (const name of obsolete) {
+ assert.ok(!guide.includes(name), `choice-context guide restored obsolete ${name}`);
+ }
+ assert.match(
+ guide,
+ /POST \/registry\/allocation-instruction\/v2\/allocation-factory/,
+ );
+ assert.match(guide, /POST \/registry\/allocation\/v2\/settlement-factory/);
+ assert.match(guide, /RegistryError\("unsupported", \.\.\.\)/);
+ assert.match(guide, /do not exist before that transaction/);
+ });
+});
diff --git a/services/operator-backend/test/validation.test.ts b/services/operator-backend/test/validation.test.ts
index 118520eb..56af968a 100644
--- a/services/operator-backend/test/validation.test.ts
+++ b/services/operator-backend/test/validation.test.ts
@@ -8,24 +8,7 @@ import assert from "node:assert/strict";
import { InMemoryLedger } from "../src/ledger/in-memory.js";
import { OperatorBackend } from "../src/index.js";
import { startHttpServer } from "../src/http/index.js";
-import { RegistryClient } from "@canton-dex/registry-client";
-import type { ChoiceContextRef, ContractId } from "@canton-dex/registry-client";
-
-class StubRegistry extends RegistryClient {
- constructor() {
- super({ baseUrl: "http://stub" });
- }
- override async getFactories() {
- return {
- allocationFactoryCid: "#alloc:0" as ContractId<"AllocationFactory">,
- settlementFactoryCid: "#settle:0" as ContractId<"SettlementFactory">,
- disclosure: [] as never[],
- };
- }
- override async getChoiceContext(): Promise {
- return { context: { values: {} }, disclosure: [] };
- }
-}
+import { StubRegistry } from "./stub-registry.js";
let baseUrl: string;
let close: () => Promise;
@@ -46,10 +29,6 @@ before(async () => {
operator: "op" as never,
lpRegistrar: "lp" as never,
admin: "ad" as never,
- allocationFactoryCid: "#alloc:0",
- settlementFactoryCid: "#settle:0",
- allocationFactoryExtraArgs: { context: { values: {} }, meta: { values: {} } },
- allocationFactoryDisclosure: [],
network: "canton:test",
},
// Dev-open so the operator-auth gate does not 401 the write
@@ -113,7 +92,7 @@ describe("HTTP input validation", () => {
const body = r.body as { network: string; slot: number; synced: boolean };
assert.equal(typeof body.network, "string");
assert.equal(typeof body.slot, "number");
- assert.equal(typeof body.synced, "boolean");
+ assert.equal(body.synced, true);
});
it("GET /v1/context returns shaped context", async () => {
diff --git a/services/operator-backend/tsconfig.live-scripts.json b/services/operator-backend/tsconfig.live-scripts.json
new file mode 100644
index 00000000..3ea1125f
--- /dev/null
+++ b/services/operator-backend/tsconfig.live-scripts.json
@@ -0,0 +1,14 @@
+{
+ "extends": "./tsconfig.json",
+ "compilerOptions": {
+ "declaration": false,
+ "noEmit": true,
+ "typeRoots": ["./node_modules/@types"]
+ },
+ "include": [
+ "../../scripts/bootstrap-registry.ts",
+ "../../scripts/live-amm-roundtrip.ts",
+ "../../scripts/seed-testnet-pool.ts",
+ "../../scripts/testnet-v2registry-trade.ts"
+ ]
+}
diff --git a/services/registry-client/src/index.ts b/services/registry-client/src/index.ts
index 558c8c7e..346641b4 100644
--- a/services/registry-client/src/index.ts
+++ b/services/registry-client/src/index.ts
@@ -1,104 +1,122 @@
-// Registry client. Single integration point between the operator
-// backend and an asset registrar's HTTP endpoints.
-//
-// Endpoints (matching docs/guides/choice-context.md):
-// GET /registry/factories/:admin
-// GET /registry/choice-context/:admin
-//
-// The client owns its caches. Operator modules use this boundary rather than
-// calling registry endpoints directly, keeping validation and invalidation in
-// one place.
-
-import { TtlCache } from "./cache.js";
+// Token Standard V2 registry client. Every lookup is operation-specific and
+// carries the exact Daml JSON choice argument, as required by the upstream
+// allocation/allocation-instruction OpenAPI. Choice contexts are deliberately
+// not cached: the standard permits them to be specific to one exercise.
+
import {
+ ChoiceArguments,
ChoiceContextRef,
- FactoryRefs,
+ FactoryChoiceContextRef,
Party,
+ RegistryDiscovery,
RegistryError,
+ FactoryRefs,
} from "./types.js";
import {
validateChoiceContextRef,
- validateFactoryRefs,
+ validateFactoryChoiceContextRef,
} from "./validate.js";
export * from "./types.js";
export interface RegistryClientConfig {
- baseUrl: string;
+ /** One registry URL, or a resolver for deployments listing several admins. */
+ baseUrl: string | ((admin: Party) => string);
authToken?: string;
- choiceContextTtlMs?: number;
/** Override fetch for tests. */
fetchImpl?: typeof fetch;
}
-export class RegistryClient {
- private readonly factoryCache = new TtlCache(
- (a) => `fac:${a}`,
- );
- private readonly choiceContextCache = new TtlCache(
- (a) => `ctx:${a}`,
- );
+export class RegistryClient implements RegistryDiscovery {
private readonly fetchImpl: typeof fetch;
constructor(private readonly config: RegistryClientConfig) {
this.fetchImpl = config.fetchImpl ?? fetch;
}
- async getFactories(admin: Party): Promise {
- const cached = this.factoryCache.get(admin);
- if (cached) return cached;
- const refs = await this.fetchJson(
- `/registry/factories/${encodeURIComponent(admin)}`,
- validateFactoryRefs,
+ async getAllocationFactory(
+ admin: Party,
+ choiceArguments: ChoiceArguments,
+ ): Promise {
+ return this.requireJson(
+ admin,
+ "/registry/allocation-instruction/v2/allocation-factory",
+ { choiceArguments },
+ validateFactoryChoiceContextRef,
);
- if (!refs) {
- throw new RegistryError("factory-stale", `admin=${admin}`, true);
- }
- this.factoryCache.set(admin, refs);
- return refs;
}
- /**
- * Off-ledger choice context for token-standard factory choices.
- * Token-standard registries compute this (disclosed config contracts,
- * featured-app rights, …) and the caller threads it into the choice's
- * ExtraArgs. Registries that need no context may return 404; callers
- * treat that as empty context + no disclosure.
- */
- async getChoiceContext(admin: Party): Promise {
- const cached = this.choiceContextCache.get(admin);
- if (cached) return cached;
- const ctx =
- (await this.fetchJson(
- `/registry/choice-context/${encodeURIComponent(admin)}`,
- validateChoiceContextRef,
- )) ?? { context: { values: {} }, disclosure: [] };
- this.choiceContextCache.set(admin, ctx, this.config.choiceContextTtlMs);
- return ctx;
+ async getSettlementFactory(
+ admin: Party,
+ choiceArguments: ChoiceArguments,
+ ): Promise {
+ return this.requireJson(
+ admin,
+ "/registry/allocation/v2/settlement-factory",
+ { choiceArguments },
+ validateFactoryChoiceContextRef,
+ );
+ }
+
+ async getAllocationCancelContext(
+ admin: Party,
+ allocationId: string,
+ meta: Record = {},
+ ): Promise {
+ return this.requireJson(
+ admin,
+ `/registry/allocations/v2/${encodeURIComponent(allocationId)}/choice-contexts/cancel`,
+ { meta },
+ validateChoiceContextRef,
+ );
}
- invalidateAll(): void {
- this.factoryCache.invalidateAll();
- this.choiceContextCache.invalidateAll();
+ async getAllocationWithdrawContext(
+ admin: Party,
+ allocationId: string,
+ meta: Record = {},
+ ): Promise {
+ return this.requireJson(
+ admin,
+ `/registry/allocations/v2/${encodeURIComponent(allocationId)}/choice-contexts/withdraw`,
+ { meta },
+ validateChoiceContextRef,
+ );
}
/**
* Fetch + validate a registry response. `validate` turns the parsed JSON
* into a checked `T`, throwing RegistryError("malformed", ...) on a shape
* mismatch. Registry output is never trusted via a bare `as T` cast.
- * Returns null on 404 (callers treat absent as empty/not-found).
+ * A missing canonical endpoint is an integration error, not permission to
+ * silently submit empty context.
*/
- private async fetchJson(
+ private async requireJson(
+ admin: Party,
path: string,
+ body: Record,
validate: (raw: unknown) => T,
- ): Promise {
- const url = new URL(path, this.config.baseUrl);
- const headers: Record = { Accept: "application/json" };
+ ): Promise {
+ const baseUrl =
+ typeof this.config.baseUrl === "function"
+ ? this.config.baseUrl(admin)
+ : this.config.baseUrl;
+ const url = new URL(path, baseUrl);
+ const headers: Record