diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index bc3a428d6..ceba416c4 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -144,6 +144,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 @@ -151,7 +152,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 @@ -163,7 +164,7 @@ jobs: uses: ./.github/actions/save_cache_if_absent 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') }} initial_cache_hit: ${{ steps.cached-poetry-dependencies.outputs.cache-hit }} - name: Build docs @@ -457,8 +458,7 @@ jobs: wallet-sdk-scripts-e2e-multi-sync: name: wallet-sdk-scripts-e2e-multi-sync (${{ matrix.network }}) runs-on: ubuntu-latest - needs: [build, e2e-affected] - if: needs.e2e-affected.outputs.affected_wallet_sdk == 'true' + needs: [version-config, hydrate-canton-caches] strategy: fail-fast: false matrix: @@ -469,12 +469,18 @@ jobs: uses: actions/checkout@v6 - uses: ./.github/actions/setup_yarn + with: + daml_release_version: ${{ needs.version-config.outputs.daml_release_version }} - - uses: ./.github/actions/setup_canton + - uses: ./.github/actions/setup_localnet with: network: ${{ matrix.network }} - instance: localnet - multi-sync: 'true' + 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 @@ -490,6 +496,7 @@ jobs: # 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 @@ -513,7 +520,6 @@ jobs: name: test-wallet-sdk-e2e runs-on: ubuntu-latest needs: [ - e2e-affected, 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 @@ -523,27 +529,18 @@ jobs: steps: - name: Report wallet-sdk e2e execution run: | - if [ "${{ needs.e2e-affected.outputs.affected_wallet_sdk }}" = "true" ]; then - if [ "${{ needs.wallet-sdk-snippets-e2e.result }}" != "success" ]; then - echo "wallet-sdk snippets e2e was scheduled but did not succeed" - exit 1 - fi - if [ "${{ needs.wallet-sdk-scripts-e2e.result }}" != "success" ]; then - echo "wallet-sdk scripts e2e was scheduled but did not succeed" - exit 1 - fi - if [ "${{ needs.wallet-sdk-scripts-e2e-multi-sync.result }}" != "success" ]; then - echo "wallet-sdk scripts e2e (multi-sync) was scheduled but did not succeed" - exit 1 - fi - echo "all wallet-sdk-e2e jobs passed" - else - echo "wallet-sdk e2e skipped (no affected wallet-sdk dependencies)" + if [ "${{ needs.wallet-sdk-snippets-e2e.result }}" != "success" ]; then + echo "wallet-sdk snippets e2e did not succeed" + exit 1 fi if [ "${{ needs.wallet-sdk-scripts-e2e.result }}" != "success" ]; then 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/_config.ts b/docs/wallet-integration-guide/examples/scripts/15-multi-sync/_config.ts index 4bbb9e456..9ee72d011 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 @@ -17,6 +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') +// 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}` +) + // Party hint labels used when allocating parties export const PARTY_HINT_ALICE = 'Alice' export const PARTY_HINT_BOB = 'Bob' diff --git a/docs/wallet-integration-guide/examples/scripts/15-multi-sync/_registry/features/admin/handlers.ts b/docs/wallet-integration-guide/examples/scripts/15-multi-sync/_registry/features/admin/handlers.ts new file mode 100644 index 000000000..5af0beef9 --- /dev/null +++ b/docs/wallet-integration-guide/examples/scripts/15-multi-sync/_registry/features/admin/handlers.ts @@ -0,0 +1,84 @@ +// Copyright (c) 2025-2026 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +/** + * TestToken admin endpoint handlers. + * + * These endpoints allow the example to bootstrap on-ledger state + * (TokenRules + Token holdings) entirely through the registry's off-ledger + * HTTP API, without the example code ever calling the Daml Ledger API directly + * for Token-related operations. + * + * POST /admin/v1/setup — creates TokenRules on both synchronizers + * POST /admin/v1/mint — mints a Token holding for tokenAdmin + */ + +import type { AdminHandlers, SubmitAsTokenAdmin } from '../../types.js' +import { invalidateCache } from '../../ledger.js' + +const TEST_TOKEN_PREFIX = + '#splice-test-token-v1:Splice.Testing.Tokens.TestTokenV1' + +export interface AdminHandlerContext { + tokenAdminPartyId: string + globalSynchronizerId: string + appSynchronizerId: string + submitAsTokenAdmin: SubmitAsTokenAdmin +} + +export function createAdminHandlers(ctx: AdminHandlerContext): AdminHandlers { + return { + async setupTokenRules(): Promise { + // Create TokenRules on both synchronizers in parallel so the + // registry's ACS cache picks them up on the next call. + await Promise.all([ + ctx.submitAsTokenAdmin({ + commands: { + CreateCommand: { + templateId: `${TEST_TOKEN_PREFIX}:TokenRules`, + createArguments: { admin: ctx.tokenAdminPartyId }, + }, + }, + synchronizerId: ctx.globalSynchronizerId, + }), + ctx.submitAsTokenAdmin({ + commands: { + CreateCommand: { + templateId: `${TEST_TOKEN_PREFIX}:TokenRules`, + createArguments: { admin: ctx.tokenAdminPartyId }, + }, + }, + synchronizerId: ctx.appSynchronizerId, + }), + ]) + // Invalidate the ACS cache so transfer-factory requests see the + // newly created TokenRules contracts immediately. + invalidateCache() + }, + + async mintToken({ amount }: { amount: string }): Promise { + await ctx.submitAsTokenAdmin({ + commands: [ + { + CreateCommand: { + templateId: `${TEST_TOKEN_PREFIX}:Token`, + createArguments: { + holding: { + owner: ctx.tokenAdminPartyId, + instrumentId: { + admin: ctx.tokenAdminPartyId, + id: 'TestToken', + }, + amount, + lock: null, + meta: { values: {} }, + }, + }, + }, + }, + ], + synchronizerId: ctx.appSynchronizerId, + }) + }, + } +} 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..5383aac44 --- /dev/null +++ b/docs/wallet-integration-guide/examples/scripts/15-multi-sync/_registry/features/allocation-instruction/handlers.ts @@ -0,0 +1,59 @@ +// 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 { + /** Returns the TokenRules on the requested synchronizer, or the first one if not found. */ + getTokenRules: ( + synchronizerId?: string + ) => Promise + /** ID of the global (trade) synchronizer — allocations must use this synchronizer's factory. */ + globalSynchronizerId: string +} + +export function createAllocationInstructionHandlers( + ctx: AllocationInstructionHandlerContext +): AllocationInstructionHandlers { + return { + getAllocationFactory: async ( + _req: GetFactoryRequest + ): Promise => { + // Always use the global-synchronizer TokenRules as the allocation factory. + // Allocations for trade settlement are executed on the global synchronizer, + // so the factory contract must live there. + 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..49226c82c --- /dev/null +++ b/docs/wallet-integration-guide/examples/scripts/15-multi-sync/_registry/features/transfer/handlers.ts @@ -0,0 +1,103 @@ +// 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: + * - Self-transfers target the app synchronizer, so the app-sync TokenRules is + * returned as the factory. + * - Other transfers use the first available TokenRules (falls back gracefully + * when only one TokenRules exists). + * + * 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 + /** ID of the app synchronizer — returned as the factory for self-transfers. */ + appSynchronizerId: string +} + +export function createTransferHandlers( + ctx: TransferHandlerContext +): TransferHandlers { + return { + getTransferFactory: async ( + req: GetFactoryRequest + ): Promise => { + // Inspect choiceArguments to determine the kind of transfer. + // The SDK sends the full TransferFactory_Transfer args as choiceArguments. + 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' + + // All TestToken transfers target the app synchronizer, so always use its TokenRules + // as the factory regardless of whether this is a self-transfer or an 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..46c81ae93 --- /dev/null +++ b/docs/wallet-integration-guide/examples/scripts/15-multi-sync/_registry/index.ts @@ -0,0 +1,377 @@ +// 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, + SubmitAsTokenAdmin, +} 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' +import { createAdminHandlers } from './features/admin/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, + }, + // admin + { + method: 'POST', + pattern: '/admin/v1/setup', + operationId: 'adminSetupTokenRules', + }, + { + method: 'POST', + pattern: '/admin/v1/mint', + operationId: 'adminMintToken', + }, + // 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', + }, +] + +// ── public API ───────────────────────────────────────────────────────────── +export interface RegistryConfig { + tokenAdminPartyId: string + port: number + ledgerUrl: URL + logger: Logger + /** ID of the global (trade) synchronizer — used to select the allocation factory. */ + globalSynchronizerId: string + /** ID of the app synchronizer — used to select the factory for self-transfers. */ + appSynchronizerId: string + /** + * Callback to submit signed commands on behalf of tokenAdmin. + * Provided by the example so the registry never holds the private key. + */ + submitAsTokenAdmin: SubmitAsTokenAdmin +} + +export interface RegistryHandle { + /** Gracefully stops the HTTP server. */ + 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, + submitAsTokenAdmin, + } = 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]! + } + + // Build handler objects (one per API slice) + 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() + const admin = createAdminHandlers({ + tokenAdminPartyId, + globalSynchronizerId, + appSynchronizerId, + submitAsTokenAdmin, + }) + + // 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 + ), + ], + // Admin + [ + 'adminSetupTokenRules', + async () => { + await admin.setupTokenRules() + return {} + }, + ], + [ + 'adminMintToken', + async (_, b) => { + await admin.mintToken(b as { amount: string }) + return {} + }, + ], + // 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..912928fd6 --- /dev/null +++ b/docs/wallet-integration-guide/examples/scripts/15-multi-sync/_registry/ledger.ts @@ -0,0 +1,127 @@ +// 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, and caches results 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' + +// Template ID of the TestToken TokenRules contract (package:module:entity) +export const TOKEN_RULES_TEMPLATE_ID = + '#splice-test-token-v1:Splice.Testing.Tokens.TestTokenV1:TokenRules' + +// Matches the fields from jsActiveContract.createdEvent + synchronizerId +export interface TokenRulesContract { + contractId: string + templateId: string + createdEventBlob: string + synchronizerId: string +} + +// ── cache ───────────────────────────────────────────────────────────────────── +interface Cache { + contracts: TokenRulesContract[] + expireAt: number +} + +let cache: Cache | null = null +const CACHE_TTL_MS = 5_000 + +// ── client factory ──────────────────────────────────────────────────────────── +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 }) +} + +// ── ACS read ────────────────────────────────────────────────────────────────── +/** + * Returns all `TokenRules` contracts visible to `tokenAdminPartyId`, served from a + * short-lived cache so each HTTP request does not cause a ledger round-trip. + */ +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…') + + // Get the current ledger end so the ACS query is anchored to a consistent offset + 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 +} + +/** Invalidate the ACS cache (call after known on-ledger state changes). */ +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..45ca2871f --- /dev/null +++ b/docs/wallet-integration-guide/examples/scripts/15-multi-sync/_registry/types.ts @@ -0,0 +1,153 @@ +// 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 +} + +// ── admin ────────────────────────────────────────────────────────────────── + +/** + * Callback provided by the caller so the registry can submit signed + * commands on behalf of tokenAdmin without holding the private key itself. + */ +export type SubmitAsTokenAdmin = (opts: { + commands: unknown + synchronizerId: string +}) => Promise + +export interface AdminHandlers { + setupTokenRules(): Promise + mintToken(body: { amount: string }): 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 5e7b4e4db..410bd05b1 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 @@ -25,6 +25,7 @@ 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, @@ -74,22 +75,30 @@ export interface MultiSyncSetup { export async function setupMultiSyncTrade( logger: Logger ): Promise { - // Create three SDK instances — one per participant node + // Create three SDK instances — one per participant node. + // Include the TestToken registry URL so sdk.token.transfer.create() can discover the TestToken asset. + 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, }), ]) @@ -154,7 +163,9 @@ export async function setupMultiSyncTrade( fs.readFile(path.join(here, TEST_TOKEN_V1_DAR)), ]) - // P1, P2 and P3 vet DARs on both synchronizers + // P1, P2 and P3 vet DARs on both synchronizers. + // sv (P3) is connected to both synchronizers so that TokenAdmin can submit + // TokenRules and Token contracts on the app-synchronizer. await Promise.all( [p1SdkCtx, p2SdkCtx, p3SdkCtx].flatMap((ctx) => [globalSynchronizerId, appSynchronizerId].flatMap((sid) => 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 02d6e5cff..3399f3024 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 @@ -10,6 +10,7 @@ import { 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) ─────────────────────── @@ -140,164 +141,89 @@ export async function createTokenRulesAndMintForBob( const { p2Sdk, p3Sdk, + tokenNamespaceP2, bob, tokenAdmin, - globalSynchronizerId, appSynchronizerId, } = setup - await Promise.all([ - p3Sdk.ledger - .prepare({ - partyId: tokenAdmin.partyId, - commands: { - CreateCommand: { - templateId: `${TEST_TOKEN_PREFIX}:TokenRules`, - createArguments: { admin: tokenAdmin.partyId }, - }, - }, - disclosedContracts: [], - synchronizerId: globalSynchronizerId, - }) - .sign(tokenAdmin.keyPair.privateKey) - .execute({ partyId: tokenAdmin.partyId }), - p3Sdk.ledger - .prepare({ - partyId: tokenAdmin.partyId, - commands: { - CreateCommand: { - templateId: `${TEST_TOKEN_PREFIX}:TokenRules`, - createArguments: { admin: tokenAdmin.partyId }, - }, - }, - disclosedContracts: [], - synchronizerId: appSynchronizerId, - }) - .sign(tokenAdmin.keyPair.privateKey) - .execute({ partyId: tokenAdmin.partyId }), - ]) + // Create TokenRules on global + app synchronizers via registry admin API. + const registryBase = LOCALNET_TEST_TOKEN_REGISTRY_URL.href.replace( + /\/$/, + '' + ) + await fetch(`${registryBase}/admin/v1/setup`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: '{}', + }) - await p3Sdk.ledger - .prepare({ - partyId: tokenAdmin.partyId, - commands: [ - { - CreateCommand: { - templateId: `${TEST_TOKEN_PREFIX}:Token`, - createArguments: { - holding: { - owner: tokenAdmin.partyId, - instrumentId: { - admin: tokenAdmin.partyId, - id: 'TestToken', - }, - amount: BOB_TOKEN_MINT_AMOUNT, - lock: null, - meta: { values: {} }, - }, - }, - }, - }, - ], - disclosedContracts: [], - synchronizerId: appSynchronizerId, - }) - .sign(tokenAdmin.keyPair.privateKey) - .execute({ partyId: tokenAdmin.partyId }) + // Mint a Token holding for tokenAdmin via registry admin API. + await fetch(`${registryBase}/admin/v1/mint`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ amount: BOB_TOKEN_MINT_AMOUNT }), + }) - const [tokenRulesContracts, adminTokenHoldings] = await Promise.all([ - p3Sdk.ledger.acs.read({ - templateIds: [`${TEST_TOKEN_PREFIX}:TokenRules`], - parties: [tokenAdmin.partyId], - filterByParty: true, - }), - p3Sdk.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') + 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 + // Cross-participant propagation from P3 → P2 is async: P3's execute() returns once + // the transaction is committed on P3's participant, but the resulting TokenTransferOffer + // event must still be delivered to P2's participant before it appears on P2's ACS. + // Poll until the contract is visible or a 30-second deadline is exceeded. + 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) @@ -484,14 +410,8 @@ export async function allocateTokenForBob( setup: MultiSyncSetup, logger: Logger ): Promise<{ legId: string }> { - const { - p2Sdk, - p3Sdk, - tokenNamespaceP2, - bob, - tokenAdmin, - globalSynchronizerId, - } = setup + const { p2Sdk, tokenNamespaceP2, bob, tokenAdmin, globalSynchronizerId } = + setup const pendingRequests = await tokenNamespaceP2.allocation.request.pending( bob.partyId @@ -502,26 +422,14 @@ export async function allocateTokenForBob( )! 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, - }), - p3Sdk.ledger.acs.read({ - templateIds: [`${TEST_TOKEN_PREFIX}:TokenRules`], - parties: [tokenAdmin.partyId], - filterByParty: true, - }), - ]) + 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') if (tokenHolding.synchronizerId !== globalSynchronizerId) { await p2Sdk.ledger.internal.reassign({ @@ -543,33 +451,18 @@ export async function allocateTokenForBob( id: 'TestToken', displayName: 'TestToken', symbol: 'TT', - registryUrl: 'http://unused.invalid', + registryUrl: LOCALNET_TEST_TOKEN_REGISTRY_URL.href, 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) @@ -596,6 +489,7 @@ export async function settleOtcTrade( const { p3Sdk, tokenNamespaceP1: tokenNamespaceP1, + tokenNamespaceP2, alice, tradingApp, globalSynchronizerId, @@ -610,10 +504,16 @@ 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 allocationsWithContext = { [legIdAlice]: { @@ -632,13 +532,30 @@ export async function settleOtcTrade( }, [legIdBob]: { _1: testTokenAllocationCid, - _2: { context: { values: {} }, meta: { values: {} } }, + _2: { + context: { + ...(tokenExecCtx.choiceContextData ?? {}), + values: + (tokenExecCtx.choiceContextData?.values as Record< + string, + unknown + >) ?? {}, + }, + meta: { values: {} }, + }, }, } - 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({ @@ -668,70 +585,32 @@ export async function aliceSelfTransferToApp( setup: MultiSyncSetup, logger: Logger ): Promise { - const { p1Sdk, p3Sdk, 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, - }), - p3Sdk.ledger.acs.read({ - templateIds: [`${TEST_TOKEN_PREFIX}:TokenRules`], - parties: [tokenAdmin.partyId], - filterByParty: true, - }), - ]) + 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') + + 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], + }) 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: [aliceTokenCid], - meta: { values: {} }, - }, - extraArgs: { - context: { values: {} }, - meta: { values: {} }, - }, - }, - }, - }, - ], - disclosedContracts: [ - { - templateId: tokenRules.templateId, - contractId: tokenRules.contractId, - createdEventBlob: tokenRules.createdEventBlob!, - synchronizerId: tokenRules.synchronizerId, - }, - ], + commands: [transferCommand], + disclosedContracts: transferDisclosed, synchronizerId: appSynchronizerId, }) .sign(alice.keyPair.privateKey) @@ -747,29 +626,18 @@ export async function bobSelfTransferToApp( setup: MultiSyncSetup, logger: Logger ): Promise { - const { p2Sdk, p3Sdk, 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, - }), - p3Sdk.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 = ( @@ -780,50 +648,21 @@ export async function bobSelfTransferToApp( if (!holdingAmount) throw new Error('Cannot read amount from Bob Token holding') + 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], + }) + 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, - }, - ], + commands: [transferCommand], + disclosedContracts: transferDisclosed, synchronizerId: appSynchronizerId, }) .sign(bob.keyPair.privateKey) 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 fa44c1a16..4f8441309 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,6 +1,8 @@ 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, @@ -26,8 +28,38 @@ const logger = pino({ name: 'v1-15-multi-sync-trade', level: 'info' }) // Step 2: Vet DARs on all synchronizers (global + app) and all participants (P1, P2, P3) // 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, + amuletAdmin, + 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. +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, + submitAsTokenAdmin: ({ commands, synchronizerId }) => + setup.p3Sdk.ledger + .prepare({ + partyId: tokenAdmin.partyId, + commands, + disclosedContracts: [], + synchronizerId, + }) + .sign(tokenAdmin.keyPair.privateKey) + .execute({ partyId: tokenAdmin.partyId }), +}) const allPartySpecs = buildContractReadSpec(setup) @@ -101,3 +133,6 @@ await Promise.all([ ]) logger.info('Final contract state:') await logAllContracts(logger, synchronizers, allPartySpecs) + +await registry.stop() +logger.info('Token Standard registry server stopped') diff --git a/scripts/src/generate-registry-routes.ts b/scripts/src/generate-registry-routes.ts new file mode 100644 index 000000000..ec36ec64a --- /dev/null +++ b/scripts/src/generate-registry-routes.ts @@ -0,0 +1,436 @@ +// Copyright (c) 2025-2026 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +/** + * Generates typed route files for the TestToken registry server from the four + * Token Standard OpenAPI specs located in api-specs/splice/0.6.1/. + * + * For each spec it produces a `routes.ts` in the corresponding feature-slice directory: + * features/metadata/routes.ts ← token-metadata-v1.yaml + * features/transfer/routes.ts ← transfer-instruction-v1.yaml + * features/allocation-instruction/routes.ts← allocation-instruction-v1.yaml + * features/allocation/routes.ts ← allocation-v1.yaml + * + * Each generated file contains: + * • TypeScript types derived from components.schemas + * • A typed handler interface (one method per OpenAPI operation) + * • A registerXxxRoutes(route, respond, handlers) function + * + * The implementation (handler logic) lives in the manually maintained handlers.ts + * files beside each generated routes.ts. + */ + +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' +) + +// ── Raw OpenAPI types (only the subset we need) ─────────────────────────────── + +interface JsonSchema { + type?: string + format?: string + properties?: Record + required?: string[] + items?: JsonSchema + additionalProperties?: JsonSchema | boolean + $ref?: string + default?: unknown + enum?: string[] + description?: string +} + +interface Parameter { + name: string + in: 'path' | 'query' | 'header' | 'cookie' + required?: boolean + schema: JsonSchema + description?: string +} + +interface PathItem { + operationId: string + description?: 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 + } +} + +// ── Generator configuration ─────────────────────────────────────────────────── + +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: [], + }, +] + +// ── Schema → TypeScript conversion ──────────────────────────────────────────── + +function refName(ref: string): string { + return ref.split('/').pop()! +} + +/** Convert an inline JSON Schema to a TypeScript type expression. */ +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' +} + +/** Generate a named TypeScript type or interface for a top-level schema. */ +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') +} + +// ── Operation extraction ────────────────────────────────────────────────────── + +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 +} + +// ── Handler interface generation ────────────────────────────────────────────── + +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}` +} + +// ── Route registration generation ───────────────────────────────────────────── + +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) { + // Query param extraction — callers may parse req.url for actual values if needed. + 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') +} + +// ── File assembly ───────────────────────────────────────────────────────────── + +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'`, + '', + '// ── Schema types (generated from components.schemas) ─────────────────────────', + schemaSection, + '', + '// ── Handler interface ────────────────────────────────────────────────────────', + handlerSection, + '', + '// ── Route registration ───────────────────────────────────────────────────────', + registerSection, + '', + ].join('\n') +} + +// ── Entry point ─────────────────────────────────────────────────────────────── + +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 dd24896c2..b41f95858 100644 --- a/scripts/src/start-localnet.ts +++ b/scripts/src/start-localnet.ts @@ -1,7 +1,7 @@ // 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 { @@ -25,6 +25,11 @@ const GENERATED_COMPOSE_OVERRIDE = path.join( const CANTON_MAX_COMMANDS_IN_FLIGHT = 256 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 }) const lines = [ @@ -40,7 +45,12 @@ function ensureComposeOverride() { lines.push( ' multi-sync-startup:', ' volumes:', - ` - ${LOCALNET_DARS_DIR}:/app/dars:ro` + ` - ${LOCALNET_DARS_DIR}:/app/dars:ro`, + // Mount our custom bootstrap script over the downloaded .localnet version. + // Ours adds package vetting topology for all participants on app-synchronizer + // and waits for propagation — ensuring the package service is ready before + // the container exits and before start:localnet returns. + ` - ${MULTI_SYNC_APP_SYNCHRONIZER_SC}:/app/app-synchronizer.sc:ro` ) } lines.push('') @@ -88,6 +98,32 @@ 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) { + // Block until the multi-sync bootstrap finishes. + // We first follow logs (for CI visibility), then use `docker wait` as the + // authoritative gate: unlike `docker logs --follow`, `docker wait` is guaranteed + // to block until the container exits on all Docker versions / CI environments. + // (`docker logs --follow` can return prematurely on some CI runners before the + // container actually exits, which would cause start:localnet to return while the + // app-synchronizer.sc package-vetting script is still running.) + console.log( + 'Waiting for multi-sync bootstrap (package vetting) to complete...' + ) + // `docker wait` blocks until the container exits and returns its exit code. + // This is the authoritative gate: unlike `docker logs --follow`, it is + // guaranteed to block until exit on all Docker versions / CI environments. + 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',