Deployment guide
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.
1. Local dev (no Canton)
Section titled “1. Local dev (no Canton)”For UI work. npm run dev boots the backend on an
InMemoryLedger and
seeds a BTC/USDC pair, a funded pool, and a demo trader — no participant, no
token.
# backendcd services/operator-backendnpm installnpm run dev # in-memory ledger, listens on :8080
# frontend (separate terminal)cd app/webnpm installcp .env.example .env.local # VITE_API_BASE defaults to http://localhost:8080npm run dev # Vite dev server on :5173Read paths work immediately. State-changing routes are auth-gated and return
401 in the demo unless you set DEX_DEV_OPEN=1. The full local walkthrough —
write-gate flags, wallet options, and the test suites — is in
Local Setup & Testing; this page covers the real-Canton
paths.
2. DPM sandbox (default live Canton proof)
Section titled “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 scripts/run-dpm-sandbox-proof.shThe 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 for the phase log, party model, failure artifacts, and exact proof boundary.
3. DevKit LocalNet (optional persistent Canton)
Section titled “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 scripts/run-localnet-roundtrip.sh canton-dexThe instance remains available for contract inspection. Stop its containers while preserving ledger volumes with:
canton-devkit localnet down --name canton-dexDevKit 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 for the prerequisite check, role allocation, inspection commands, and destructive cleanup warning.
4. Docker Compose
Section titled “4. Docker Compose”The packaged edge, for running against a remote Canton testnet or MainNet. Two containers come up:
- backend —
Dockerfile.backendrunstestnet-server.tson:8080, persisting the indexer DB to thebackend-datavolume. - frontend — nginx on
:80serves the Vite build and reverse-proxies/v1/*to the backend, pernginx.conf.
flowchart LR
B["Browser (dApp)"] -->|"HTTP :80"| N["frontend nginx :80"]
N -->|"serves Vite build"| B
N -->|"/v1/* → proxy"| A["backend testnet-server.ts :8080"]
A -->|"SQLite"| V[("backend-data volume")]
A -->|"JSON Ledger API (configured operator/LP rights)"| P[("Canton participant CANTON_LEDGER_URL")]
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.
cp services/operator-backend/.env.example .env# 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=1export VITE_PARTYLAYER_NETWORK=canton:testnetexport VITE_PARTYLAYER_WALLET_IDS=console,nightly,send
docker compose builddocker compose up -dCompose reads the repo-root .env for both the backend environment and the
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
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:
docker compose down -v && docker compose up -d5. Testnet deployment (no containers)
Section titled “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
Section titled “Automated: 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.
export CANTON_LEDGER_URL=...export CANTON_LEDGER_TOKEN=...export CANTON_OPERATOR=...export CANTON_LP_REGISTRAR=...export CANTON_ADMIN=...export CANTON_DEX_PACKAGE_ID=...
bash scripts/deploy-testnet.shEach 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:
DEPLOY_SKIP_BUILD=1 \DEPLOY_SKIP_UPLOAD=1 \DEPLOY_SKIP_BOOTSTRAP=1 \DEPLOY_SEED_MARKETS=1 \bash scripts/deploy-testnet.shThat phase requires OPERATOR_ADMIN_TOKEN, checks backend health first, and
queries existing contracts before creating missing market metadata.
Manual: run the backend
Section titled “Manual: run the backend”cd services/operator-backendnpm installexport CANTON_LEDGER_URL=...export CANTON_LEDGER_TOKEN=...# ... (see Environment variables below)npm run testnet # runs testnet-server.tsThe full walkthrough — smoke checks, package-hash alignment, and the PartyLayer live probe — is in Run on a Testnet.
One-time bootstrap
Section titled “One-time bootstrap”Before the backend can serve trades, the registry must have the right contracts
on-ledger. deploy-testnet.sh runs this for you; run it standalone with
scripts/bootstrap-registry.ts:
export CANTON_LEDGER_URL=...export CANTON_LEDGER_TOKEN=...export CANTON_ADMIN=...export CANTON_LP_REGISTRAR=...export CANTON_OPERATOR=...export CANTON_DEX_PACKAGE_ID=...
cd services/operator-backendnode --import tsx ../../scripts/bootstrap-registry.tsThe script is idempotent: running it twice is a no-op. See Registry Integration for what contracts are created and why.
Among them is a Registry.V2 under the lpRegistrar. That one is not
optional: the pool’s LP token is issued by this repository, and its allocation
specs name the lpRegistrar as admin, which Registry.V2 asserts against its own.
Without it, add- and remove-liquidity cannot allocate, whatever the pool trades.
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
Section titled “Environment variables”services/operator-backend/.env.example
and app/web/.env.example are the canonical lists
(including the wallet-provider flags). The backend variables that matter for a
real deployment:
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 |
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_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 |
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; no participant or HTTP
API bearer token is an accepted production build argument.
Production checklist
Section titled “Production checklist”- Separate strong
OPERATOR_ADMIN_TOKENandDEX_OPERATOR_API_TOKENvalues set - Tokens delivered through a trusted session/BFF or short-lived validator tab—not compiled as
VITE_* -
ALLOWED_ORIGINScontains 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_IDandCANTON_SYNCHRONIZERpinned to the vetted values - Asset factory pair set to the live asset registry cid; LP factory pair also set when the registrar differs
-
/v1/statusreportssynced: trueafter 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=1andVITE_ENABLE_HOSTED_RFQ=1, mandatory caller binding, and scoped trader rights - Backend is private behind ingress and runs as the image’s non-root
nodeuser - Indexer DB on a persistent volume (
backend-dataunder Compose;DB_PATH=/var/lib/dex/operator.dbbare) - Process supervisor restarts on crash (systemd / pm2 /
restart: unless-stopped) - TLS terminated at your ingress in front of
:80(Compose) or:8080(bare) - Backups for the indexer DB (it carries trade history and idempotency keys)
- Registry bootstrap run once per ledger
- Monitoring: scrape stdout/stderr; alert on
level: errorlines
Where to read next: Operator Guide · Run on a Testnet · All docs