Skip to content

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.

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.

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.

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.

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.

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.

  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 and pool/index.ts.

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.

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.

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:

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

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.

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