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 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. It does
not invent admin-wide generic factory or context endpoints.
1. The problem in one picture
Section titled “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.
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
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.
2. Three values that must stay together
Section titled “2. Three values that must stay together”One factory lookup returns a normalized
FactoryChoiceContextRef:
{ 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
helper only converts the normalized response into Daml’s ExtraArgs shape:
export function asChoiceContext(ctx: ChoiceContextRef) { return { extraArgs: { context: ctx.context, meta: { values: {} }, }, disclosure: ctx.disclosure, };}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
Section titled “3. Canonical V2 HTTP endpoints”The client is
services/registry-client/src/index.ts.
Its source OpenAPI files are
allocation-instruction-v2.yaml
and
allocation-v2.yaml.
| Operation | Method and path | Request body |
|---|---|---|
| Find an allocation factory | POST /registry/allocation-instruction/v2/allocation-factory |
{ "choiceArguments": <AllocationFactory_Allocate argument> } |
| Find a settlement factory | POST /registry/allocation/v2/settlement-factory |
{ "choiceArguments": <SettlementFactory_SettleBatch argument> } |
| 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:
{ "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
Section titled “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
Section titled “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:
const choiceArguments = { settlement, allocation, requestedAt, inputHoldingCids, actors, extraArgs: EMPTY_EXTRA_ARGS,};
const surface = await operator.getAllocationFactory({ admin: allocation.admin, choiceArguments,});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.
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
and
services/operator-backend/src/http/index.ts.
5. Settlement: preview, discover, execute
Section titled “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
Section titled “Pool swap”PoolRules_PreviewSwapSettlementreads the current pool and returns the candidate settlement batch.- The backend calls
getSettlementFactory(pool.admin, previewResult). PoolRules_Swapreceives that factory, its context, and disclosures.- The real choice re-reads current state and enforces quote binding, constant-product calculation, allocation binding, and minimum output.
Code: PoolRules.daml and
pool/index.ts.
Matched trade
Section titled “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
and
matched-trade/index.ts.
Order match
Section titled “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
and order/index.ts.
6. Cancellation and withdrawal are allocation-specific
Section titled “6. Cancellation and withdrawal are allocation-specific”Cancel and withdraw context is queried with an allocation ID:
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
Section titled “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.
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"]
The repository handles this limitation explicitly:
FixedRegistryClientsupports 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
RegistryClientthrowsRegistryError("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
Section titled “8. Disclosure handling”The backend passes normalized disclosure to the JSON Ledger API as
disclosedContracts. When a transaction has several registry operations,
mergeDisclosures
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
Section titled “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
Section titled “10. Executable proofs”| Question | Proof |
|---|---|
| Is the exact request body sent, normalized, and never cached? | registry-client.test.ts |
| Does a two-admin trade keep preview arguments, contexts, and disclosures separate? | matched-trade.test.ts |
| Does order matching preview before one atomic value-moving execute? | match-leg-shape.test.ts and order-fill-recording.test.ts |
| Is the fixed atomic-liquidity path explicit, and does generic HTTP discovery fail before submission? | pool.test.ts |
| Are split-admin Daml contexts kept in their correct fields? | testDvpSettleThreadsBothAdminContexts in ChoiceContextWorkflowTests.daml |
| Does a context-requiring registry reject missing context? | testRealRegistryDvpRejectsMissingContext in RealRegistryDvpTests.daml |
Reference: Daml choice fields
Section titled “Reference: Daml choice fields”AllocationFactory_Allocate
Section titled “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
Section titled “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 · Allocation surface · Daml proof map