diff --git a/.github/actions/setup_localnet/action.yml b/.github/actions/setup_localnet/action.yml new file mode 100644 index 000000000..e79ae3167 --- /dev/null +++ b/.github/actions/setup_localnet/action.yml @@ -0,0 +1,38 @@ +name: 'Setup Localnet' +description: 'Restore cached Localnet environment and optionally start services' +inputs: + network: + description: 'Network type: devnet or mainnet' + required: true + splice_version: + description: 'Splice version (required)' + required: true + start_services: + description: 'Whether to start Localnet services after setup' + required: false + default: 'true' +runs: + using: 'composite' + steps: + - name: Cache Localnet files + uses: actions/cache/restore@v5 + with: + path: | + .localnet/docker-compose/localnet + .localnet/dars + /tmp/docker-images + key: ${{ runner.os }}-localnet-${{ inputs.splice_version }} + fail-on-cache-miss: true + + - name: Load cached Docker images + shell: bash + run: | + if [ -f /tmp/docker-images/images.tar ]; then + docker load -i /tmp/docker-images/images.tar + echo "Docker images loaded from cache." + fi + + - name: Start Localnet + if: ${{ inputs.start_services == 'true' }} + shell: bash + run: yarn start:localnet -- --network=${{ inputs.network }} diff --git a/.github/actions/setup_yarn/action.yml b/.github/actions/setup_yarn/action.yml new file mode 100644 index 000000000..d7667454a --- /dev/null +++ b/.github/actions/setup_yarn/action.yml @@ -0,0 +1,55 @@ +name: 'Setup Yarn' +description: 'Setup Yarn environment (full initial setup without pre-built artifacts)' +inputs: + daml_release_version: + description: 'Resolved DAML release version from workflow metadata job' + required: true + save_cache: + description: 'Whether to save the DPM cache on a cache miss' + required: false + default: 'false' +runs: + using: composite + steps: + - name: Enable Corepack + shell: bash + run: corepack enable + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: 'v24.9.0' + cache: 'yarn' + + - name: Restore DPM cache + id: dpm_cache + uses: actions/cache/restore@v5 + with: + path: | + ~/.dpm + key: dpm-${{ runner.os }}-${{ inputs.daml_release_version }} + + - name: Add DPM to PATH + shell: bash + run: echo "$HOME/.dpm/bin" >> "$GITHUB_PATH" + + - name: reduce DPM size + if: ${{ steps.dpm_cache.outputs.cache-hit != 'true' }} + shell: bash + #the OCI is only used for multiple version support and can safely be deleted to save ~ 1.3 gb. + run: rm -rf ~/.dpm/cache/oci-layout + + - name: Install dependencies + shell: bash + run: yarn install --immutable + + - name: generate all + shell: bash + run: yarn generate:all + + - name: Save DPM cache + uses: ./.github/actions/save_cache_if_absent + with: + path: | + ~/.dpm + key: ${{ steps.dpm_cache.outputs.cache-primary-key }} diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index fa73f9c7e..7aeb5e916 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -139,6 +139,7 @@ jobs: uses: snok/install-poetry@v1 with: version: 2.1.3 + virtualenvs-in-project: true # load cached venv if cache exists - name: Load cached venv @@ -146,7 +147,7 @@ jobs: uses: actions/cache/restore@v5 with: path: docs/wallet-integration-guide/.venv - key: venv-${{ runner.os }}-${{ steps.setup-python.outputs.python-version }}-${{ hashFiles('docs/wallet-integration-guide/poetry.lock') }} + key: venv-v1-${{ runner.os }}-${{ steps.setup-python.outputs.python-version }}-${{ hashFiles('docs/wallet-integration-guide/poetry.lock') }} # install dependencies if cache does not exist - name: Install dependencies @@ -159,6 +160,7 @@ jobs: with: path: docs/wallet-integration-guide/.venv key: venv-${{ runner.os }}-${{ steps.setup-python.outputs.python-version }}-${{ hashFiles('docs/wallet-integration-guide/poetry.lock') }} + initial_cache_hit: ${{ steps.cached-poetry-dependencies.outputs.cache-hit }} - name: Build docs working-directory: docs/wallet-integration-guide @@ -459,7 +461,7 @@ jobs: run: yarn nx snippets docs-wallet-integration-guide-examples - uses: ./.github/actions/check_resources - + # TODO (#1721): make multi-sync the default and remove the flag once multi-sync is fully supported and tested in the main scripts e2e tests, but for now we want to keep it as an option to avoid accidentally running multi-sync e2e tests without updating the main scripts e2e tests to cover multi-sync as well - name: Stop Localnet (${{ matrix.network }}) if: always() run: yarn stop:localnet -- --network=${{ matrix.network }} @@ -511,7 +513,7 @@ jobs: run: yarn script:test:examples - uses: ./.github/actions/check_resources - + # TODO (#1721): make multi-sync the default and remove the flag once multi-sync is fully supported and tested in the main scripts e2e tests, but for now we want to keep it as an option to avoid accidentally running multi-sync e2e tests without updating the main scripts e2e tests to cover multi-sync as well - name: Stop Localnet (${{ matrix.network }}) if: always() run: yarn stop:localnet -- --network=${{ matrix.network }} @@ -533,10 +535,77 @@ jobs: name: docker-logs-scripts-${{ matrix.network }} path: logs/ + # TODO (#1721): remove multi-sync scripts e2e tests once multi-sync is fully supported and tested in the main scripts e2e tests + wallet-sdk-scripts-e2e-multi-sync: + name: wallet-sdk-scripts-e2e-multi-sync (${{ matrix.network }}) + runs-on: ubuntu-latest + needs: [version-config, hydrate-canton-caches] + strategy: + fail-fast: false + matrix: + network: [devnet, mainnet] + + steps: + - name: Checkout + uses: actions/checkout@v6 + + - uses: ./.github/actions/setup_yarn + with: + daml_release_version: ${{ needs.version-config.outputs.daml_release_version }} + + - uses: ./.github/actions/setup_localnet + with: + network: ${{ matrix.network }} + splice_version: ${{ matrix.network == 'devnet' && needs.version-config.outputs.devnet_splice_version || needs.version-config.outputs.mainnet_splice_version }} + start_services: 'false' + + # TODO (#1721): make multi-sync the default and remove the flag once multi-sync is fully supported and tested in the main scripts e2e tests, but for now we want to keep it as an option to avoid accidentally running multi-sync e2e tests without updating the main scripts e2e tests to cover multi-sync as well + - name: Start Localnet with multi-sync (${{ matrix.network }}) + run: yarn start:localnet -- --network=${{ matrix.network }} --multi-sync + + - uses: ./.github/actions/check_resources + + - name: Build project + run: yarn build:all + + - name: Test multi-sync example script (${{ matrix.network }}) + env: + MAX_IO_LISTENERS: '50' + run: yarn script:test:examples:multi-sync + + - uses: ./.github/actions/check_resources + # TODO (#1721): make multi-sync the default and remove the flag once multi-sync is fully supported and tested in the main scripts e2e tests, but for now we want to keep it as an option to avoid accidentally running multi-sync e2e tests without updating the main scripts e2e tests to cover multi-sync as well + - name: Stop Localnet (${{ matrix.network }}) + if: always() + continue-on-error: true + run: yarn stop:localnet -- --network=${{ matrix.network }} --multi-sync + + - name: Save container logs + if: failure() + run: | + #!/usr/bin/env bash + set -euo pipefail + mkdir -p logs + for c in $(docker ps -a --format '{{.Names}}'); do + docker logs "$c" &> "logs/$c.log" || true + done + + - name: Upload logs as artifacts + if: failure() + uses: actions/upload-artifact@v7 + with: + name: docker-logs-scripts-multi-sync-${{ matrix.network }} + path: logs/ + test-wallet-sdk-e2e: name: test-wallet-sdk-e2e runs-on: ubuntu-latest - needs: [wallet-sdk-snippets-e2e, wallet-sdk-scripts-e2e, wallet-sdk-pkg] + needs: [ + wallet-sdk-snippets-e2e, + wallet-sdk-scripts-e2e, + wallet-sdk-scripts-e2e-multi-sync, # TODO (#1721): remove multi-sync scripts e2e tests once multi-sync is fully supported and tested in the main scripts e2e tests, but for now we want to keep it as a gate to ensure multi-sync e2e tests are not accidentally skipped without updating the main scripts e2e tests to cover multi-sync as well + wallet-sdk-pkg, + ] if: always() steps: - name: Report wallet-sdk e2e execution @@ -549,6 +618,10 @@ jobs: echo "wallet-sdk scripts e2e did not succeed" exit 1 fi + if [ "${{ needs.wallet-sdk-scripts-e2e-multi-sync.result }}" != "success" ]; then + echo "wallet-sdk scripts e2e (multi-sync) did not succeed" + exit 1 + fi if [ "${{ needs.wallet-sdk-pkg.result }}" != "success" ]; then echo "wallet-sdk package validation did not succeed" exit 1 diff --git a/canton/multi-sync/app-synchronizer.sc b/canton/multi-sync/app-synchronizer.sc index 5a9f45411..4c66ffd55 100644 --- a/canton/multi-sync/app-synchronizer.sc +++ b/canton/multi-sync/app-synchronizer.sc @@ -10,25 +10,29 @@ bootstrap.synchronizer( staticSynchronizerParameters = StaticSynchronizerParameters.defaultsWithoutKMS(ProtocolVersion.latest), ) -// Connect app-user and app-provider to the new synchronizer. +// Connect all participants to the new synchronizer. // app-user — global + app-synchronizer // app-provider — global + app-synchronizer -// sv — global only (TradingApp is only an observer of Token Allocations; -// it learns about them when they are reassigned to global before settlement) +// sv — global + app-synchronizer (TokenAdmin on sv submits TokenRules +// and Token contracts on the app-synchronizer) // // The global domain is connected first (before this bootstrap script runs), // so connectedSynchronizers[0] remains global for all participants — the // default synchronizer selection is unaffected. `app-provider`.synchronizers.connect_local(`app-sequencer`, "app-synchronizer") `app-user`.synchronizers.connect_local(`app-sequencer`, "app-synchronizer") +`sv`.synchronizers.connect_local(`app-sequencer`, "app-synchronizer") -// Wait for both participants to be active on app-synchronizer +// Wait for all participants to be active on app-synchronizer utils.retry_until_true { `app-provider`.synchronizers.active("app-synchronizer") } utils.retry_until_true { `app-user`.synchronizers.active("app-synchronizer") } +utils.retry_until_true { + `sv`.synchronizers.active("app-synchronizer") +} // Vet packages on app-synchronizer for all three participants. // The Splice app already uploaded DARs and vetted them on global-domain. @@ -39,7 +43,7 @@ val appSyncId = `app-provider`.synchronizers.list_connected() .getOrElse(throw new RuntimeException("app-synchronizer not found in connected synchronizers")) .synchronizerId -for (participant <- Seq(`app-provider`, `app-user`)) { +for (participant <- Seq(`app-provider`, `app-user`, `sv`)) { val vettedFromAuthorized = participant.topology.vetted_packages .list(store = Some(TopologyStoreId.Authorized), filterParticipant = participant.id.filterString) .flatMap(_.item.packages) @@ -54,7 +58,7 @@ for (participant <- Seq(`app-provider`, `app-user`)) { } } -// Wait for vetting topology to propagate for app-provider and app-user +// Wait for vetting topology to propagate for all participants utils.retry_until_true { val providerVetted = `app-provider`.topology.vetted_packages .list(store = Some(appSyncId), filterParticipant = `app-provider`.id.filterString) @@ -65,5 +69,24 @@ utils.retry_until_true { .list(store = Some(appSyncId), filterParticipant = `app-user`.id.filterString) userVetted.nonEmpty && userVetted.head.item.packages.nonEmpty } +utils.retry_until_true { + val svVetted = `sv`.topology.vetted_packages + .list(store = Some(appSyncId), filterParticipant = `sv`.id.filterString) + svVetted.nonEmpty && svVetted.head.item.packages.nonEmpty +} -logger.info("app-synchronizer bootstrap with package vetting completed successfully for app-provider and app-user") +logger.info("app-synchronizer bootstrap with package vetting completed successfully for app-provider, app-user, and sv") + +// Final gate: confirm all participants are active on the global synchronizer +// (Canton alias "global", as configured in conf/splice/app.conf domains.global.alias). +// On slower CI environments (e.g. devnet) sv's global synchronizer ledger API connection +// can still be initialising when the app-synchronizer steps above finish. +// docker wait multi-sync-startup will not return until this check passes, +// preventing the "Unknown or not connected synchronizer global-domain::..." error +// that occurs when party allocation is attempted before sv is ready. +utils.retry_until_true { + `app-provider`.synchronizers.active("global") && + `app-user`.synchronizers.active("global") && + `sv`.synchronizers.active("global") +} +logger.info("All participants confirmed active on global synchronizer — localnet ready") diff --git a/docs/wallet-integration-guide/examples/scripts/15-multi-sync/README.md b/docs/wallet-integration-guide/examples/scripts/15-multi-sync/README.md index 8b02e46db..6d1f1e0be 100644 --- a/docs/wallet-integration-guide/examples/scripts/15-multi-sync/README.md +++ b/docs/wallet-integration-guide/examples/scripts/15-multi-sync/README.md @@ -1,6 +1,22 @@ # Example 15: Multi-Synchronizer DvP Trade -This example implements a Delivery vs Payment (DvP) flow across two synchronizers: Amulet on the global synchronizer and a Token instrument on a private app-synchronizer, settled via the OTC Trading App using only single-party submissions. +This example implements a Delivery vs Payment (DvP) flow across two synchronizers using the **v2 OTC trading app** (`splice-token-test-trading-app-v2`): Alice pays 100 Amulet on the global synchronizer, and Bob delivers 20 TestToken whose home is a private app-synchronizer. + +## DAR vetting placement + +The two apps are vetted only where they belong: + +- **Trading-app v2 DAR → global synchronizer only.** The venue, the `OTCTrade`, and the `OTCTradeAllocationRequest`s all live on global, and `OTCTrade_Settle` runs there. +- **TestToken v1 DAR → app-synchronizer (its home) and global (transit only).** The Token is minted on, and returns to, the private app-synchronizer. It is also vetted on global because `OTCTrade_Settle` is a single atomic Daml transaction on global that touches the Token allocation — Canton requires every package referenced by a transaction, and every package of a contract reassigned onto a synchronizer, to be vetted there. An atomic cross-synchronizer DvP cannot avoid vetting the Token package on the settlement synchronizer. + +## Flow + +1. Mint Amulet for Alice (global); mint TestToken for Bob (app-synchronizer). +2. The venue creates the v2 `OTCTrade` (signatory: venue only) and exercises `OTCTrade_RequestAllocations`. +3. The venue and each trader co-sign a `TradeSettlementAgreement` — the v2 trading app needs it to settle V1 token-standard assets. +4. Alice allocates Amulet; Bob allocates TestToken. Bob's Token auto-reassigns app-sync → global (no explicit reassignment) because he already holds global-synchronizer contracts. +5. The venue settles via `OTCTrade_Settle` (`SettlementBatchV1` for both legs). +6. Alice and Bob self-transfer their TestToken holdings back to the app-synchronizer. ## Running Locally diff --git a/docs/wallet-integration-guide/examples/scripts/15-multi-sync/_config.ts b/docs/wallet-integration-guide/examples/scripts/15-multi-sync/_config.ts index e37146711..41cccfc64 100644 --- a/docs/wallet-integration-guide/examples/scripts/15-multi-sync/_config.ts +++ b/docs/wallet-integration-guide/examples/scripts/15-multi-sync/_config.ts @@ -7,7 +7,7 @@ * Port layout (PARTICIPANT_JSON_API_PORT_SUFFIX = 975): * 2975 — app-user (P1): global + app-synchronizer * 3975 — app-provider (P2): global + app-synchronizer - * 4975 — sv (P3): global only + * 4975 — sv (P3): global + app-synchronizer * */ @@ -17,7 +17,15 @@ export const LOCALNET_BOB_LEDGER_URL = new URL('http://localhost:3975') // trading-app-participant JSON API (4 + PARTICIPANT_JSON_API_PORT_SUFFIX 975) export const LOCALNET_TRADING_APP_LEDGER_URL = new URL('http://localhost:4975') -// Party hint labels used when allocating parties +// TestToken Token Standard registry (hosted in-process by example 15) +export const LOCALNET_TEST_TOKEN_REGISTRY_PORT = parseInt( + process.env['REGISTRY_PORT'] ?? '5975', + 10 +) +export const LOCALNET_TEST_TOKEN_REGISTRY_URL = new URL( + `http://localhost:${LOCALNET_TEST_TOKEN_REGISTRY_PORT}` +) + export const PARTY_HINT_ALICE = 'Alice' export const PARTY_HINT_BOB = 'Bob' export const PARTY_HINT_TRADING_APP = 'TradingApp' diff --git a/docs/wallet-integration-guide/examples/scripts/15-multi-sync/_registry/features/allocation-instruction/handlers.ts b/docs/wallet-integration-guide/examples/scripts/15-multi-sync/_registry/features/allocation-instruction/handlers.ts new file mode 100644 index 000000000..a87e063b9 --- /dev/null +++ b/docs/wallet-integration-guide/examples/scripts/15-multi-sync/_registry/features/allocation-instruction/handlers.ts @@ -0,0 +1,55 @@ +// Copyright (c) 2025-2026 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +/** + * TestToken implementation of AllocationInstructionHandlers. + * + * Resolves the AllocationFactory by looking up the live TokenRules contract on the + * *global* synchronizer from the ledger ACS. For trade settlement the token must + * be allocated on the global (trade) synchronizer, so we always return the + * TokenRules contract that lives there. The TokenRules contract is also included + * as a disclosed contract so the wallet SDK can pass it through to the Ledger API + * when exercising AllocationFactory_Allocate via the interface. + */ + +import type { + FactoryWithChoiceContext, + AllocationInstructionHandlers, + GetFactoryRequest, +} from '../../types.js' +import type { TokenRulesContract } from '../../ledger.js' + +export interface AllocationInstructionHandlerContext { + getTokenRules: ( + synchronizerId?: string + ) => Promise + + globalSynchronizerId: string +} + +export function createAllocationInstructionHandlers( + ctx: AllocationInstructionHandlerContext +): AllocationInstructionHandlers { + return { + getAllocationFactory: async ( + _req: GetFactoryRequest + ): Promise => { + const tokenRules = await ctx.getTokenRules(ctx.globalSynchronizerId) + if (!tokenRules) return null + return { + factoryId: tokenRules.contractId, + choiceContext: { + choiceContextData: {}, + disclosedContracts: [ + { + templateId: tokenRules.templateId, + contractId: tokenRules.contractId, + createdEventBlob: tokenRules.createdEventBlob, + synchronizerId: tokenRules.synchronizerId, + }, + ], + }, + } + }, + } +} diff --git a/docs/wallet-integration-guide/examples/scripts/15-multi-sync/_registry/features/allocation/handlers.ts b/docs/wallet-integration-guide/examples/scripts/15-multi-sync/_registry/features/allocation/handlers.ts new file mode 100644 index 000000000..6bc430720 --- /dev/null +++ b/docs/wallet-integration-guide/examples/scripts/15-multi-sync/_registry/features/allocation/handlers.ts @@ -0,0 +1,27 @@ +// Copyright (c) 2025-2026 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +/** + * TestToken implementation of AllocationHandlers. + * + * All allocation choice-context endpoints return an empty context — no extra + * contracts need to be disclosed for execute-transfer, withdraw, or cancel. + */ + +import type { ChoiceContext, AllocationHandlers } from '../../types.js' + +export function createAllocationHandlers(): AllocationHandlers { + const emptyContext: ChoiceContext = { + choiceContextData: {}, + disclosedContracts: [], + } + + return { + getAllocationTransferContext: async (): Promise => + emptyContext, + getAllocationWithdrawContext: async (): Promise => + emptyContext, + getAllocationCancelContext: async (): Promise => + emptyContext, + } +} diff --git a/docs/wallet-integration-guide/examples/scripts/15-multi-sync/_registry/features/metadata/handlers.ts b/docs/wallet-integration-guide/examples/scripts/15-multi-sync/_registry/features/metadata/handlers.ts new file mode 100644 index 000000000..27534aceb --- /dev/null +++ b/docs/wallet-integration-guide/examples/scripts/15-multi-sync/_registry/features/metadata/handlers.ts @@ -0,0 +1,49 @@ +// Copyright (c) 2025-2026 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +/** + * TestToken implementation of MetadataHandlers. + * + * Provides static metadata about the TestToken instrument and the registry's + * supported Token Standard APIs. Logic here is specific to the TestToken + * example — the route wiring lives in the auto-generated routes.ts. + */ + +import type { + GetRegistryInfoResponse, + Instrument, + ListInstrumentsResponse, + MetadataHandlers, +} from '../../types.js' + +export interface MetadataHandlerContext { + tokenAdminPartyId: string + supportedApis: Record + instrumentId: string +} + +export function createMetadataHandlers( + ctx: MetadataHandlerContext +): MetadataHandlers { + const instrument: Instrument = { + id: ctx.instrumentId, + name: 'TestToken', + symbol: 'TT', + decimals: 10, + supportedApis: ctx.supportedApis, + } + + return { + getRegistryInfo: (): GetRegistryInfoResponse => ({ + adminId: ctx.tokenAdminPartyId, + supportedApis: ctx.supportedApis, + }), + + listInstruments: (): ListInstrumentsResponse => ({ + instruments: [instrument], + }), + + getInstrument: ({ instrumentId }): Instrument | null => + instrumentId === ctx.instrumentId ? instrument : null, + } +} diff --git a/docs/wallet-integration-guide/examples/scripts/15-multi-sync/_registry/features/transfer/handlers.ts b/docs/wallet-integration-guide/examples/scripts/15-multi-sync/_registry/features/transfer/handlers.ts new file mode 100644 index 000000000..a2cca7155 --- /dev/null +++ b/docs/wallet-integration-guide/examples/scripts/15-multi-sync/_registry/features/transfer/handlers.ts @@ -0,0 +1,97 @@ +// Copyright (c) 2025-2026 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +/** + * TestToken implementation of TransferHandlers. + * + * Resolves the TransferFactory by looking up the live TokenRules contract from the + * ledger ACS, then exposes it as a disclosed contract in the choice context. + * + * transferKind is inferred from the choiceArguments: + * - 'self' when sender === receiver (self-transfer, typically to move a token + * across synchronizers — Canton auto-reassigns the holding) + * - 'offer' otherwise (creates a TokenTransferOffer the receiver must accept) + * + * Synchronizer selection: + * - All transfers (self or offer) use the app-synchronizer TokenRules as the factory, + * because Token holdings live on app-sync. Disclosed TokenRules must match the + * transaction target synchronizer to avoid PRESCRIBED_SYNCHRONIZER_ID_MISMATCH. + * + * Accept/reject/withdraw context endpoints return an empty context — no extra + * contracts need to be disclosed for those choices. + */ + +import type { + TransferFactoryWithChoiceContext, + ChoiceContext, + TransferHandlers, + GetFactoryRequest, +} from '../../types.js' +import type { TokenRulesContract } from '../../ledger.js' + +export interface TransferHandlerContext { + getTokenRules: ( + synchronizerId?: string + ) => Promise + appSynchronizerId: string +} + +export function createTransferHandlers( + ctx: TransferHandlerContext +): TransferHandlers { + return { + getTransferFactory: async ( + req: GetFactoryRequest + ): Promise => { + const args = req.choiceArguments as unknown as Record< + string, + unknown + > + const transfer = args?.transfer as + | Record + | undefined + const isSelf = + transfer !== undefined && + transfer.sender !== undefined && + transfer.sender === transfer.receiver + const transferKind: 'self' | 'offer' = isSelf ? 'self' : 'offer' + + const synchronizerId = ctx.appSynchronizerId + const tokenRules = await ctx.getTokenRules(synchronizerId) + if (!tokenRules) return null + return { + factoryId: tokenRules.contractId, + transferKind, + choiceContext: { + choiceContextData: { values: {} }, + disclosedContracts: [ + { + templateId: tokenRules.templateId, + contractId: tokenRules.contractId, + createdEventBlob: tokenRules.createdEventBlob, + synchronizerId: tokenRules.synchronizerId, + }, + ], + }, + } + }, + + getTransferInstructionAcceptContext: + async (): Promise => ({ + choiceContextData: { values: {} }, + disclosedContracts: [], + }), + + getTransferInstructionRejectContext: + async (): Promise => ({ + choiceContextData: { values: {} }, + disclosedContracts: [], + }), + + getTransferInstructionWithdrawContext: + async (): Promise => ({ + choiceContextData: { values: {} }, + disclosedContracts: [], + }), + } +} diff --git a/docs/wallet-integration-guide/examples/scripts/15-multi-sync/_registry/http/router.ts b/docs/wallet-integration-guide/examples/scripts/15-multi-sync/_registry/http/router.ts new file mode 100644 index 000000000..367a2ab80 --- /dev/null +++ b/docs/wallet-integration-guide/examples/scripts/15-multi-sync/_registry/http/router.ts @@ -0,0 +1,99 @@ +// Copyright (c) 2025-2026 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +/** + * Minimal path-parameter router for the TestToken registry server. + * + * Provides `createRouter()` for route registration / matching, and two + * standalone helpers (`respond`, `readBody`) that every feature slice shares. + */ + +import type { IncomingMessage, ServerResponse } from 'node:http' + +export type RouteHandler = ( + req: IncomingMessage, + res: ServerResponse, + body: unknown, + params: Record +) => Promise + +interface Route { + method: string + /** Literal path segments or `:paramName` placeholders. */ + pattern: string[] + handler: RouteHandler +} + +export interface Router { + route: (method: string, pattern: string, handler: RouteHandler) => void + matchRoute: ( + method: string, + pathname: string + ) => { handler: RouteHandler; params: Record } | null +} + +export function createRouter(): Router { + const routes: Route[] = [] + + function route( + method: string, + pattern: string, + handler: RouteHandler + ): void { + routes.push({ method, pattern: pattern.split('/'), handler }) + } + + function matchRoute( + method: string, + pathname: string + ): { handler: RouteHandler; params: Record } | null { + const segments = pathname.split('/') + for (const r of routes) { + if (r.method !== method) continue + if (r.pattern.length !== segments.length) continue + const params: Record = {} + let ok = true + for (let i = 0; i < r.pattern.length; i++) { + const p = r.pattern[i]! + if (p.startsWith(':')) { + params[p.slice(1)] = decodeURIComponent(segments[i]!) + } else if (p !== segments[i]) { + ok = false + break + } + } + if (ok) return { handler: r.handler, params } + } + return null + } + + return { route, matchRoute } +} + +export function respond( + res: ServerResponse, + status: number, + body: unknown +): void { + const payload = JSON.stringify(body) + res.writeHead(status, { + 'Content-Type': 'application/json', + 'Content-Length': Buffer.byteLength(payload), + }) + res.end(payload) +} + +export async function readBody(req: IncomingMessage): Promise { + return new Promise((resolve, reject) => { + let raw = '' + req.on('data', (chunk: Buffer) => (raw += chunk.toString())) + req.on('end', () => { + try { + resolve(raw.length ? JSON.parse(raw) : {}) + } catch { + reject(new Error('Invalid JSON body')) + } + }) + req.on('error', reject) + }) +} diff --git a/docs/wallet-integration-guide/examples/scripts/15-multi-sync/_registry/index.ts b/docs/wallet-integration-guide/examples/scripts/15-multi-sync/_registry/index.ts new file mode 100644 index 000000000..001905442 --- /dev/null +++ b/docs/wallet-integration-guide/examples/scripts/15-multi-sync/_registry/index.ts @@ -0,0 +1,328 @@ +// Copyright (c) 2025-2026 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +/** + * TestToken Registry — entry point. + * + * Wires the HTTP router, all feature-slice route handlers, and the ledger client + * into a single `startRegistry()` factory that is called from the example's + * initialization phase once the tokenAdmin party ID is known. + * + * Implements all four Token Standard off-ledger registry APIs: + * api-specs/splice/0.6.1/token-metadata-v1.yaml + * api-specs/splice/0.6.1/transfer-instruction-v1.yaml + * api-specs/splice/0.6.1/allocation-v1.yaml + * api-specs/splice/0.6.1/allocation-instruction-v1.yaml + */ + +import { + createServer, + type IncomingMessage, + type ServerResponse, +} from 'node:http' +import type { Logger } from 'pino' +import { buildLedgerClient, readTokenRules } from './ledger.js' +import type { LedgerClient } from '@canton-network/core-ledger-client' +import type { TokenRulesContract } from './ledger.js' +import { createRouter, respond, readBody } from './http/router.js' +import type { GetFactoryRequest, GetChoiceContextRequest } from './types.js' +import { createMetadataHandlers } from './features/metadata/handlers.js' +import { createTransferHandlers } from './features/transfer/handlers.js' +import { createAllocationInstructionHandlers } from './features/allocation-instruction/handlers.js' +import { createAllocationHandlers } from './features/allocation/handlers.js' + +// ── static instrument metadata ───────────────────────────────────────────── +const TEST_TOKEN_INSTRUMENT_ID = 'TestToken' + +const SUPPORTED_APIS: Record = { + 'splice-api-token-metadata-v1': 0, + 'splice-api-token-transfer-instruction-v1': 1, + 'splice-api-token-allocation-instruction-v1': 0, + 'splice-api-token-allocation-v1': 1, +} + +// ── Route table (source of truth: api-specs/splice/0.6.1/) ──────────────── +interface RouteEntry { + method: string + pattern: string + operationId: string + nullable?: boolean +} + +const ROUTES: RouteEntry[] = [ + // token-metadata-v1 + { + method: 'GET', + pattern: '/registry/metadata/v1/info', + operationId: 'getRegistryInfo', + }, + { + method: 'GET', + pattern: '/registry/metadata/v1/instruments', + operationId: 'listInstruments', + }, + { + method: 'GET', + pattern: '/registry/metadata/v1/instruments/:instrumentId', + operationId: 'getInstrument', + nullable: true, + }, + // transfer-instruction-v1 + { + method: 'POST', + pattern: '/registry/transfer-instruction/v1/transfer-factory', + operationId: 'getTransferFactory', + nullable: true, + }, + { + method: 'POST', + pattern: + '/registry/transfer-instruction/v1/:transferInstructionId/choice-contexts/accept', + operationId: 'getTransferInstructionAcceptContext', + }, + { + method: 'POST', + pattern: + '/registry/transfer-instruction/v1/:transferInstructionId/choice-contexts/reject', + operationId: 'getTransferInstructionRejectContext', + }, + { + method: 'POST', + pattern: + '/registry/transfer-instruction/v1/:transferInstructionId/choice-contexts/withdraw', + operationId: 'getTransferInstructionWithdrawContext', + }, + // allocation-instruction-v1 + { + method: 'POST', + pattern: '/registry/allocation-instruction/v1/allocation-factory', + operationId: 'getAllocationFactory', + nullable: true, + }, + // allocation-v1 + { + method: 'POST', + pattern: + '/registry/allocations/v1/:allocationId/choice-contexts/execute-transfer', + operationId: 'getAllocationTransferContext', + }, + { + method: 'POST', + pattern: + '/registry/allocations/v1/:allocationId/choice-contexts/withdraw', + operationId: 'getAllocationWithdrawContext', + }, + { + method: 'POST', + pattern: + '/registry/allocations/v1/:allocationId/choice-contexts/cancel', + operationId: 'getAllocationCancelContext', + }, +] +export interface RegistryConfig { + tokenAdminPartyId: string + port: number + ledgerUrl: URL + logger: Logger + globalSynchronizerId: string + appSynchronizerId: string +} + +export interface RegistryHandle { + stop(): Promise +} + +/** + * Starts the TestToken registry HTTP server. + * + * @param config - Runtime configuration (party ID, port, ledger URL, logger). + * @returns A handle with a `stop()` method for graceful shutdown. + */ +export async function startRegistry( + config: RegistryConfig +): Promise { + const { + tokenAdminPartyId, + port, + ledgerUrl, + logger, + globalSynchronizerId, + appSynchronizerId, + } = config + + const ledgerClient: LedgerClient = buildLedgerClient(ledgerUrl, logger) + + async function getTokenRules( + synchronizerId?: string + ): Promise { + const all = await readTokenRules( + ledgerClient, + tokenAdminPartyId, + logger + ) + if (all.length === 0) return null + if (!synchronizerId) return all[0]! + return all.find((c) => c.synchronizerId === synchronizerId) ?? all[0]! + } + + const metadata = createMetadataHandlers({ + tokenAdminPartyId, + supportedApis: SUPPORTED_APIS, + instrumentId: TEST_TOKEN_INSTRUMENT_ID, + }) + const transfer = createTransferHandlers({ + getTokenRules, + appSynchronizerId, + }) + const allocInstr = createAllocationInstructionHandlers({ + getTokenRules, + globalSynchronizerId, + }) + const alloc = createAllocationHandlers() + + // Dispatch map: operationId → (params, body) → Promise + type DispatchFn = ( + params: Record, + body: unknown + ) => Promise + const dispatch = new Map([ + // Metadata + ['getRegistryInfo', async () => metadata.getRegistryInfo()], + ['listInstruments', async () => metadata.listInstruments()], + [ + 'getInstrument', + async (p) => + metadata.getInstrument({ instrumentId: p['instrumentId']! }), + ], + // Transfer + [ + 'getTransferFactory', + async (_, b) => transfer.getTransferFactory(b as GetFactoryRequest), + ], + [ + 'getTransferInstructionAcceptContext', + async (p, b) => + transfer.getTransferInstructionAcceptContext( + { transferInstructionId: p['transferInstructionId']! }, + b as GetChoiceContextRequest + ), + ], + [ + 'getTransferInstructionRejectContext', + async (p, b) => + transfer.getTransferInstructionRejectContext( + { transferInstructionId: p['transferInstructionId']! }, + b as GetChoiceContextRequest + ), + ], + [ + 'getTransferInstructionWithdrawContext', + async (p, b) => + transfer.getTransferInstructionWithdrawContext( + { transferInstructionId: p['transferInstructionId']! }, + b as GetChoiceContextRequest + ), + ], + // Allocation Instruction + [ + 'getAllocationFactory', + async (_, b) => + allocInstr.getAllocationFactory(b as GetFactoryRequest), + ], + // Allocation + [ + 'getAllocationTransferContext', + async (p, b) => + alloc.getAllocationTransferContext( + { allocationId: p['allocationId']! }, + b as GetChoiceContextRequest + ), + ], + [ + 'getAllocationWithdrawContext', + async (p, b) => + alloc.getAllocationWithdrawContext( + { allocationId: p['allocationId']! }, + b as GetChoiceContextRequest + ), + ], + [ + 'getAllocationCancelContext', + async (p, b) => + alloc.getAllocationCancelContext( + { allocationId: p['allocationId']! }, + b as GetChoiceContextRequest + ), + ], + ]) + + const { route, matchRoute } = createRouter() + for (const { method, pattern, operationId, nullable = false } of ROUTES) { + route(method, pattern, async (_req, res, body, params) => { + const fn = dispatch.get(operationId)! + const result = await fn(params, body) + if (nullable && result === null) { + respond(res, 404, { error: `${operationId}: not found` }) + } else { + respond(res, 200, result) + } + }) + } + + const server = createServer( + async (req: IncomingMessage, res: ServerResponse) => { + const url = new URL(req.url ?? '/', 'http://localhost') + const method = req.method?.toUpperCase() ?? 'GET' + const pathname = url.pathname + + logger.debug({ method, pathname }, 'incoming request') + + try { + const match = matchRoute(method, pathname) + if (!match) { + respond(res, 404, { + error: `${method} ${pathname} not found`, + }) + return + } + const body = + method === 'POST' || method === 'PUT' + ? await readBody(req) + : {} + await match.handler(req, res, body, match.params) + } catch (err) { + logger.error(err, 'request handler error') + if (!res.headersSent) { + respond(res, 500, { + error: err instanceof Error ? err.message : String(err), + }) + } + } + } + ) + + await new Promise((resolve) => server.listen(port, resolve)) + + logger.info( + { port, tokenAdminPartyId, ledgerUrl: ledgerUrl.href }, + 'TestToken registry server started' + ) + logger.info(` GET http://localhost:${port}/registry/metadata/v1/info`) + logger.info( + ` GET http://localhost:${port}/registry/metadata/v1/instruments` + ) + logger.info( + ` POST http://localhost:${port}/registry/transfer-instruction/v1/transfer-factory` + ) + logger.info( + ` POST http://localhost:${port}/registry/allocation-instruction/v1/allocation-factory` + ) + + return { + stop(): Promise { + return new Promise((resolve, reject) => + server.close((err) => (err ? reject(err) : resolve())) + ) + }, + } +} diff --git a/docs/wallet-integration-guide/examples/scripts/15-multi-sync/_registry/ledger.ts b/docs/wallet-integration-guide/examples/scripts/15-multi-sync/_registry/ledger.ts new file mode 100644 index 000000000..5c591ffd9 --- /dev/null +++ b/docs/wallet-integration-guide/examples/scripts/15-multi-sync/_registry/ledger.ts @@ -0,0 +1,122 @@ +// Copyright (c) 2025-2026 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +/** + * Ledger access helpers for the TestToken registry server. + * + * Reads `TokenRules` contracts from P3 (sv participant, port 4975) on behalf of the + * tokenAdmin party. P3 is connected to both synchronizers (global + app) and hosts tokenAdmin + * on both, so it can return TokenRules for either. Results are cached for a configurable TTL + * to avoid hammering the ledger on every incoming HTTP request. + */ + +import { LedgerClient } from '@canton-network/core-ledger-client' +import { AuthTokenProvider } from '@canton-network/core-wallet-auth' +import type { Logger } from 'pino' + +export const TOKEN_RULES_TEMPLATE_ID = + '#splice-test-token-v1:Splice.Testing.Tokens.TestTokenV1:TokenRules' + +export interface TokenRulesContract { + contractId: string + templateId: string + createdEventBlob: string + synchronizerId: string +} + +interface Cache { + contracts: TokenRulesContract[] + expireAt: number +} + +let cache: Cache | null = null +const CACHE_TTL_MS = 5_000 + +export function buildLedgerClient( + ledgerUrl: URL, + logger: Logger +): LedgerClient { + const accessTokenProvider = new AuthTokenProvider( + { + method: 'self_signed', + issuer: 'unsafe-auth', + credentials: { + clientId: 'ledger-api-user', + clientSecret: 'unsafe', + audience: 'https://canton.network.global', + scope: '', + }, + }, + logger + ) + + return new LedgerClient({ baseUrl: ledgerUrl, logger, accessTokenProvider }) +} + +/** + * Reads `TokenRules` contracts visible to `tokenAdminPartyId` from P3 (sv participant, + * port 4975) — the participant that hosts tokenAdmin on both synchronizers. Caches results + * for a configurable TTL to + */ +export async function readTokenRules( + client: LedgerClient, + tokenAdminPartyId: string, + logger: Logger +): Promise { + const now = Date.now() + if (cache && now < cache.expireAt) { + logger.debug('TokenRules cache hit') + return cache.contracts + } + + logger.debug('Fetching TokenRules from ledger ACS…') + + const ledgerEnd = await client.get('/v2/state/ledger-end') + const offset = ledgerEnd.offset ?? 0 + + const rawAcs = await client.activeContracts({ + offset, + templateIds: [TOKEN_RULES_TEMPLATE_ID], + parties: [tokenAdminPartyId], + }) + + const contracts: TokenRulesContract[] = rawAcs + .filter( + (entry) => + entry.contractEntry != null && + 'JsActiveContract' in entry.contractEntry + ) + .map((entry) => { + const jsAC = ( + entry.contractEntry as { + JsActiveContract: { + createdEvent: { + contractId: string + templateId: string + createdEventBlob: string + } + synchronizerId: string + } + } + ).JsActiveContract + + return { + contractId: jsAC.createdEvent.contractId, + templateId: jsAC.createdEvent.templateId, + createdEventBlob: jsAC.createdEvent.createdEventBlob, + synchronizerId: jsAC.synchronizerId, + } + }) + + logger.debug( + { count: contracts.length }, + 'TokenRules contracts fetched from ledger' + ) + + cache = { contracts, expireAt: now + CACHE_TTL_MS } + return contracts +} + +export function invalidateCache(): void { + cache = null +} diff --git a/docs/wallet-integration-guide/examples/scripts/15-multi-sync/_registry/types.ts b/docs/wallet-integration-guide/examples/scripts/15-multi-sync/_registry/types.ts new file mode 100644 index 000000000..06fa1e3da --- /dev/null +++ b/docs/wallet-integration-guide/examples/scripts/15-multi-sync/_registry/types.ts @@ -0,0 +1,137 @@ +// Copyright (c) 2025-2026 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +/** + * Shared Token Standard types for the TestToken registry server. + * + * Derived from the four API specs in api-specs/splice/0.6.1/. Each handler + * interface corresponds to one spec; common primitives are deduplicated here. + */ + +// ── Shared primitives ────────────────────────────────────────────────────── + +export interface DisclosedContract { + templateId: string + contractId: string + createdEventBlob: string + synchronizerId: string + debugPackageName?: string + debugPayload?: Record + debugCreatedAt?: string +} + +export interface ChoiceContext { + choiceContextData: Record + disclosedContracts: DisclosedContract[] +} + +/** Used by getTransferFactory and getAllocationFactory. */ +export interface GetFactoryRequest { + choiceArguments: Record + excludeDebugFields?: boolean +} + +/** Used by transfer-instruction and allocation choice-context endpoints. */ +export interface GetChoiceContextRequest { + meta?: Record + excludeDebugFields?: boolean +} + +// ── token-metadata-v1 ────────────────────────────────────────────────────── + +export type SupportedApis = Record + +export interface GetRegistryInfoResponse { + adminId: string + supportedApis: SupportedApis +} + +export interface Instrument { + id: string + name: string + symbol: string + totalSupply?: string + totalSupplyAsOf?: string + decimals: number + supportedApis: SupportedApis +} + +export interface ListInstrumentsResponse { + instruments: Instrument[] + nextPageToken?: string +} + +export interface MetadataHandlers { + getRegistryInfo(): + | GetRegistryInfoResponse + | Promise + listInstruments(query?: { + pageSize?: number + pageToken?: string + }): ListInstrumentsResponse | Promise + getInstrument(path: { + instrumentId: string + }): Instrument | null | Promise +} + +// ── transfer-instruction-v1 ──────────────────────────────────────────────── + +export interface TransferFactoryWithChoiceContext { + factoryId: string + transferKind: 'self' | 'direct' | 'offer' + choiceContext: ChoiceContext +} + +export interface TransferHandlers { + getTransferFactory( + body: GetFactoryRequest + ): + | TransferFactoryWithChoiceContext + | null + | Promise + getTransferInstructionAcceptContext( + path: { transferInstructionId: string }, + body: GetChoiceContextRequest + ): ChoiceContext | Promise + getTransferInstructionRejectContext( + path: { transferInstructionId: string }, + body: GetChoiceContextRequest + ): ChoiceContext | Promise + getTransferInstructionWithdrawContext( + path: { transferInstructionId: string }, + body: GetChoiceContextRequest + ): ChoiceContext | Promise +} + +// ── allocation-instruction-v1 ────────────────────────────────────────────── + +export interface FactoryWithChoiceContext { + factoryId: string + choiceContext: ChoiceContext +} + +export interface AllocationInstructionHandlers { + getAllocationFactory( + body: GetFactoryRequest + ): + | FactoryWithChoiceContext + | null + | Promise +} + +// ── allocation-v1 ────────────────────────────────────────────────────────── + +export interface AllocationHandlers { + getAllocationTransferContext( + path: { allocationId: string }, + body: GetChoiceContextRequest + ): ChoiceContext | Promise + getAllocationWithdrawContext( + path: { allocationId: string }, + body: GetChoiceContextRequest + ): ChoiceContext | Promise + getAllocationCancelContext( + path: { allocationId: string }, + body: GetChoiceContextRequest + ): ChoiceContext | Promise +} diff --git a/docs/wallet-integration-guide/examples/scripts/15-multi-sync/_setup.ts b/docs/wallet-integration-guide/examples/scripts/15-multi-sync/_setup.ts index 2f61d71ee..5203adc55 100644 --- a/docs/wallet-integration-guide/examples/scripts/15-multi-sync/_setup.ts +++ b/docs/wallet-integration-guide/examples/scripts/15-multi-sync/_setup.ts @@ -19,18 +19,22 @@ import { AuthTokenProvider } from '@canton-network/core-wallet-auth' import { TOKEN_NAMESPACE_CONFIG, TOKEN_PROVIDER_CONFIG_DEFAULT, - resolveGlobalSynchronizerId, vetDar, } from '../utils/index.js' import type { SynchronizerMap } from '../utils/index.js' import { LOCALNET_BOB_LEDGER_URL, LOCALNET_TRADING_APP_LEDGER_URL, + LOCALNET_TEST_TOKEN_REGISTRY_URL, PARTY_HINT_ALICE, PARTY_HINT_BOB, PARTY_HINT_TRADING_APP, PARTY_HINT_TOKEN_ADMIN, } from './_config.js' +const TRADING_APP_V2_DAR_LOCAL_PATH = + './daml/splice-token-test-trading-app-v2/splice-token-test-trading-app-v2-1.0.0.dar' +const TEST_TOKEN_V1_DAR_LOCAL_PATH = + './daml/splice-test-token-v1/splice-test-token-v1-1.0.0.dar' export type PartyInfo = Omit< GenerateTransactionResponse, @@ -40,8 +44,6 @@ export type PartyInfo = Omit< keyPair: KeyPair } -const TEST_TOKEN_V1_DAR = 'splice-test-token-v1-1.0.0.dar' - export interface MultiSyncSetup { p1Sdk: SDKInterface<'token'> p2Sdk: SDKInterface<'token'> @@ -66,29 +68,40 @@ export interface MultiSyncSetup { * Bootstraps a fresh multi-synchronizer environment: * - Creates SDK instances for P1 (app-user), P2 (app-provider), P3 (sv) * - Discovers global + app synchronizer IDs from P1 - * - Allocates alice (P1), bob (P2), tradingApp (P3) on global synchronizer - * - Registers alice and bob on app-synchronizer; tradingApp is global-only + * - Allocates alice (P1), bob (P2), tradingApp + tokenAdmin (P3) on global synchronizer + * (tradingApp is co-hosted on P1 + P2 in the same allocation, then granted + * actAs rights on the P1/P2 users so each trader participant can prepare the + * co-signed TradeSettlementAgreement) + * - Registers alice (P1) and bob (P2) on app-synchronizer + * - Registers tokenAdmin (P3) on app-synchronizer (secondary — needed so tokenAdmin + * is a valid informee for app-sync transactions; P3 is connected to both synchronizers) * - Connects the scan proxy and returns the Amulet admin party ID */ export async function setupMultiSyncTrade( logger: Logger ): Promise { - // Create three SDK instances — one per participant node + const testTokenTokenConfig = { + ...TOKEN_NAMESPACE_CONFIG, + registries: [ + ...(TOKEN_NAMESPACE_CONFIG.registries as URL[]), + LOCALNET_TEST_TOKEN_REGISTRY_URL, + ], + } const [p1Sdk, p2Sdk, p3Sdk] = await Promise.all([ SDK.create({ auth: TOKEN_PROVIDER_CONFIG_DEFAULT, ledgerClientUrl: localNetStaticConfig.LOCALNET_APP_USER_LEDGER_URL, - token: TOKEN_NAMESPACE_CONFIG, + token: testTokenTokenConfig, }), SDK.create({ auth: TOKEN_PROVIDER_CONFIG_DEFAULT, ledgerClientUrl: LOCALNET_BOB_LEDGER_URL, - token: TOKEN_NAMESPACE_CONFIG, + token: testTokenTokenConfig, }), SDK.create({ auth: TOKEN_PROVIDER_CONFIG_DEFAULT, ledgerClientUrl: LOCALNET_TRADING_APP_LEDGER_URL, - token: TOKEN_NAMESPACE_CONFIG, + token: testTokenTokenConfig, }), ]) @@ -108,11 +121,14 @@ export async function setupMultiSyncTrade( `Expected at least 2 connected synchronizers (global + app), found ${allSynchronizers.length}` ) - const globalSynchronizerId = resolveGlobalSynchronizerId(allSynchronizers) + const globalSynchronizerId = allSynchronizers.find( + (s) => s.synchronizerAlias === 'global' + )?.synchronizerId const appSynchronizerId = allSynchronizers.find( (s) => s.synchronizerAlias === 'app-synchronizer' )?.synchronizerId + if (!globalSynchronizerId) throw new Error('Global synchronizer not found') if (!appSynchronizerId) throw new Error( 'App synchronizer not found — start localnet with --multi-sync to enable it.' @@ -130,31 +146,44 @@ export async function setupMultiSyncTrade( appSynchronizerId, } - // Load and vet the TestTokenV1 DAR (bundled alongside this script). - // The trading-app DAR is already uploaded and vetted by Splice on startup; - // app-synchronizer.sc replicates that vetting to the app synchronizer. const here = path.dirname(fileURLToPath(import.meta.url)) - const testTokenV1Dar = await fs.readFile(path.join(here, TEST_TOKEN_V1_DAR)) + const tradingAppV2DarPath = path.join(here, TRADING_APP_V2_DAR_LOCAL_PATH) + const testTokenV1DarPath = path.join(here, TEST_TOKEN_V1_DAR_LOCAL_PATH) + for (const [darPath, darName] of [ + [tradingAppV2DarPath, TRADING_APP_V2_DAR_LOCAL_PATH], + [testTokenV1DarPath, TEST_TOKEN_V1_DAR_LOCAL_PATH], + ] as [string, string][]) { + try { + await fs.stat(darPath) + } catch { + throw new Error( + `Required DAR not found: ${darPath}\n` + + ` "${darName}" must be present at the expected path.` + ) + } + } + + const [tradingAppDar, testTokenV1Dar] = await Promise.all([ + fs.readFile(tradingAppV2DarPath), + fs.readFile(testTokenV1DarPath), + ]) - // P1 and P2 vet on both synchronizers; P3 (sv) is global-only await Promise.all([ - ...[p1SdkCtx, p2SdkCtx].flatMap((ctx) => - [globalSynchronizerId, appSynchronizerId].map((sid) => - vetDar(ctx.ledgerProvider, testTokenV1Dar, sid) - ) + ...[p1SdkCtx, p2SdkCtx, p3SdkCtx].map((ctx) => + vetDar(ctx.ledgerProvider, tradingAppDar, globalSynchronizerId) + ), + ...[p1SdkCtx, p2SdkCtx, p3SdkCtx].map((ctx) => + vetDar(ctx.ledgerProvider, testTokenV1Dar, appSynchronizerId) ), - vetDar(p3SdkCtx.ledgerProvider, testTokenV1Dar, globalSynchronizerId), ]) logger.info( - 'TestTokenV1 DAR vetted: P1+P2 on both synchronizers, P3 on global only' + 'DARs vetted: trading-app-v2 on global only; test-token-v1 on app-sync only' ) - // Allocate parties: alice on P1, bob on P2, tradingApp on P3, tokenAdmin on P2 (all on global synchronizer) - // tokenAdmin is on P2 (app-provider), not P3 (sv), because sv is global-only const aliceKey = p1Sdk.keys.generate() const bobKey = p1Sdk.keys.generate() const tradingAppKey = p1Sdk.keys.generate() - const tokenAdminKey = p2Sdk.keys.generate() + const tokenAdminKey = p3Sdk.keys.generate() const [ allocatedAlice, @@ -176,14 +205,27 @@ export async function setupMultiSyncTrade( }) .sign(bobKey.privateKey) .execute(), + p3Sdk.party.external .create(tradingAppKey.publicKey, { partyHint: PARTY_HINT_TRADING_APP, synchronizerId: globalSynchronizerId, + confirmingParticipantEndpoints: [ + { + url: new URL( + localNetStaticConfig.LOCALNET_APP_USER_LEDGER_URL + ), + tokenProviderConfig: TOKEN_PROVIDER_CONFIG_DEFAULT, + }, + { + url: new URL(LOCALNET_BOB_LEDGER_URL), + tokenProviderConfig: TOKEN_PROVIDER_CONFIG_DEFAULT, + }, + ], }) .sign(tradingAppKey.privateKey) .execute(), - p2Sdk.party.external + p3Sdk.party.external .create(tokenAdminKey.publicKey, { partyHint: PARTY_HINT_TOKEN_ADMIN, synchronizerId: globalSynchronizerId, @@ -204,10 +246,9 @@ export async function setupMultiSyncTrade( } logger.info( - `Parties allocated — alice: ${alice.partyId} (P1), bob: ${bob.partyId} (P2), tradingApp: ${tradingApp.partyId} (P3), tokenAdmin: ${tokenAdmin.partyId} (P2)` + `Parties allocated — alice: ${alice.partyId} (P1), bob: ${bob.partyId} (P2), tradingApp: ${tradingApp.partyId} (P3), tokenAdmin: ${tokenAdmin.partyId} (P3)` ) - // Register Alice, Bob, and TokenAdmin on app-synchronizer so they can transact there. await Promise.all([ p1Sdk.party.external .create(alice.keyPair.publicKey, { @@ -223,8 +264,8 @@ export async function setupMultiSyncTrade( }) .sign(bob.keyPair.privateKey) .execute({ grantUserRights: false }), - // tokenAdmin must be registered via P2 (app-provider) — sv (P3) is global-only - p2Sdk.party.external + + p3Sdk.party.external .create(tokenAdmin.keyPair.publicKey, { partyHint: tokenAdmin.partyId.split('::')[0], synchronizerId: appSynchronizerId, @@ -234,7 +275,16 @@ export async function setupMultiSyncTrade( ]) logger.info('Alice, Bob, and TokenAdmin registered on app-synchronizer') - // Connect scan proxy and discover Amulet admin + await Promise.all([ + p1Sdk.user.rights.grant({ + userRights: { actAs: [tradingApp.partyId] }, + }), + p2Sdk.user.rights.grant({ + userRights: { actAs: [tradingApp.partyId] }, + }), + ]) + logger.info('Venue (tradingApp) actAs rights granted on P1 and P2') + const auth = new AuthTokenProvider(TOKEN_PROVIDER_CONFIG_DEFAULT, logger) const scanProxy = new ScanProxyClient( localNetStaticConfig.LOCALNET_APP_VALIDATOR_URL, diff --git a/docs/wallet-integration-guide/examples/scripts/15-multi-sync/_trade_ops.ts b/docs/wallet-integration-guide/examples/scripts/15-multi-sync/_trade_ops.ts index 2ae3b2a54..c58a0380c 100644 --- a/docs/wallet-integration-guide/examples/scripts/15-multi-sync/_trade_ops.ts +++ b/docs/wallet-integration-guide/examples/scripts/15-multi-sync/_trade_ops.ts @@ -1,36 +1,92 @@ // Copyright (c) 2025-2026 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. // SPDX-License-Identifier: Apache-2.0 +import { randomUUID } from 'node:crypto' import type { Logger } from 'pino' import { localNetStaticConfig } from '@canton-network/wallet-sdk' +import { signTransactionHash } from '@canton-network/core-signing-lib' import type { ContractSpec } from '../utils/index.js' -import type { MultiSyncSetup } from './_setup.js' +import type { MultiSyncSetup, PartyInfo } from './_setup.js' import { PARTY_HINT_ALICE, PARTY_HINT_BOB, PARTY_HINT_TRADING_APP, PARTY_HINT_TOKEN_ADMIN, + LOCALNET_TEST_TOKEN_REGISTRY_URL, } from './_config.js' -// ── ACS contract entry (as returned by ledger.acs.read) ─────────────────────── - -interface AcsContractEntry { - contractId: string - templateId: string - createdEventBlob?: string - synchronizerId: string -} - -// ── Template / interface identifiers ───────────────────────────────────────── - export const AMULET_TEMPLATE_ID = '#splice-amulet:Splice.Amulet:Amulet' export const TEST_TOKEN_PREFIX = '#splice-test-token-v1:Splice.Testing.Tokens.TestTokenV1' export const TRADING_APP_PREFIX = - '#splice-token-test-trading-app:Splice.Testing.Apps.TradingApp' + '#splice-token-test-trading-app-v2:Splice.Testing.Apps.TradingAppV2' + +export const ALICE_AMULET_TAP_AMOUNT = '2000000' +export const BOB_TOKEN_MINT_AMOUNT = '500' +export const TRADE_AMULET_AMOUNT = '100' +export const TRADE_TOKEN_AMOUNT = '20' + +const MS_1_HOUR = 60 * 60 * 1000 + +// Splice.Api.Token.HoldingV2:Account +export interface V2Account { + owner: string | null + provider: string | null + id: string +} +// Splice.Api.Token.AllocationV2:TransferLeg +export interface V2TransferLeg { + transferLegId: string + sender: V2Account + receiver: V2Account + amount: string + instrumentId: string + meta: { values: Record } +} +// Splice.Testing.Apps.TradingAppV2:TradeLeg +export interface V2TradeLeg { + admin: string + leg: V2TransferLeg +} + +/** A regular (non-delegated) account: just an owner, no provider, empty id. */ +function account(partyId: string): V2Account { + return { owner: partyId, provider: null, id: '' } +} + +/** + * Builds the two DvP transfer legs for the OTCTrade: + * leg-0: Alice → Bob, 100 Amulet (instrument admin: Amulet DSO) + * leg-1: Bob → Alice, 20 TestToken (instrument admin: tokenAdmin) + */ +export function buildTradeLegs(setup: MultiSyncSetup): V2TradeLeg[] { + const { alice, bob, tokenAdmin, amuletAdmin } = setup + return [ + { + admin: amuletAdmin, + leg: { + transferLegId: 'leg-0', + sender: account(alice.partyId), + receiver: account(bob.partyId), + amount: TRADE_AMULET_AMOUNT, + instrumentId: 'Amulet', + meta: { values: {} }, + }, + }, + { + admin: tokenAdmin.partyId, + leg: { + transferLegId: 'leg-1', + sender: account(bob.partyId), + receiver: account(alice.partyId), + amount: TRADE_TOKEN_AMOUNT, + instrumentId: 'TestToken', + meta: { values: {} }, + }, + }, + ] +} -const TRANSFER_FACTORY_IFACE = - '#splice-api-token-transfer-instruction-v1:Splice.Api.Token.TransferInstructionV1:TransferFactory' export function buildContractReadSpec(setup: MultiSyncSetup): ContractSpec[] { const { p1Sdk, p2Sdk, p3Sdk, alice, bob, tradingApp, tokenAdmin } = setup return [ @@ -40,20 +96,23 @@ export function buildContractReadSpec(setup: MultiSyncSetup): ContractSpec[] { templateIds: [ AMULET_TEMPLATE_ID, `${TEST_TOKEN_PREFIX}:Token`, - `${TRADING_APP_PREFIX}:OTCTradeProposal`, - `${TRADING_APP_PREFIX}:OTCTrade`, + `${TRADING_APP_PREFIX}:OTCTradeAllocationRequest`, ], parties: [alice.partyId], }, { label: PARTY_HINT_BOB, sdk: p2Sdk, - templateIds: [AMULET_TEMPLATE_ID, `${TEST_TOKEN_PREFIX}:Token`], + templateIds: [ + AMULET_TEMPLATE_ID, + `${TEST_TOKEN_PREFIX}:Token`, + `${TRADING_APP_PREFIX}:OTCTradeAllocationRequest`, + ], parties: [bob.partyId], }, { label: PARTY_HINT_TOKEN_ADMIN, - sdk: p2Sdk, + sdk: p3Sdk, templateIds: [`${TEST_TOKEN_PREFIX}:TokenRules`], parties: [tokenAdmin.partyId], }, @@ -61,23 +120,15 @@ export function buildContractReadSpec(setup: MultiSyncSetup): ContractSpec[] { label: PARTY_HINT_TRADING_APP, sdk: p3Sdk, templateIds: [ - `${TRADING_APP_PREFIX}:OTCTradeProposal`, `${TRADING_APP_PREFIX}:OTCTrade`, + `${TRADING_APP_PREFIX}:OTCTradeAllocationRequest`, + `${TRADING_APP_PREFIX}:TradeSettlementAgreement`, ], parties: [tradingApp.partyId], }, ] } -export const ALICE_AMULET_TAP_AMOUNT = '2000000' -export const BOB_TOKEN_MINT_AMOUNT = '500' -export const TRADE_AMULET_AMOUNT = '100' -export const TRADE_TOKEN_AMOUNT = '20' - -const MS_30_MIN = 30 * 60 * 1000 -const MS_1_HOUR = 60 * 60 * 1000 -const MS_24_HOURS = 24 * 60 * 60 * 1000 - export async function mintAmuletForAlice( setup: MultiSyncSetup, logger: Logger @@ -137,12 +188,21 @@ export async function createTokenRulesAndMintForBob( setup: MultiSyncSetup, logger: Logger ): Promise { - const { p2Sdk, bob, tokenAdmin, globalSynchronizerId, appSynchronizerId } = - setup + const { + p2Sdk, + p3Sdk, + tokenNamespaceP2, + bob, + tokenAdmin, + appSynchronizerId, + globalSynchronizerId, + } = setup - // tokenAdmin is hosted on P2; use p2Sdk for all tokenAdmin submissions + // Create TokenRules on both synchronizers in parallel via p3Sdk. + // P3 (sv) is connected to both global and app-synchronizer, so p3Sdk can submit + // as tokenAdmin (primary on P3) to either synchronizer without any secondary registrations. await Promise.all([ - p2Sdk.ledger + p3Sdk.ledger .prepare({ partyId: tokenAdmin.partyId, commands: { @@ -156,7 +216,7 @@ export async function createTokenRulesAndMintForBob( }) .sign(tokenAdmin.keyPair.privateKey) .execute({ partyId: tokenAdmin.partyId }), - p2Sdk.ledger + p3Sdk.ledger .prepare({ partyId: tokenAdmin.partyId, commands: { @@ -172,8 +232,8 @@ export async function createTokenRulesAndMintForBob( .execute({ partyId: tokenAdmin.partyId }), ]) - // Mint Token on app-synchronizer via P2 (sv/P3 is global-only) - await p2Sdk.ledger + // Mint Token on app-synchronizer via p3Sdk (P3/sv is connected to both synchronizers). + await p3Sdk.ledger .prepare({ partyId: tokenAdmin.partyId, commands: [ @@ -201,101 +261,63 @@ export async function createTokenRulesAndMintForBob( .sign(tokenAdmin.keyPair.privateKey) .execute({ partyId: tokenAdmin.partyId }) - // Read tokenAdmin's contracts via P2 (P2 is connected to both synchronizers) - const [tokenRulesContracts, adminTokenHoldings] = await Promise.all([ - p2Sdk.ledger.acs.read({ - templateIds: [`${TEST_TOKEN_PREFIX}:TokenRules`], - parties: [tokenAdmin.partyId], - filterByParty: true, - }), - p2Sdk.ledger.acs.read({ - templateIds: [`${TEST_TOKEN_PREFIX}:Token`], - parties: [tokenAdmin.partyId], - filterByParty: true, - }), - ]) - const appTokenRules = tokenRulesContracts.find( - (c) => c.synchronizerId === appSynchronizerId - ) - if (!appTokenRules) - throw new Error( - 'TokenRules not found on app synchronizer after creation' - ) + const adminTokenHoldings = await p3Sdk.ledger.acs.read({ + templateIds: [`${TEST_TOKEN_PREFIX}:Token`], + parties: [tokenAdmin.partyId], + filterByParty: true, + }) const adminTokenCid = adminTokenHoldings[0]?.contractId if (!adminTokenCid) throw new Error('TokenAdmin Token holding not found after mint') - // Transfer Token to Bob on app-synchronizer via P2 (sv/P3 is global-only) - await p2Sdk.ledger + // Transfer Token from tokenAdmin to Bob on app-synchronizer. + // The registry returns the app-sync TokenRules as the transfer factory. + const [transferCommand, transferDisclosed] = + await p3Sdk.token.transfer.create({ + sender: tokenAdmin.partyId, + recipient: bob.partyId, + amount: BOB_TOKEN_MINT_AMOUNT, + instrumentId: 'TestToken', + registryUrl: LOCALNET_TEST_TOKEN_REGISTRY_URL, + inputUtxos: [adminTokenCid], + }) + + await p3Sdk.ledger .prepare({ partyId: tokenAdmin.partyId, - commands: [ - { - ExerciseCommand: { - templateId: TRANSFER_FACTORY_IFACE, - contractId: appTokenRules.contractId, - choice: 'TransferFactory_Transfer', - choiceArgument: { - expectedAdmin: tokenAdmin.partyId, - transfer: { - sender: tokenAdmin.partyId, - receiver: bob.partyId, - amount: BOB_TOKEN_MINT_AMOUNT, - instrumentId: { - admin: tokenAdmin.partyId, - id: 'TestToken', - }, - requestedAt: new Date(Date.now()).toISOString(), - executeBefore: new Date( - Date.now() + MS_24_HOURS - ).toISOString(), - inputHoldingCids: [adminTokenCid], - meta: { values: {} }, - }, - extraArgs: { - context: { values: {} }, - meta: { values: {} }, - }, - }, - }, - }, - ], - disclosedContracts: [], + commands: [transferCommand], + disclosedContracts: transferDisclosed, synchronizerId: appSynchronizerId, }) .sign(tokenAdmin.keyPair.privateKey) .execute({ partyId: tokenAdmin.partyId }) - const transferOffers = await p2Sdk.ledger.acs.read({ - templateIds: [`${TEST_TOKEN_PREFIX}:TokenTransferOffer`], - parties: [bob.partyId], - filterByParty: true, - }) - const transferOfferCid = transferOffers[0]?.contractId + let transferOfferCid: string | undefined + const deadline = Date.now() + 30_000 + while (!transferOfferCid && Date.now() < deadline) { + const transferOffers = await p2Sdk.ledger.acs.read({ + templateIds: [`${TEST_TOKEN_PREFIX}:TokenTransferOffer`], + parties: [bob.partyId], + filterByParty: true, + }) + transferOfferCid = transferOffers[0]?.contractId + if (!transferOfferCid) + await new Promise((res) => setTimeout(res, 2_000)) + } if (!transferOfferCid) - throw new Error('TokenTransferOffer not found for Bob') + throw new Error('TokenTransferOffer not found for Bob after 30s') + + const [acceptCommand, acceptDisclosed] = + await tokenNamespaceP2.transfer.accept({ + transferInstructionCid: transferOfferCid, + registryUrl: LOCALNET_TEST_TOKEN_REGISTRY_URL, + }) - const transferInstructionIface = - '#splice-api-token-transfer-instruction-v1:Splice.Api.Token.TransferInstructionV1:TransferInstruction' await p2Sdk.ledger .prepare({ partyId: bob.partyId, - commands: [ - { - ExerciseCommand: { - templateId: transferInstructionIface, - contractId: transferOfferCid, - choice: 'TransferInstruction_Accept', - choiceArgument: { - extraArgs: { - context: { values: {} }, - meta: { values: {} }, - }, - }, - }, - }, - ], - disclosedContracts: [], + commands: [acceptCommand], + disclosedContracts: acceptDisclosed, synchronizerId: appSynchronizerId, }) .sign(bob.keyPair.privateKey) @@ -306,79 +328,59 @@ export async function createTokenRulesAndMintForBob( ) } -export async function createAndInitiateOtcTrade( +/** + * Venue creates the v2 `OTCTrade` and requests allocations from the traders. + * + * The v2 trading app splits the trade into single-signatory steps: + * 1. Venue creates `OTCTrade` — signatory is the venue ONLY (no trader approval + * dance like the v1 `OTCTradeProposal`). Single-party submission. + * 2. Venue exercises `OTCTrade_RequestAllocations` (nonconsuming) → one + * `OTCTradeAllocationRequest` per authorizing account. Traders observe these + * requests and allocate against them. + * + * Because `OTCTrade` carries only the venue's authority, settling V1 assets later + * needs the `TradeSettlementAgreement` infrastructure (see createSettlementAgreement). + */ +export async function createOtcTradeAndRequestAllocations( setup: MultiSyncSetup, - transferLegs: Record, + tradeLegs: V2TradeLeg[], logger: Logger -): Promise { - const { - p1Sdk, - p2Sdk, - p3Sdk, - alice, - bob, - tradingApp, - globalSynchronizerId, - } = setup +): Promise<{ otcTradeCid: string; allocationRequestCids: string[] }> { + const { p3Sdk, tradingApp, globalSynchronizerId } = setup - const readProposalCid = async ( - sdk: typeof p1Sdk, - party: string - ): Promise => { - const contracts = await sdk.ledger.acs.read({ - templateIds: [`${TRADING_APP_PREFIX}:OTCTradeProposal`], - parties: [party], - filterByParty: true, - }) - if (!contracts.length) throw new Error('OTCTradeProposal not found') - return contracts[0].contractId - } + const now = Date.now() + const createdAt = new Date(now).toISOString() + const settleAt = new Date(now + MS_1_HOUR).toISOString() - await p1Sdk.ledger + await p3Sdk.ledger .prepare({ - partyId: alice.partyId, + partyId: tradingApp.partyId, commands: { CreateCommand: { - templateId: `${TRADING_APP_PREFIX}:OTCTradeProposal`, + templateId: `${TRADING_APP_PREFIX}:OTCTrade`, createArguments: { venue: tradingApp.partyId, - tradeCid: null, - transferLegs, - approvers: [alice.partyId], + tradeLegs, + createdAt, + settleAt, + settlementDeadline: null, }, }, }, disclosedContracts: [], synchronizerId: globalSynchronizerId, }) - .sign(alice.keyPair.privateKey) - .execute({ partyId: alice.partyId }) - logger.info( - `Alice: OTCTradeProposal created (leg-0: ${TRADE_AMULET_AMOUNT} Amulet → Bob, leg-1: ${TRADE_TOKEN_AMOUNT} TestToken → Alice)` - ) - - await p2Sdk.ledger - .prepare({ - partyId: bob.partyId, - commands: [ - { - ExerciseCommand: { - templateId: `${TRADING_APP_PREFIX}:OTCTradeProposal`, - contractId: await readProposalCid(p2Sdk, bob.partyId), - choice: 'OTCTradeProposal_Accept', - choiceArgument: { approver: bob.partyId }, - }, - }, - ], - disclosedContracts: [], - synchronizerId: globalSynchronizerId, - }) - .sign(bob.keyPair.privateKey) - .execute({ partyId: bob.partyId }) - logger.info('Bob: OTCTradeProposal_Accept executed') + .sign(tradingApp.keyPair.privateKey) + .execute({ partyId: tradingApp.partyId }) - const prepareUntil = new Date(Date.now() + MS_30_MIN).toISOString() - const settleBefore = new Date(Date.now() + MS_1_HOUR).toISOString() + const otcTradeContracts = await p3Sdk.ledger.acs.read({ + templateIds: [`${TRADING_APP_PREFIX}:OTCTrade`], + parties: [tradingApp.partyId], + filterByParty: true, + }) + const otcTradeCid = otcTradeContracts[0]?.contractId + if (!otcTradeCid) throw new Error('OTCTrade not found after creation') + logger.info('TradingApp: OTCTrade created (leg-0 Amulet, leg-1 TestToken)') await p3Sdk.ledger .prepare({ @@ -386,13 +388,10 @@ export async function createAndInitiateOtcTrade( commands: [ { ExerciseCommand: { - templateId: `${TRADING_APP_PREFIX}:OTCTradeProposal`, - contractId: await readProposalCid( - p3Sdk, - tradingApp.partyId - ), - choice: 'OTCTradeProposal_InitiateSettlement', - choiceArgument: { prepareUntil, settleBefore }, + templateId: `${TRADING_APP_PREFIX}:OTCTrade`, + contractId: otcTradeCid, + choice: 'OTCTrade_RequestAllocations', + choiceArgument: {}, }, }, ], @@ -401,19 +400,122 @@ export async function createAndInitiateOtcTrade( }) .sign(tradingApp.keyPair.privateKey) .execute({ partyId: tradingApp.partyId }) + + const allocationRequests = await p3Sdk.ledger.acs.read({ + templateIds: [`${TRADING_APP_PREFIX}:OTCTradeAllocationRequest`], + parties: [tradingApp.partyId], + filterByParty: true, + }) + const allocationRequestCids = allocationRequests.map((c) => c.contractId) + if (allocationRequestCids.length === 0) + throw new Error('No OTCTradeAllocationRequest created') logger.info( - 'TradingApp: OTCTradeProposal_InitiateSettlement executed → OTCTrade created' + `TradingApp: OTCTrade_RequestAllocations executed → ${allocationRequestCids.length} allocation request(s)` ) - const otcTradeContracts = await p3Sdk.ledger.acs.read({ - templateIds: [`${TRADING_APP_PREFIX}:OTCTrade`], - parties: [tradingApp.partyId], + return { otcTradeCid, allocationRequestCids } +} + +/** + * Creates a `TradeSettlementAgreement` between the venue and a single trader. + * + * This contract is `signatory venue, trader`, so creating it needs the authority + * of BOTH parties — it cannot be a single-party submission. The wallet SDK only + * exposes single-party `prepare().sign().execute()`, so we drive the interactive + * submission flow by hand: prepare once with `actAs: [venue, trader]`, have each + * party sign the prepared-transaction hash, then submit `executeAndWait` with both + * party signatures. + * + * `preparingSdk` must be the trader's own participant SDK (P1 for Alice, P2 for Bob): + * the preparing participant has to be able to act for both `actAs` parties, and the + * setup co-hosts the venue on P1/P2 exactly so the trader participant can do this. + * + * The agreement gives the venue the standing it needs to drive V1 allocation + * settlement on the trader's behalf inside `OTCTrade_Settle`. + */ +export async function createSettlementAgreement( + setup: MultiSyncSetup, + preparingSdk: MultiSyncSetup['p1Sdk'], + preparingSdkCtx: MultiSyncSetup['p1SdkCtx'], + trader: PartyInfo, + logger: Logger +): Promise { + const { tradingApp, globalSynchronizerId } = setup + + const prepared = await preparingSdk.ledger.internal.prepare({ + commands: [ + { + CreateCommand: { + templateId: `${TRADING_APP_PREFIX}:TradeSettlementAgreement`, + createArguments: { + venue: tradingApp.partyId, + trader: trader.partyId, + }, + }, + }, + ], + actAs: [tradingApp.partyId, trader.partyId], + synchronizerId: globalSynchronizerId, + disclosedContracts: [], + }) + + const partySignatures = [tradingApp, trader].map((p) => ({ + party: p.partyId, + signatures: [ + { + signature: signTransactionHash( + prepared.preparedTransactionHash, + p.keyPair.privateKey + ), + // The fingerprint is the namespace part of the party id. + signedBy: p.partyId.split('::')[1], + format: 'SIGNATURE_FORMAT_CONCAT', + signingAlgorithmSpec: 'SIGNING_ALGORITHM_SPEC_ED25519', + }, + ], + })) + + const ledgerProvider = preparingSdkCtx.ledgerProvider as unknown as { + request: (opts: { + method: string + params: Record + }) => Promise + } + await ledgerProvider.request({ + method: 'ledgerApi', + params: { + resource: '/v2/interactive-submission/executeAndWait', + requestMethod: 'post', + body: { + userId: preparingSdkCtx.userId, + preparedTransaction: prepared.preparedTransaction, + hashingSchemeVersion: 'HASHING_SCHEME_VERSION_V2', + submissionId: randomUUID(), + deduplicationPeriod: { Empty: {} }, + partySignatures: { signatures: partySignatures }, + }, + }, + }) + + const agreements = await preparingSdk.ledger.acs.read({ + templateIds: [`${TRADING_APP_PREFIX}:TradeSettlementAgreement`], + parties: [trader.partyId], filterByParty: true, }) - const otcTradeCid = otcTradeContracts[0]?.contractId - if (!otcTradeCid) - throw new Error('OTCTrade contract not found after initiation') - return otcTradeCid + const agreement = agreements.find( + (c) => + (c as unknown as { createArgument?: { trader?: string } }) + .createArgument?.trader === trader.partyId + ) + if (!agreement) + throw new Error( + `TradeSettlementAgreement not found for trader ${trader.partyId}` + ) + + logger.info( + `TradeSettlementAgreement created: venue + ${trader.partyId.split('::')[0]}` + ) + return agreement.contractId } export async function allocateAmuletForAlice( @@ -422,7 +524,7 @@ export async function allocateAmuletForAlice( ): Promise { const { p1Sdk, - tokenNamespaceP1: tokenNamespaceP1, + tokenNamespaceP1, alice, globalSynchronizerId, amuletAdmin, @@ -431,11 +533,24 @@ export async function allocateAmuletForAlice( const pendingRequests = await tokenNamespaceP1.allocation.request.pending( alice.partyId ) - const requestView = pendingRequests[0].interfaceViewValue! - const legId = Object.keys(requestView.transferLegs).find( - (key) => requestView.transferLegs[key].sender === alice.partyId - )! - if (!legId) throw new Error('No transfer leg found for Alice') + let requestView: + | (typeof pendingRequests)[number]['interfaceViewValue'] + | undefined = undefined + let legId: string | undefined = undefined + for (const req of pendingRequests) { + const view = req.interfaceViewValue + if (!view) continue + const found = Object.keys(view.transferLegs).find( + (key) => view.transferLegs[key].sender === alice.partyId + ) + if (found) { + requestView = view + legId = found + break + } + } + if (!requestView || !legId) + throw new Error('No transfer leg found for Alice') const amuletHoldings = await p1Sdk.ledger.acs.read({ templateIds: [AMULET_TEMPLATE_ID], @@ -487,46 +602,31 @@ export async function allocateTokenForBob( const pendingRequests = await tokenNamespaceP2.allocation.request.pending( bob.partyId ) - const requestView = pendingRequests[0].interfaceViewValue! - const legId = Object.keys(requestView.transferLegs).find( - (key) => requestView.transferLegs[key].sender === bob.partyId - )! - if (!legId) throw new Error('No transfer leg found for Bob') - - const [tokenHoldings, tokenRulesContracts] = await Promise.all([ - p2Sdk.ledger.acs.read({ - templateIds: [`${TEST_TOKEN_PREFIX}:Token`], - parties: [bob.partyId], - filterByParty: true, - }), - // Read tokenAdmin's TokenRules via P2 (P2 is connected to both synchronizers) - p2Sdk.ledger.acs.read({ - templateIds: [`${TEST_TOKEN_PREFIX}:TokenRules`], - parties: [tokenAdmin.partyId], - filterByParty: true, - }), - ]) + let requestView: + | (typeof pendingRequests)[number]['interfaceViewValue'] + | undefined = undefined + let legId: string | undefined = undefined + for (const req of pendingRequests) { + const view = req.interfaceViewValue + if (!view) continue + const found = Object.keys(view.transferLegs).find( + (key) => view.transferLegs[key].sender === bob.partyId + ) + if (found) { + requestView = view + legId = found + break + } + } + if (!requestView || !legId) throw new Error('No transfer leg found for Bob') + const tokenHoldings = await p2Sdk.ledger.acs.read({ + templateIds: [`${TEST_TOKEN_PREFIX}:Token`], + parties: [bob.partyId], + filterByParty: true, + }) const tokenHolding = tokenHoldings[0] if (!tokenHolding) throw new Error('Token holding not found for Bob') - const tokenRulesOnGlobal = tokenRulesContracts.find( - (c) => c.synchronizerId === globalSynchronizerId - ) - if (!tokenRulesOnGlobal) - throw new Error('TokenRules not found on global synchronizer') - - // Explicitly reassign Bob's token from app-synchronizer to global before allocation. - // Canton requires the submitter to be a stakeholder of a contract already on the - // target synchronizer (SUBMITTER_ALWAYS_STAKEHOLDER policy). Without this step, - // Bob has no contracts on global, so the allocation submission would be rejected. - if (tokenHolding.synchronizerId !== globalSynchronizerId) { - await p2Sdk.ledger.internal.reassign({ - submitter: bob.partyId, - contractId: tokenHolding.contractId, - source: tokenHolding.synchronizerId, - target: globalSynchronizerId, - }) - } const [command, disclosedFromHelper] = await tokenNamespaceP2.allocation.instruction.create({ @@ -539,40 +639,25 @@ export async function allocateTokenForBob( id: 'TestToken', displayName: 'TestToken', symbol: 'TT', - registryUrl: new URL('http://unused.invalid'), + registryUrl: LOCALNET_TEST_TOKEN_REGISTRY_URL, admin: tokenAdmin.partyId, }, inputUtxos: [tokenHolding.contractId], requestedAt: new Date(Date.now()).toISOString(), - prefetchedRegistryChoiceContext: { - factoryId: tokenRulesOnGlobal.contractId, - choiceContext: { - choiceContextData: {} as Record, - disclosedContracts: [], - }, - }, }) await p2Sdk.ledger .prepare({ partyId: bob.partyId, commands: [command], - disclosedContracts: [ - ...disclosedFromHelper, - { - templateId: tokenRulesOnGlobal.templateId, - contractId: tokenRulesOnGlobal.contractId, - createdEventBlob: tokenRulesOnGlobal.createdEventBlob!, - synchronizerId: tokenRulesOnGlobal.synchronizerId, - }, - ], + disclosedContracts: disclosedFromHelper, synchronizerId: globalSynchronizerId, }) .sign(bob.keyPair.privateKey) .execute({ partyId: bob.partyId }) logger.info( - 'Bob: TestToken allocated for leg-1 (global synchronizer, single-party)' + 'Bob: TestToken allocated for leg-1 (global synchronizer; input auto-reassigned app-sync → global)' ) return { legId } } @@ -582,21 +667,49 @@ export interface SettleParams { legIdAlice: string legIdBob: string testTokenAllocationCid: string + aliceAgreementCid: string + bobAgreementCid: string + allocationRequestCids: string[] } -export async function settleOtcTrade( +/** + * Venue settles the trade with the v2 `OTCTrade_Settle` choice. + * + * Both legs are V1-token-standard assets (Amulet and the v1 TestToken), so each is + * settled through the `SettlementBatchV1` path: per leg the venue supplies the V1 + * allocation, the registry-provided choice context (`extraArgs`), and the sender's + * and receiver's `TradeSettlementAgreement`s — the latter carry the trader authority + * needed to exercise `Allocation_ExecuteTransfer`. + * + * `OTCTrade_Settle` is a single atomic transaction on the global synchronizer, so the + * Token allocation must be on global at this point (the allocation step above moved + * it there). After settlement the Token holdings are on global; the self-transfer + * step returns them to the app-synchronizer. + */ +export async function settleOtcTradeV2( setup: MultiSyncSetup, params: SettleParams, logger: Logger ): Promise { const { p3Sdk, - tokenNamespaceP1: tokenNamespaceP1, + tokenNamespaceP1, + tokenNamespaceP2, alice, tradingApp, + tokenAdmin, + amuletAdmin, globalSynchronizerId, } = setup - const { otcTradeCid, legIdAlice, legIdBob, testTokenAllocationCid } = params + const { + otcTradeCid, + legIdAlice, + legIdBob, + testTokenAllocationCid, + aliceAgreementCid, + bobAgreementCid, + allocationRequestCids, + } = params const allocationsAlice = await tokenNamespaceP1.allocation.pending( alice.partyId @@ -606,35 +719,70 @@ export async function settleOtcTrade( ) if (!amuletAllocation) throw new Error('Amulet allocation not found') - const amuletExecCtx = await tokenNamespaceP1.allocation.context.execute({ - allocationCid: amuletAllocation.contractId, - registryUrl: localNetStaticConfig.LOCALNET_REGISTRY_API_URL, + const [amuletExecCtx, tokenExecCtx] = await Promise.all([ + tokenNamespaceP1.allocation.context.execute({ + allocationCid: amuletAllocation.contractId, + registryUrl: localNetStaticConfig.LOCALNET_REGISTRY_API_URL, + }), + tokenNamespaceP2.allocation.context.execute({ + allocationCid: testTokenAllocationCid, + registryUrl: LOCALNET_TEST_TOKEN_REGISTRY_URL, + }), + ]) + + const toExtraArgs = (ctx: { + choiceContextData?: { values?: Record } + }) => ({ + context: { values: ctx.choiceContextData?.values ?? {} }, + meta: { values: {} }, }) - const allocationsWithContext = { - [legIdAlice]: { - _1: amuletAllocation.contractId, - _2: { - context: { - ...(amuletExecCtx.choiceContextData ?? {}), - values: - (amuletExecCtx.choiceContextData?.values as Record< - string, - unknown - >) ?? {}, + const batchesByAdmin = [ + [ + amuletAdmin, + { + tag: 'SettlementBatchV1', + value: { + allocationsWithContext: { + [legIdAlice]: { + allocationCid: amuletAllocation.contractId, + extraArgs: toExtraArgs(amuletExecCtx), + // leg-0 sender = Alice, receiver = Bob + senderAgreementCid: aliceAgreementCid, + receiverAgreementCid: bobAgreementCid, + }, + }, }, - meta: { values: {} }, }, - }, - [legIdBob]: { - _1: testTokenAllocationCid, - _2: { context: { values: {} }, meta: { values: {} } }, - }, - } + ], + [ + tokenAdmin.partyId, + { + tag: 'SettlementBatchV1', + value: { + allocationsWithContext: { + [legIdBob]: { + allocationCid: testTokenAllocationCid, + extraArgs: toExtraArgs(tokenExecCtx), + senderAgreementCid: bobAgreementCid, + receiverAgreementCid: aliceAgreementCid, + }, + }, + }, + }, + ], + ] - const disclosedContracts = (amuletExecCtx.disclosedContracts ?? []).map( - (c) => ({ ...c, synchronizerId: '' }) - ) + const disclosedContracts = [ + ...(amuletExecCtx.disclosedContracts ?? []).map((c) => ({ + ...c, + synchronizerId: '', + })), + ...(tokenExecCtx.disclosedContracts ?? []).map((c) => ({ + ...c, + synchronizerId: '', + })), + ] await p3Sdk.ledger .prepare({ @@ -645,7 +793,10 @@ export async function settleOtcTrade( templateId: `${TRADING_APP_PREFIX}:OTCTrade`, contractId: otcTradeCid, choice: 'OTCTrade_Settle', - choiceArgument: { allocationsWithContext }, + choiceArgument: { + batchesByAdmin, + allocationRequests: allocationRequestCids, + }, }, }, ], @@ -656,7 +807,7 @@ export async function settleOtcTrade( .execute({ partyId: tradingApp.partyId }) logger.info( - `TradingApp: OTCTrade settled — ${TRADE_AMULET_AMOUNT} Amulet transferred to Bob, ${TRADE_TOKEN_AMOUNT} TestToken transferred to Alice` + `TradingApp: OTCTrade_Settle executed — ${TRADE_AMULET_AMOUNT} Amulet → Bob, ${TRADE_TOKEN_AMOUNT} TestToken → Alice` ) } @@ -664,87 +815,36 @@ export async function aliceSelfTransferToApp( setup: MultiSyncSetup, logger: Logger ): Promise { - const { p1Sdk, p2Sdk, alice, tokenAdmin, appSynchronizerId } = setup + const { p1Sdk, tokenNamespaceP1, alice, appSynchronizerId } = setup - const [aliceTokens, tokenRulesContracts] = await Promise.all([ - p1Sdk.ledger.acs.read({ - templateIds: [`${TEST_TOKEN_PREFIX}:Token`], - parties: [alice.partyId], - filterByParty: true, - }), - // Read tokenAdmin's TokenRules via P2 (sv/P3 is global-only) - p2Sdk.ledger.acs.read({ - templateIds: [`${TEST_TOKEN_PREFIX}:TokenRules`], - parties: [tokenAdmin.partyId], - filterByParty: true, - }), - ]) - const aliceToken = aliceTokens[0] - if (!aliceToken) + const aliceTokens = await p1Sdk.ledger.acs.read({ + templateIds: [`${TEST_TOKEN_PREFIX}:Token`], + parties: [alice.partyId], + filterByParty: true, + }) + const aliceTokenCid = aliceTokens[0]?.contractId + if (!aliceTokenCid) throw new Error('Alice: Token holding not found after settlement') - const tokenRules = tokenRulesContracts.find( - (c) => c.synchronizerId === appSynchronizerId - ) - if (!tokenRules) throw new Error('TokenRules not found on app-synchronizer') - - // Explicitly reassign Alice's token from global to app-synchronizer before the self-transfer. - // Canton's SUBMITTER_ALWAYS_STAKEHOLDER policy requires the submitter to be a stakeholder - // of a contract already on the target synchronizer. Without this, Alice has no - // contracts on app-synchronizer and the submission is rejected. - if (aliceToken.synchronizerId !== appSynchronizerId) { - await p1Sdk.ledger.internal.reassign({ - submitter: alice.partyId, - contractId: aliceToken.contractId, - source: aliceToken.synchronizerId, - target: appSynchronizerId, + + const [transferCommand, transferDisclosed] = + await tokenNamespaceP1.transfer.create({ + sender: alice.partyId, + recipient: alice.partyId, + amount: TRADE_TOKEN_AMOUNT, + instrumentId: 'TestToken', + registryUrl: LOCALNET_TEST_TOKEN_REGISTRY_URL, + inputUtxos: [aliceTokenCid], }) - } + // Alice's Token is on global after settlement; targeting app-sync causes Canton to + // auto-reassign it. Alice is the owner/stakeholder of her Token, so this is allowed. + // The registry returns the app-sync TokenRules as the factory, which is already on + // app-sync — no PRESCRIBED_SYNCHRONIZER_ID_MISMATCH. await p1Sdk.ledger .prepare({ partyId: alice.partyId, - commands: [ - { - ExerciseCommand: { - templateId: TRANSFER_FACTORY_IFACE, - contractId: tokenRules.contractId, - choice: 'TransferFactory_Transfer', - choiceArgument: { - expectedAdmin: tokenAdmin.partyId, - transfer: { - sender: alice.partyId, - receiver: alice.partyId, - amount: TRADE_TOKEN_AMOUNT, - instrumentId: { - admin: tokenAdmin.partyId, - id: 'TestToken', - }, - requestedAt: new Date(Date.now()).toISOString(), - executeBefore: new Date( - Date.now() + MS_24_HOURS - ).toISOString(), - inputHoldingCids: [aliceToken.contractId], - meta: { values: {} }, - }, - extraArgs: { - context: { values: {} }, - meta: { values: {} }, - }, - }, - }, - }, - ], - disclosedContracts: [ - { - templateId: tokenRules.templateId, - contractId: tokenRules.contractId, - createdEventBlob: tokenRules.createdEventBlob!, - synchronizerId: tokenRules.synchronizerId, - }, - // Alice's token is in her own ACS (she is a stakeholder) and has - // already been reassigned to app-synchronizer above, so no - // disclosure is needed for it. - ], + commands: [transferCommand], + disclosedContracts: transferDisclosed, synchronizerId: appSynchronizerId, }) .sign(alice.keyPair.privateKey) @@ -759,30 +859,18 @@ export async function bobSelfTransferToApp( setup: MultiSyncSetup, logger: Logger ): Promise { - const { p2Sdk, bob, tokenAdmin, appSynchronizerId } = setup + const { p2Sdk, tokenNamespaceP2, bob, appSynchronizerId } = setup - const [bobTokens, tokenRulesContracts] = await Promise.all([ - p2Sdk.ledger.acs.read({ - templateIds: [`${TEST_TOKEN_PREFIX}:Token`], - parties: [bob.partyId], - filterByParty: true, - }), - // Read tokenAdmin's TokenRules via P2 (sv/P3 is global-only) - p2Sdk.ledger.acs.read({ - templateIds: [`${TEST_TOKEN_PREFIX}:TokenRules`], - parties: [tokenAdmin.partyId], - filterByParty: true, - }), - ]) + const bobTokens = await p2Sdk.ledger.acs.read({ + templateIds: [`${TEST_TOKEN_PREFIX}:Token`], + parties: [bob.partyId], + filterByParty: true, + }) if (bobTokens.length === 0) { logger.info('Bob: no TestToken holdings to self-transfer') return } - const tokenRules = tokenRulesContracts.find( - (c) => c.synchronizerId === appSynchronizerId - ) - if (!tokenRules) throw new Error('TokenRules not found on app-synchronizer') for (const token of bobTokens) { const holdingAmount = ( @@ -793,72 +881,29 @@ export async function bobSelfTransferToApp( if (!holdingAmount) throw new Error('Cannot read amount from Bob Token holding') - // Explicitly reassign Bob's token to app-synchronizer before the self-transfer - // (same SUBMITTER_ALWAYS_STAKEHOLDER constraint as above) - if (token.synchronizerId !== appSynchronizerId) { - await p2Sdk.ledger.internal.reassign({ - submitter: bob.partyId, - contractId: token.contractId, - source: token.synchronizerId, - target: appSynchronizerId, + const [transferCommand, transferDisclosed] = + await tokenNamespaceP2.transfer.create({ + sender: bob.partyId, + recipient: bob.partyId, + amount: holdingAmount, + instrumentId: 'TestToken', + registryUrl: LOCALNET_TEST_TOKEN_REGISTRY_URL, + inputUtxos: [token.contractId], }) - } + // Bob's Token is on global after the allocation; targeting app-sync causes + // Canton to auto-reassign it. Bob is the owner/stakeholder, so this is allowed. + // The registry returns the app-sync TokenRules as the factory. await p2Sdk.ledger .prepare({ partyId: bob.partyId, - commands: [ - { - ExerciseCommand: { - templateId: TRANSFER_FACTORY_IFACE, - contractId: tokenRules.contractId, - choice: 'TransferFactory_Transfer', - choiceArgument: { - expectedAdmin: tokenAdmin.partyId, - transfer: { - sender: bob.partyId, - receiver: bob.partyId, - amount: holdingAmount, - instrumentId: { - admin: tokenAdmin.partyId, - id: 'TestToken', - }, - requestedAt: new Date( - Date.now() - ).toISOString(), - executeBefore: new Date( - Date.now() + MS_24_HOURS - ).toISOString(), - inputHoldingCids: [token.contractId], - meta: { values: {} }, - }, - extraArgs: { - context: { values: {} }, - meta: { values: {} }, - }, - }, - }, - }, - ], - disclosedContracts: [ - { - templateId: tokenRules.templateId, - contractId: tokenRules.contractId, - createdEventBlob: tokenRules.createdEventBlob!, - synchronizerId: tokenRules.synchronizerId, - }, - // Bob's token is in his own ACS (he is a stakeholder) and has - // already been reassigned to app-synchronizer above, so no - // disclosure is needed for it. - ], + commands: [transferCommand], + disclosedContracts: transferDisclosed, synchronizerId: appSynchronizerId, }) .sign(bob.keyPair.privateKey) .execute({ partyId: bob.partyId }) } - logger.info( - `Bob: TestToken self-transferred on app-synchronizer ` + - `(Canton auto-reassigned Bob's Token from global → app)` - ) + logger.info(`Bob: TestToken self-transferred on app-synchronizer`) } diff --git a/docs/wallet-integration-guide/examples/scripts/15-multi-sync/daml/splice-test-token-v1/daml/Splice/Testing/Tokens/TestTokenV1.daml b/docs/wallet-integration-guide/examples/scripts/15-multi-sync/daml/splice-test-token-v1/daml/Splice/Testing/Tokens/TestTokenV1.daml new file mode 100644 index 000000000..3f97872eb --- /dev/null +++ b/docs/wallet-integration-guide/examples/scripts/15-multi-sync/daml/splice-test-token-v1/daml/Splice/Testing/Tokens/TestTokenV1.daml @@ -0,0 +1,306 @@ +-- Copyright (c) 2025-2026 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +-- SPDX-License-Identifier: Apache-2.0 + +-- | A mock implementation of a test token that only implements the V1 token standard APIs. +-- Used to test V1 token standard workflows on their own, and mixed with V2 workflows. +module Splice.Testing.Tokens.TestTokenV1 where + +import DA.Assert +import DA.Optional + +import Splice.Api.Token.MetadataV1 +import Splice.Api.Token.HoldingV1 qualified as V1 +import Splice.Api.Token.AllocationV1 qualified as V1 +import Splice.Api.Token.AllocationInstructionV1 qualified as V1 +import Splice.Api.Token.TransferInstructionV1 qualified as V1 +import Splice.TokenStandard.Utils + + +-- | A token holding. +template Token with + holding : V1.HoldingView + -- ^ Stores the holding data using the the type from the interface definition. + -- In production code, one might want to avoid that to have better control + -- over smart contract upgrading. + where + signatory holding.owner, holding.instrumentId.admin + + ensure + holding.amount > 0.0 && -- positive amounts + isNone holding.lock -- no locked holdings + + interface instance V1.Holding for Token where + view = holding + +-- | Transfer instruction representing a Token offer. Functions as both the offer and the holding for +-- the offered amount. +template TokenTransferOffer with + transfer : V1.Transfer + where + ensure transfer.amount > 0.0 && transfer.requestedAt <= transfer.executeBefore + + signatory transfer.sender, transfer.instrumentId.admin + observer transfer.receiver + + -- Here we use the shortcut to make the allocation itself a Holding. This is + -- not recommended for production code, as withdrawing the allocation will fail + -- if the counter-party unvets the token .dar file. + interface instance V1.Holding for TokenTransferOffer where + view = transferHoldingView transfer + + interface instance V1.TransferInstruction for TokenTransferOffer where + view = V1.TransferInstructionView with + -- This highlights another shortcoming of making the transfer offer a + -- holding: wallets will have to understand this special construction to + -- properly correlate the allocation to the holding. + -- + -- Furthermore, the wallets must ensure to filter the holdings they see to + -- only the ones where the wallet user party is the owner. + originalInstructionCid = None + transfer + status = V1.TransferPendingReceiverAcceptance + meta = emptyMetadata + + transferInstruction_withdrawImpl _self _arg = do + tokenCid <- create Token with + holding = (transferHoldingView transfer) with lock = None + pure V1.TransferInstructionResult with + output = V1.TransferInstructionResult_Failed + senderChangeCids = [toInterfaceContractId tokenCid] + meta = emptyMetadata + + transferInstruction_rejectImpl _self _arg = do + tokenCid <- create Token with + holding = (transferHoldingView transfer) with lock = None + pure V1.TransferInstructionResult with + output = V1.TransferInstructionResult_Failed + senderChangeCids = [toInterfaceContractId tokenCid] + meta = emptyMetadata + + transferInstruction_updateImpl _self _arg = + fail "V1.TransferInstruction_Update: not supported by TokenTransferOffer" + + transferInstruction_acceptImpl _self _arg = do + tokenCid <- create Token with + holding = (transferHoldingView transfer) with + owner = transfer.receiver + lock = None + pure V1.TransferInstructionResult with + senderChangeCids = [] + output = V1.TransferInstructionResult_Completed with + receiverHoldingCids = [toInterfaceContractId tokenCid] + meta = emptyMetadata + + +-- | Allocation of a Token. Functions as both the allocation and the holding for +-- the allocated amount. +template TokenAllocation with + allocation : V1.AllocationSpecification + where + signatory allocation.transferLeg.sender, allocation.transferLeg.instrumentId.admin + observer allocation.settlement.executor + ensure isValidAllocationSpecificationV1 allocation + + -- Here we use the shortcut to make the allocation itself a Holding. This is + -- not recommended for production code, as withdrawing the allocation will fail + -- if the counter-party unvets the token .dar file. + interface instance V1.Holding for TokenAllocation where + view = allocationHoldingView allocation + + interface instance V1.Allocation for TokenAllocation where + view = V1.AllocationView with + -- This highlights another shortcoming of making the allocation a + -- holding: wallets will have to understand this special construction to + -- properly correlate the allocation to the holding. + -- + -- Furthermore, the wallets must ensure to filter the holdings they see to + -- only the ones where the wallet user party is the owner. + allocation + holdingCids = [] + meta = emptyMetadata + + allocation_withdrawImpl _self _arg = do + tokenCid <- create Token with + holding = (allocationHoldingView allocation) with lock = None + pure V1.Allocation_WithdrawResult with + senderHoldingCids = [toInterfaceContractId tokenCid] + meta = emptyMetadata + + allocation_cancelImpl _self _arg = do + tokenCid <- create Token with + holding = (allocationHoldingView allocation) with lock = None + pure V1.Allocation_CancelResult with + senderHoldingCids = [toInterfaceContractId tokenCid] + meta = emptyMetadata + + allocation_executeTransferImpl _self _arg = do + tokenCid <- create Token with + holding = (allocationHoldingView allocation) with + owner = allocation.transferLeg.receiver + lock = None + pure V1.Allocation_ExecuteTransferResult with + senderHoldingCids = [] + receiverHoldingCids = [toInterfaceContractId tokenCid] + meta = emptyMetadata + + +-- | Template providing all the rules for how the tokens can be changed; i.e., +-- the choices provided by the token standard V1 factories. +template TokenRules with + admin : Party + where + signatory admin + + interface instance V1.TransferFactory for TokenRules where + view = V1.TransferFactoryView with + admin + meta = emptyMetadata + + transferFactory_publicFetchImpl _self _arg = pure V1.TransferFactoryView with + admin + meta = emptyMetadata + + transferFactory_transferImpl _self arg = do + requireMatchExpected ("expectedAdmin", arg.expectedAdmin) admin + let transfer = arg.transfer + + -- == validate each field of the transfer specification == + -- sender: nothing to validate + -- receiver: nothing to validate + -- instrumentId: + require "Instrument-admin must match the factory" (transfer.instrumentId.admin == admin) + -- amount: + require "Amount must be positive" (transfer.amount > 0.0) + -- requestedAt: + assertDeadlineExceeded "Transfer.requestedAt" transfer.requestedAt + -- executeBefore: + assertWithinDeadline "Transfer.executeBefore" transfer.executeBefore + + -- split off transfer amount and create self-transfer or offer + optSenderChangeCid <- consumeHoldingAmount transfer.sender transfer.inputHoldingCids transfer.amount transfer.instrumentId + let senderChangeCids = optionalToList (toInterfaceContractId <$> optSenderChangeCid) + if transfer.receiver == transfer.sender + then do + splitCid <- create Token with + holding = (transferHoldingView transfer) with lock = None + pure $ V1.TransferInstructionResult with + senderChangeCids + output = V1.TransferInstructionResult_Completed with + receiverHoldingCids = [toInterfaceContractId splitCid] + meta = emptyMetadata + else do + instrCid <- create TokenTransferOffer with + transfer + pure $ V1.TransferInstructionResult with + senderChangeCids + output = V1.TransferInstructionResult_Pending with + transferInstructionCid = toInterfaceContractId instrCid + meta = emptyMetadata + + + interface instance V1.AllocationFactory for TokenRules where + view = V1.AllocationFactoryView with + admin + meta = emptyMetadata + + allocationFactory_publicFetchImpl _self _arg = pure V1.AllocationFactoryView with + admin + meta = emptyMetadata + + allocationFactory_allocateImpl _self arg = do + requireMatchExpected ("expectedAdmin", arg.expectedAdmin) admin + let V1.AllocationFactory_Allocate {allocation, requestedAt, inputHoldingCids} = arg + + -- == validate each field of the requested allocation + let settlement = allocation.settlement + let transferLeg = allocation.transferLeg + + -- settlement.executor: no check + -- settlement.settlementRef: no check + -- settlement.requestedAt: + assertDeadlineExceeded "Allocation.settlement.requestedAt" settlement.requestedAt + -- settlement.allocateBefore: + assertWithinDeadline "Allocation.settlement.allocateBefore" settlement.allocateBefore + -- settlement.settleBefore: + require "Allocation.settlement.allocateBefore <= Allocation.settlement.settleBefore" (settlement.allocateBefore <= settlement.settleBefore) + + -- transferLegId: no check + + -- transferLeg.sender: no check + -- transferLeg.receiver: nothing to check + -- transferLeg.amount + require "Transfer amount must be positive" (transferLeg.amount > 0.0) + -- transferLeg.instrumentId + require "Instrument-admin must match the factory" (transferLeg.instrumentId.admin == admin) + -- transferLeg.meta: no check + + -- requestedAt (of the allocation instruction itself): + assertDeadlineExceeded "requestedAt" requestedAt + + -- inputHoldingCids: + require "At least one input holding must be provided" (not $ null inputHoldingCids) + + -- split off the required amount and create allocation + optSenderChangeCid <- consumeHoldingAmount transferLeg.sender inputHoldingCids transferLeg.amount transferLeg.instrumentId + allocationCid <- toInterfaceContractId <$> create TokenAllocation with + allocation = arg.allocation + + -- done: return the result + pure V1.AllocationInstructionResult with + senderChangeCids = optionalToList (toInterfaceContractId <$> optSenderChangeCid) + output = V1.AllocationInstructionResult_Completed with allocationCid + meta = emptyMetadata + + +-- Utils +-------- + +consumeHoldingAmount : Party -> [ContractId V1.Holding] -> Decimal -> V1.InstrumentId -> Update (Optional (ContractId V1.Holding)) +consumeHoldingAmount sender inputCids amount instrumentId = do + inputAmounts <- forA inputCids $ \cid -> do + holding <- fetch cid + archive cid + let holdingView = view holding + require' ("holding owner", holdingView.owner) isEqualR ("transfer.sender", sender) + require' ("holding.instrumentId", holdingView.instrumentId) isEqualR ("transfer.instrumentId", instrumentId) + pure holdingView.amount + let totalAmount = sum inputAmounts + require' ("input amount", totalAmount) isGreaterOrEqualR ("transfer.amount", amount) + let remainder = totalAmount - amount + if remainder == 0.0 + then pure None + else (Some . toInterfaceContractId)<$> create Token with + holding = V1.HoldingView with + owner = sender + amount = remainder + instrumentId + lock = None + meta = emptyMetadata + +-- | V1 view of the allocation as a holding. +allocationHoldingView : V1.AllocationSpecification -> V1.HoldingView +allocationHoldingView (V1.AllocationSpecification with settlement, transferLeg) = + V1.HoldingView with + owner = transferLeg.sender + amount = transferLeg.amount + instrumentId = transferLeg.instrumentId + lock = Some V1.Lock with + holders = [transferLeg.sender, transferLeg.instrumentId.admin] + expiresAt = Some settlement.settleBefore + expiresAfter = None + context = Some $ "allocation for settlement of " <> settlement.settlementRef.id + meta = emptyMetadata + +-- | V1 view of the transfer as a holding. +transferHoldingView : V1.Transfer -> V1.HoldingView +transferHoldingView transfer = + V1.HoldingView with + owner = transfer.sender + amount = transfer.amount + instrumentId = transfer.instrumentId + lock = Some V1.Lock with + holders = [transfer.sender, transfer.instrumentId.admin] + expiresAt = Some transfer.executeBefore + expiresAfter = None + context = Some $ "transfer to " <> show transfer.receiver + meta = emptyMetadata diff --git a/docs/wallet-integration-guide/examples/scripts/15-multi-sync/daml/splice-test-token-v1/splice-test-token-v1-1.0.0.dar b/docs/wallet-integration-guide/examples/scripts/15-multi-sync/daml/splice-test-token-v1/splice-test-token-v1-1.0.0.dar new file mode 100644 index 000000000..a7ef357c1 Binary files /dev/null and b/docs/wallet-integration-guide/examples/scripts/15-multi-sync/daml/splice-test-token-v1/splice-test-token-v1-1.0.0.dar differ diff --git a/docs/wallet-integration-guide/examples/scripts/15-multi-sync/daml/splice-token-test-trading-app-v2/splice-token-test-trading-app-v2-1.0.0.dar b/docs/wallet-integration-guide/examples/scripts/15-multi-sync/daml/splice-token-test-trading-app-v2/splice-token-test-trading-app-v2-1.0.0.dar new file mode 100644 index 000000000..f21367e74 Binary files /dev/null and b/docs/wallet-integration-guide/examples/scripts/15-multi-sync/daml/splice-token-test-trading-app-v2/splice-token-test-trading-app-v2-1.0.0.dar differ diff --git a/docs/wallet-integration-guide/examples/scripts/15-multi-sync/index.ts b/docs/wallet-integration-guide/examples/scripts/15-multi-sync/index.ts index 4b0209b4f..75bc365fd 100644 --- a/docs/wallet-integration-guide/examples/scripts/15-multi-sync/index.ts +++ b/docs/wallet-integration-guide/examples/scripts/15-multi-sync/index.ts @@ -1,40 +1,64 @@ import pino from 'pino' import { logAllContracts } from '../utils/index.js' import { setupMultiSyncTrade } from './_setup.js' +import { startRegistry } from './_registry/index.js' +import { LOCALNET_TRADING_APP_LEDGER_URL } from './_config.js' import { - TRADE_AMULET_AMOUNT, - TRADE_TOKEN_AMOUNT, mintAmuletForAlice, createTokenRulesAndMintForBob, - createAndInitiateOtcTrade, + buildTradeLegs, + createOtcTradeAndRequestAllocations, + createSettlementAgreement, allocateAmuletForAlice, allocateTokenForBob, - settleOtcTrade, + settleOtcTradeV2, aliceSelfTransferToApp, bobSelfTransferToApp, buildContractReadSpec, } from './_trade_ops.js' -// Multi-Synchronizer DvP: Alice pays 100 Amulet on global; Bob delivers 20 TestToken from app-sync. -// P1 = app-user (Alice), P2 = app-provider (Bob), P3 = sv (TradingApp). +// Multi-Synchronizer DvP via the v2 OTC trading app. +// Alice pays 100 Amulet on global; Bob delivers 20 TestToken (home: app-synchronizer). +// P1 = app-user (Alice), P2 = app-provider (Bob), P3 = sv (TradingApp + TokenAdmin). +// See README.md for the full flow description. const logger = pino({ name: 'v1-15-multi-sync-trade', level: 'info' }) // ── Setup: create SDKs, discover synchronizers, vet DARs, allocate parties ─── // Step 1: Create SDKs for all 3 participants (P1, P2, P3) and discover global + app synchronizers -// Step 2: Vet DARs on both synchronizers for P1+P2; global only for P3 (sv is not connected to app-synchronizer) +// Step 2: Vet DARs — trading-app-v2 on global only; test-token-v1 on app-sync + global // Step 3: Allocate parties for Alice (P1), Bob (P2), TradingApp (P3), and TokenAdmin (P3) const setup = await setupMultiSyncTrade(logger) -const { tokenNamespaceP2, alice, bob, tokenAdmin, synchronizers, amuletAdmin } = - setup +const { + tokenNamespaceP2, + alice, + bob, + tokenAdmin, + synchronizers, + globalSynchronizerId, + appSynchronizerId, +} = setup + +// Start the Token Standard registry server now that tokenAdmin party ID is known. +// The server must be up before wallet-SDK calls for allocation and transfer factory. +// The registry uses P3's ledger URL: P3 (sv) hosts tokenAdmin on both synchronizers, +// so it can read TokenRules on both global and app-synchronizer. +const REGISTRY_PORT = parseInt(process.env['REGISTRY_PORT'] ?? '5975', 10) +const registry = await startRegistry({ + tokenAdminPartyId: tokenAdmin.partyId, + port: REGISTRY_PORT, + ledgerUrl: LOCALNET_TRADING_APP_LEDGER_URL, + globalSynchronizerId, + appSynchronizerId, + logger, +}) const allPartySpecs = buildContractReadSpec(setup) -// ── Steps 4–5: Init holdings ──────────────────────────────────────────────── +// ── Step 4–5: Init holdings ───────────────────────────────────────────────── // Step 4: Mint Amulet for Alice (global synchronizer) -// Steps 5a–5e: TokenAdmin creates TokenRules on global + app, self-mints Token, -// offers to Bob via TransferFactory_Transfer; Bob accepts via -// TransferInstruction_Accept — all single-party submissions +// Step 5: TokenAdmin creates TokenRules on global + app, self-mints Token, +// offers to Bob and Bob accepts — Bob's TestToken lands on app-synchronizer await Promise.all([ mintAmuletForAlice(setup, logger), createTokenRulesAndMintForBob(setup, logger), @@ -43,32 +67,41 @@ await Promise.all([ logger.info('Contracts after setup:') await logAllContracts(logger, synchronizers, allPartySpecs) -// ── OTC trade terms ─────────────────────────────────────────────────────────── -const transferLegs = { - 'leg-0': { - sender: alice.partyId, - receiver: bob.partyId, - amount: TRADE_AMULET_AMOUNT, - instrumentId: { admin: amuletAdmin, id: 'Amulet' }, - meta: { values: {} }, - }, - 'leg-1': { - sender: bob.partyId, - receiver: alice.partyId, - amount: TRADE_TOKEN_AMOUNT, - instrumentId: { admin: tokenAdmin.partyId, id: 'TestToken' }, - meta: { values: {} }, - }, -} +// ── Step 6: Venue creates the v2 OTCTrade and requests allocations ────────── +// The v2 OTCTrade is signatory-venue-only; OTCTrade_RequestAllocations issues an +// OTCTradeAllocationRequest per trading party. +const tradeLegs = buildTradeLegs(setup) +const { otcTradeCid, allocationRequestCids } = + await createOtcTradeAndRequestAllocations(setup, tradeLegs, logger) + +// ── Step 7: Create TradeSettlementAgreements (venue + each trader) ────────── +// Required by the v2 trading app to settle V1 token-standard assets: the agreement +// carries the trader authority the venue needs inside OTCTrade_Settle. Each is a +// two-signatory contract, created via a co-signed interactive submission. +// Created before allocation so each trader already has global-synchronizer standing. +// Each agreement is prepared on the trader's own participant (which co-hosts the venue). +const aliceAgreementCid = await createSettlementAgreement( + setup, + setup.p1Sdk, + setup.p1SdkCtx, + alice, + logger +) +const bobAgreementCid = await createSettlementAgreement( + setup, + setup.p2Sdk, + setup.p2SdkCtx, + bob, + logger +) -// ── Steps 6a–6c + 7: Propose → Accept → Initiate settlement → Read OTCTrade ─ -const otcTradeCid = await createAndInitiateOtcTrade(setup, transferLegs, logger) logger.info('Contracts after trade initiation:') await logAllContracts(logger, synchronizers, allPartySpecs) // ── Steps 8–9: Allocate in parallel ──────────────────────────────────────── -// Step 8: Alice allocates Amulet for leg-0 (global synchronizer) -// Step 9: Bob allocates TestToken for leg-1 (global synchronizer) +// Step 8: Alice allocates Amulet for leg-0 (global synchronizer) +// Step 9: Bob allocates TestToken for leg-1 (global synchronizer; the input Token +// auto-reassigns app-sync → global — no explicit reassignment) const [legIdAlice, { legId: legIdBob }] = await Promise.all([ allocateAmuletForAlice(setup, logger), allocateTokenForBob(setup, logger), @@ -76,7 +109,7 @@ const [legIdAlice, { legId: legIdBob }] = await Promise.all([ logger.info('Contracts after allocations:') await logAllContracts(logger, synchronizers, allPartySpecs) -// ── Step 10a: Locate Bob's TestToken allocation ──────────────────────────────────── +// ── Step 10a: Locate Bob's TestToken allocation ──────────────────────────── const allocationsBob = await tokenNamespaceP2.allocation.pending(bob.partyId) const testTokenAllocation = allocationsBob.find( (a) => a.interfaceViewValue.allocation.transferLegId === legIdBob @@ -84,19 +117,30 @@ const testTokenAllocation = allocationsBob.find( if (!testTokenAllocation) throw new Error('TestToken allocation not found') const testTokenAllocationCid = testTokenAllocation.contractId -// ── Step 10b: TradingApp settles the OTCTrade ───────────────────────────────── -await settleOtcTrade( +// ── Step 10b: TradingApp settles the OTCTrade ────────────────────────────── +await settleOtcTradeV2( setup, - { otcTradeCid, legIdAlice, legIdBob, testTokenAllocationCid }, + { + otcTradeCid, + legIdAlice, + legIdBob, + testTokenAllocationCid, + aliceAgreementCid, + bobAgreementCid, + allocationRequestCids, + }, logger ) logger.info('Contracts after settlement:') await logAllContracts(logger, synchronizers, allPartySpecs) -// ── Step 11: Self-transfer TestTokens back to app-synchronizer ───────────────── +// ── Step 11: Self-transfer TestTokens back to app-synchronizer ───────────── await Promise.all([ aliceSelfTransferToApp(setup, logger), bobSelfTransferToApp(setup, logger), ]) logger.info('Final contract state:') await logAllContracts(logger, synchronizers, allPartySpecs) + +await registry.stop() +logger.info('Token Standard registry server stopped') diff --git a/docs/wallet-integration-guide/examples/scripts/15-multi-sync/splice-test-token-v1-1.0.0.dar b/docs/wallet-integration-guide/examples/scripts/15-multi-sync/splice-test-token-v1-1.0.0.dar deleted file mode 100644 index 8aea10a1a..000000000 Binary files a/docs/wallet-integration-guide/examples/scripts/15-multi-sync/splice-test-token-v1-1.0.0.dar and /dev/null differ diff --git a/scripts/src/generate-registry-routes.ts b/scripts/src/generate-registry-routes.ts new file mode 100644 index 000000000..68525a612 --- /dev/null +++ b/scripts/src/generate-registry-routes.ts @@ -0,0 +1,390 @@ +// Copyright (c) 2025-2026 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import * as fs from 'fs' +import * as path from 'path' +import * as yaml from 'js-yaml' +import { fileURLToPath } from 'url' + +const __filename = fileURLToPath(import.meta.url) +const __dirname = path.dirname(__filename) +const repoRoot = path.resolve(__dirname, '../..') +const specsDir = path.join(repoRoot, 'api-specs/splice/0.6.1') +const featuresDir = path.join( + repoRoot, + 'docs/wallet-integration-guide/examples/scripts/15-multi-sync/_registry/features' +) + +interface JsonSchema { + type?: string + properties?: Record + required?: string[] + items?: JsonSchema + additionalProperties?: JsonSchema | boolean + $ref?: string + enum?: string[] +} + +interface Parameter { + name: string + in: 'path' | 'query' | 'header' | 'cookie' + required?: boolean + schema: JsonSchema +} + +interface PathItem { + operationId: string + parameters?: Parameter[] + requestBody?: { + required?: boolean + content: { 'application/json'?: { schema: JsonSchema } } + } + responses: Record< + string, + | { content?: { 'application/json'?: { schema: JsonSchema } } } + | undefined + > +} + +type HttpMethod = 'get' | 'post' | 'put' | 'delete' | 'patch' + +interface OpenApiSpec { + paths: Record>> + components: { + schemas: Record + } +} + +interface SpecConfig { + specFile: string + outDir: string + handlerName: string + registerFn: string + /** operationIds whose handler may return null, triggering a 404. */ + nullableOps: string[] +} + +const SPEC_CONFIGS: SpecConfig[] = [ + { + specFile: 'token-metadata-v1.yaml', + outDir: path.join(featuresDir, 'metadata'), + handlerName: 'MetadataHandlers', + registerFn: 'registerMetadataRoutes', + nullableOps: ['getInstrument'], + }, + { + specFile: 'transfer-instruction-v1.yaml', + outDir: path.join(featuresDir, 'transfer'), + handlerName: 'TransferHandlers', + registerFn: 'registerTransferRoutes', + nullableOps: ['getTransferFactory'], + }, + { + specFile: 'allocation-instruction-v1.yaml', + outDir: path.join(featuresDir, 'allocation-instruction'), + handlerName: 'AllocationInstructionHandlers', + registerFn: 'registerAllocationInstructionRoutes', + nullableOps: ['getAllocationFactory'], + }, + { + specFile: 'allocation-v1.yaml', + outDir: path.join(featuresDir, 'allocation'), + handlerName: 'AllocationHandlers', + registerFn: 'registerAllocationRoutes', + nullableOps: [], + }, +] + +function refName(ref: string): string { + return ref.split('/').pop()! +} + +function schemaToTs(schema: JsonSchema): string { + if (schema.$ref) return refName(schema.$ref) + + if (schema.enum) { + return schema.enum.map((v) => `'${v}'`).join(' | ') + } + + if (schema.type === 'object') { + if (schema.additionalProperties) { + if (typeof schema.additionalProperties === 'boolean') + return 'Record' + return `Record` + } + if (!schema.properties || Object.keys(schema.properties).length === 0) { + return 'Record' + } + const props = Object.entries(schema.properties).map(([k, v]) => { + const isReq = schema.required?.includes(k) ?? false + return `${k}${isReq ? '' : '?'}: ${schemaToTs(v)}` + }) + return `{ ${props.join('; ')} }` + } + + if (schema.type === 'array') { + return schema.items ? `${schemaToTs(schema.items)}[]` : 'unknown[]' + } + + if (schema.type === 'string') return 'string' + if (schema.type === 'integer' || schema.type === 'number') return 'number' + if (schema.type === 'boolean') return 'boolean' + return 'unknown' +} + +function generateNamedType(name: string, schema: JsonSchema): string { + if (schema.$ref) return `export type ${name} = ${refName(schema.$ref)}` + + if (schema.enum) { + return `export type ${name} = ${schema.enum.map((v) => `'${v}'`).join(' | ')}` + } + + if ( + schema.type === 'object' && + !schema.additionalProperties && + schema.properties && + Object.keys(schema.properties).length > 0 + ) { + const props = Object.entries(schema.properties).map(([k, v]) => { + const isReq = schema.required?.includes(k) ?? false + return ` ${k}${isReq ? '' : '?'}: ${schemaToTs(v)}` + }) + return `export interface ${name} {\n${props.join('\n')}\n}` + } + + return `export type ${name} = ${schemaToTs(schema)}` +} + +function generateSchemaTypes(schemas: Record): string { + return Object.entries(schemas) + .map(([name, schema]) => generateNamedType(name, schema)) + .join('\n\n') +} + +interface OperationInfo { + operationId: string + method: string + oasPath: string + routerPath: string + pathParams: Parameter[] + queryParams: Parameter[] + bodySchema: JsonSchema | null + responseSchema: JsonSchema | null +} + +function extractOperations(spec: OpenApiSpec): OperationInfo[] { + const ops: OperationInfo[] = [] + for (const [oasPath, pathItem] of Object.entries(spec.paths)) { + for (const method of [ + 'get', + 'post', + 'put', + 'delete', + 'patch', + ] as HttpMethod[]) { + const op = pathItem[method] + if (!op) continue + const routerPath = oasPath.replace(/\{([^}]+)\}/g, ':$1') + const pathParams = (op.parameters ?? []).filter( + (p) => p.in === 'path' + ) + const queryParams = (op.parameters ?? []).filter( + (p) => p.in === 'query' + ) + const bodySchema = + op.requestBody?.content['application/json']?.schema ?? null + const response200 = op.responses['200'] + const responseSchema = + response200?.content?.['application/json']?.schema ?? null + ops.push({ + operationId: op.operationId, + method: method.toUpperCase(), + oasPath, + routerPath, + pathParams, + queryParams, + bodySchema, + responseSchema, + }) + } + } + return ops +} + +function operationToHandlerMethod( + op: OperationInfo, + nullable: boolean +): string { + const args: string[] = [] + + if (op.pathParams.length > 0) { + const fields = op.pathParams + .map((p) => `${p.name}: ${schemaToTs(p.schema)}`) + .join('; ') + args.push(`path: { ${fields} }`) + } + + if (op.queryParams.length > 0) { + const fields = op.queryParams + .map((p) => `${p.name}?: ${schemaToTs(p.schema)}`) + .join('; ') + args.push(`query?: { ${fields} }`) + } + + if (op.bodySchema) { + args.push(`body: ${schemaToTs(op.bodySchema)}`) + } + + const retBase = op.responseSchema ? schemaToTs(op.responseSchema) : 'void' + const ret = nullable ? `${retBase} | null` : retBase + return ` ${op.operationId}(${args.join(', ')}): ${ret} | Promise<${ret}>` +} + +function generateHandlerInterface( + name: string, + ops: OperationInfo[], + nullableOps: string[] +): string { + const methods = ops.map((op) => + operationToHandlerMethod(op, nullableOps.includes(op.operationId)) + ) + return `export interface ${name} {\n${methods.join('\n')}\n}` +} + +function generateRouteBody(op: OperationInfo, nullable: boolean): string { + const callArgs: string[] = [] + + if (op.pathParams.length > 0) { + const fields = op.pathParams + .map((p) => `${p.name}: params['${p.name}']!`) + .join(', ') + callArgs.push(`{ ${fields} }`) + } + + if (op.queryParams.length > 0) { + callArgs.push('{}') + } + + if (op.bodySchema) { + callArgs.push(`body as ${schemaToTs(op.bodySchema)}`) + } + + const call = `handlers.${op.operationId}(${callArgs.join(', ')})` + + if (nullable) { + const errDetail = + op.pathParams.length > 0 + ? `${op.pathParams.map((p) => `${p.name}=\${params['${p.name}']}`).join(', ')} not found` + : 'not found' + return [ + ` const result = await ${call}`, + ` if (result === null) {`, + ` respond(res, 404, { error: \`${op.operationId}: ${errDetail}\` })`, + ` } else {`, + ` respond(res, 200, result)`, + ` }`, + ].join('\n') + } + + return ` respond(res, 200, await ${call})` +} + +function generateRegisterFunction( + fnName: string, + handlerName: string, + ops: OperationInfo[], + nullableOps: string[] +): string { + const body = ops + .map((op) => { + const hasBody = !!op.bodySchema + const hasPathParams = op.pathParams.length > 0 + const reqName = '_req' + const bodyName = hasBody ? 'body' : '_body' + const paramsName = hasPathParams ? 'params' : '_params' + return [ + ` // ${op.method} ${op.oasPath} → ${op.operationId}`, + ` route('${op.method}', '${op.routerPath}', async (${reqName}, res, ${bodyName}, ${paramsName}) => {`, + generateRouteBody(op, nullableOps.includes(op.operationId)), + ` })`, + ].join('\n') + }) + .join('\n\n') + + return [ + `export function ${fnName}(`, + ` route: (method: string, pattern: string, handler: RouteHandler) => void,`, + ` respond: (res: ServerResponse, status: number, body: unknown) => void,`, + ` handlers: ${handlerName}`, + `): void {`, + body, + `}`, + ].join('\n') +} + +const FILE_HEADER = `\ +// Copyright (c) 2025-2026 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 +// +// DO NOT EDIT — generated by scripts/src/generate-registry-routes.ts` + +function generateRoutesFile( + spec: OpenApiSpec, + specFile: string, + cfg: SpecConfig +): string { + const ops = extractOperations(spec) + const schemaSection = generateSchemaTypes(spec.components.schemas) + const handlerSection = generateHandlerInterface( + cfg.handlerName, + ops, + cfg.nullableOps + ) + const registerSection = generateRegisterFunction( + cfg.registerFn, + cfg.handlerName, + ops, + cfg.nullableOps + ) + + return [ + FILE_HEADER, + `// Source: api-specs/splice/0.6.1/${specFile}`, + '', + `import type { ServerResponse } from 'node:http'`, + `import type { RouteHandler } from '../../http/router.js'`, + '', + schemaSection, + '', + handlerSection, + '', + registerSection, + '', + ].join('\n') +} + +async function main(): Promise { + let generated = 0 + for (const cfg of SPEC_CONFIGS) { + const specPath = path.join(specsDir, cfg.specFile) + if (!fs.existsSync(specPath)) { + console.warn(` SKIP ${cfg.specFile} (not found at ${specPath})`) + continue + } + const spec = yaml.load(fs.readFileSync(specPath, 'utf8')) as OpenApiSpec + + const output = generateRoutesFile(spec, cfg.specFile, cfg) + const outFile = path.join(cfg.outDir, 'routes.ts') + + fs.mkdirSync(cfg.outDir, { recursive: true }) + fs.writeFileSync(outFile, output, 'utf8') + console.log(` OK ${path.relative(repoRoot, outFile)}`) + generated++ + } + console.log(`\nGenerated ${generated} file(s).`) +} + +main().catch((err: unknown) => { + console.error(err) + process.exit(1) +}) diff --git a/scripts/src/start-localnet.ts b/scripts/src/start-localnet.ts index 5bfda0742..20ff34a09 100644 --- a/scripts/src/start-localnet.ts +++ b/scripts/src/start-localnet.ts @@ -1,14 +1,20 @@ // Copyright (c) 2025-2026 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import { execFileSync } from 'child_process' +import { execFileSync, spawnSync } from 'child_process' import fs from 'fs' import path from 'path' -import { getRepoRoot, getNetworkArg, SUPPORTED_VERSIONS } from './lib/utils.js' +import { + getRepoRoot, + getNetworkArg, + hasFlag, + SUPPORTED_VERSIONS, +} from './lib/utils.js' const args = process.argv.slice(2) const command = args[0] -const multiSync = !args.includes('--no-multi-sync') +const multiSync = hasFlag('multi-sync') + const rootDir = getRepoRoot() const LOCALNET_DIR = path.join(rootDir, '.localnet/docker-compose/localnet') const GENERATED_COMPOSE_OVERRIDE = path.join( @@ -17,32 +23,37 @@ const GENERATED_COMPOSE_OVERRIDE = path.join( ) const CANTON_MAX_COMMANDS_IN_FLIGHT = 256 -const CUSTOM_APP_SYNCHRONIZER_SC = path.join( +const LOCALNET_DARS_DIR = path.join(rootDir, '.localnet/dars') +// TODO (#1721): make multi-sync the default and remove the flag once multi-sync is fully supported and tested in the main scripts e2e tests, but for now we want to keep it as an option to avoid accidentally running multi-sync e2e tests without updating the main scripts e2e tests to cover multi-sync as well +const MULTI_SYNC_APP_SYNCHRONIZER_SC = path.join( rootDir, 'canton/multi-sync/app-synchronizer.sc' ) function ensureComposeOverride() { fs.mkdirSync(path.dirname(GENERATED_COMPOSE_OVERRIDE), { recursive: true }) - fs.writeFileSync( - GENERATED_COMPOSE_OVERRIDE, - [ - 'services:', - ' canton:', - ' environment:', - ' ADDITIONAL_CONFIG_MAX_COMMANDS_IN_FLIGHT: |-', - ` canton.participants.app-provider.ledger-api.command-service.max-commands-in-flight = ${CANTON_MAX_COMMANDS_IN_FLIGHT}`, - ` canton.participants.app-user.ledger-api.command-service.max-commands-in-flight = ${CANTON_MAX_COMMANDS_IN_FLIGHT}`, - ` canton.participants.sv.ledger-api.command-service.max-commands-in-flight = ${CANTON_MAX_COMMANDS_IN_FLIGHT}`, + const lines = [ + 'services:', + ' canton:', + ' environment:', + ' ADDITIONAL_CONFIG_MAX_COMMANDS_IN_FLIGHT: |-', + ` canton.participants.app-provider.ledger-api.command-service.max-commands-in-flight = ${CANTON_MAX_COMMANDS_IN_FLIGHT}`, + ` canton.participants.app-user.ledger-api.command-service.max-commands-in-flight = ${CANTON_MAX_COMMANDS_IN_FLIGHT}`, + ` canton.participants.sv.ledger-api.command-service.max-commands-in-flight = ${CANTON_MAX_COMMANDS_IN_FLIGHT}`, + ] + if (multiSync) { + lines.push( ' multi-sync-startup:', ' volumes:', - ` - ${CUSTOM_APP_SYNCHRONIZER_SC}:/app/app-synchronizer.sc`, - '', - ].join('\n'), - 'utf8' - ) + ` - ${LOCALNET_DARS_DIR}:/app/dars:ro`, + ` - ${MULTI_SYNC_APP_SYNCHRONIZER_SC}:/app/app-synchronizer.sc:ro` + ) + } + lines.push('') + fs.writeFileSync(GENERATED_COMPOSE_OVERRIDE, lines.join('\n'), 'utf8') } +// TODO (#1721): make multi-sync the default and remove the flag once multi-sync is fully supported and tested in the main scripts e2e tests, but for now we want to keep it as an option to avoid accidentally running multi-sync e2e tests without updating the main scripts e2e tests to cover multi-sync as well const composeBase = [ 'docker', 'compose', @@ -83,6 +94,22 @@ if (command === 'pull') { stdio: 'inherit', env, }) + // TODO (#1721): make multi-sync the default and remove the flag once multi-sync is fully supported and tested in the main scripts e2e tests, but for now we want to keep it as an option to avoid accidentally running multi-sync e2e tests without updating the main scripts e2e tests to cover multi-sync as well + if (multiSync) { + console.log( + 'Waiting for multi-sync bootstrap (package vetting) to complete...' + ) + const waitResult = spawnSync('docker', ['wait', 'multi-sync-startup'], { + encoding: 'utf8', + }) + const exitCode = waitResult.stdout?.trim() ?? '1' + if (exitCode !== '0') { + throw new Error( + `multi-sync bootstrap script failed with exit code ${exitCode}` + ) + } + console.log('Multi-sync bootstrap completed successfully.') + } } else if (command === 'stop') { execFileSync(composeBase[0], [...composeBase.slice(1), 'down', '-v'], { stdio: 'inherit', diff --git a/sdk/wallet-sdk/src/wallet/namespace/party/external/service.ts b/sdk/wallet-sdk/src/wallet/namespace/party/external/service.ts index 8475e2207..8f6dcf688 100644 --- a/sdk/wallet-sdk/src/wallet/namespace/party/external/service.ts +++ b/sdk/wallet-sdk/src/wallet/namespace/party/external/service.ts @@ -32,7 +32,7 @@ export class ExternalPartyNamespace { this.resolveParticipantUids( options?.confirmingParticipantEndpoints ?? [] ), - options?.synchronizerId || this.resolveSynchronizerId(), + options?.synchronizerId || this.findGlobalSynchronizer(), ]).then( ([ observingParticipantUids, @@ -79,7 +79,7 @@ export class ExternalPartyNamespace { ) } - private async resolveSynchronizerId() { + private async findGlobalSynchronizer() { const connectedSynchronizers = await this.ctx.ledgerProvider.request( { @@ -92,19 +92,16 @@ export class ExternalPartyNamespace { } ) - if (!connectedSynchronizers.connectedSynchronizers?.[0]) { - throw new Error('No connected synchronizers found') - } - - const synchronizerId = - connectedSynchronizers.connectedSynchronizers[0].synchronizerId - if (connectedSynchronizers.connectedSynchronizers.length > 1) { - this.logger.warn( - `Found ${connectedSynchronizers.connectedSynchronizers.length} synchronizers, defaulting to ${synchronizerId}` + const global = connectedSynchronizers.connectedSynchronizers?.find( + (s) => s.synchronizerAlias === 'global' + ) + if (!global) { + throw new Error( + 'Global synchronizer not found among connected synchronizers' ) } - return synchronizerId + return global.synchronizerId } /**