diff --git a/.github/actions/setup_localnet/artifacts/action.yml b/.github/actions/setup_localnet/artifacts/action.yml index fccd83382..fa6841664 100644 --- a/.github/actions/setup_localnet/artifacts/action.yml +++ b/.github/actions/setup_localnet/artifacts/action.yml @@ -7,6 +7,10 @@ inputs: splice_version: description: 'Splice version (required)' required: true + multi_sync: + description: 'Enable multi-sync profile (default: true)' + required: false + default: 'true' runs: using: 'composite' steps: @@ -31,4 +35,4 @@ runs: - name: Start Localnet shell: bash - run: yarn start:localnet -- --network=${{ inputs.network }} + run: yarn start:localnet -- --network=${{ inputs.network }} ${{ inputs.multi_sync == 'false' && '--no-multi-sync' || '' }} diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 0b82edee6..01577b5aa 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -398,6 +398,7 @@ jobs: with: network: ${{ matrix.network }} splice_version: ${{ matrix.network == 'devnet' && needs.version-config.outputs.devnet_splice_version || needs.version-config.outputs.mainnet_splice_version }} + multi_sync: 'true' - name: Start remote WK run: yarn pm2 start ecosystem.ci.config.js --env development @@ -459,6 +460,7 @@ jobs: with: network: ${{ matrix.network }} splice_version: ${{ matrix.network == 'devnet' && needs.version-config.outputs.devnet_splice_version || needs.version-config.outputs.mainnet_splice_version }} + multi_sync: 'true' - uses: ./.github/actions/check_resources @@ -509,6 +511,21 @@ jobs: with: network: ${{ matrix.network }} splice_version: ${{ matrix.network == 'devnet' && needs.version-config.outputs.devnet_splice_version || needs.version-config.outputs.mainnet_splice_version }} + multi_sync: 'true' + + - name: Restore DPM cache + uses: actions/cache/restore@v5 + with: + path: | + ~/.dpm + key: dpm-${{ runner.os }}-${{ needs.version-config.outputs.daml_release_version }} + + - name: Generate featured DARs + run: yarn script:generate:featured-dars + + - name: Rebuild DAML-dependent packages + run: | + yarn workspace @canton-network/core-test-token build - uses: ./.github/actions/check_resources diff --git a/.github/workflows/stress-tests.yml b/.github/workflows/stress-tests.yml index a838f9512..208cd8094 100644 --- a/.github/workflows/stress-tests.yml +++ b/.github/workflows/stress-tests.yml @@ -65,7 +65,6 @@ jobs: run: yarn script:test:stress-scripts - uses: ./.github/actions/check_resources - - name: Stop localnet (${{ github.event.inputs.network || 'devnet' }}) if: always() run: yarn stop:localnet -- --network=${{ github.event.inputs.network || 'devnet' }} diff --git a/canton/multi-sync/app-synchronizer.sc b/canton/multi-sync/app-synchronizer.sc deleted file mode 100644 index d3731c3c1..000000000 --- a/canton/multi-sync/app-synchronizer.sc +++ /dev/null @@ -1,69 +0,0 @@ -// Copyright (c) 2025-2026 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -bootstrap.synchronizer( - synchronizerName = "app-synchronizer", - sequencers = Seq(`app-sequencer`), - mediators = Seq(`app-mediator`), - synchronizerOwners = Seq(`app-sequencer`), - synchronizerThreshold = 1, - staticSynchronizerParameters = StaticSynchronizerParameters.defaultsWithoutKMS(ProtocolVersion.latest), -) - -// Connect app-provider to the new synchronizer. -// TODO: app-user is intentionally NOT connected to app-synchronizer so that -// the SDK (which picks connectedSynchronizers[0]) always selects the global synchronizer. -// This is a temporary workaround until we have a better way to select synchronizers in the SDK. -`app-provider`.synchronizers.connect_local(`app-sequencer`, "app-synchronizer") - -// Wait for app-provider to be active on app-synchronizer -utils.retry_until_true { - `app-provider`.synchronizers.active("app-synchronizer") -} - -// Replicate package vetting from the global synchronizer to app-synchronizer so that -// the new synchronizer is fully functional for app-provider. -// -// Splice connects app-provider to the global synchronizer under the alias "global". -// We read vetting from its per-synchronizer store rather than the authorized store -// because we want to replicate exactly what is active on the global synchronizer. -// We wait until the global-synchronizer view is non-empty to avoid a topology- -// propagation race (which caused `multi-sync-startup` to fail in CI). -val connectedSynchronizers = `app-provider`.synchronizers.list_connected() -val appSyncId = connectedSynchronizers - .find(_.synchronizerAlias.unwrap == "app-synchronizer") - .getOrElse(throw new RuntimeException("app-synchronizer not found in connected synchronizers")) - .synchronizerId -val globalSyncId = connectedSynchronizers - .find(_.synchronizerAlias.unwrap == "global") - .getOrElse(throw new RuntimeException( - s"'global' synchronizer not found. Connected: ${connectedSynchronizers.map(_.synchronizerAlias.unwrap).mkString(", ")}" - )) - .synchronizerId - -utils.retry_until_true { - `app-provider`.topology.vetted_packages - .list(store = Some(TopologyStoreId.Synchronizer(globalSyncId)), filterParticipant = `app-provider`.id.filterString) - .flatMap(_.item.packages) - .nonEmpty -} - -val vettedPackages = `app-provider`.topology.vetted_packages - .list(store = Some(TopologyStoreId.Synchronizer(globalSyncId)), filterParticipant = `app-provider`.id.filterString) - .flatMap(_.item.packages) - -logger.info(s"Vetting ${vettedPackages.size} packages on app-synchronizer for app-provider") -`app-provider`.topology.vetted_packages.propose_delta( - participant = `app-provider`.id, - store = appSyncId, - adds = vettedPackages.toSeq, -) - -// Wait for vetting to propagate on app-synchronizer -utils.retry_until_true { - val providerVetted = `app-provider`.topology.vetted_packages - .list(store = Some(appSyncId), filterParticipant = `app-provider`.id.filterString) - providerVetted.nonEmpty && providerVetted.head.item.packages.nonEmpty -} - -logger.info("app-synchronizer bootstrap with package vetting completed successfully") diff --git a/core/ledger-client/src/ledger-client.ts b/core/ledger-client/src/ledger-client.ts index 12ddadbee..218c662fd 100644 --- a/core/ledger-client/src/ledger-client.ts +++ b/core/ledger-client/src/ledger-client.ts @@ -511,20 +511,31 @@ export class LedgerClient { return this.valueOrError(resp) } - // Retrieve an (arbitrary) synchronizer id from the validator. + // Retrieve the default synchronizer id from the validator. + // Prefers a synchronizer aliased 'global' or 'global-domain' over application-specific ones. // This synchronizer id is cached for the remainder of this object's life. public async getSynchronizerId(): Promise { if (this.synchronizerId) return this.synchronizerId const response = await this.getWithRetry( '/v2/state/connected-synchronizers' ) - if (!response.connectedSynchronizers?.[0]) { + const synchronizers = response.connectedSynchronizers + if (!synchronizers?.[0]) { throw new Error('No connected synchronizers found') } - const synchronizerId = response.connectedSynchronizers[0].synchronizerId - if (response.connectedSynchronizers.length > 1) { + const defaultEntry = + synchronizers.find((s) => s.synchronizerAlias === 'global') ?? + synchronizers.find( + (s) => s.synchronizerAlias === 'global-domain' + ) ?? + synchronizers.find( + (s) => s.synchronizerAlias !== 'app-synchronizer' + ) ?? + synchronizers[0] + const synchronizerId = defaultEntry.synchronizerId + if (synchronizers.length > 1) { this.logger.warn( - `Found ${response.connectedSynchronizers.length} synchronizers, defaulting to ${synchronizerId}` + `Found ${synchronizers.length} synchronizers, defaulting to ${synchronizerId}` ) } this.synchronizerId = synchronizerId diff --git a/core/test-token-v1/README.md b/core/test-token-v1/README.md new file mode 100644 index 000000000..ba381826c --- /dev/null +++ b/core/test-token-v1/README.md @@ -0,0 +1,19 @@ +# @canton-network/core-test-token + +A minimal, self-contained test token that implements the Canton Network Token +Standard (CIP-56). It exists so wallet and dApp flows can be exercised end-to-end +against a real registry-backed token without depending on Amulet. + +Exports: + +- **Main export** - TypeScript codegen bindings for the `splice-test-token-v1` + DAML package (`Splice`, `packageId`) plus small command builders + (`buildCreateTokenRulesCommand`, `buildMintTokenCommand`). +- **`./registry`** - `startTestTokenRegistry()`, an HTTP server that creates the + token's `TokenRules` contracts and serves the four Token Standard off-ledger + registry APIs (metadata, transfer-instruction, allocation-instruction, + allocation). Once started, the token looks like any other CIP-56 token, with an + admin party and a registry URL. +- **`./setup`** - helpers to locate and read the compiled + `splice-test-token-v1` DAR (`TEST_TOKEN_V1_DAR_PATH`, `readTestTokenV1Dar`) so + callers can vet it on their synchronizers. diff --git a/core/test-token-v1/package.json b/core/test-token-v1/package.json new file mode 100644 index 000000000..eb4ad0a1e --- /dev/null +++ b/core/test-token-v1/package.json @@ -0,0 +1,68 @@ +{ + "name": "@canton-network/core-test-token", + "version": "0.0.1", + "type": "module", + "description": "DAML codegen JS for splice-test-token-v1", + "license": "Apache-2.0", + "main": "dist/index.cjs", + "module": "dist/index.js", + "types": "dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "browser": "./dist/index.browser.js", + "import": "./dist/index.js", + "require": "./dist/index.cjs", + "default": "./dist/index.js" + }, + "./registry": { + "types": "./dist/registry/index.d.ts", + "import": "./dist/registry/index.js", + "require": "./dist/registry/index.cjs", + "default": "./dist/registry/index.js" + }, + "./setup": { + "types": "./dist/setup/index.d.ts", + "import": "./dist/setup/index.js", + "require": "./dist/setup/index.cjs", + "default": "./dist/setup/index.js" + } + }, + "scripts": { + "build": "yarn clean && rollup -c && yarn clean:types-tmp", + "clean:types-tmp": "rm -rf dist/types", + "clean": "rm -rf dist" + }, + "dependencies": { + "@canton-network/core-ledger-client": "workspace:^", + "@canton-network/core-token-standard": "workspace:^", + "@canton-network/core-wallet-auth": "workspace:^", + "@daml/types": "^3.5.0", + "@mojotech/json-type-validation": "^3.1.0", + "express": "^5.2.1", + "pino": "^10.3.1" + }, + "devDependencies": { + "@rollup/plugin-alias": "^5.0.0", + "@rollup/plugin-commonjs": "^29.0.0", + "@rollup/plugin-json": "^6.1.0", + "@rollup/plugin-node-resolve": "^16.0.3", + "@rollup/plugin-typescript": "^12.3.0", + "@types/express": "^5.0.6", + "rollup": "^4.59.0", + "rollup-plugin-dts": "^6.3.0", + "tslib": "^2.8.1", + "typescript": "^5.9.3" + }, + "files": [ + "dist/**" + ], + "publishConfig": { + "access": "public" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/canton-network/wallet.git", + "directory": "core/test-token-v1" + } +} diff --git a/core/test-token-v1/rollup.config.js b/core/test-token-v1/rollup.config.js new file mode 100644 index 000000000..e93ad5239 --- /dev/null +++ b/core/test-token-v1/rollup.config.js @@ -0,0 +1,261 @@ +// Copyright (c) 2025-2026 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import typescript from '@rollup/plugin-typescript' +import commonjs from '@rollup/plugin-commonjs' +import { nodeResolve } from '@rollup/plugin-node-resolve' +import json from '@rollup/plugin-json' +import alias from '@rollup/plugin-alias' + +import fs from 'node:fs' +import path from 'node:path' +import dts from 'rollup-plugin-dts' + +const DAML_JS_BASE = path.resolve( + import.meta.dirname, + '../../damljs/splice-test-token-v1' +) + +/** Auto-discover every @daml.js/* package present in baseDir. */ +function discoverDamlJsPackages(baseDir) { + const packages = {} + for (const entry of fs.readdirSync(baseDir, { withFileTypes: true })) { + if (!entry.isDirectory()) continue + const pkgJsonPath = path.join(baseDir, entry.name, 'package.json') + if (!fs.existsSync(pkgJsonPath)) continue + const pkgJson = JSON.parse(fs.readFileSync(pkgJsonPath, 'utf8')) + if (pkgJson.name?.startsWith('@daml.js/')) { + packages[pkgJson.name] = path.join(baseDir, entry.name) + } + } + return packages +} + +function buildPathsMap(packageDirs) { + const map = {} + for (const [name, pkgDir] of Object.entries(packageDirs)) { + const pkgJson = JSON.parse( + fs.readFileSync(path.join(pkgDir, 'package.json'), 'utf8') + ) + const typesRel = pkgJson.types || pkgJson.typings || 'lib/index.d.ts' + const typesAbs = path.resolve(pkgDir, typesRel) + const libDir = path.resolve(pkgDir, 'lib') + map[name] = [typesAbs] + map[`${name}/*`] = [path.join(libDir, '*')] + map[`${name}/lib/*/module.js`] = [path.join(libDir, '*/module.d.ts')] + map[`${name}/lib/*/index.js`] = [path.join(libDir, '*/index.d.ts')] + } + return map +} + +function buildAliasEntries(packageDirs) { + const entries = [] + for (const [name, pkgDir] of Object.entries(packageDirs)) { + const pkgJson = JSON.parse( + fs.readFileSync(path.join(pkgDir, 'package.json'), 'utf8') + ) + const mainAbs = path.resolve(pkgDir, pkgJson.main || 'lib/index.js') + const escapedName = name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + entries.push({ + find: new RegExp(`^${escapedName}/(.+)$`), + replacement: `${pkgDir}/$1`, + }) + entries.push({ find: name, replacement: mainAbs }) + } + return entries +} + +const DAML_JS_PACKAGES = discoverDamlJsPackages(DAML_JS_BASE) +const pathsMap = buildPathsMap(DAML_JS_PACKAGES) +const damlJsAlias = alias({ entries: buildAliasEntries(DAML_JS_PACKAGES) }) +const commonjsPlugin = commonjs({ + transformMixedEsModules: true, + esmExternals: true, + requireReturnsDefault: false, +}) + +const pkgPath = path.resolve(process.cwd(), 'package.json') +const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8')) + +const exceptions = [ + '@daml/types', + '@daml/ledger', + '@mojotech/json-type-validation', +] +const external = [ + ...Object.keys(pkg.dependencies || {}), + ...Object.keys(pkg.peerDependencies || {}), +].filter((dep) => !exceptions.includes(dep)) + +function isExternal(id) { + if (id.startsWith('node:')) return true + return external.some((dep) => id === dep || id.startsWith(`${dep}/`)) +} + +// bundle ESM +const codeEsm = { + input: 'src/index.ts', + output: { file: 'dist/index.js', format: 'es', sourcemap: true }, + external, + plugins: [damlJsAlias, json(), commonjsPlugin, nodeResolve(), typescript()], +} + +// bundle CJS +const codeCjs = { + input: 'src/index.ts', + output: { + file: 'dist/index.cjs', + format: 'cjs', + interop: 'auto', + sourcemap: true, + exports: 'named', + }, + external, + plugins: [damlJsAlias, json(), commonjsPlugin, nodeResolve(), typescript()], +} + +// bundle for browser +const codeBrowser = { + input: 'src/index.ts', + output: { + file: 'dist/index.browser.js', + format: 'es', + sourcemap: true, + }, + external, + plugins: [ + damlJsAlias, + json(), + commonjsPlugin, + nodeResolve({ + browser: true, + preferBuiltins: false, + }), + typescript(), + ], +} + +// bundle DTS including types from codegen +const types = { + input: 'src/index.ts', + output: { file: 'dist/index.d.ts', format: 'es' }, + plugins: [ + dts({ + respectExternal: false, + compilerOptions: { + baseUrl: '.', + paths: pathsMap, + declaration: true, + emitDeclarationOnly: true, + }, + }), + ], +} + +const registryTsOptions = { + compilerOptions: { declaration: false, declarationMap: false }, +} +const registryEsm = { + input: 'src/registry/index.ts', + output: { file: 'dist/registry/index.js', format: 'es', sourcemap: true }, + external: isExternal, + plugins: [ + json(), + commonjsPlugin, + nodeResolve(), + typescript(registryTsOptions), + ], +} + +const registryCjs = { + input: 'src/registry/index.ts', + output: { + file: 'dist/registry/index.cjs', + format: 'cjs', + interop: 'auto', + sourcemap: true, + exports: 'named', + }, + external: isExternal, + plugins: [ + json(), + commonjsPlugin, + nodeResolve(), + typescript(registryTsOptions), + ], +} + +const registryTypes = { + input: 'src/registry/index.ts', + output: { file: 'dist/registry/index.d.ts', format: 'es' }, + external: isExternal, + plugins: [ + dts({ + respectExternal: false, + compilerOptions: { + baseUrl: '.', + declaration: true, + emitDeclarationOnly: true, + }, + }), + ], +} + +const setupEsm = { + input: 'src/setup/index.ts', + output: { file: 'dist/setup/index.js', format: 'es', sourcemap: true }, + external: isExternal, + plugins: [ + json(), + commonjsPlugin, + nodeResolve(), + typescript(registryTsOptions), + ], +} + +const setupCjs = { + input: 'src/setup/index.ts', + output: { + file: 'dist/setup/index.cjs', + format: 'cjs', + interop: 'auto', + sourcemap: true, + exports: 'named', + }, + external: isExternal, + plugins: [ + json(), + commonjsPlugin, + nodeResolve(), + typescript(registryTsOptions), + ], +} + +const setupTypes = { + input: 'src/setup/index.ts', + output: { file: 'dist/setup/index.d.ts', format: 'es' }, + external: isExternal, + plugins: [ + dts({ + respectExternal: false, + compilerOptions: { + baseUrl: '.', + declaration: true, + emitDeclarationOnly: true, + }, + }), + ], +} + +export default [ + codeEsm, + codeCjs, + codeBrowser, + types, + registryEsm, + registryCjs, + registryTypes, + setupEsm, + setupCjs, + setupTypes, +] diff --git a/core/test-token-v1/src/index.ts b/core/test-token-v1/src/index.ts new file mode 100644 index 000000000..9e9852bfe --- /dev/null +++ b/core/test-token-v1/src/index.ts @@ -0,0 +1,39 @@ +// Copyright (c) 2025-2026 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { Splice, packageId } from '@daml.js/splice-test-token-v1-1.0.0' +export { Splice, packageId } + +const T = Splice.Testing.Tokens.TestTokenV1 + +/** Build a CreateCommand that creates a TokenRules contract for the given admin party. */ +export function buildCreateTokenRulesCommand(adminParty: string) { + return { + CreateCommand: { + templateId: T.TokenRules.templateId, + createArguments: { admin: adminParty }, + }, + } +} + +/** Build a CreateCommand that mints a Token held by `owner`. */ +export function buildMintTokenCommand(params: { + owner: string + admin: string + amount: string +}) { + return { + CreateCommand: { + templateId: T.Token.templateId, + createArguments: { + holding: { + owner: params.owner, + instrumentId: { admin: params.admin, id: 'TestToken' }, + amount: params.amount, + lock: null, + meta: { values: {} }, + }, + }, + }, + } +} diff --git a/core/test-token-v1/src/registry/features/allocation-instruction/handlers.ts b/core/test-token-v1/src/registry/features/allocation-instruction/handlers.ts new file mode 100644 index 000000000..b0dabf8cc --- /dev/null +++ b/core/test-token-v1/src/registry/features/allocation-instruction/handlers.ts @@ -0,0 +1,50 @@ +// Copyright (c) 2025-2026 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +/** + * TestToken implementation of the allocation-instruction-v1 API + * (api-specs/splice/0.6.1/allocation-instruction-v1.yaml). + * + * The allocation factory returns the live `TokenRules` contract on the + * configured allocation synchronizer. + */ + +import type { FactoryWithChoiceContext } from '../../types.js' +import type { allocationInstructionApiOperations } from '@canton-network/core-token-standard' +import type { OperationHandlers } from '../../http/openapi-router.js' +import type { TokenRulesContract } from '../../ledger.js' + +export interface AllocationInstructionHandlerContext { + getTokenRules: ( + synchronizerId?: string + ) => Promise + allocationSynchronizerId: string +} + +export function createAllocationInstructionHandlers( + ctx: AllocationInstructionHandlerContext +): OperationHandlers { + return { + getAllocationFactory: + async (): Promise => { + const tokenRules = await ctx.getTokenRules( + ctx.allocationSynchronizerId + ) + if (!tokenRules) return null + return { + factoryId: tokenRules.contractId, + choiceContext: { + choiceContextData: { values: {} }, + disclosedContracts: [ + { + templateId: tokenRules.templateId, + contractId: tokenRules.contractId, + createdEventBlob: tokenRules.createdEventBlob, + synchronizerId: tokenRules.synchronizerId, + }, + ], + }, + } + }, + } +} diff --git a/core/test-token-v1/src/registry/features/allocation/handlers.ts b/core/test-token-v1/src/registry/features/allocation/handlers.ts new file mode 100644 index 000000000..bc1a36a85 --- /dev/null +++ b/core/test-token-v1/src/registry/features/allocation/handlers.ts @@ -0,0 +1,62 @@ +// Copyright (c) 2025-2026 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +/** + * TestToken implementation of the allocation-v1 API + * (api-specs/splice/0.6.1/allocation-v1.yaml). + * + * The execute-transfer choice context mirrors the Amulet registry's shape + * + */ + +import type { ChoiceContext } from '../../types.js' +import type { allocationApiOperations } from '@canton-network/core-token-standard' +import type { OperationHandlers } from '../../http/openapi-router.js' +import type { TokenRulesContract } from '../../ledger.js' + +export interface AllocationHandlerContext { + getTokenRules: ( + synchronizerId?: string + ) => Promise + allocationSynchronizerId: string +} + +export function createAllocationHandlers( + ctx: AllocationHandlerContext +): OperationHandlers { + const emptyContext: ChoiceContext = { + choiceContextData: { values: {} }, + disclosedContracts: [], + } + + return { + getAllocationTransferContext: async (): Promise => { + const tokenRules = await ctx.getTokenRules( + ctx.allocationSynchronizerId + ) + if (!tokenRules) return emptyContext + return { + choiceContextData: { + values: { + 'token-rules': { + tag: 'AV_ContractId', + value: tokenRules.contractId, + }, + }, + }, + disclosedContracts: [ + { + templateId: tokenRules.templateId, + contractId: tokenRules.contractId, + createdEventBlob: tokenRules.createdEventBlob, + synchronizerId: tokenRules.synchronizerId, + }, + ], + } + }, + getAllocationWithdrawContext: async (): Promise => + emptyContext, + getAllocationCancelContext: async (): Promise => + emptyContext, + } +} diff --git a/core/test-token-v1/src/registry/features/metadata/handlers.ts b/core/test-token-v1/src/registry/features/metadata/handlers.ts new file mode 100644 index 000000000..de62512eb --- /dev/null +++ b/core/test-token-v1/src/registry/features/metadata/handlers.ts @@ -0,0 +1,57 @@ +// Copyright (c) 2025-2026 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +/** + * TestToken implementation of the token-metadata-v1 API. + * + * Provides static metadata about the TestToken instrument and the registry's + * supported Token Standard APIs (api-specs/splice/0.6.1/token-metadata-v1.yaml). + */ + +import type { + GetRegistryInfoResponse, + Instrument, + ListInstrumentsResponse, + SupportedApis, +} from '../../types.js' +import type { metadataApiOperations } from '@canton-network/core-token-standard' +import type { OperationHandlers } from '../../http/openapi-router.js' + +// Token Standard APIs implemented by this registry (api-specs/splice/0.6.1/). +const SUPPORTED_APIS: SupportedApis = { + 'splice-api-token-metadata-v1': 1, + 'splice-api-token-transfer-instruction-v1': 1, + 'splice-api-token-allocation-instruction-v1': 1, + 'splice-api-token-allocation-v1': 1, +} + +export interface MetadataHandlerContext { + adminPartyId: string + instrumentId: string +} + +export function createMetadataHandlers( + ctx: MetadataHandlerContext +): OperationHandlers { + const instrument: Instrument = { + id: ctx.instrumentId, + name: 'TestToken', + symbol: 'TT', + decimals: 10, + supportedApis: SUPPORTED_APIS, + } + + return { + getRegistryInfo: (): GetRegistryInfoResponse => ({ + adminId: ctx.adminPartyId, + supportedApis: SUPPORTED_APIS, + }), + + listInstruments: (): ListInstrumentsResponse => ({ + instruments: [instrument], + }), + + getInstrument: ({ params }): Instrument | null => + params.instrumentId === ctx.instrumentId ? instrument : null, + } +} diff --git a/core/test-token-v1/src/registry/features/transfer/handlers.ts b/core/test-token-v1/src/registry/features/transfer/handlers.ts new file mode 100644 index 000000000..c6809a436 --- /dev/null +++ b/core/test-token-v1/src/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 the transfer-instruction-v1 API + * (api-specs/splice/0.6.1/transfer-instruction-v1.yaml). + * + * The transfer factory is the live `TokenRules` contract on the configured + * transfer synchronizer — TestToken transfers (mints and self-transfers) are + * submitted there. The factory contract is disclosed in the choice context so + * the submitting party can exercise `TransferFactory_Transfer` on it. + * + * For TestToken, the on-ledger choices read no additional contracts, so the + * accept/reject/withdraw choice contexts are empty. + */ + +import type { + TransferFactoryWithChoiceContext, + ChoiceContext, +} from '../../types.js' +import type { transferInstructionApiOperations } from '@canton-network/core-token-standard' +import type { OperationHandlers } from '../../http/openapi-router.js' +import type { TokenRulesContract } from '../../ledger.js' + +export interface TransferHandlerContext { + getTokenRules: ( + synchronizerId?: string + ) => Promise + transferSynchronizerId: string +} + +export function createTransferHandlers( + ctx: TransferHandlerContext +): OperationHandlers { + return { + getTransferFactory: async ({ + body, + }): Promise => { + const transfer = body.choiceArguments?.['transfer'] as + Record | undefined + if (transfer === undefined) + throw new Error( + 'getTransferFactory: missing "transfer" choice argument' + ) + if ( + transfer['sender'] === undefined || + transfer['receiver'] === undefined + ) + throw new Error( + 'getTransferFactory: "transfer" argument must include sender and receiver' + ) + const transferKind: 'self' | 'offer' = + transfer['sender'] === transfer['receiver'] ? 'self' : 'offer' + + const tokenRules = await ctx.getTokenRules( + ctx.transferSynchronizerId + ) + if (!tokenRules) + throw new Error( + `getTransferFactory: TokenRules not found on transfer synchronizer ${ctx.transferSynchronizerId}` + ) + 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/core/test-token-v1/src/registry/generated-server/registry-server.ts b/core/test-token-v1/src/registry/generated-server/registry-server.ts new file mode 100644 index 000000000..bb709dceb --- /dev/null +++ b/core/test-token-v1/src/registry/generated-server/registry-server.ts @@ -0,0 +1,95 @@ +// Copyright (c) 2025-2026 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +/** + * AUTO-GENERATED by scripts/src/lib/generate-registry-server.ts — DO NOT EDIT. + * + * Spec-derived routing table and operation types for the Token Standard + * off-ledger registry APIs. The reusable Express router runtime is hand-written + * in ../http/openapi-router.ts; only the data below is generated from the specs: + * 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-instruction-v1.yaml + * api-specs/splice/0.6.1/allocation-v1.yaml + * + * Regenerate with: yarn script:generate:openapi + */ + +import type { + metadataApiOperations, + transferInstructionApiOperations, + allocationInstructionApiOperations, + allocationApiOperations, +} from '@canton-network/core-token-standard' +import type { OperationHandlers, Route } from '../http/openapi-router.js' + +/** Every registry operation across the specs, keyed by `operationId`. */ +export type RegistryOperations = metadataApiOperations & + transferInstructionApiOperations & + allocationInstructionApiOperations & + allocationApiOperations + +/** + * The complete registry handler map. Every `operationId` must be implemented, + * with params/body/response types checked against the specs. + */ +export type RegistryHandlers = OperationHandlers + +/** Routing table (method + path per `operationId`) extracted from the specs. */ +export const REGISTRY_ROUTES: readonly Route[] = [ + { + operationId: 'getRegistryInfo', + method: 'get', + path: '/registry/metadata/v1/info', + }, + { + operationId: 'listInstruments', + method: 'get', + path: '/registry/metadata/v1/instruments', + }, + { + operationId: 'getInstrument', + method: 'get', + path: '/registry/metadata/v1/instruments/:instrumentId', + }, + { + operationId: 'getTransferFactory', + method: 'post', + path: '/registry/transfer-instruction/v1/transfer-factory', + }, + { + operationId: 'getTransferInstructionAcceptContext', + method: 'post', + path: '/registry/transfer-instruction/v1/:transferInstructionId/choice-contexts/accept', + }, + { + operationId: 'getTransferInstructionRejectContext', + method: 'post', + path: '/registry/transfer-instruction/v1/:transferInstructionId/choice-contexts/reject', + }, + { + operationId: 'getTransferInstructionWithdrawContext', + method: 'post', + path: '/registry/transfer-instruction/v1/:transferInstructionId/choice-contexts/withdraw', + }, + { + operationId: 'getAllocationFactory', + method: 'post', + path: '/registry/allocation-instruction/v1/allocation-factory', + }, + { + operationId: 'getAllocationTransferContext', + method: 'post', + path: '/registry/allocations/v1/:allocationId/choice-contexts/execute-transfer', + }, + { + operationId: 'getAllocationWithdrawContext', + method: 'post', + path: '/registry/allocations/v1/:allocationId/choice-contexts/withdraw', + }, + { + operationId: 'getAllocationCancelContext', + method: 'post', + path: '/registry/allocations/v1/:allocationId/choice-contexts/cancel', + }, +] diff --git a/core/test-token-v1/src/registry/http/openapi-router.ts b/core/test-token-v1/src/registry/http/openapi-router.ts new file mode 100644 index 000000000..8f5a68612 --- /dev/null +++ b/core/test-token-v1/src/registry/http/openapi-router.ts @@ -0,0 +1,156 @@ +// Copyright (c) 2025-2026 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +/** + * Generic, type-safe Express router driven by OpenAPI `operations` types. + * + * This is the hand-written runtime that the code-generated registry routing + * table plugs into (see ../generated-server/registry-server.ts). The route + * definitions and operation set are generated from the specs; all logic and the + * shared type machinery live here. + */ + +import express, { + type Request, + type RequestHandler, + type Router, +} from 'express' + +export type HttpMethod = 'get' | 'post' | 'put' | 'delete' | 'patch' + +/** Path parameters declared by an operation (empty object when none). */ +type PathParams = Op extends { parameters: { path: infer P } } + ? [P] extends [never] + ? Record + : P + : Record + +/** Query parameters declared by an operation (empty object when none). */ +type QueryParams = Op extends { parameters: { query?: infer Q } } + ? [Q] extends [never] + ? Record + : NonNullable + : Record + +/** JSON request body of an operation (`never` when none). */ +type RequestBody = Op extends { + requestBody?: { content: { 'application/json': infer B } } +} + ? B + : never + +/** Success (200) JSON response body of an operation. */ +type SuccessResponse = Op extends { + responses: { 200: { content: { 'application/json': infer R } } } +} + ? R + : void + +/** + * openapi-typescript renders bare `object` schemas (e.g. Daml choice-context + * data) as `Record`. Widen those to accept any JSON object so a + * handler can return the real payload, while keeping precise schemas strict. + */ +type Loosen = [T] extends [Record] + ? Record + : T extends readonly (infer E)[] + ? Loosen[] + : T extends object + ? { [K in keyof T]: Loosen } + : T + +/** Response payload a handler may return for an operation. */ +type HandlerResponse = Loosen> + +/** Arguments handed to an operation handler, fully typed from the OpenAPI spec. */ +export interface OperationRequest { + params: PathParams + query: QueryParams + body: RequestBody + req: Request +} + +/** + * Implements a single operation. Return the success payload to send it as + * `200 application/json`, or `null` to respond with `404 Not Found`. + */ +export type OperationHandler = ( + request: OperationRequest +) => HandlerResponse | null | Promise | null> + +/** + * The complete set of handlers for an OpenAPI `operations` type. Every + * `operationId` must be implemented; omissions and signature mismatches are + * compile errors. + */ +export type OperationHandlers = { + [Id in keyof Operations]: OperationHandler +} + +/** A route binding one `operationId` to an HTTP method and Express path. */ +export interface Route { + operationId: keyof Operations + method: HttpMethod + path: string +} + +type UntypedHandler = (request: { + params: Record + query: Record + body: unknown + req: Request +}) => unknown + +/** + * Builds an Express router that binds each route to its handler. Routing is + * data-driven: pass the generated route table and the `operationId -> handler` + * map; nothing here is spec-specific. + */ +export function createOpenApiRouter( + routes: readonly Route[], + handlers: OperationHandlers +): Router { + const router = express.Router() + for (const route of routes) { + const handle = handlers[route.operationId] as UntypedHandler + const requestHandler: RequestHandler = (req, res, next) => { + Promise.resolve() + .then(() => + handle({ + params: req.params as Record, + query: req.query as Record, + body: req.body, + req, + }) + ) + .then((result) => { + if (result === null || result === undefined) { + res.status(404).json({ + error: String(route.operationId) + ': not found', + }) + } else { + res.status(200).json(result) + } + }) + .catch(next) + } + switch (route.method) { + case 'get': + router.get(route.path, requestHandler) + break + case 'post': + router.post(route.path, requestHandler) + break + case 'put': + router.put(route.path, requestHandler) + break + case 'delete': + router.delete(route.path, requestHandler) + break + case 'patch': + router.patch(route.path, requestHandler) + break + } + } + return router +} diff --git a/core/test-token-v1/src/registry/index.ts b/core/test-token-v1/src/registry/index.ts new file mode 100644 index 000000000..220794d3a --- /dev/null +++ b/core/test-token-v1/src/registry/index.ts @@ -0,0 +1,211 @@ +// Copyright (c) 2025-2026 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +/** + * TestToken registry — HTTP server entry point. + * + * Wires the HTTP router, all feature-slice route handlers, and the ledger client + * into a single `startTestTokenRegistry()` factory. Given a deployment config + * (admin party, the synchronizers on which `TokenRules` contracts must exist, + * and a callback that performs the signed `TokenRules` creation), the server + * creates the `TokenRules` contracts as part of initialization and then serves + * the Token Standard off-ledger registry APIs for them. Once started, the token + * looks like any other CIP-56 token: it has an admin party and a registry URL + * that serves the choice contexts. + * + * 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-instruction-v1.yaml + * api-specs/splice/0.6.1/allocation-v1.yaml + */ + +import express, { type ErrorRequestHandler } from 'express' +import type { Server } from 'node:http' +import type { Logger } from 'pino' +import type { LedgerClient } from '@canton-network/core-ledger-client' +import { buildLedgerClient, invalidateCache, readTokenRules } from './ledger.js' +import type { TokenRulesContract } from './ledger.js' +import { createOpenApiRouter } from './http/openapi-router.js' +import { + REGISTRY_ROUTES, + type RegistryHandlers, +} from './generated-server/registry-server.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' + +/** + * Deployment configuration for a single TestToken instance. + * + * Describes how an instance of the test token should be deployed: which admin + * party owns it, on which synchronizers its `TokenRules` contracts must be + * created during initialization, and how to perform that (signed) creation. + */ +export interface TestTokenRegistryConfig { + /** Admin party that owns the TestToken instrument and its `TokenRules`. */ + admin: string + /** Port the registry HTTP server listens on. */ + port: number + /** Base URL of the participant's JSON Ledger API used to read `TokenRules`. */ + ledgerUrl: URL + logger: Logger + /** + * Synchronizers on which a `TokenRules` contract must be created as part of + * initialization. `createTokenRules` is invoked once per entry. + */ + synchronizerIds: string[] + /** + * Creates a `TokenRules` contract for `admin` on `synchronizerId`. This + * encapsulates the (deployment-specific) signed ledger submission and is + * called once per entry in `synchronizerIds` before the server starts + * serving. + */ + createTokenRules: (synchronizerId: string) => Promise + /** + * Synchronizer whose `TokenRules` backs the transfer-instruction factory. + * Defaults to `synchronizerIds[0]`. + */ + transferSynchronizerId?: string + /** + * Synchronizer whose `TokenRules` backs the allocation-instruction factory. + * Defaults to `synchronizerIds[0]`. + */ + allocationSynchronizerId?: string +} + +export interface TestTokenRegistry { + /** Base URL at which the registry serves the Token Standard APIs. */ + registryUrl: URL + /** Gracefully shuts the HTTP server down. */ + stop(): Promise +} + +/** + * Deploys a TestToken instance and starts its registry HTTP server. + * + * Creates the `TokenRules` contracts described by `config` and then serves the + * four Token Standard off-ledger registry APIs for them. + * + * @param config - Deployment configuration for the token instance. + * @returns A handle with the registry URL and a `stop()` method. + */ +export async function startTestTokenRegistry( + config: TestTokenRegistryConfig +): Promise { + const { admin, port, ledgerUrl, logger, synchronizerIds } = config + + if (synchronizerIds.length === 0) + throw new Error( + 'startTestTokenRegistry: at least one synchronizer id is required' + ) + + const transferSynchronizerId = + config.transferSynchronizerId ?? synchronizerIds[0]! + const allocationSynchronizerId = + config.allocationSynchronizerId ?? synchronizerIds[0]! + + // ── Initialization: create the TokenRules contracts the token needs ───── + await Promise.all( + synchronizerIds.map((synchronizerId) => + config.createTokenRules(synchronizerId) + ) + ) + // TokenRules may already have been cached (as empty) by an earlier run. + invalidateCache() + logger.info( + { admin, synchronizerIds }, + 'TestToken TokenRules created on configured synchronizers' + ) + + const ledgerClient: LedgerClient = buildLedgerClient(ledgerUrl, logger) + + async function getTokenRules( + synchronizerId?: string + ): Promise { + const all = await readTokenRules(ledgerClient, admin, logger) + if (all.length === 0) return null + if (!synchronizerId) return all[0]! + return all.find((c) => c.synchronizerId === synchronizerId) ?? all[0]! + } + + const metadata = createMetadataHandlers({ + adminPartyId: admin, + instrumentId: TEST_TOKEN_INSTRUMENT_ID, + }) + const transfer = createTransferHandlers({ + getTokenRules, + transferSynchronizerId, + }) + const allocInstr = createAllocationInstructionHandlers({ + getTokenRules, + allocationSynchronizerId, + }) + const alloc = createAllocationHandlers({ + getTokenRules, + allocationSynchronizerId, + }) + + // Every OpenAPI operationId maps to its feature handler. TypeScript checks — + // via the generated `RegistryHandlers` type — that every operation is + // implemented and that params/body/response types match the spec. + const handlers: RegistryHandlers = { + ...metadata, + ...transfer, + ...allocInstr, + ...alloc, + } + + // Routing (method + path per operationId) is generated from the OpenAPI + // specs; here we only mount the router and cross-cutting middleware. + const app = express() + app.use(express.json()) + app.use((req, _res, next) => { + logger.debug( + { method: req.method, path: req.path }, + 'incoming registry request' + ) + next() + }) + app.use(createOpenApiRouter(REGISTRY_ROUTES, handlers)) + + // Unmatched routes → 404 + app.use((req, res) => { + res.status(404).json({ error: `${req.method} ${req.path} not found` }) + }) + + // Uncaught handler errors → 500 + const onError: ErrorRequestHandler = (err, _req, res, next) => { + logger.error(err, 'registry request handler error') + if (res.headersSent) { + next(err) + return + } + res.status(500).json({ + error: err instanceof Error ? err.message : String(err), + }) + } + app.use(onError) + + const server = await new Promise((resolve) => { + const httpServer = app.listen(port, () => resolve(httpServer)) + }) + + const registryUrl = new URL(`http://localhost:${port}`) + logger.info( + { port, admin, ledgerUrl: ledgerUrl.href }, + 'TestToken registry server started' + ) + + return { + registryUrl, + stop: () => + new Promise((resolve, reject) => + server.close((err) => (err ? reject(err) : resolve())) + ), + } +} diff --git a/core/test-token-v1/src/registry/ledger.ts b/core/test-token-v1/src/registry/ledger.ts new file mode 100644 index 000000000..4b584bcd5 --- /dev/null +++ b/core/test-token-v1/src/registry/ledger.ts @@ -0,0 +1,150 @@ +// 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 the `TokenRules` contracts visible to the admin party from the + * configured participant. When the admin is hosted on more than one + * synchronizer, every visible `TokenRules` contract is returned so callers can + * select the one on a specific synchronizer. Results are cached for a short 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 }) +} + +interface JsActiveContractEntry { + JsActiveContract: { + createdEvent: { + contractId: string + templateId: string + createdEventBlob: string + } + synchronizerId: string + } +} + +/** + * Reads `TokenRules` contracts visible to `adminPartyId` from the configured + * participant. Caches results for a short TTL. + */ +export async function readTokenRules( + client: LedgerClient, + adminPartyId: 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` initializes the client (negotiates the ledger API version) before the + // subsequent `post`, which does not init on its own. + const ledgerEnd = await client.get('/v2/state/ledger-end') + const offset = ledgerEnd.offset ?? 0 + + const body = { + filter: { + filtersByParty: { + [adminPartyId]: { + cumulative: [ + { + identifierFilter: { + TemplateFilter: { + value: { + templateId: TOKEN_RULES_TEMPLATE_ID, + includeCreatedEventBlob: true, + }, + }, + }, + }, + ], + }, + }, + }, + verbose: false, + activeAtOffset: offset, + } + + const rawAcs = await client.post( + '/v2/state/active-contracts', + // The generated request type is far stricter than what we need to send. + body as unknown as Parameters[1], + { query: { limit: 100 } } + ) + + const contracts: TokenRulesContract[] = ( + rawAcs as unknown as Array<{ contractEntry?: unknown }> + ) + .filter( + (entry) => + entry.contractEntry != null && + 'JsActiveContract' in (entry.contractEntry as object) + ) + .map((entry) => { + const jsAC = (entry.contractEntry as JsActiveContractEntry) + .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/core/test-token-v1/src/registry/types.ts b/core/test-token-v1/src/registry/types.ts new file mode 100644 index 000000000..3563747d0 --- /dev/null +++ b/core/test-token-v1/src/registry/types.ts @@ -0,0 +1,51 @@ +// 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. + * + * The request/response data shapes are reused directly from the generated + * OpenAPI clients in `@canton-network/core-token-standard` + */ + +import type { + metadataRegistryTypes, + transferInstructionRegistryTypes, +} from '@canton-network/core-token-standard' + +// ── Reused generated schema types ────────────────────────────────────────── + +export type DisclosedContract = + transferInstructionRegistryTypes['schemas']['DisclosedContract'] + +export type SupportedApis = metadataRegistryTypes['schemas']['SupportedApis'] + +export type GetRegistryInfoResponse = + metadataRegistryTypes['schemas']['GetRegistryInfoResponse'] + +export type Instrument = metadataRegistryTypes['schemas']['Instrument'] + +export type ListInstrumentsResponse = + metadataRegistryTypes['schemas']['ListInstrumentsResponse'] + +// ── Local context type ───────────────────────────────────────────────────── + +export interface ChoiceContext { + choiceContextData: { values: Record } + disclosedContracts: DisclosedContract[] +} + +// ── transfer-instruction-v1 ──────────────────────────────────────────────── + +export interface TransferFactoryWithChoiceContext { + factoryId: string + transferKind: 'self' | 'direct' | 'offer' + choiceContext: ChoiceContext +} + +// ── allocation-instruction-v1 ────────────────────────────────────────────── + +export interface FactoryWithChoiceContext { + factoryId: string + choiceContext: ChoiceContext +} diff --git a/core/test-token-v1/src/setup/index.ts b/core/test-token-v1/src/setup/index.ts new file mode 100644 index 000000000..a9c72e5fc --- /dev/null +++ b/core/test-token-v1/src/setup/index.ts @@ -0,0 +1,29 @@ +// Copyright (c) 2025-2026 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +/** + * TestToken setup helpers. + * + * Locates and reads the compiled `splice-test-token-v1` DAR so callers can vet + * it on their synchronizers without hard-coding the DAR's location relative to + * their own source tree. + */ + +import path from 'node:path' +import { readFile } from 'node:fs/promises' +import { fileURLToPath } from 'node:url' + +// This module is bundled to `dist/setup/index.{js,cjs}`; the DAR lives at +// `damljs/splice-test-token-v1/.daml/dist/` at the repo root, four levels up. +const HERE = path.dirname(fileURLToPath(import.meta.url)) + +/** Absolute path to the compiled `splice-test-token-v1-1.0.0.dar`. */ +export const TEST_TOKEN_V1_DAR_PATH = path.resolve( + HERE, + '../../../../damljs/splice-test-token-v1/.daml/dist/splice-test-token-v1-1.0.0.dar' +) + +/** Reads the compiled `splice-test-token-v1` DAR and returns its bytes. */ +export function readTestTokenV1Dar(): Promise { + return readFile(TEST_TOKEN_V1_DAR_PATH) +} diff --git a/core/test-token-v1/tsconfig.json b/core/test-token-v1/tsconfig.json new file mode 100644 index 000000000..59119778a --- /dev/null +++ b/core/test-token-v1/tsconfig.json @@ -0,0 +1,60 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "./src", + "outDir": "./dist", + "moduleResolution": "bundler", + "baseUrl": ".", + "paths": { + "@daml.js/splice-test-token-v1-1.0.0": [ + "../../damljs/splice-test-token-v1/splice-test-token-v1-1.0.0/lib/index.d.ts" + ], + "@daml.js/splice-test-token-v1-1.0.0/*": [ + "../../damljs/splice-test-token-v1/splice-test-token-v1-1.0.0/*" + ], + "@daml.js/ghc-stdlib-DA-Internal-Template-1.0.0": [ + "../../damljs/splice-test-token-v1/ghc-stdlib-DA-Internal-Template-1.0.0/lib/index.d.ts" + ], + "@daml.js/ghc-stdlib-DA-Internal-Template-1.0.0/*": [ + "../../damljs/splice-test-token-v1/ghc-stdlib-DA-Internal-Template-1.0.0/*" + ], + "@daml.js/daml-stdlib-DA-Time-Types-1.0.0": [ + "../../damljs/splice-test-token-v1/daml-stdlib-DA-Time-Types-1.0.0/lib/index.d.ts" + ], + "@daml.js/daml-stdlib-DA-Time-Types-1.0.0/*": [ + "../../damljs/splice-test-token-v1/daml-stdlib-DA-Time-Types-1.0.0/*" + ], + "@daml.js/splice-api-token-holding-v1-1.0.0": [ + "../../damljs/splice-test-token-v1/splice-api-token-holding-v1-1.0.0/lib/index.d.ts" + ], + "@daml.js/splice-api-token-holding-v1-1.0.0/*": [ + "../../damljs/splice-test-token-v1/splice-api-token-holding-v1-1.0.0/*" + ], + "@daml.js/splice-api-token-allocation-v1-1.0.0": [ + "../../damljs/splice-test-token-v1/splice-api-token-allocation-v1-1.0.0/lib/index.d.ts" + ], + "@daml.js/splice-api-token-allocation-v1-1.0.0/*": [ + "../../damljs/splice-test-token-v1/splice-api-token-allocation-v1-1.0.0/*" + ], + "@daml.js/splice-api-token-allocation-instruction-v1-1.0.0": [ + "../../damljs/splice-test-token-v1/splice-api-token-allocation-instruction-v1-1.0.0/lib/index.d.ts" + ], + "@daml.js/splice-api-token-allocation-instruction-v1-1.0.0/*": [ + "../../damljs/splice-test-token-v1/splice-api-token-allocation-instruction-v1-1.0.0/*" + ], + "@daml.js/splice-api-token-transfer-instruction-v1-1.0.0": [ + "../../damljs/splice-test-token-v1/splice-api-token-transfer-instruction-v1-1.0.0/lib/index.d.ts" + ], + "@daml.js/splice-api-token-transfer-instruction-v1-1.0.0/*": [ + "../../damljs/splice-test-token-v1/splice-api-token-transfer-instruction-v1-1.0.0/*" + ], + "@daml.js/splice-api-token-metadata-v1-1.0.0": [ + "../../damljs/splice-test-token-v1/splice-api-token-metadata-v1-1.0.0/lib/index.d.ts" + ], + "@daml.js/splice-api-token-metadata-v1-1.0.0/*": [ + "../../damljs/splice-test-token-v1/splice-api-token-metadata-v1-1.0.0/*" + ] + } + }, + "include": ["src"] +} diff --git a/core/token-standard/src/token-standard-client.ts b/core/token-standard/src/token-standard-client.ts index 4e7dc6506..7c05cbf56 100644 --- a/core/token-standard/src/token-standard-client.ts +++ b/core/token-standard/src/token-standard-client.ts @@ -15,6 +15,13 @@ export { components as metadataRegistryTypes } from './generated-clients/splice- export { components as transferInstructionRegistryTypes } from './generated-clients/splice-api-token-transfer-instruction-v1/transfer-instruction-v1.js' export { components as allocationInstructionRegistryTypes } from './generated-clients/splice-api-token-allocation-instruction-v1/allocation-instruction-v1.js' +// OpenAPI `operations` (keyed by operationId) for the registry server side. +// Consumed by the generated Express server stub in core/test-token-v1. +export { operations as metadataApiOperations } from './generated-clients/splice-api-token-metadata-v1/token-metadata-v1.js' +export { operations as transferInstructionApiOperations } from './generated-clients/splice-api-token-transfer-instruction-v1/transfer-instruction-v1.js' +export { operations as allocationInstructionApiOperations } from './generated-clients/splice-api-token-allocation-instruction-v1/allocation-instruction-v1.js' +export { operations as allocationApiOperations } from './generated-clients/splice-api-token-allocation-v1/allocation-v1.js' + type paths = allocationPaths & metadataPaths & transferInstructionPaths & diff --git a/damljs/splice-test-token-v1/.gitignore b/damljs/splice-test-token-v1/.gitignore new file mode 100644 index 000000000..ec8a629cd --- /dev/null +++ b/damljs/splice-test-token-v1/.gitignore @@ -0,0 +1,3 @@ +* +.daml/ + diff --git a/damljs/splice-test-token-v1/daml.yaml b/damljs/splice-test-token-v1/daml.yaml new file mode 100644 index 000000000..4a5352062 --- /dev/null +++ b/damljs/splice-test-token-v1/daml.yaml @@ -0,0 +1,23 @@ +sdk-version: 3.4.11 +name: splice-test-token-v1 +source: daml +version: 1.0.0 +dependencies: + - daml-prim + - daml-stdlib +data-dependencies: + - ../../.localnet/dars/splice-api-token-metadata-v1-current.dar + - ../../.localnet/dars/splice-api-token-holding-v1-current.dar + - ../../.localnet/dars/splice-api-token-transfer-instruction-v1-current.dar + - ../../.localnet/dars/splice-api-token-allocation-v1-current.dar + - ../../.localnet/dars/splice-api-token-allocation-instruction-v1-current.dar + +build-options: + - --target=2.1 + - --ghc-option=-Wunused-binds + - --ghc-option=-Wunused-matches +codegen: + java: + package-prefix: org.lfdecentralizedtrust.splice.codegen.java + decoderClass: org.lfdecentralizedtrust.splice.codegen.java.DecoderSpliceTestTokenV1 + output-directory: target/daml-codegen-java diff --git a/damljs/splice-test-token-v1/daml/Splice/Testing/Tokens/Internal/Utils.daml b/damljs/splice-test-token-v1/daml/Splice/Testing/Tokens/Internal/Utils.daml new file mode 100644 index 000000000..f7320d185 --- /dev/null +++ b/damljs/splice-test-token-v1/daml/Splice/Testing/Tokens/Internal/Utils.daml @@ -0,0 +1,77 @@ +-- Copyright (c) 2024 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +-- SPDX-License-Identifier: Apache-2.0 + +-- | Minimal, V1-only helpers used by the TestTokenV1 implementation. +-- +-- These are inlined copies of a handful of small functions from the Splice +-- @splice-token-standard-utils@ library. They are vendored here so that this +-- test token only depends on the published V1 token-standard DARs and does not +-- need to build the full (unpublished) utility library together with its V2 +-- API package closure. +module Splice.Testing.Tokens.Internal.Utils ( + require, + require', + requireMatchExpected, + isEqualR, + isGreaterR, + isGreaterOrEqualR, + isValidSettlementInfoV1, + isValidAllocationSpecificationV1, +) where + +import DA.Text qualified as T + +import Splice.Api.Token.AllocationV1 qualified as V1 + +-- | Check whether a required condition is true. If it's not, abort the +-- transaction with a message saying that the requirement was not met. +require : CanAssert m => Text -> Bool -> m () +require msg invariant = + assertMsg ("The requirement '" <> msg <> "' was not met.") invariant + +-- | Improved variant of 'require' that checks a requirement and raises a +-- user-friendly error in case the requirement is not met. +require' + : (Show a, Show b) + => (Text, a) + -- ^ Left-hand side with description. + -> (Text, a -> b -> Bool) + -- ^ Predicate to check. + -> (Text, b) + -- ^ Right-hand side with description. + -> Update () +require' (desc1, v1) (descFailure, predicate) (desc2, v2) + | predicate v1 v2 = pure () + | otherwise = fail $ T.unlines + [ "'" <> desc1 <> "' " <> descFailure <> " '" <> desc2 <> "'." + , desc1 <> ": " <> show v1 + , desc2 <> ": " <> show v2 + ] + +-- | Predicate to check equality. +isEqualR : Eq a => (Text, a -> a -> Bool) +isEqualR = ("is not equal to", (==)) + +-- | Predicate to check >=. +isGreaterOrEqualR : Ord a => (Text, a -> a -> Bool) +isGreaterOrEqualR = ("is less than", (>=)) + +-- | Predicate to check >. +isGreaterR : Ord a => (Text, a -> a -> Bool) +isGreaterR = ("is not greater than", (>)) + +-- | Abbreviation to check that an actual value matches its expected value. +requireMatchExpected : (Eq a, Show a) => (Text, a) -> a -> Update () +requireMatchExpected actual expected = require' actual isEqualR ("expected value", expected) + +-- | Check basic invariants of a `V1.SettlementInfo`. +isValidSettlementInfoV1 : V1.SettlementInfo -> Bool +isValidSettlementInfoV1 V1.SettlementInfo{..} = + requestedAt <= allocateBefore && + allocateBefore <= settleBefore + +-- | Check basic invariants of a `V1.AllocationSpecification`. +isValidAllocationSpecificationV1 : V1.AllocationSpecification -> Bool +isValidAllocationSpecificationV1 V1.AllocationSpecification{..} = + isValidSettlementInfoV1 settlement && + transferLeg.amount > 0.0 diff --git a/damljs/splice-test-token-v1/daml/Splice/Testing/Tokens/TestTokenV1.daml b/damljs/splice-test-token-v1/daml/Splice/Testing/Tokens/TestTokenV1.daml new file mode 100644 index 000000000..cd9fd9751 --- /dev/null +++ b/damljs/splice-test-token-v1/daml/Splice/Testing/Tokens/TestTokenV1.daml @@ -0,0 +1,306 @@ +-- Copyright (c) 2024 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.Testing.Tokens.Internal.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/package.json b/docs/wallet-integration-guide/examples/package.json index 033f3a816..e63f4652b 100644 --- a/docs/wallet-integration-guide/examples/package.json +++ b/docs/wallet-integration-guide/examples/package.json @@ -27,6 +27,7 @@ "run-14": "tsx ./scripts/14-offline-signing.ts | pino-pretty", "run-15": "tsx ./scripts/15-token-namespace-no-validator-url.ts | pino-pretty", "run-16": "tsx ./scripts/16-amulet-namespace-no-validator-url.ts | pino-pretty", + "run-17": "tsx ./scripts/17-multi-sync/index.ts | pino-pretty", "stress-run-01": "tsx ./scripts/stress/01-merge-utxos.ts | pino-pretty", "stress-run-02": "tsx ./scripts/stress/02-merge-utxos-delegate.ts | pino-pretty", "stress-run-03": "tsx ./scripts/stress/03-acs-cache.ts | pino-pretty" @@ -41,10 +42,13 @@ "vitest": "^4.1.10" }, "dependencies": { + "@canton-network/core-amulet-service": "workspace:^", "@canton-network/core-ledger-client": "workspace:^", "@canton-network/core-ledger-client-types": "workspace:^", "@canton-network/core-ledger-proto": "workspace:^", "@canton-network/core-signing-lib": "workspace:^", + "@canton-network/core-test-token": "workspace:^", + "@canton-network/core-token-standard": "workspace:^", "@canton-network/core-tx-parser": "workspace:^", "@canton-network/core-types": "workspace:^", "@canton-network/core-wallet-auth": "workspace:^", diff --git a/docs/wallet-integration-guide/examples/scripts/17-multi-sync/README.md b/docs/wallet-integration-guide/examples/scripts/17-multi-sync/README.md new file mode 100644 index 000000000..4c87f8853 --- /dev/null +++ b/docs/wallet-integration-guide/examples/scripts/17-multi-sync/README.md @@ -0,0 +1,94 @@ +# Example 17: 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. + +## Running Locally + +All commands are run from the **repository root** unless noted otherwise. + +```bash +# Step 0: Build all components +yarn install + +yarn build:all + +# Step 1: Fetch localnet bundle (first time or after a Splice version update) +yarn script:fetch:localnet + +# Step 2: Start localnet (multi-sync is the default; pass --no-multi-sync for single-synchronizer debug mode) +yarn start:localnet + +# Step 3: Run the example +yarn workspace docs-wallet-integration-guide-examples run-17 + +# Step 4: Stop when done (from the repository root) +yarn stop:localnet +``` + +# Example details + +The goal here is to show an exchange operation with a custom token (`TestToken`) that supports running workflows on both a private synchronizer and the global synchronizer. +A private synchronizer helps avoid some of the traffic costs of using the global synchronizer, but still enables parties to do transactions on the global network, +provided that at some point the contracts are re-assigned (automatically or explicitly) to the global synchronizer. + +The parties in the example are: + +- **Alice** - hosted on the **app-user** participant. Holds Amulet, buys `TestToken`. +- **Bob** - hosted on the **app-provider** participant. Holds `TestToken`, buys Amulet. +- **TokenAdmin** - issuer / admin of `TestToken`, also hosted on the **app-provider** participant. +- **TradingApp** - the OTC settlement venue (DvP), hosted on the **app-user** participant (which is connected to both synchronizers). + +The trade is a two-legged Delivery-vs-Payment: + +- **leg-0:** Alice pays **100 Amulet** to Bob - Amulet lives on the **global** synchronizer. +- **leg-1:** Bob delivers **20 `TestToken`** to Alice - `TestToken` lives on the **app** synchronizer. + +Bob's `TestToken` allocation for leg-1 is created **on the app-synchronizer** (where his +holding lives, so no reassignment is needed to allocate). Because `TradingApp` is hosted on +the **app-user** participant — which is connected to both synchronizers — settlement runs on +the **global** synchronizer and Canton **automatically reassigns** the app-synchronizer +allocation to global as part of the atomic settlement. `TradingApp` is a stakeholder +(observer) of the allocation, which is what authorizes that reassignment. + +The whole flow uses **single-party submissions only** (no multi-party signing) and is settled atomically by the TradingApp. + +## Topology & DAR vetting + +Vetting is per **(participant, synchronizer)**. `app-user` and `app-provider` connect to both +synchronizers and vet the same two DARs on each; `sv` connects to the **global** synchronizer only. +`TradingApp` is hosted on the **app-user** participant, so the settlement venue is itself +connected to both synchronizers — this is what lets settlement on global automatically reassign +the app-synchronizer allocation. + +```text +GLOBAL synchronizer — Amulet* · leg-0: Alice --100 CC--> Bob +══════════╤═══════════════════════╤═══════════════════════╤═════════════════ + │ │ │ + │ vetted on GLOBAL: TestTokenV1, trading-app (+ Amulet* preinstalled) — all 3 participants + │ │ │ + ┌──────┴───────┐ ┌──────┴───────┐ ┌──────┴───────┐ + │ app-user │ │ app-provider │ │ sv │ + │ participant │ │ participant │ │ participant │ + │ Alice │ │ Bob │ │ (no example │ + │ TradingApp │ │ TokenAdmin │ │ parties) │ + └──────┬───────┘ └──────┬───────┘ └──────────────┘ + │ │ + │ vetted on PRIVATE: TestTokenV1, — app-user & app-provider only (sv not connected) + │ │ +══════════╧═══════════════════════╧═════════════════════════════════════════ +APP synchronizer — TestToken · leg-1: Bob --20 TT--> Alice +``` + +Vetting matrix (which DAR is vetted where): + +| Participant (hosts) | global synchronizer | app synchronizer | +| ---------------------------------- | ------------------------------------ | ------------------------ | +| **app-user** (Alice, TradingApp) | TestTokenV1, trading-app, (Amulet\*) | TestTokenV1, trading-app | +| **app-provider** (Bob, TokenAdmin) | TestTokenV1, trading-app, (Amulet\*) | TestTokenV1, trading-app | +| **sv** (no example parties) | TestTokenV1, trading-app, (Amulet\*) | — _(not connected)_ | + +DARs referenced above: + +- **TestTokenV1** = `splice-tddest-token-v1-1.0.0.dar` (built locally from `damljs/splice-test-token-v1`) +- **trading-app** = `splice-token-test-trading-app-1.0.0.dar` (from the localnet bundle) +- **Amulet\*** = `splice-amulet` — pre-vetted on the **global** synchronizer by localnet, **not** by this example. diff --git a/docs/wallet-integration-guide/examples/scripts/17-multi-sync/_amulet_ops.ts b/docs/wallet-integration-guide/examples/scripts/17-multi-sync/_amulet_ops.ts new file mode 100644 index 000000000..36384f26a --- /dev/null +++ b/docs/wallet-integration-guide/examples/scripts/17-multi-sync/_amulet_ops.ts @@ -0,0 +1,88 @@ +// Copyright (c) 2025-2026 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import type { Logger } from 'pino' +import { localNetStaticConfig } from '@canton-network/wallet-sdk' +import type { MultiSyncSetup } from './_setup.js' +import { ALICE_AMULET_TAP_AMOUNT } from './_constants.js' + +export async function mintAmuletForAlice( + setup: MultiSyncSetup, + logger: Logger +): Promise { + const { aliceSdk, alice, globalSynchronizerId } = setup + const [aliceTapCreateCommand, aliceTapCreateDisclosedContracts] = + await aliceSdk.amulet.tap(alice.partyId, ALICE_AMULET_TAP_AMOUNT) + + await aliceSdk.ledger + .prepare({ + partyId: alice.partyId, + commands: aliceTapCreateCommand, + disclosedContracts: aliceTapCreateDisclosedContracts, + synchronizerId: globalSynchronizerId, + }) + .sign(alice.keyPair.privateKey) + .execute({ partyId: alice.partyId }) + + logger.info( + `Alice: Amulet minted (${ALICE_AMULET_TAP_AMOUNT}) on global synchronizer` + ) +} + +export async function allocateAmuletForAlice( + setup: MultiSyncSetup, + logger: Logger +): Promise { + const { aliceSdk, alice, globalSynchronizerId, amuletAdmin } = setup + + const pendingRequests = await aliceSdk.token.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') + + const amuletHoldings = await aliceSdk.token.utxos.list({ + partyId: alice.partyId, + includeLocked: false, + }) + const amuletHoldingCid = amuletHoldings.find( + (holding) => + holding.interfaceViewValue.instrumentId.id === 'Amulet' && + holding.interfaceViewValue.instrumentId.admin === amuletAdmin + )?.contractId + if (!amuletHoldingCid) throw new Error('Amulet holding not found for Alice') + + const [command, disclosedContracts] = + await aliceSdk.token.allocation.instruction.create({ + allocationSpecification: { + settlement: requestView.settlement, + transferLegId: legId, + transferLeg: requestView.transferLegs[legId], + }, + asset: { + id: 'Amulet', + displayName: 'Amulet', + symbol: 'CC', + registryUrl: localNetStaticConfig.LOCALNET_REGISTRY_API_URL, + admin: amuletAdmin, + }, + inputUtxos: [amuletHoldingCid], + requestedAt: new Date().toISOString(), + }) + + await aliceSdk.ledger + .prepare({ + partyId: alice.partyId, + commands: [command], + disclosedContracts, + synchronizerId: globalSynchronizerId, + }) + .sign(alice.keyPair.privateKey) + .execute({ partyId: alice.partyId }) + + logger.info('Alice: Amulet allocated for leg-0 (global synchronizer)') + return legId +} diff --git a/docs/wallet-integration-guide/examples/scripts/17-multi-sync/_constants.ts b/docs/wallet-integration-guide/examples/scripts/17-multi-sync/_constants.ts new file mode 100644 index 000000000..45d290752 --- /dev/null +++ b/docs/wallet-integration-guide/examples/scripts/17-multi-sync/_constants.ts @@ -0,0 +1,18 @@ +// Copyright (c) 2025-2026 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +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' + +// Port + URL of the local TestToken registry that implements the four +// CIP-56 Token Standard off-ledger APIs (served by +// `@canton-network/core-test-token/registry`). +export const TEST_TOKEN_REGISTRY_PORT = parseInt( + process.env['REGISTRY_PORT'] ?? '5975', + 10 +) +export const TEST_TOKEN_REGISTRY_URL = new URL( + `http://localhost:${TEST_TOKEN_REGISTRY_PORT}` +) diff --git a/docs/wallet-integration-guide/examples/scripts/17-multi-sync/_poll.ts b/docs/wallet-integration-guide/examples/scripts/17-multi-sync/_poll.ts new file mode 100644 index 000000000..bfc65e813 --- /dev/null +++ b/docs/wallet-integration-guide/examples/scripts/17-multi-sync/_poll.ts @@ -0,0 +1,32 @@ +// Copyright (c) 2025-2026 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +export interface PollOptions { + /** Maximum time to keep polling before giving up, in milliseconds. */ + timeoutMs: number + /** Delay between successive attempts, in milliseconds. */ + intervalMs: number + /** Error message thrown when the timeout elapses without a result. */ + timeoutMessage: string +} + +/** + * Repeatedly invokes `fn` until it resolves to a defined (non-`undefined`) + * value or the timeout elapses. Bridges cross-participant read-after-write + * delays where a contract created on one participant becomes visible on another + * only after asynchronous propagation. + * + * @throws {Error} With `timeoutMessage` when no result is produced in time. + */ +export async function pollUntil( + fn: () => Promise, + { timeoutMs, intervalMs, timeoutMessage }: PollOptions +): Promise { + const deadline = Date.now() + timeoutMs + for (;;) { + const result = await fn() + if (result !== undefined) return result + if (Date.now() >= deadline) throw new Error(timeoutMessage) + await new Promise((resolve) => setTimeout(resolve, intervalMs)) + } +} diff --git a/docs/wallet-integration-guide/examples/scripts/17-multi-sync/_setup.ts b/docs/wallet-integration-guide/examples/scripts/17-multi-sync/_setup.ts new file mode 100644 index 000000000..441d46acb --- /dev/null +++ b/docs/wallet-integration-guide/examples/scripts/17-multi-sync/_setup.ts @@ -0,0 +1,270 @@ +// Copyright (c) 2025-2026 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import path from 'path' +import { fileURLToPath } from 'url' +import fs from 'fs/promises' +import type { Logger } from 'pino' +import { + localNetStaticConfig, + SDK, + type SDKInterface, +} from '@canton-network/wallet-sdk' +import type { KeyPair } from '@canton-network/core-signing-lib' +import type { GenerateTransactionResponse } from '@canton-network/core-ledger-client' +import { readTestTokenV1Dar } from '@canton-network/core-test-token/setup' +import { + AMULET_NAMESPACE_CONFIG, + ASSET_CONFIG, + TOKEN_NAMESPACE_CONFIG, + TOKEN_PROVIDER_CONFIG_DEFAULT, + resolveGlobalSynchronizerId, +} from '../utils/index.js' +import type { KnownSynchronizers } from '../utils/index.js' +import { TEST_TOKEN_REGISTRY_URL } from './_constants.js' + +// Token namespace config that also points the SDK at the local TestToken +// registry (in addition to the Amulet scan-proxy registry). This lets the +// wallet SDK resolve TestToken via the CIP-56 metadata API and fetch its +// transfer/allocation choice contexts over HTTP. +const TOKEN_NAMESPACE_CONFIG_WITH_REGISTRIES = { + ...TOKEN_NAMESPACE_CONFIG, + registries: [ + ...(TOKEN_NAMESPACE_CONFIG.registries as URL[]), + TEST_TOKEN_REGISTRY_URL, + ], +} + +export type PartyInfo = Omit< + GenerateTransactionResponse, + 'topologyTransactions' +> & { + topologyTransactions?: string[] | undefined + keyPair: KeyPair +} + +const LOCALNET_PATH = '../../../../../.localnet' +const TRADING_APP_DAR_LOCALNET = '/dars/splice-token-test-trading-app-1.0.0.dar' + +export interface MultiSyncSetup { + // One SDK instance per party. Alice, TradingApp + Charlie are hosted on the + // app-user participant; Bob + TokenAdmin on the app-provider participant. Each + // party still gets its own wallet SDK so submissions are made through the + // party's own client, mirroring a real multi-wallet deployment. + aliceSdk: SDKInterface<'token' | 'amulet' | 'asset'> + tradingAppSdk: SDKInterface<'token'> + bobSdk: SDKInterface<'token'> + tokenAdminSdk: SDKInterface<'token'> + charlieSdk: SDKInterface<'token'> + svSdk: SDKInterface<'token'> + alice: PartyInfo + bob: PartyInfo + tradingApp: PartyInfo + tokenAdmin: PartyInfo + charlie: PartyInfo + globalSynchronizerId: string + appSynchronizerId: string + synchronizers: KnownSynchronizers + amuletAdmin: string +} + +/** + * Bootstraps a fresh multi-synchronizer environment: + * - Creates one SDK instance per party (alice, tradingApp, charlie on the + * app-user participant; bob, tokenAdmin on the app-provider participant) plus an sv SDK + * - Discovers global + app synchronizer IDs from the app-user participant + * - Allocates alice (app-user), bob (app-provider), tradingApp (app-user), tokenAdmin (app-provider), charlie (app-user) on global synchronizer + * while simultaneously registering alice, bob, tradingApp, tokenAdmin, and charlie on app-synchronizer + * - tradingApp is hosted on the app-user participant, which is connected to both synchronizers + * - Resolves the Amulet admin party ID from the registry metadata API + */ +export async function setupMultiSyncTrade( + logger: Logger +): Promise { + const [aliceSdk, tradingAppSdk, charlieSdk, bobSdk, tokenAdminSdk, svSdk] = + await Promise.all([ + SDK.create({ + auth: TOKEN_PROVIDER_CONFIG_DEFAULT, + ledgerClientUrl: + localNetStaticConfig.LOCALNET_APP_USER_LEDGER_URL, + amulet: AMULET_NAMESPACE_CONFIG, + token: TOKEN_NAMESPACE_CONFIG_WITH_REGISTRIES, + asset: ASSET_CONFIG, + }), + SDK.create({ + auth: TOKEN_PROVIDER_CONFIG_DEFAULT, + ledgerClientUrl: + localNetStaticConfig.LOCALNET_APP_USER_LEDGER_URL, + token: TOKEN_NAMESPACE_CONFIG_WITH_REGISTRIES, + }), + SDK.create({ + auth: TOKEN_PROVIDER_CONFIG_DEFAULT, + ledgerClientUrl: + localNetStaticConfig.LOCALNET_APP_USER_LEDGER_URL, + token: TOKEN_NAMESPACE_CONFIG_WITH_REGISTRIES, + }), + SDK.create({ + auth: TOKEN_PROVIDER_CONFIG_DEFAULT, + ledgerClientUrl: + localNetStaticConfig.LOCALNET_APP_PROVIDER_LEDGER_URL, + token: TOKEN_NAMESPACE_CONFIG_WITH_REGISTRIES, + }), + SDK.create({ + auth: TOKEN_PROVIDER_CONFIG_DEFAULT, + ledgerClientUrl: + localNetStaticConfig.LOCALNET_APP_PROVIDER_LEDGER_URL, + token: TOKEN_NAMESPACE_CONFIG_WITH_REGISTRIES, + }), + SDK.create({ + auth: TOKEN_PROVIDER_CONFIG_DEFAULT, + ledgerClientUrl: localNetStaticConfig.LOCALNET_SV_LEDGER_URL, + token: TOKEN_NAMESPACE_CONFIG, + }), + ]) + + const connectedSyncResponse = await aliceSdk.ledger.connectedSynchronizers() + const allSynchronizers = connectedSyncResponse.connectedSynchronizers ?? [] + if (allSynchronizers.length < 2) + throw new Error( + `Expected at least 2 connected synchronizers (global + app), found ${allSynchronizers.length}` + ) + + const globalSynchronizerId = resolveGlobalSynchronizerId(allSynchronizers) + const appSynchronizerId = allSynchronizers.find( + (s) => s.synchronizerAlias === 'app-synchronizer' + )?.synchronizerId + + if (!appSynchronizerId) + throw new Error( + 'App synchronizer not found — start localnet in multi-sync mode (the default; do not pass --no-multi-sync).' + ) + + logger.info( + `Connected synchronizers: ${allSynchronizers.map((s) => s.synchronizerAlias).join(', ')}` + ) + logger.info( + `Synchronizer IDs — global: ${globalSynchronizerId}, app: ${appSynchronizerId}` + ) + + const synchronizers: KnownSynchronizers = { + globalSynchronizerId, + appSynchronizerId, + } + + const here = path.dirname(fileURLToPath(import.meta.url)) + const [testTokenV1Dar, tradingAppDar] = await Promise.all([ + readTestTokenV1Dar(), + fs.readFile(path.join(here, LOCALNET_PATH, TRADING_APP_DAR_LOCALNET)), + ]) + + await Promise.all([ + // Vetting is per (participant, synchronizer). aliceSdk represents the + // app-user participant and bobSdk the app-provider participant, so + // vetting through one SDK per participant covers every party hosted there. + ...[testTokenV1Dar, tradingAppDar].flatMap((dar) => + [aliceSdk, bobSdk].flatMap((sdk) => + [globalSynchronizerId, appSynchronizerId].map((sid) => + sdk.ledger.dar.uploadAndVet(dar, sid) + ) + ) + ), + ...[testTokenV1Dar, tradingAppDar].map((dar) => + svSdk.ledger.dar.uploadAndVet(dar, globalSynchronizerId) + ), + ]) + logger.info( + 'DARs vetted: app-user participant node + app-provider participant node have TestTokenV1 + trading-app on both synchronizers; sv has both on global only' + ) + + const aliceKey = aliceSdk.keys.generate() + const bobKey = bobSdk.keys.generate() + const tradingAppKey = tradingAppSdk.keys.generate() + const tokenAdminKey = tokenAdminSdk.keys.generate() + const charlieKey = charlieSdk.keys.generate() + + const [ + allocatedAlice, + allocatedBob, + allocatedTradingApp, + allocatedTokenAdmin, + allocatedCharlie, + ] = await Promise.all([ + aliceSdk.party.external + .create(aliceKey.publicKey, { + partyHint: 'Alice', + synchronizerId: globalSynchronizerId, + additionalSynchronizerIds: [appSynchronizerId], + }) + .sign(aliceKey.privateKey) + .execute(), + bobSdk.party.external + .create(bobKey.publicKey, { + partyHint: 'Bob', + synchronizerId: globalSynchronizerId, + additionalSynchronizerIds: [appSynchronizerId], + }) + .sign(bobKey.privateKey) + .execute(), + tradingAppSdk.party.external + .create(tradingAppKey.publicKey, { + partyHint: 'TradingApp', + synchronizerId: globalSynchronizerId, + additionalSynchronizerIds: [appSynchronizerId], + }) + .sign(tradingAppKey.privateKey) + .execute(), + tokenAdminSdk.party.external + .create(tokenAdminKey.publicKey, { + partyHint: 'TokenAdmin', + synchronizerId: globalSynchronizerId, + additionalSynchronizerIds: [appSynchronizerId], + }) + .sign(tokenAdminKey.privateKey) + .execute(), + charlieSdk.party.external + .create(charlieKey.publicKey, { + partyHint: 'Charlie', + synchronizerId: globalSynchronizerId, + additionalSynchronizerIds: [appSynchronizerId], + }) + .sign(charlieKey.privateKey) + .execute(), + ]) + + const alice: PartyInfo = { ...allocatedAlice, keyPair: aliceKey } + const bob: PartyInfo = { ...allocatedBob, keyPair: bobKey } + const tradingApp: PartyInfo = { + ...allocatedTradingApp, + keyPair: tradingAppKey, + } + const tokenAdmin: PartyInfo = { + ...allocatedTokenAdmin, + keyPair: tokenAdminKey, + } + const charlie: PartyInfo = { ...allocatedCharlie, keyPair: charlieKey } + + logger.info( + `Parties allocated on global-synchronizer and registered on app-synchronizer — alice: ${alice.partyId} (app-user), bob: ${bob.partyId} (app-provider), tradingApp: ${tradingApp.partyId} (app-user, both synchronizers), tokenAdmin: ${tokenAdmin.partyId} (app-provider), charlie: ${charlie.partyId} (app-user)` + ) + + const { admin: amuletAdmin } = await aliceSdk.asset.find('Amulet') + logger.info(`Amulet asset discovered — admin: ${amuletAdmin}`) + + return { + aliceSdk, + tradingAppSdk, + bobSdk, + tokenAdminSdk, + charlieSdk, + svSdk, + alice, + bob, + tradingApp, + tokenAdmin, + charlie, + globalSynchronizerId, + appSynchronizerId, + synchronizers, + amuletAdmin, + } +} diff --git a/docs/wallet-integration-guide/examples/scripts/17-multi-sync/_token_allocation.ts b/docs/wallet-integration-guide/examples/scripts/17-multi-sync/_token_allocation.ts new file mode 100644 index 000000000..dd50a9e14 --- /dev/null +++ b/docs/wallet-integration-guide/examples/scripts/17-multi-sync/_token_allocation.ts @@ -0,0 +1,102 @@ +// Copyright (c) 2025-2026 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import type { Logger } from 'pino' +import * as SpliceTestTokenV1 from '@canton-network/core-test-token' +import type { MultiSyncSetup } from './_setup.js' + +const TestTokenV1 = SpliceTestTokenV1.Splice.Testing.Tokens.TestTokenV1 + +export async function allocateTokenForBob( + setup: MultiSyncSetup, + logger: Logger +): Promise<{ legId: string }> { + const { bobSdk, tokenAdminSdk, bob, tokenAdmin, appSynchronizerId } = setup + + // Resolve the TestToken registry URL from the SDK's configured registries + // (`token.find`) rather than passing it in through the setup object. + const { registryUrl: testTokenRegistryUrl } = + await bobSdk.token.find('TestToken') + + const pendingRequests = await bobSdk.token.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 = await bobSdk.ledger.acsReader.raw.readJsContracts({ + templateIds: [TestTokenV1.Token.templateId], + parties: [bob.partyId], + filterByParty: true, + }) + + const tokenHolding = tokenHoldings[0] + if (!tokenHolding) throw new Error('Token holding not found for Bob') + + // Fetch the AllocationFactory + choice context from the TestToken registry's + // allocation-instruction-v1 API. The registry returns the global-synchronizer + // TokenRules contract as the factory (disclosed in `disclosedFromHelper`). + const [command, disclosedFromHelper] = + await bobSdk.token.allocation.instruction.create({ + allocationSpecification: { + settlement: requestView.settlement, + transferLegId: legId, + transferLeg: requestView.transferLegs[legId], + }, + asset: { + id: 'TestToken', + displayName: 'TestToken', + symbol: 'TT', + registryUrl: testTokenRegistryUrl, + admin: tokenAdmin.partyId, + }, + inputUtxos: [tokenHolding.contractId], + requestedAt: new Date(Date.now()).toISOString(), + }) + + const appTokenRules = ( + await tokenAdminSdk.ledger.acsReader.raw.readJsContracts({ + templateIds: [TestTokenV1.TokenRules.templateId], + parties: [tokenAdmin.partyId], + filterByParty: true, + }) + ).find((c) => c.synchronizerId === appSynchronizerId) + if (!appTokenRules) + throw new Error('TokenRules not found on app synchronizer') + + const originalFactoryId = + 'ExerciseCommand' in command + ? command.ExerciseCommand.contractId + : undefined + if ('ExerciseCommand' in command) + command.ExerciseCommand.contractId = appTokenRules.contractId + + const disclosedOnApp = disclosedFromHelper.map((dc) => + dc.contractId === originalFactoryId + ? { + templateId: appTokenRules.templateId, + contractId: appTokenRules.contractId, + createdEventBlob: appTokenRules.createdEventBlob!, + synchronizerId: appTokenRules.synchronizerId, + } + : dc + ) + + await bobSdk.ledger + .prepare({ + partyId: bob.partyId, + commands: [command], + disclosedContracts: disclosedOnApp, + synchronizerId: appSynchronizerId, + }) + .sign(bob.keyPair.privateKey) + .execute({ partyId: bob.partyId }) + + logger.info( + 'Bob: TestToken allocated for leg-1 (app-synchronizer, single-party) via registry allocation-factory' + ) + return { legId } +} diff --git a/docs/wallet-integration-guide/examples/scripts/17-multi-sync/_token_setup.ts b/docs/wallet-integration-guide/examples/scripts/17-multi-sync/_token_setup.ts new file mode 100644 index 000000000..0cecaa513 --- /dev/null +++ b/docs/wallet-integration-guide/examples/scripts/17-multi-sync/_token_setup.ts @@ -0,0 +1,135 @@ +// Copyright (c) 2025-2026 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import type { Logger } from 'pino' +import { + buildCreateTokenRulesCommand, + buildMintTokenCommand, +} from '@canton-network/core-test-token' +import * as SpliceTestTokenV1 from '@canton-network/core-test-token' +import type { MultiSyncSetup } from './_setup.js' +import { BOB_TOKEN_MINT_AMOUNT } from './_constants.js' + +const TestTokenV1 = SpliceTestTokenV1.Splice.Testing.Tokens.TestTokenV1 + +/** + * Creates a `TokenRules` contract for the TokenAdmin party on a single + * synchronizer. Passed to the TestToken registry as its `createTokenRules` + * callback so the registry can deploy the token's `TokenRules` on each + * configured synchronizer as part of initialization. + */ +export async function createTokenRules( + setup: MultiSyncSetup, + synchronizerId: string +): Promise { + const { tokenAdminSdk, tokenAdmin } = setup + + await tokenAdminSdk.ledger + .prepare({ + partyId: tokenAdmin.partyId, + commands: buildCreateTokenRulesCommand(tokenAdmin.partyId), + disclosedContracts: [], + synchronizerId, + }) + .sign(tokenAdmin.keyPair.privateKey) + .execute({ partyId: tokenAdmin.partyId }) +} + +/** + * Mints TestTokens for the TokenAdmin and offers them to Bob via the registry's + * transfer-instruction-v1 API, which Bob then accepts. Assumes the TestToken + * `TokenRules` contracts already exist (created by the registry on start-up). + */ +export async function mintAndTransferTokenToBob( + setup: MultiSyncSetup, + logger: Logger +): Promise { + const { bobSdk, tokenAdminSdk, bob, tokenAdmin, appSynchronizerId } = setup + + // The TestToken registry URL comes from the SDK's own configured registries + // (`token.find` resolves assets from the registries passed to `SDK.create`), + // so it no longer needs to be threaded through the setup object. + const { registryUrl: testTokenRegistryUrl } = + await tokenAdminSdk.token.find('TestToken') + + await tokenAdminSdk.ledger + .prepare({ + partyId: tokenAdmin.partyId, + commands: [ + buildMintTokenCommand({ + owner: tokenAdmin.partyId, + admin: tokenAdmin.partyId, + amount: BOB_TOKEN_MINT_AMOUNT, + }), + ], + disclosedContracts: [], + synchronizerId: appSynchronizerId, + }) + .sign(tokenAdmin.keyPair.privateKey) + .execute({ partyId: tokenAdmin.partyId }) + + const adminTokenHoldings = + await tokenAdminSdk.ledger.acsReader.raw.readJsContracts({ + templateIds: [TestTokenV1.Token.templateId], + parties: [tokenAdmin.partyId], + filterByParty: true, + }) + const adminTokenCid = adminTokenHoldings[0]?.contractId + if (!adminTokenCid) + throw new Error('TokenAdmin Token holding not found after mint') + + // TokenAdmin offers the freshly-minted TestToken to Bob. The transfer factory + // and choice context come from the registry's transfer-instruction-v1 API + // (the TestToken registry is also resolved via the metadata-v1 API). + const [transferCommand, transferDisclosed] = + await tokenAdminSdk.token.transfer.create({ + sender: tokenAdmin.partyId, + recipient: bob.partyId, + amount: BOB_TOKEN_MINT_AMOUNT, + instrumentId: 'TestToken', + registryUrl: testTokenRegistryUrl, + inputUtxos: [adminTokenCid], + }) + + await tokenAdminSdk.ledger + .prepare({ + partyId: tokenAdmin.partyId, + commands: [transferCommand], + disclosedContracts: transferDisclosed, + synchronizerId: appSynchronizerId, + }) + .sign(tokenAdmin.keyPair.privateKey) + .execute({ partyId: tokenAdmin.partyId }) + + const transferOffers = await bobSdk.ledger.acsReader.raw.readJsContracts({ + templateIds: [TestTokenV1.TokenTransferOffer.templateId], + parties: [bob.partyId], + filterByParty: true, + }) + const transferOfferCid = transferOffers[0]?.contractId + if (!transferOfferCid) + throw new Error('TokenTransferOffer not found for Bob') + + // Bob accepts the transfer offer using the registry's transfer-instruction-v1 + // accept choice context. + const [acceptCommand, acceptDisclosed] = await bobSdk.token.transfer.accept( + { + transferInstructionCid: transferOfferCid, + registryUrl: testTokenRegistryUrl, + } + ) + + await bobSdk.ledger + .prepare({ + partyId: bob.partyId, + commands: [acceptCommand], + disclosedContracts: acceptDisclosed, + synchronizerId: appSynchronizerId, + }) + .sign(bob.keyPair.privateKey) + .execute({ partyId: bob.partyId }) + + logger.info( + `Bob: ${BOB_TOKEN_MINT_AMOUNT} TestToken minted on app-synchronizer via registry transfer-factory` + ) +} diff --git a/docs/wallet-integration-guide/examples/scripts/17-multi-sync/_token_transfer.ts b/docs/wallet-integration-guide/examples/scripts/17-multi-sync/_token_transfer.ts new file mode 100644 index 000000000..9b5ecd00e --- /dev/null +++ b/docs/wallet-integration-guide/examples/scripts/17-multi-sync/_token_transfer.ts @@ -0,0 +1,176 @@ +// Copyright (c) 2025-2026 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import type { Logger } from 'pino' +import * as SpliceTestTokenV1 from '@canton-network/core-test-token' +import type { Splice as SpliceTestTokenTypes } from '@canton-network/core-test-token' +import type { MultiSyncSetup } from './_setup.js' +import { TRADE_TOKEN_AMOUNT } from './_constants.js' + +const TestTokenV1 = SpliceTestTokenV1.Splice.Testing.Tokens.TestTokenV1 + +const TOKEN_POLL_TIMEOUT_MS = 30_000 +const TOKEN_POLL_INTERVAL_MS = 500 + +export async function aliceTransferToCharlie( + setup: MultiSyncSetup, + logger: Logger +): Promise { + const { aliceSdk, charlieSdk, alice, charlie, appSynchronizerId } = setup + + // Resolve the TestToken registry URL from the SDK's configured registries + // (`token.find`) instead of threading it through the setup object. + const { registryUrl: testTokenRegistryUrl } = + await aliceSdk.token.find('TestToken') + + // The settlement is submitted by TradingApp (sv), so Alice's resulting Token + // holding propagates to her participant (app-user) asynchronously. Poll app-user until it + // becomes visible instead of reading once (cross-participant read-after-write). + const deadline = Date.now() + TOKEN_POLL_TIMEOUT_MS + let aliceToken + for (;;) { + const aliceTokens = await aliceSdk.ledger.acsReader.raw.readJsContracts( + { + templateIds: [TestTokenV1.Token.templateId], + parties: [alice.partyId], + filterByParty: true, + } + ) + aliceToken = aliceTokens[0] + if (aliceToken) break + if (Date.now() >= deadline) + throw new Error('Alice: Token holding not found after settlement') + await new Promise((resolve) => + setTimeout(resolve, TOKEN_POLL_INTERVAL_MS) + ) + } + + // The settled holding lands on the global synchronizer; move it to the + // app-synchronizer before transferring it to Charlie there (mirrors Bob's flow). + // TODO #2097 remove after bugfix in canton + if (aliceToken.synchronizerId !== appSynchronizerId) { + await aliceSdk.ledger.internal.reassign({ + submitter: alice.partyId, + contractId: aliceToken.contractId, + source: aliceToken.synchronizerId, + target: appSynchronizerId, + }) + } + + // Alice offers her freshly-received TestToken to Charlie via the registry's + const [transferCommand, transferDisclosed] = + await aliceSdk.token.transfer.create({ + sender: alice.partyId, + recipient: charlie.partyId, + amount: TRADE_TOKEN_AMOUNT, + instrumentId: 'TestToken', + registryUrl: testTokenRegistryUrl, + inputUtxos: [aliceToken.contractId], + }) + + await aliceSdk.ledger + .prepare({ + partyId: alice.partyId, + commands: [transferCommand], + disclosedContracts: transferDisclosed, + synchronizerId: appSynchronizerId, + }) + .sign(alice.keyPair.privateKey) + .execute({ partyId: alice.partyId }) + + const transferOffers = + await charlieSdk.ledger.acsReader.raw.readJsContracts({ + templateIds: [TestTokenV1.TokenTransferOffer.templateId], + parties: [charlie.partyId], + filterByParty: true, + }) + const transferOfferCid = transferOffers[0]?.contractId + if (!transferOfferCid) + throw new Error('TokenTransferOffer not found for Charlie') + + const [acceptCommand, acceptDisclosed] = + await charlieSdk.token.transfer.accept({ + transferInstructionCid: transferOfferCid, + registryUrl: testTokenRegistryUrl, + }) + + await charlieSdk.ledger + .prepare({ + partyId: charlie.partyId, + commands: [acceptCommand], + disclosedContracts: acceptDisclosed, + synchronizerId: appSynchronizerId, + }) + .sign(charlie.keyPair.privateKey) + .execute({ partyId: charlie.partyId }) + + logger.info( + `Alice: ${TRADE_TOKEN_AMOUNT} TestToken transferred to Charlie on app-synchronizer via registry transfer-factory` + ) +} + +export async function bobSelfTransferToApp( + setup: MultiSyncSetup, + logger: Logger +): Promise { + const { bobSdk, bob, appSynchronizerId } = setup + + const bobTokens = await bobSdk.ledger.acsReader.raw.readJsContracts({ + templateIds: [TestTokenV1.Token.templateId], + parties: [bob.partyId], + filterByParty: true, + }) + + if (bobTokens.length === 0) { + logger.info('Bob: no TestToken holdings to self-transfer') + return + } + + // Resolve the TestToken registry URL from the SDK's configured registries. + const { registryUrl: testTokenRegistryUrl } = + await bobSdk.token.find('TestToken') + + for (const token of bobTokens) { + if (token.synchronizerId !== appSynchronizerId) { + //TODO #2097 remove after bugfix in canton + await bobSdk.ledger.internal.reassign({ + submitter: bob.partyId, + contractId: token.contractId, + source: token.synchronizerId, + target: appSynchronizerId, + }) + } + + const holdingAmount = ( + token as unknown as { + createArgument: SpliceTestTokenTypes.Testing.Tokens.TestTokenV1.Token + } + ).createArgument.holding.amount + if (!holdingAmount) + throw new Error('Cannot read amount from Bob Token holding') + + const [transferCommand, transferDisclosed] = + await bobSdk.token.transfer.create({ + sender: bob.partyId, + recipient: bob.partyId, + amount: holdingAmount, + instrumentId: 'TestToken', + registryUrl: testTokenRegistryUrl, + inputUtxos: [token.contractId], + }) + + await bobSdk.ledger + .prepare({ + partyId: bob.partyId, + commands: [transferCommand], + disclosedContracts: transferDisclosed, + synchronizerId: appSynchronizerId, + }) + .sign(bob.keyPair.privateKey) + .execute({ partyId: bob.partyId }) + } + + logger.info( + `Bob: TestToken self-transferred on app-synchronizer via registry transfer-factory` + ) +} diff --git a/docs/wallet-integration-guide/examples/scripts/17-multi-sync/_trade_propose.ts b/docs/wallet-integration-guide/examples/scripts/17-multi-sync/_trade_propose.ts new file mode 100644 index 000000000..15d5650a8 --- /dev/null +++ b/docs/wallet-integration-guide/examples/scripts/17-multi-sync/_trade_propose.ts @@ -0,0 +1,188 @@ +// Copyright (c) 2025-2026 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import type { Logger } from 'pino' +import type { SDKInterface } from '@canton-network/wallet-sdk' +import type { MultiSyncSetup } from './_setup.js' +import { TRADE_AMULET_AMOUNT, TRADE_TOKEN_AMOUNT } from './_constants.js' +import { pollUntil } from './_poll.js' + +const OTC_TRADE_PROPOSAL_TEMPLATE_ID = + '#splice-token-test-trading-app:Splice.Testing.Apps.TradingApp:OTCTradeProposal' +const OTC_TRADE_TEMPLATE_ID = + '#splice-token-test-trading-app:Splice.Testing.Apps.TradingApp:OTCTrade' + +function buildOtcTradeProposalCommand(params: { + venue: string + transferLegs: Record + approvers: string[] + tradeCid?: string | null +}) { + return { + CreateCommand: { + templateId: OTC_TRADE_PROPOSAL_TEMPLATE_ID, + createArguments: { + venue: params.venue, + tradeCid: params.tradeCid ?? null, + transferLegs: params.transferLegs, + approvers: params.approvers, + }, + }, + } +} + +function buildAcceptOtcTradeCommand(params: { + proposalCid: string + approver: string +}) { + return { + ExerciseCommand: { + templateId: OTC_TRADE_PROPOSAL_TEMPLATE_ID, + contractId: params.proposalCid, + choice: 'OTCTradeProposal_Accept', + choiceArgument: { approver: params.approver }, + }, + } +} + +function buildInitiateSettlementCommand(params: { + proposalCid: string + prepareUntil: string + settleBefore: string +}) { + return { + ExerciseCommand: { + templateId: OTC_TRADE_PROPOSAL_TEMPLATE_ID, + contractId: params.proposalCid, + choice: 'OTCTradeProposal_InitiateSettlement', + choiceArgument: { + prepareUntil: params.prepareUntil, + settleBefore: params.settleBefore, + }, + }, + } +} + +const MS_30_MIN = 30 * 60 * 1000 +const MS_1_HOUR = 60 * 60 * 1000 + +const PROPOSAL_POLL_TIMEOUT_MS = 30_000 +const PROPOSAL_POLL_INTERVAL_MS = 500 + +export async function createAndInitiateOtcTrade( + setup: MultiSyncSetup, + transferLegs: Record, + logger: Logger +): Promise { + const { + aliceSdk, + bobSdk, + tradingAppSdk, + alice, + bob, + tradingApp, + globalSynchronizerId, + } = setup + + // The proposal is created on Alice's participant but read from other + // participants (Bob, TradingApp) + const readProposalCid = ( + sdk: SDKInterface<'token'>, + party: string, + predicate: (approvers: string[]) => boolean = () => true + ): Promise => + pollUntil( + async () => { + const proposals = + await sdk.ledger.acsReader.raw.readJsContracts({ + templateIds: [OTC_TRADE_PROPOSAL_TEMPLATE_ID], + parties: [party], + filterByParty: true, + }) + return proposals.find((proposal) => + predicate( + (( + proposal as unknown as { + createArgument?: { approvers?: string[] } + } + ).createArgument?.approvers ?? []) as string[] + ) + )?.contractId + }, + { + timeoutMs: PROPOSAL_POLL_TIMEOUT_MS, + intervalMs: PROPOSAL_POLL_INTERVAL_MS, + timeoutMessage: `OTCTradeProposal not visible to ${party} within ${PROPOSAL_POLL_TIMEOUT_MS}ms`, + } + ) + + await aliceSdk.ledger + .prepare({ + partyId: alice.partyId, + commands: buildOtcTradeProposalCommand({ + venue: tradingApp.partyId, + transferLegs, + approvers: [alice.partyId], + }), + 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 bobSdk.ledger + .prepare({ + partyId: bob.partyId, + commands: [ + buildAcceptOtcTradeCommand({ + proposalCid: await readProposalCid(bobSdk, bob.partyId), + approver: bob.partyId, + }), + ], + disclosedContracts: [], + synchronizerId: globalSynchronizerId, + }) + .sign(bob.keyPair.privateKey) + .execute({ partyId: bob.partyId }) + logger.info('Bob: OTCTradeProposal_Accept executed') + + const prepareUntil = new Date(Date.now() + MS_30_MIN).toISOString() + const settleBefore = new Date(Date.now() + MS_1_HOUR).toISOString() + + await tradingAppSdk.ledger + .prepare({ + partyId: tradingApp.partyId, + commands: [ + buildInitiateSettlementCommand({ + proposalCid: await readProposalCid( + tradingAppSdk, + tradingApp.partyId, + (approvers) => approvers.includes(bob.partyId) + ), + prepareUntil, + settleBefore, + }), + ], + disclosedContracts: [], + synchronizerId: globalSynchronizerId, + }) + .sign(tradingApp.keyPair.privateKey) + .execute({ partyId: tradingApp.partyId }) + logger.info( + 'TradingApp: OTCTradeProposal_InitiateSettlement executed → OTCTrade created' + ) + + const otcTradeContracts = + await tradingAppSdk.ledger.acsReader.raw.readJsContracts({ + templateIds: [OTC_TRADE_TEMPLATE_ID], + parties: [tradingApp.partyId], + filterByParty: true, + }) + const otcTradeCid = otcTradeContracts[0]?.contractId + if (!otcTradeCid) + throw new Error('OTCTrade contract not found after initiation') + return otcTradeCid +} diff --git a/docs/wallet-integration-guide/examples/scripts/17-multi-sync/_trade_settle.ts b/docs/wallet-integration-guide/examples/scripts/17-multi-sync/_trade_settle.ts new file mode 100644 index 000000000..386275af2 --- /dev/null +++ b/docs/wallet-integration-guide/examples/scripts/17-multi-sync/_trade_settle.ts @@ -0,0 +1,287 @@ +// Copyright (c) 2025-2026 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import type { Logger } from 'pino' +import { localNetStaticConfig } from '@canton-network/wallet-sdk' +import type { MultiSyncSetup } from './_setup.js' +import { TRADE_AMULET_AMOUNT, TRADE_TOKEN_AMOUNT } from './_constants.js' + +const OTC_TRADE_TEMPLATE_ID = + '#splice-token-test-trading-app:Splice.Testing.Apps.TradingApp:OTCTrade' + +function buildSettleOtcTradeCommand(params: { + tradeCid: string + allocationsWithContext: Record +}) { + return { + ExerciseCommand: { + templateId: OTC_TRADE_TEMPLATE_ID, + contractId: params.tradeCid, + choice: 'OTCTrade_Settle', + choiceArgument: { + allocationsWithContext: params.allocationsWithContext, + }, + }, + } +} + +function buildCancelOtcTradeCommand(params: { + tradeCid: string + allocationsWithContext: Record +}) { + return { + ExerciseCommand: { + templateId: OTC_TRADE_TEMPLATE_ID, + contractId: params.tradeCid, + choice: 'OTCTrade_Cancel', + choiceArgument: { + allocationsWithContext: params.allocationsWithContext, + }, + }, + } +} + +export interface SettleParams { + otcTradeCid: string + legIdAlice: string + legIdBob: string + amuletAllocationCid: string + testTokenAllocationCid: string +} + +interface CancelParams { + otcTradeCid: string + legIdAlice: string + legIdBob: string + amuletAllocationCid: string + testTokenAllocationCid: string +} + +/** + * Cancels both allocations in a single venue-authorized `OTCTrade_Cancel` after + * settlement has definitively failed, releasing the locked holdings back to each + * party. Unlike a per-party withdraw, cancellation requires sender, receiver, and + * executor authorization, which the OTCTrade contract delegates to the venue. + */ +async function cancelAllocationsOnFailure( + setup: MultiSyncSetup, + params: CancelParams, + logger: Logger +): Promise { + const { tradingAppSdk, tradingApp, globalSynchronizerId } = setup + const { + otcTradeCid, + legIdAlice, + legIdBob, + amuletAllocationCid, + testTokenAllocationCid, + } = params + + const tokenNamespace = tradingAppSdk.token + // TestToken registry URL comes from the SDK's configured registries. + const { registryUrl: testTokenRegistryUrl } = + await tokenNamespace.find('TestToken') + // Fetch each allocation's cancel choice context from its registry's + // allocation-v1 API (Amulet from the scan-proxy registry, TestToken from the + // local TestToken registry). + const [amuletCancelCtx, testTokenCancelCtx] = await Promise.all([ + tokenNamespace.allocation.context.cancel({ + allocationCid: amuletAllocationCid, + registryUrl: localNetStaticConfig.LOCALNET_REGISTRY_API_URL, + }), + tokenNamespace.allocation.context.cancel({ + allocationCid: testTokenAllocationCid, + registryUrl: testTokenRegistryUrl, + }), + ]) + + const allocationsWithContext = { + [legIdAlice]: { + _1: amuletAllocationCid, + _2: { + context: { + ...(amuletCancelCtx.choiceContextData ?? {}), + values: + (amuletCancelCtx.choiceContextData?.values as Record< + string, + unknown + >) ?? {}, + }, + meta: { values: {} }, + }, + }, + [legIdBob]: { + _1: testTokenAllocationCid, + _2: { + context: { + ...(testTokenCancelCtx.choiceContextData ?? {}), + values: + (testTokenCancelCtx.choiceContextData?.values as Record< + string, + unknown + >) ?? {}, + }, + meta: { values: {} }, + }, + }, + } + + const disclosedContracts = [ + ...(amuletCancelCtx.disclosedContracts ?? []).map((c) => ({ + ...c, + synchronizerId: '', + })), + ...(testTokenCancelCtx.disclosedContracts ?? []).map((c) => ({ + ...c, + synchronizerId: '', + })), + ] + + await tradingAppSdk.ledger + .prepare({ + partyId: tradingApp.partyId, + commands: [ + buildCancelOtcTradeCommand({ + tradeCid: otcTradeCid, + allocationsWithContext, + }), + ], + disclosedContracts, + synchronizerId: globalSynchronizerId, + }) + .sign(tradingApp.keyPair.privateKey) + .execute({ partyId: tradingApp.partyId }) + + logger.info( + 'TradingApp: OTCTrade cancelled — allocations released, funds returned to Alice and Bob' + ) +} + +export async function settleOtcTrade( + setup: MultiSyncSetup, + params: SettleParams, + logger: Logger +): Promise { + const { tradingAppSdk, tradingApp, globalSynchronizerId } = setup + const { + otcTradeCid, + legIdAlice, + legIdBob, + amuletAllocationCid, + testTokenAllocationCid, + } = params + + const tokenNamespace = tradingAppSdk.token + // TestToken registry URL comes from the SDK's configured registries. + const { registryUrl: testTokenRegistryUrl } = + await tokenNamespace.find('TestToken') + const amuletExecCtx = await tokenNamespace.allocation.context.execute({ + allocationCid: amuletAllocationCid, + registryUrl: localNetStaticConfig.LOCALNET_REGISTRY_API_URL, + }) + + // Fetch Bob's TestToken execute-transfer choice context from the registry's + // allocation-v1 API (instead of hard-coding an empty context). + const testTokenExecCtx = await tokenNamespace.allocation.context.execute({ + allocationCid: testTokenAllocationCid, + registryUrl: testTokenRegistryUrl, + }) + + const allocationsWithContext = { + [legIdAlice]: { + _1: amuletAllocationCid, + _2: { + context: { + ...(amuletExecCtx.choiceContextData ?? {}), + values: + (amuletExecCtx.choiceContextData?.values as Record< + string, + unknown + >) ?? {}, + }, + meta: { values: {} }, + }, + }, + [legIdBob]: { + _1: testTokenAllocationCid, + _2: { + context: { + ...(testTokenExecCtx.choiceContextData ?? {}), + values: + (testTokenExecCtx.choiceContextData?.values as Record< + string, + unknown + >) ?? {}, + }, + meta: { values: {} }, + }, + }, + } + + const disclosedContracts = [ + ...(amuletExecCtx.disclosedContracts ?? []).map((c) => ({ + ...c, + synchronizerId: '', + })), + ...(testTokenExecCtx.disclosedContracts ?? []).map((c) => ({ + ...c, + synchronizerId: '', + })), + ] + + const submitSettlement = () => + tradingAppSdk.ledger + .prepare({ + partyId: tradingApp.partyId, + commands: [ + buildSettleOtcTradeCommand({ + tradeCid: otcTradeCid, + allocationsWithContext, + }), + ], + disclosedContracts, + synchronizerId: globalSynchronizerId, + }) + .sign(tradingApp.keyPair.privateKey) + .execute({ partyId: tradingApp.partyId }) + + try { + await submitSettlement() + } catch (firstError) { + logger.warn( + { err: firstError }, + 'Settlement failed — retrying once before cancelling allocations' + ) + try { + await submitSettlement() + } catch (retryError) { + logger.error( + { err: retryError }, + 'Settlement retry failed — cancelling allocations to return funds' + ) + try { + await cancelAllocationsOnFailure( + setup, + { + otcTradeCid, + legIdAlice, + legIdBob, + amuletAllocationCid, + testTokenAllocationCid, + }, + logger + ) + } catch (compensationError) { + logger.error( + { err: compensationError }, + 'Compensation failed — manual intervention required to cancel allocations' + ) + } + throw retryError + } + } + + logger.info( + `TradingApp: OTCTrade settled — ${TRADE_AMULET_AMOUNT} Amulet transferred to Bob, ${TRADE_TOKEN_AMOUNT} TestToken transferred to Alice` + ) +} diff --git a/docs/wallet-integration-guide/examples/scripts/17-multi-sync/index.ts b/docs/wallet-integration-guide/examples/scripts/17-multi-sync/index.ts new file mode 100644 index 000000000..ea363057e --- /dev/null +++ b/docs/wallet-integration-guide/examples/scripts/17-multi-sync/index.ts @@ -0,0 +1,198 @@ +import pino from 'pino' +import { localNetStaticConfig } from '@canton-network/wallet-sdk' +import { startTestTokenRegistry } from '@canton-network/core-test-token/registry' +import { logAllContracts } from '../utils/index.js' +import { setupMultiSyncTrade } from './_setup.js' +import { + TEST_TOKEN_REGISTRY_PORT, + TRADE_AMULET_AMOUNT, + TRADE_TOKEN_AMOUNT, +} from './_constants.js' +import { mintAmuletForAlice, allocateAmuletForAlice } from './_amulet_ops.js' +import { createTokenRules, mintAndTransferTokenToBob } from './_token_setup.js' +import { allocateTokenForBob } from './_token_allocation.js' +import { + aliceTransferToCharlie, + bobSelfTransferToApp, +} from './_token_transfer.js' +import { createAndInitiateOtcTrade } from './_trade_propose.js' +import { settleOtcTrade } from './_trade_settle.js' + +// Multi-Synchronizer DvP: Alice pays 100 Amulet on global; Bob delivers 20 TestToken from app-sync. +// app-user participant hosts Alice + TradingApp, app-provider hosts Bob (+ TokenAdmin); both +// app-user and app-provider connect to the global + app synchronizers, sv is global-only. + +const logger = pino({ name: 'v1-17-multi-sync-trade', level: 'info' }) + +// ── Setup: create SDKs, discover synchronizers, vet DARs, allocate parties ─── +// Step 1: Create one SDK per party (Alice, TradingApp on the app-user participant; Bob, TokenAdmin on the app-provider participant; sv on its own) and discover global + app synchronizers +// Step 2: Vet DARs on both synchronizers for app-user + app-provider; global only for sv (not connected to app-synchronizer) +// Step 3: Allocate parties for Alice (app-user), Bob (app-provider), TradingApp (app-user, both synchronizers), and TokenAdmin (app-provider) +const setup = await setupMultiSyncTrade(logger) +const { + aliceSdk, + bobSdk, + tradingAppSdk, + tokenAdminSdk, + charlieSdk, + alice, + bob, + tradingApp, + tokenAdmin, + charlie, + synchronizers, + amuletAdmin, +} = setup + +// ── Start the TestToken registry (CIP-56 off-ledger APIs) ─────────────────── +// The registry creates the TestToken `TokenRules` on both synchronizers as part +// of initialization, then serves the four Token Standard registry APIs for them. +const registry = await startTestTokenRegistry({ + admin: tokenAdmin.partyId, + port: TEST_TOKEN_REGISTRY_PORT, + ledgerUrl: localNetStaticConfig.LOCALNET_APP_PROVIDER_LEDGER_URL, + synchronizerIds: [setup.globalSynchronizerId, setup.appSynchronizerId], + transferSynchronizerId: setup.appSynchronizerId, + allocationSynchronizerId: setup.globalSynchronizerId, + createTokenRules: (synchronizerId) => + createTokenRules(setup, synchronizerId), + logger, +}) + +// ── Steps 4–5: Init holdings ──────────────────────────────────────────────── +// Step 4: Mint Amulet for Alice (global synchronizer) +// Steps 5a–5e: TokenAdmin self-mints Token, offers to Bob via +// TransferFactory_Transfer; Bob accepts via +// TransferInstruction_Accept — all single-party submissions +// (the TokenRules were created by the registry on start-up) +await Promise.all([ + mintAmuletForAlice(setup, logger), + mintAndTransferTokenToBob(setup, logger), +]) + +logger.info('Contracts after setup:') +await logAllContracts(logger, synchronizers, [ + { sdk: aliceSdk, parties: [alice.partyId] }, + { sdk: bobSdk, parties: [bob.partyId] }, + { sdk: tokenAdminSdk, parties: [tokenAdmin.partyId] }, + { sdk: tradingAppSdk, parties: [tradingApp.partyId] }, +]) + +// ── 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: {} }, + }, +} + +// ── 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, [ + { sdk: aliceSdk, parties: [alice.partyId] }, + { sdk: bobSdk, parties: [bob.partyId] }, + { sdk: tokenAdminSdk, parties: [tokenAdmin.partyId] }, + { sdk: tradingAppSdk, parties: [tradingApp.partyId] }, +]) +// ── 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) +const [legIdAlice, { legId: legIdBob }] = await Promise.all([ + allocateAmuletForAlice(setup, logger), + allocateTokenForBob(setup, logger), +]) +logger.info('Contracts after allocations:') +await logAllContracts(logger, synchronizers, [ + { sdk: aliceSdk, parties: [alice.partyId] }, + { sdk: bobSdk, parties: [bob.partyId] }, + { sdk: tokenAdminSdk, parties: [tokenAdmin.partyId] }, + { sdk: tradingAppSdk, parties: [tradingApp.partyId] }, +]) +// ── Step 10a: Locate Bob's TestToken allocation ──────────────────────────────────── +const allocationsBob = await bobSdk.token.allocation.pending(bob.partyId) +const testTokenAllocation = allocationsBob.find( + (a) => a.interfaceViewValue.allocation.transferLegId === legIdBob +) +if (!testTokenAllocation) throw new Error('TestToken allocation not found') +// ── Step 10b: Reassign Bob's TestToken allocation app-synchronizer → global ── +// TODO #2097 remove after bugfix in canton +await bobSdk.ledger.internal.reassign({ + submitter: bob.partyId, + contractId: testTokenAllocation.contractId, + source: synchronizers.appSynchronizerId, + target: synchronizers.globalSynchronizerId, +}) +logger.info( + 'Bob: TestToken allocation reassigned app-synchronizer → global ahead of settlement' +) + +// ── Step 10c: Scan for both allocations from the TradingApp's perspective ───── +// Poll the TradingApp's ACS until Alice's Amulet allocation (leg-0) and Bob's +// reassigned TestToken allocation (leg-1) have both propagated — this replaces +// disclosing the TestToken allocation to the TradingApp's settlement participant. +const allocationsByLeg = await tradingAppSdk.token.allocation.scan({ + partyId: tradingApp.partyId, + transferLegIds: [legIdAlice, legIdBob], +}) +const amuletAllocationCid = allocationsByLeg[legIdAlice].contractId +const testTokenAllocationCid = allocationsByLeg[legIdBob].contractId + +// ── Step 10d: TradingApp settles the OTCTrade ───────────────────────────────── +try { + await settleOtcTrade( + setup, + { + otcTradeCid, + legIdAlice, + legIdBob, + amuletAllocationCid, + testTokenAllocationCid, + }, + logger + ) +} catch (e) { + logger.error( + { err: e }, + 'Settlement failed — allocations cancelled, funds returned to Alice and Bob' + ) + logger.info('Contracts after settlement failure (allocations cancelled):') + await logAllContracts(logger, synchronizers, [ + { sdk: aliceSdk, parties: [alice.partyId] }, + { sdk: bobSdk, parties: [bob.partyId] }, + { sdk: tokenAdminSdk, parties: [tokenAdmin.partyId] }, + { sdk: tradingAppSdk, parties: [tradingApp.partyId] }, + ]) + await registry.stop() + process.exit(1) +} +logger.info('Contracts after settlement:') +await logAllContracts(logger, synchronizers, [ + { sdk: aliceSdk, parties: [alice.partyId] }, + { sdk: bobSdk, parties: [bob.partyId] }, + { sdk: tokenAdminSdk, parties: [tokenAdmin.partyId] }, + { sdk: tradingAppSdk, parties: [tradingApp.partyId] }, +]) +// ── Step 11: Alice transfers her received TestToken to Charlie on app-synchronizer ─ +await aliceTransferToCharlie(setup, logger) + +logger.info('Final contract state:') +await logAllContracts(logger, synchronizers, [ + { sdk: aliceSdk, parties: [alice.partyId] }, + { sdk: bobSdk, parties: [bob.partyId] }, + { sdk: tokenAdminSdk, parties: [tokenAdmin.partyId] }, + { sdk: tradingAppSdk, parties: [tradingApp.partyId] }, + { sdk: charlieSdk, parties: [charlie.partyId] }, +]) + +await registry.stop() diff --git a/docs/wallet-integration-guide/examples/scripts/utils/acs-logger.ts b/docs/wallet-integration-guide/examples/scripts/utils/acs-logger.ts new file mode 100644 index 000000000..28ee0ca86 --- /dev/null +++ b/docs/wallet-integration-guide/examples/scripts/utils/acs-logger.ts @@ -0,0 +1,154 @@ +// Copyright (c) 2025-2026 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import type { SDKInterface } from '@canton-network/wallet-sdk' +import type { Logger } from 'pino' +import type { KnownSynchronizers } from './index.js' + +export type ContractReadSpec = { + sdk: SDKInterface + parties: string[] +} + +/** Resolve a synchronizer ID to a logical role alias */ +function syncAlias(syncId: string, synchronizers: KnownSynchronizers): string { + if (syncId === synchronizers.globalSynchronizerId) return 'global' + if (syncId === synchronizers.appSynchronizerId) return 'app-synchronizer' + throw new Error(`Unknown synchronizer ID ${syncId}`) +} + +/** + * Query contracts for all given specs in parallel, then log the results as a + * formatted ASCII table. Queries run concurrently; rows are printed in + * declaration order. + */ +export async function logAllContracts( + logger: Logger, + synchronizers: KnownSynchronizers, + specs: ContractReadSpec[] +): Promise { + const results = await Promise.all( + specs.map(({ sdk, parties }) => + sdk.ledger.acsReader.raw.readJsContracts({ + parties, + filterByParty: true, + }) + ) + ) + + type Row = { + label: string + template: string + amount: string + cid: string + sync: string + } + const rows: Row[] = [] + const seenCids = new Set() + + for (let i = 0; i < specs.length; i++) { + const spec = specs[i] + const fallbackLabel = shortenParty(spec.parties[0]) + const contracts = results[i] + if (contracts.length === 0) { + rows.push({ + label: fallbackLabel, + template: '(none)', + amount: '-', + cid: '-', + sync: '-', + }) + continue + } + for (const c of contracts) { + // De-duplicate: a contract can appear in multiple participants' ACS + // streams (e.g. Alice's Token where Bob is the admin/signatory). + if (seenCids.has(c.contractId)) continue + seenCids.add(c.contractId) + + const tplParts = (c.templateId ?? '').split(':') + const template = tplParts[tplParts.length - 1] || c.templateId + const amount = extractAmount(c.createArgument) + const rowLabel = + shortenParty(extractOwner(c.createArgument)) || fallbackLabel + rows.push({ + label: rowLabel, + template, + amount, + cid: `${c.contractId.substring(0, 16)}...`, + sync: syncAlias(c.synchronizerId, synchronizers), + }) + } + } + + const HEADERS = [ + 'Party / Owner', + 'Template', + 'Amount', + 'Contract ID', + 'Synchronizer', + ] as const + const KEYS = ['label', 'template', 'amount', 'cid', 'sync'] as const + + const colWidths = HEADERS.map((h, i) => + Math.max(h.length, ...rows.map((r) => r[KEYS[i]].length)) + ) + + const pad = (s: string, w: number) => s.padEnd(w) + const sep = '+' + colWidths.map((w) => '-'.repeat(w + 2)).join('+') + '+' + const headerRow = + '|' + HEADERS.map((h, i) => ` ${pad(h, colWidths[i])} `).join('|') + '|' + + logger.info(sep) + logger.info(headerRow) + logger.info(sep) + for (const r of rows) { + const line = + '|' + + KEYS.map((k, i) => ` ${pad(r[k], colWidths[i])} `).join('|') + + '|' + logger.info(line) + } + logger.info(sep) +} + +/** Extract a human-readable amount from a contract's createArgument */ +function extractAmount(createArgument: unknown): string { + if (!createArgument || typeof createArgument !== 'object') return '' + const arg = createArgument as Record + // Token: { holding: { amount } } + if (arg.holding && typeof arg.holding === 'object') { + const amount = (arg.holding as Record).amount + if (amount != null) return String(amount) + } + // Amulet: { amount: { initialAmount } } + if (arg.amount && typeof arg.amount === 'object') { + const initial = (arg.amount as Record).initialAmount + if (initial != null) return String(initial) + } + return '' +} + +/** Extract the owner (or admin for rules contracts) from a createArgument */ +function extractOwner(createArgument: unknown): string { + if (!createArgument || typeof createArgument !== 'object') return '' + const arg = createArgument as Record + // Token: { holding: { owner } } + if (arg.holding && typeof arg.holding === 'object') { + const owner = (arg.holding as Record).owner + if (typeof owner === 'string') return owner + } + // Amulet: { owner } + if (typeof arg.owner === 'string') return arg.owner + // TokenRules / TradingApp: { admin } / { venue } + if (typeof arg.admin === 'string') return arg.admin + if (typeof arg.venue === 'string') return arg.venue + return '' +} + +/** Shorten a party id "name::1220abcd..." → "name" for compact display */ +function shortenParty(p: string): string { + if (!p) return '' + const idx = p.indexOf('::') + return idx > 0 ? p.substring(0, idx) : p +} diff --git a/docs/wallet-integration-guide/examples/scripts/utils/index.ts b/docs/wallet-integration-guide/examples/scripts/utils/index.ts index 7f8a79f10..24191f08f 100644 --- a/docs/wallet-integration-guide/examples/scripts/utils/index.ts +++ b/docs/wallet-integration-guide/examples/scripts/utils/index.ts @@ -1,4 +1,5 @@ import { JSContractEntry } from '@canton-network/core-ledger-client' +import type { Provider as Ops } from '@canton-network/core-ledger-client-types' import { TokenProviderConfig, localNetStaticConfig, @@ -9,12 +10,41 @@ import { AssetConfig, } from '@canton-network/wallet-sdk' +export { logAllContracts } from './acs-logger.js' +export type { ContractReadSpec as ContractSpec } from './acs-logger.js' export function getActiveContractCid(entry: JSContractEntry) { if ('JsActiveContract' in entry) { return entry.JsActiveContract.createdEvent.contractId } } +/** Maps the two synchronizer roles used in multi-synchronizer setups. */ +export type KnownSynchronizers = { + globalSynchronizerId: string + appSynchronizerId: string +} + +/** + * Resolve the global synchronizer ID from the list returned by the ledger API. + * + * Looks for the entry whose alias is `'global'` and returns its synchronizer ID. + * `synchronizers` is the `connectedSynchronizers` array from the Ledger API + * `GET /v2/state/connected-synchronizers` method + * ({@link Ops.GetV2StateConnectedSynchronizers}), exposed via the SDK as + * `sdk.ledger.connectedSynchronizers()`. + * + * @throws {Error} When no entry with alias `'global'` is present. + */ +export function resolveGlobalSynchronizerId( + synchronizers: NonNullable< + Ops.GetV2StateConnectedSynchronizers['ledgerApi']['result']['connectedSynchronizers'] + > +): string { + const global = synchronizers.find((s) => s.synchronizerAlias === 'global') + if (!global) throw new Error('Global synchronizer not found') + return global.synchronizerId +} + export const TOKEN_PROVIDER_CONFIG_DEFAULT: TokenProviderConfig = { method: 'self_signed', issuer: 'unsafe-auth', diff --git a/scripts/src/generate-featured-dars.ts b/scripts/src/generate-featured-dars.ts index faeec96b4..3b0f74601 100644 --- a/scripts/src/generate-featured-dars.ts +++ b/scripts/src/generate-featured-dars.ts @@ -8,36 +8,19 @@ import { generateDamlJsBindings } from './lib/daml-codegen.js' const repoRoot = getRepoRoot() -const FEATURED_APP_PROXIES_CONFIG = { - destDir: path.join(repoRoot, 'damljs/featured-app-proxies'), - packageName: 'splice-util-featured-app-proxies', +const SPLICE_TEST_TOKEN_V1_CONFIG = { + destDir: path.join(repoRoot, 'damljs/splice-test-token-v1'), + packageName: 'splice-test-token-v1', version: '1.0.0', - dependencies: [ - path.join( - repoRoot, - '.splice/daml/dars/splice-api-token-holding-v1-1.0.0.dar' - ), - path.join( - repoRoot, - '.splice/daml/dars/splice-api-token-transfer-instruction-v1-1.0.0.dar' - ), - path.join( - repoRoot, - '.splice/daml/dars/splice-api-token-allocation-v1-1.0.0.dar' - ), - ], } -// TODO: this is a work in progress and should not currently be used async function main() { await installDPM() - console.log(info('\n=== Generating Featured App Proxies bindings ===\n')) - await generateDamlJsBindings(FEATURED_APP_PROXIES_CONFIG) + console.log(info('\n=== Generating splice-test-token-v1 bindings ===\n')) + await generateDamlJsBindings(SPLICE_TEST_TOKEN_V1_CONFIG) - console.log( - info('\n=== All featured DAR bindings generated successfully ===\n') - ) + console.log(info('\n=== All Daml JS bindings generated successfully ===\n')) } main() diff --git a/scripts/src/generate-openapi-clients.ts b/scripts/src/generate-openapi-clients.ts index 581dd3b56..a424f7b86 100644 --- a/scripts/src/generate-openapi-clients.ts +++ b/scripts/src/generate-openapi-clients.ts @@ -20,6 +20,7 @@ import generateSchema, { astToString } from 'openapi-typescript' import * as path from 'path' import crypto from 'crypto' import { generateLedgerProviderTypes } from './lib/ledger-provider-type-generator.js' +import { generateRegistryServerStub } from './lib/generate-registry-server.js' /** * OpenAPI specification details. @@ -179,7 +180,10 @@ async function main(network: Network = 'devnet') { SUPPORTED_VERSIONS[network].splice.version, SUPPORTED_VERSIONS[network].canton.version.split('-')[0] ).map(generateOpenApiClient) - ).then(() => { + ).then(async () => { + // Generate the Express server stub for the token-standard registry APIs + // from the same specs the clients above were generated from. + await generateRegistryServerStub(network) console.log( success('Generated fresh TypeScript clients for all OpenAPI specs') ) diff --git a/scripts/src/lib/daml-codegen.ts b/scripts/src/lib/daml-codegen.ts index 46376c69e..c66715595 100644 --- a/scripts/src/lib/daml-codegen.ts +++ b/scripts/src/lib/daml-codegen.ts @@ -152,16 +152,32 @@ export function runDamlCodegen(workingDir: string, darFileName: string): void { } } +/** + * Build a Daml package without generating JS bindings. + * Used for packages that are only needed as data-dependencies by other packages. + */ +export function buildDamlPackage(destDir: string): void { + const damlYamlPath = path.join(destDir, 'daml.yaml') + if (!fs.existsSync(damlYamlPath)) { + throw new Error(`Missing daml.yaml in Daml project: ${damlYamlPath}`) + } + const damlFiles = getAllFilesWithExtension(destDir, '.daml') + if (damlFiles.length === 0) { + throw new Error(`No Daml source files found in ${destDir}`) + } + runDamlBuild(destDir) +} + /** * Generate DAML JavaScript bindings from an existing DAML project at destination * Uses DPM (Daml Package Manager) for the complete workflow: - * 1. Validate destination contains a DAML project - * 2. Build DAR with dpm build - * 3. Generate JS bindings with dpm codegen js + * builds with dpm build, then generates TypeScript bindings with dpm codegen-js */ export async function generateDamlJsBindings( config: DamlCodegenConfig ): Promise { + const darFileName = `${config.packageName}-${config.version}.dar` + const damlYamlPath = path.join(config.destDir, 'daml.yaml') if (!fs.existsSync(damlYamlPath)) { throw new Error( @@ -171,16 +187,9 @@ export async function generateDamlJsBindings( const damlFiles = getAllFilesWithExtension(config.destDir, '.daml') if (damlFiles.length === 0) { - console.log( - warn( - `No .daml files found in ${config.destDir}. Skipping build and codegen.` - ) - ) - return + throw new Error(`No Daml source files found in ${config.destDir}`) } runDamlBuild(config.destDir) - - const darFileName = `${config.packageName}-${config.version}.dar` runDamlCodegen(config.destDir, darFileName) } diff --git a/scripts/src/lib/generate-registry-server.ts b/scripts/src/lib/generate-registry-server.ts new file mode 100644 index 000000000..d738f510c --- /dev/null +++ b/scripts/src/lib/generate-registry-server.ts @@ -0,0 +1,157 @@ +// Copyright (c) 2025-2026 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +/** + * Generates the spec-derived half of the Token Standard registry server: a + * typed routing table plus the `RegistryOperations`/`RegistryHandlers` types, + * from the registry OpenAPI specifications. + * + * Only spec-specific data is generated. The reusable Express router runtime and + * the shared type machinery are hand-written in + * core/test-token-v1/src/registry/http/openapi-router.ts, which the generated + * file imports. Implementers supply an `operationId -> handler` map that is + * type-checked against the specs. + */ + +import * as fs from 'fs' +import * as path from 'path' +import * as yaml from 'js-yaml' +import { getRepoRoot, SUPPORTED_VERSIONS, success, info } from './utils.js' +import type { Network } from './utils.js' + +const HTTP_METHODS = ['get', 'post', 'put', 'delete', 'patch'] as const +type HttpMethod = (typeof HTTP_METHODS)[number] + +interface Route { + operationId: string + method: HttpMethod + path: string +} + +/** + * The registry OpenAPI specs, paired with the token-standard export name of the + * `operations` type generated from each. + */ +const SPECS = [ + { file: 'token-metadata-v1.yaml', operations: 'metadataApiOperations' }, + { + file: 'transfer-instruction-v1.yaml', + operations: 'transferInstructionApiOperations', + }, + { + file: 'allocation-instruction-v1.yaml', + operations: 'allocationInstructionApiOperations', + }, + { file: 'allocation-v1.yaml', operations: 'allocationApiOperations' }, +] as const + +const OUTPUT = + 'core/test-token-v1/src/registry/generated-server/registry-server.ts' + +interface OpenApiDoc { + paths?: Record< + string, + Record | undefined + > +} + +/** Convert an OpenAPI path template (`/x/{id}`) to an Express path (`/x/:id`). */ +function toExpressPath(openApiPath: string): string { + return openApiPath.replace(/\{([^}]+)\}/g, ':$1') +} + +function extractRoutes(doc: OpenApiDoc): Route[] { + const routes: Route[] = [] + for (const [pathTemplate, item] of Object.entries(doc.paths ?? {})) { + for (const method of HTTP_METHODS) { + const operationId = item?.[method]?.operationId + if (operationId) + routes.push({ + operationId, + method, + path: toExpressPath(pathTemplate), + }) + } + } + return routes +} + +function renderStub(routes: Route[], version: string): string { + const year = new Date().getFullYear() + const specList = SPECS.map( + (s) => ` * api-specs/splice/${version}/${s.file}` + ).join('\n') + const imports = SPECS.map((s) => ` ${s.operations},`).join('\n') + const operationsUnion = SPECS.map((s) => s.operations).join(' &\n ') + const routeLines = routes + .map( + (r) => + ` { operationId: '${r.operationId}', method: '${r.method}', path: '${r.path}' },` + ) + .join('\n') + + return `// Copyright (c) 2025-${year} Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +/** + * AUTO-GENERATED by scripts/src/lib/generate-registry-server.ts — DO NOT EDIT. + * + * Spec-derived routing table and operation types for the Token Standard + * off-ledger registry APIs. The reusable Express router runtime is hand-written + * in ../http/openapi-router.ts; only the data below is generated from the specs: +${specList} + * + * Regenerate with: yarn script:generate:openapi + */ + +import type { +${imports} +} from '@canton-network/core-token-standard' +import type { OperationHandlers, Route } from '../http/openapi-router.js' + +/** Every registry operation across the specs, keyed by \`operationId\`. */ +export type RegistryOperations = ${operationsUnion} + +/** + * The complete registry handler map. Every \`operationId\` must be implemented, + * with params/body/response types checked against the specs. + */ +export type RegistryHandlers = OperationHandlers + +/** Routing table (method + path per \`operationId\`) extracted from the specs. */ +export const REGISTRY_ROUTES: readonly Route[] = [ +${routeLines} +] +` +} + +/** + * Generates the registry Express server stub from the local OpenAPI specs for + * the Splice version configured for `network`. Reads specs from + * `api-specs/splice//`; performs no network access. + */ +export async function generateRegistryServerStub( + network: Network = 'devnet' +): Promise { + const root = getRepoRoot() + const version = SUPPORTED_VERSIONS[network].splice.version + const specDir = path.join(root, 'api-specs', 'splice', version) + + const routes: Route[] = [] + for (const { file } of SPECS) { + const doc = yaml.load( + fs.readFileSync(path.join(specDir, file), 'utf8') + ) as OpenApiDoc + routes.push(...extractRoutes(doc)) + } + + const outPath = path.join(root, OUTPUT) + fs.mkdirSync(path.dirname(outPath), { recursive: true }) + fs.writeFileSync(outPath, renderStub(routes, version)) + + console.log( + success( + `Generated registry Express server stub (${routes.length} routes): ${info(OUTPUT)}` + ) + ) +} diff --git a/scripts/src/start-localnet.ts b/scripts/src/start-localnet.ts index 1f904b066..540e1ddcb 100644 --- a/scripts/src/start-localnet.ts +++ b/scripts/src/start-localnet.ts @@ -8,6 +8,7 @@ import { getRepoRoot, getNetworkArg, SUPPORTED_VERSIONS } from './lib/utils.js' const args = process.argv.slice(2) const command = args[0] +const multiSync = !args.includes('--no-multi-sync') const rootDir = getRepoRoot() const LOCALNET_DIR = path.join(rootDir, '.localnet/docker-compose/localnet') const GENERATED_COMPOSE_OVERRIDE = path.join( @@ -16,10 +17,6 @@ const GENERATED_COMPOSE_OVERRIDE = path.join( ) const CANTON_MAX_COMMANDS_IN_FLIGHT = 256 -const CUSTOM_APP_SYNCHRONIZER_SC = path.join( - rootDir, - 'canton/multi-sync/app-synchronizer.sc' -) function ensureComposeOverride() { fs.mkdirSync(path.dirname(GENERATED_COMPOSE_OVERRIDE), { recursive: true }) @@ -33,9 +30,6 @@ function ensureComposeOverride() { ` 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}`, - ' multi-sync-startup:', - ' volumes:', - ` - ${CUSTOM_APP_SYNCHRONIZER_SC}:/app/app-synchronizer.sc`, '', ].join('\n'), 'utf8' @@ -61,8 +55,7 @@ const composeBase = [ 'app-provider', '--profile', 'app-user', - '--profile', - 'multi-sync', + ...(multiSync ? ['--profile', 'multi-sync'] : []), ] const network = getNetworkArg() diff --git a/sdk/wallet-sdk/src/config.ts b/sdk/wallet-sdk/src/config.ts index e318d35e8..1b2292e7b 100644 --- a/sdk/wallet-sdk/src/config.ts +++ b/sdk/wallet-sdk/src/config.ts @@ -8,6 +8,8 @@ const LOCALNET_APP_VALIDATOR_URL = new URL( const LOCALNET_SCAN_API_URL = new URL('http://scan.localhost:4000/api/scan') const LOCALNET_APP_USER_LEDGER_URL = new URL('http://localhost:2975') +const LOCALNET_APP_PROVIDER_LEDGER_URL = new URL('http://localhost:3975') +const LOCALNET_SV_LEDGER_URL = new URL('http://localhost:4975') const LOCALNET_TOKEN_STANDARD_URL = new URL('http://localhost:5003') @@ -22,6 +24,8 @@ export const localNetStaticConfig = { LOCALNET_SCAN_API_URL, LOCALNET_REGISTRY_API_URL, LOCALNET_APP_USER_LEDGER_URL, + LOCALNET_APP_PROVIDER_LEDGER_URL, + LOCALNET_SV_LEDGER_URL, LOCALNET_TOKEN_STANDARD_URL, LOCALNET_USER_ID, } diff --git a/sdk/wallet-sdk/src/wallet/namespace/ledger/dar/index.ts b/sdk/wallet-sdk/src/wallet/namespace/ledger/dar/index.ts index c35d271ab..8a8447210 100644 --- a/sdk/wallet-sdk/src/wallet/namespace/ledger/dar/index.ts +++ b/sdk/wallet-sdk/src/wallet/namespace/ledger/dar/index.ts @@ -54,4 +54,49 @@ export class DarNamespace { result.packageIds.includes(packageId) ) } + + /** + * Vets a DAR package on the specified synchronizer. + * + * Tolerates the case where a package with the same name+version is already + * vetted on the participant. + * @param darBytes - Raw DAR file bytes. + * @param synchronizerId - The synchronizer on which the package should be + * vetted. Defaults to the SDK's configured synchronizer. + */ + async uploadAndVet( + darBytes: Uint8Array | Buffer, + synchronizerId?: string + ): Promise { + try { + await this.sdkContext.ledgerProvider.request({ + method: 'ledgerApi', + params: { + resource: '/v2/packages', + requestMethod: 'post', + query: { + synchronizerId: + synchronizerId ?? + this.sdkContext.defaultSynchronizerId, + vetAllPackages: true, + }, + body: darBytes as never, + headers: { 'Content-Type': 'application/octet-stream' }, + }, + }) + } catch (e) { + const code = (e as { code?: string })?.code + const message = `${(e as { cause?: unknown })?.cause ?? (e as Error)?.message ?? e}` + if ( + code === 'KNOWN_PACKAGE_VERSION' || + message.includes('same name and version') + ) { + this.sdkContext.logger.warn( + 'A package with the same name+version is already vetted; reusing the existing package.' + ) + return + } + throw e + } + } } diff --git a/sdk/wallet-sdk/src/wallet/namespace/ledger/internal/namespace.ts b/sdk/wallet-sdk/src/wallet/namespace/ledger/internal/namespace.ts index 813c38408..bf9936cb0 100644 --- a/sdk/wallet-sdk/src/wallet/namespace/ledger/internal/namespace.ts +++ b/sdk/wallet-sdk/src/wallet/namespace/ledger/internal/namespace.ts @@ -4,11 +4,139 @@ import { SDKContext } from '../../../sdk.js' import { v4 } from 'uuid' import { Ops } from '@canton-network/core-provider-ledger' -import { InternalOperationParams } from './types.js' +import { InternalOperationParams, ReassignParams } from './types.js' export class InternalLedgerNamespace { constructor(private readonly ctx: SDKContext) {} + /** + * Reassigns a contract from one synchronizer to another. + * Performs the two-phase Canton reassignment (Unassign → Assign) via + * `/v2/commands/submit-and-wait-for-reassignment`. + * + * TODO #2097 + */ + async reassign(params: ReassignParams): Promise { + const { submitter, contractId, source, target } = params + + // Phase 1: Unassign + let unassignResponse: Awaited< + ReturnType< + typeof this.ctx.ledgerProvider.request + > + > + try { + unassignResponse = + await this.ctx.ledgerProvider.request( + { + method: 'ledgerApi', + params: { + resource: + '/v2/commands/submit-and-wait-for-reassignment', + requestMethod: 'post', + body: { + reassignmentCommands: { + commandId: v4(), + submitter, + commands: [ + { + command: { + UnassignCommand: { + value: { + contractId, + source, + target, + }, + }, + }, + }, + ], + }, + eventFormat: { + filtersByParty: { [submitter]: {} }, + verbose: false, + }, + }, + }, + } + ) + } catch (e: unknown) { + if ( + typeof e === 'object' && + e !== null && + 'code' in e && + (e as { code: string }).code === 'SUBMITTER_ALWAYS_STAKEHOLDER' + ) { + this.ctx.error.throw({ + message: + `Cannot reassign contract ${contractId} from ${source} to ${target}: ` + + `submitter "${submitter}" is not a stakeholder. ` + + `Only a stakeholder of the contract may initiate a reassignment.`, + type: 'CantonError', + originalError: e, + }) + } + + // TODO #2103 implement proper compensation algorigthms for reassign + throw e + } + + const events = unassignResponse.reassignment?.events ?? [] + const unassignedEvent = events.find((e) => 'JsUnassignedEvent' in e) + if (!unassignedEvent || !('JsUnassignedEvent' in unassignedEvent)) { + this.ctx.error.throw({ + message: `No unassigned event returned for contract ${contractId} reassignment`, + type: 'Unexpected', + }) + } + const reassignmentId = + unassignedEvent.JsUnassignedEvent.value.reassignmentId + + // Phase 2: Assign + try { + await this.ctx.ledgerProvider.request( + { + method: 'ledgerApi', + params: { + resource: + '/v2/commands/submit-and-wait-for-reassignment', + requestMethod: 'post', + body: { + reassignmentCommands: { + commandId: v4(), + submitter, + commands: [ + { + command: { + AssignCommand: { + value: { + reassignmentId, + source, + target, + }, + }, + }, + }, + ], + }, + }, + }, + } + ) + } catch (e) { + throw Object.assign( + new Error( + `Phase 2 (Assign) failed for contract ${contractId} ` + + `(reassignmentId: ${reassignmentId}). ` + + `The contract is in-flight on source synchronizer "${source}" and must be ` + + `assigned to "${target}" using the reassignmentId above.`, + { cause: e } + ), + { reassignmentId, source, target, contractId } + ) + } + } + public async submit( args: InternalOperationParams ) { diff --git a/sdk/wallet-sdk/src/wallet/namespace/ledger/internal/types.ts b/sdk/wallet-sdk/src/wallet/namespace/ledger/internal/types.ts index 35e2ff42a..f968214d7 100644 --- a/sdk/wallet-sdk/src/wallet/namespace/ledger/internal/types.ts +++ b/sdk/wallet-sdk/src/wallet/namespace/ledger/internal/types.ts @@ -34,3 +34,10 @@ export type InternalOperationParams = UnusedParams & RequiredParamsFor > > + +export interface ReassignParams { + submitter: string + contractId: string + source: string + target: string +} diff --git a/sdk/wallet-sdk/src/wallet/namespace/ledger/namespace.ts b/sdk/wallet-sdk/src/wallet/namespace/ledger/namespace.ts index e8230c696..357bbde7a 100644 --- a/sdk/wallet-sdk/src/wallet/namespace/ledger/namespace.ts +++ b/sdk/wallet-sdk/src/wallet/namespace/ledger/namespace.ts @@ -23,6 +23,42 @@ export class LedgerNamespace { this.acsReader = new ACSReader(sdkContext.ledgerProvider) } + /** + * Returns connected synchronizers visible to the caller, optionally filtered + * by party, participant, or identity provider. + * + * Uses the Ledger API endpoint GET /v2/state/connected-synchronizers. + */ + public async connectedSynchronizers( + options?: Ops.GetV2StateConnectedSynchronizers['ledgerApi']['params']['query'] + ) { + this.sdkContext.logger.debug( + { options }, + 'Fetching connected synchronizers' + ) + + return this.sdkContext.ledgerProvider.request( + { + method: 'ledgerApi', + params: { + resource: '/v2/state/connected-synchronizers', + requestMethod: 'get', + query: { + ...(options?.party !== undefined && { + party: options.party, + }), + ...(options?.participantId !== undefined && { + participantId: options.participantId, + }), + ...(options?.identityProviderId !== undefined && { + identityProviderId: options.identityProviderId, + }), + }, + }, + } + ) + } + public async ledgerEnd() { return ( await this.sdkContext.ledgerProvider.request( @@ -212,5 +248,10 @@ export class LedgerNamespace { } }) }, + /** + * Queries the ACS and returns the first matching contract, throwing if none is found. + * @param options AcsOptions for querying the Active Contract Set (ACS). + * @throws {SDKError} When no matching contract is found. + */ } } diff --git a/sdk/wallet-sdk/src/wallet/namespace/ledger/types.ts b/sdk/wallet-sdk/src/wallet/namespace/ledger/types.ts index f1e5eb3ff..a168af2e3 100644 --- a/sdk/wallet-sdk/src/wallet/namespace/ledger/types.ts +++ b/sdk/wallet-sdk/src/wallet/namespace/ledger/types.ts @@ -17,7 +17,6 @@ export type ExecuteOptions = { submissionId?: string partyId: PartyId } - export type RawCommandMap = { ExerciseCommand: LedgerCommonSchemas['ExerciseCommand'] CreateCommand: LedgerCommonSchemas['CreateCommand'] diff --git a/sdk/wallet-sdk/src/wallet/namespace/party/external/prepared.ts b/sdk/wallet-sdk/src/wallet/namespace/party/external/prepared.ts index 8c922e95d..cf265d92c 100644 --- a/sdk/wallet-sdk/src/wallet/namespace/party/external/prepared.ts +++ b/sdk/wallet-sdk/src/wallet/namespace/party/external/prepared.ts @@ -4,6 +4,7 @@ import { GenerateTransactionResponse } from './types.js' import { PrivateKey, + PublicKey, signTransactionHash, } from '@canton-network/core-signing-lib' import { SDKContext } from '../../../sdk.js' @@ -18,7 +19,8 @@ export class PreparedPartyCreationService { constructor( private readonly ctx: SDKContext, private readonly partyCreationPromise: Promise, - private readonly createPartyOptions?: CreatePartyOptions + private readonly createPartyOptions?: CreatePartyOptions, + private readonly publicKey?: PublicKey ) {} /** @@ -40,7 +42,9 @@ export class PreparedPartyCreationService { return new SignedPartyCreationService( this.ctx, signedPartyPromise, - this.createPartyOptions + this.createPartyOptions, + this.publicKey, + privateKey ) } 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..178f8a164 100644 --- a/sdk/wallet-sdk/src/wallet/namespace/party/external/service.ts +++ b/sdk/wallet-sdk/src/wallet/namespace/party/external/service.ts @@ -75,36 +75,13 @@ export class ExternalPartyNamespace { logger: this.logger, }, partyCreationPromise, - options + options, + publicKey ) } - private async resolveSynchronizerId() { - const connectedSynchronizers = - await this.ctx.ledgerProvider.request( - { - method: 'ledgerApi', - params: { - resource: '/v2/state/connected-synchronizers', - requestMethod: 'get', - query: {}, - }, - } - ) - - 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}` - ) - } - - return synchronizerId + private resolveSynchronizerId() { + return Promise.resolve(this.ctx.defaultSynchronizerId) } /** diff --git a/sdk/wallet-sdk/src/wallet/namespace/party/external/signed.ts b/sdk/wallet-sdk/src/wallet/namespace/party/external/signed.ts index ab98168bc..aa75ea222 100644 --- a/sdk/wallet-sdk/src/wallet/namespace/party/external/signed.ts +++ b/sdk/wallet-sdk/src/wallet/namespace/party/external/signed.ts @@ -17,6 +17,11 @@ import { Ops, } from '@canton-network/core-provider-ledger' import { AuthTokenProvider } from '@canton-network/core-wallet-auth' +import { + PrivateKey, + PublicKey, + signTransactionHash, +} from '@canton-network/core-signing-lib' /** * Represents a signed party creation, ready to be allocated on the ledger. @@ -26,7 +31,9 @@ export class SignedPartyCreationService { constructor( private readonly ctx: SDKContext, private readonly signedPartyPromise: Promise, - private readonly createPartyOptions?: CreatePartyOptions + private readonly createPartyOptions?: CreatePartyOptions, + private readonly publicKey?: PublicKey, + private readonly privateKey?: PrivateKey ) {} /** @@ -50,7 +57,14 @@ export class SignedPartyCreationService { type: 'SDKOperationUnsupported', }) - if (await this.checkIfPartyExists(party.partyId)) { + // When a specific synchronizerId is provided, check whether the party + // is already registered on that synchronizer (not just on the participant). + if ( + await this.checkIfPartyExists( + party.partyId, + this.createPartyOptions?.synchronizerId + ) + ) { this.ctx.logger.info('Party already created.') return party } @@ -78,6 +92,15 @@ export class SignedPartyCreationService { }) } + const additionalSynchronizerIds = + this.createPartyOptions?.additionalSynchronizerIds ?? [] + for (const synchronizerId of additionalSynchronizerIds) { + await this.registerOnAdditionalSynchronizer( + party.partyId, + synchronizerId + ) + } + const grantUserRights = options?.grantUserRights ?? true if (grantUserRights) { @@ -95,6 +118,81 @@ export class SignedPartyCreationService { return party } + /** + * Registers the party on an additional synchronizer without granting user rights. + * Used when additionalSynchronizerIds is provided in CreatePartyOptions. + * @param partyId - The party ID returned from primary allocation + * @param synchronizerId - The secondary synchronizer to register the party on + */ + private async registerOnAdditionalSynchronizer( + partyId: PartyId, + synchronizerId: string + ) { + if (!this.publicKey || !this.privateKey) { + this.ctx.error.throw({ + message: + 'Cannot register party on additional synchronizer: publicKey and privateKey must be provided (offline signing is not supported for additionalSynchronizerIds)', + type: 'BadRequest', + }) + } + + if (await this.checkIfPartyExists(partyId, synchronizerId)) { + this.ctx.logger.info( + `Party already registered on synchronizer ${synchronizerId}.` + ) + return + } + + const topology = + await this.ctx.ledgerProvider.request( + { + method: 'ledgerApi', + params: { + resource: '/v2/parties/external/generate-topology', + body: { + synchronizer: synchronizerId, + partyHint: this.createPartyOptions?.partyHint ?? '', + publicKey: { + format: 'CRYPTO_KEY_FORMAT_RAW', + keyData: this.publicKey, + keySpec: 'SIGNING_KEY_SPEC_EC_CURVE25519', + }, + localParticipantObservationOnly: false, + confirmationThreshold: 1, + otherConfirmingParticipantUids: [], + observingParticipantUids: [], + }, + requestMethod: 'post', + }, + } + ) + + const signature = signTransactionHash( + topology.multiHash, + this.privateKey + ) + + await this.allocate( + this.ctx.ledgerProvider, + synchronizerId, + topology.topologyTransactions!.map((transaction) => ({ + transaction, + })), + [ + { + format: 'SIGNATURE_FORMAT_CONCAT', + signature, + signedBy: topology.publicKeyFingerprint, + signingAlgorithmSpec: 'SIGNING_ALGORITHM_SPEC_ED25519', + }, + ] + ) + + this.ctx.logger.info( + `Party registered on additional synchronizer ${synchronizerId}.` + ) + } + /** * Allocates the prepared party to additional participant nodes. * Ensures the party topology is synchronized across confirming and observing participants. @@ -144,7 +242,9 @@ export class SignedPartyCreationService { } = options const ledgerProvider = defaultLedgerProvider ?? this.ctx.ledgerProvider try { - const synchronizerId = this.ctx.defaultSynchronizerId + const synchronizerId = + this.createPartyOptions?.synchronizerId ?? + this.ctx.defaultSynchronizerId await this.allocate( ledgerProvider, @@ -185,8 +285,30 @@ export class SignedPartyCreationService { } } - private async checkIfPartyExists(partyId: PartyId): Promise { + private async checkIfPartyExists( + partyId: PartyId, + synchronizerId?: string + ): Promise { try { + if (synchronizerId) { + const response = + await this.ctx.ledgerProvider.request( + { + method: 'ledgerApi', + params: { + resource: '/v2/state/connected-synchronizers', + requestMethod: 'get', + query: { party: partyId }, + }, + } + ) + return ( + response.connectedSynchronizers?.some( + (s) => s.synchronizerId === synchronizerId + ) ?? false + ) + } + const party = await this.ctx.ledgerProvider.request({ method: 'ledgerApi', @@ -224,9 +346,10 @@ export class SignedPartyCreationService { } if (tries >= maxTries) { - throw new Error( - `timed out waiting for new party to appear after ${maxTries} tries` - ) + this.ctx.error.throw({ + message: `timed out waiting for new party to appear after ${maxTries} tries`, + type: 'Unexpected', + }) } const result = await this.grantRights(userId, { @@ -234,7 +357,10 @@ export class SignedPartyCreationService { }) if (!result.newlyGrantedRights) { - throw new Error('Failed to grant user rights') + this.ctx.error.throw({ + message: 'Failed to grant user rights', + type: 'Unexpected', + }) } return @@ -307,7 +433,10 @@ export class SignedPartyCreationService { }, }) if (!result.newlyGrantedRights) { - throw new Error('Failed to grant user rights') + this.ctx.error.throw({ + message: 'Failed to grant user rights', + type: 'Unexpected', + }) } return result @@ -320,9 +449,11 @@ export class SignedPartyCreationService { multiHashSignatures: MultiHashSignatures ): Promise { if (!onboardingTransactions || !multiHashSignatures) { - throw new Error( - 'onboardingTransactions and multiHashSignatures must be provided for party allocation' - ) + this.ctx.error.throw({ + message: + 'onboardingTransactions and multiHashSignatures must be provided for party allocation', + type: 'BadRequest', + }) } const resp = await ledgerProvider.request({ diff --git a/sdk/wallet-sdk/src/wallet/namespace/party/external/types.ts b/sdk/wallet-sdk/src/wallet/namespace/party/external/types.ts index 54f11b172..36391ae6b 100644 --- a/sdk/wallet-sdk/src/wallet/namespace/party/external/types.ts +++ b/sdk/wallet-sdk/src/wallet/namespace/party/external/types.ts @@ -9,6 +9,7 @@ export type CreatePartyOptions = Partial<{ partyHint: string confirmingThreshold: number synchronizerId: string + additionalSynchronizerIds: string[] confirmingParticipantEndpoints: ParticipantEndpointConfig[] observingParticipantEndpoints: ParticipantEndpointConfig[] localParticipantObservationOnly: boolean diff --git a/sdk/wallet-sdk/src/wallet/namespace/party/party.test.ts b/sdk/wallet-sdk/src/wallet/namespace/party/party.test.ts index 4d51ab748..6449466a8 100644 --- a/sdk/wallet-sdk/src/wallet/namespace/party/party.test.ts +++ b/sdk/wallet-sdk/src/wallet/namespace/party/party.test.ts @@ -372,15 +372,6 @@ describe('Party namespace', () => { }) }) - // Mock get connected synchronizers - ledgerProvider.request.mockResolvedValueOnce({ - connectedSynchronizers: [ - { - synchronizerId: 'syncId', - }, - ], - }) - party.external.create('publicKey', { observingParticipantEndpoints, confirmingParticipantEndpoints, @@ -400,14 +391,6 @@ describe('Party namespace', () => { } ) }) - expect(ledgerProvider.request).toHaveBeenNthCalledWith(6, { - method: 'ledgerApi', - params: { - resource: '/v2/state/connected-synchronizers', - requestMethod: 'get', - query: {}, - }, - }) }) }) diff --git a/sdk/wallet-sdk/src/wallet/namespace/token/allocation/allocation.test.ts b/sdk/wallet-sdk/src/wallet/namespace/token/allocation/allocation.test.ts index 4f42d85a8..744cbe3cc 100644 --- a/sdk/wallet-sdk/src/wallet/namespace/token/allocation/allocation.test.ts +++ b/sdk/wallet-sdk/src/wallet/namespace/token/allocation/allocation.test.ts @@ -10,7 +10,10 @@ import { AllocationContextParams, AllocationInstructionCreateParams, } from './types' -import { ALLOCATION_REQUEST_INTERFACE_ID } from '@canton-network/core-token-standard' +import { + ALLOCATION_INTERFACE_ID, + ALLOCATION_REQUEST_INTERFACE_ID, +} from '@canton-network/core-token-standard' /* eslint-disable @typescript-eslint/no-explicit-any */ const { ctx, mockLogger } = mock @@ -241,6 +244,71 @@ describe('allocation namespace namespace', () => { }) }) + describe('scanning allocations by transfer leg', () => { + const allocationForLeg = (transferLegId: string, contractId: string) => + ({ + contractId, + interfaceViewValue: { allocation: { transferLegId } }, + activeContract: 'contract', + fetchedAtOffset: 10, + }) as any + + it('should return each requested leg once all are visible', async () => { + const spy = mockTokenStandard.listContractsByInterface + spy.mockResolvedValue([ + allocationForLeg('leg-0', 'amulet-cid'), + allocationForLeg('leg-1', 'test-token-cid'), + ]) + + const result = await allocation.scan({ + partyId: 'venue::abc', + transferLegIds: ['leg-0', 'leg-1'], + }) + + expect(spy).toHaveBeenCalledExactlyOnceWith( + ALLOCATION_INTERFACE_ID, + 'venue::abc' + ) + expect(result['leg-0'].contractId).toBe('amulet-cid') + expect(result['leg-1'].contractId).toBe('test-token-cid') + }) + + it('should poll until a lagging allocation propagates', async () => { + const spy = mockTokenStandard.listContractsByInterface + spy.mockResolvedValueOnce([ + allocationForLeg('leg-0', 'amulet-cid'), + ]).mockResolvedValueOnce([ + allocationForLeg('leg-0', 'amulet-cid'), + allocationForLeg('leg-1', 'test-token-cid'), + ]) + + const result = await allocation.scan({ + partyId: 'venue::abc', + transferLegIds: ['leg-0', 'leg-1'], + retryIntervalMs: 0, + }) + + expect(spy).toHaveBeenCalledTimes(2) + expect(result['leg-1'].contractId).toBe('test-token-cid') + }) + + it('should throw when a leg never propagates within maxAttempts', async () => { + const spy = mockTokenStandard.listContractsByInterface + spy.mockResolvedValue([allocationForLeg('leg-0', 'amulet-cid')]) + + await expect( + allocation.scan({ + partyId: 'venue::abc', + transferLegIds: ['leg-0', 'leg-1'], + maxAttempts: 2, + retryIntervalMs: 0, + }) + ).rejects.toThrow(/did not propagate/) + + expect(spy).toHaveBeenCalledTimes(2) + }) + }) + describe('fetching allocation context', () => { const resp = { choiceContextData: { diff --git a/sdk/wallet-sdk/src/wallet/namespace/token/allocation/service.ts b/sdk/wallet-sdk/src/wallet/namespace/token/allocation/service.ts index 322c52d65..4d43a4035 100644 --- a/sdk/wallet-sdk/src/wallet/namespace/token/allocation/service.ts +++ b/sdk/wallet-sdk/src/wallet/namespace/token/allocation/service.ts @@ -16,6 +16,7 @@ import { AllocationParams, AllocationInstructionCreateParams, AllocationContextParams, + AllocationScanParams, } from './types.js' import { TokenNamespaceConfig } from '../../../sdk.js' import { ParsedURL } from '../../utils/url.js' @@ -33,6 +34,66 @@ export class AllocationNamespace { ) } + /** + * Polls {@link pending} until an allocation is visible for every requested + * `transferLegId`, then returns each leg's allocation contract. Matching is + * purely on `transferLegId`, so it is token-agnostic — the same call scans + * for Amulet, TestToken, or any mix of allocations across a settlement. + + * @param params.partyId Party whose visible allocations are polled. + * @param params.transferLegIds Transfer leg ids to wait for; every one must + * become visible for the call to resolve. + * @param params.maxAttempts Maximum polling attempts before failing. Default 30. + * @param params.retryIntervalMs Delay between attempts in ms. Default 1000. + * @returns A map from `transferLegId` to its `PrettyContract`. + * @throws If any requested leg is still missing once attempts are exhausted. + */ + async scan( + params: AllocationScanParams + ): Promise>> { + const { + partyId, + transferLegIds, + maxAttempts = 30, + retryIntervalMs = 1000, + } = params + const { logger } = this.sdkContext.commonCtx + + for (let attempt = 1; attempt <= maxAttempts; attempt++) { + const allocations = await this.pending(partyId) + + const found: Record> = {} + for (const legId of transferLegIds) { + const match = allocations.find( + (a) => + a.interfaceViewValue.allocation.transferLegId === legId + ) + if (match) found[legId] = match + } + + const missing = transferLegIds.filter((legId) => !(legId in found)) + if (missing.length === 0) { + logger.info( + { partyId, transferLegIds }, + 'All requested allocations are visible' + ) + return found + } + + logger.info( + { partyId, attempt, maxAttempts, missing }, + 'Waiting for allocations to propagate' + ) + await new Promise((resolve) => setTimeout(resolve, retryIntervalMs)) + } + + throw new Error( + `Allocations for transfer legs [${transferLegIds.join( + ', ' + )}] did not propagate to party ${partyId} in time` + ) + } + /** * Executes ExecuteTransferAllocation choice on an allocation instruction to execute the allocation * @param allocationCid Allocation contract ID @@ -51,7 +112,7 @@ export class AllocationNamespace { return [{ ExerciseCommand: command }, disclosedConctracts] } - async withdraw(params: AllocationParams) { + async withdraw(params: AllocationParams): Promise { const [command, disclosedConctracts] = await this.sdkContext.tokenStandardService.allocation.createWithdrawAllocation( params.allocationCid, @@ -62,7 +123,7 @@ export class AllocationNamespace { return [{ ExerciseCommand: command }, disclosedConctracts] } - async cancel(params: AllocationParams) { + async cancel(params: AllocationParams): Promise { const [command, disclosedConctracts] = await this.sdkContext.tokenStandardService.allocation.createCancelAllocation( params.allocationCid, diff --git a/sdk/wallet-sdk/src/wallet/namespace/token/allocation/types.ts b/sdk/wallet-sdk/src/wallet/namespace/token/allocation/types.ts index 5d17779a7..337b6e69b 100644 --- a/sdk/wallet-sdk/src/wallet/namespace/token/allocation/types.ts +++ b/sdk/wallet-sdk/src/wallet/namespace/token/allocation/types.ts @@ -1,6 +1,7 @@ // Copyright (c) 2025-2026 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. // SPDX-License-Identifier: Apache-2.0 +import { PartyId } from '@canton-network/core-types' import { AssetBody } from '../../asset/index.js' import { allocationInstructionRegistryTypes, @@ -28,3 +29,14 @@ export type AllocationContextParams = { allocationCid: string registryUrl: URL | string } + +export type AllocationScanParams = { + /** Party whose visible allocations are polled. */ + partyId: PartyId + /** Transfer leg ids to wait for; each one must become visible. */ + transferLegIds: string[] + /** Maximum number of polling attempts before giving up. Default 30. */ + maxAttempts?: number + /** Delay between polling attempts in milliseconds. Default 1000. */ + retryIntervalMs?: number +} diff --git a/sdk/wallet-sdk/src/wallet/namespace/token/namespace.ts b/sdk/wallet-sdk/src/wallet/namespace/token/namespace.ts index 5cdd76458..b6cf605a6 100644 --- a/sdk/wallet-sdk/src/wallet/namespace/token/namespace.ts +++ b/sdk/wallet-sdk/src/wallet/namespace/token/namespace.ts @@ -8,7 +8,8 @@ import { TokenStandardService } from '@canton-network/core-token-standard-servic import { PartyId } from '@canton-network/core-types' import { PrettyTransactions } from '@canton-network/core-tx-parser' import type { SDKContext } from '../../init/types/context.js' -import { ParsedURL } from '../utils/url.js' +import { ParsedURL, parseAssets } from '../utils/url.js' +import { findAsset, type AssetBody } from '../asset/index.js' export type TokenNamespaceConfig = { tokenStandardService: TokenStandardService @@ -54,4 +55,36 @@ export class TokenNamespace { params.partyId ) } + + /** + * Lists the token-standard assets served by the registries this namespace + * was configured with (the `registries` passed to `token` in `SDK.create`). + * + * Unlike the `asset` namespace — which resolves its list once when the SDK + * is created — this queries the configured registries on each call, so it + * also works with registries that only come online after the SDK exists. + */ + async assets(): Promise { + return parseAssets( + this.tokenContext.commonCtx, + await this.tokenContext.tokenStandardService.registriesToAssets( + this.tokenContext.registryUrls.map((url) => url.href) + ) + ) + } + + /** + * Resolves a single asset — including the `registryUrl` that serves it — by + * instrument id from the configured registries. Provide `registryUrl` to + * disambiguate when the same instrument id is served by more than one + * registry. + */ + async find(instrumentId: string, registryUrl?: URL): Promise { + return findAsset( + await this.assets(), + instrumentId, + this.tokenContext.commonCtx.error, + registryUrl + ) + } } diff --git a/sdk/wallet-sdk/src/wallet/sdk.ts b/sdk/wallet-sdk/src/wallet/sdk.ts index b4f0c858f..d0b726934 100644 --- a/sdk/wallet-sdk/src/wallet/sdk.ts +++ b/sdk/wallet-sdk/src/wallet/sdk.ts @@ -57,6 +57,7 @@ export type { } from './namespace/ledger/index.js' export * from './namespace/transactions/prepared.js' export * from './namespace/transactions/signed.js' +export { ScanProxyClient } from '@canton-network/core-splice-client' export class SDK { static async create< @@ -123,7 +124,8 @@ export class SDK { const defaultSynchronizerId = await getDefaultSynchronizerId( ledgerProvider, - logger + logger, + error ) const ctx: SDKContext = { @@ -166,7 +168,8 @@ export class SDK { async function getDefaultSynchronizerId( provider: AbstractLedgerProvider, - logger: SDKLogger + logger: SDKLogger, + error: SDKErrorHandler ) { const connectedSynchronizers = await provider.request({ @@ -178,15 +181,25 @@ async function getDefaultSynchronizerId( }, }) - if (!connectedSynchronizers.connectedSynchronizers?.[0]) { - throw new Error('No connected synchronizers found') + const synchronizers = connectedSynchronizers.connectedSynchronizers + if (!synchronizers?.[0]) { + error.throw({ + message: 'No connected synchronizers found', + type: 'NotFound', + }) } - - const defaultSynchronizerId = - connectedSynchronizers.connectedSynchronizers[0].synchronizerId - if (connectedSynchronizers.connectedSynchronizers.length > 1) { + // TODO #1740 this logic is a temporary workaround to make sdk work with multiple synchronizers and ensure the + // the choice of default synchronizer is not random. In subsequent PR we remove this logic from sdk code (and fix existing tests) + const defaultEntry = + synchronizers.find((s) => s.synchronizerAlias === 'global') ?? + synchronizers.find((s) => s.synchronizerAlias === 'global-domain') ?? + synchronizers.find((s) => s.synchronizerAlias !== 'app-synchronizer') ?? + synchronizers[0] + + const defaultSynchronizerId = defaultEntry.synchronizerId + if (synchronizers.length > 1) { logger.warn( - `Found ${connectedSynchronizers.connectedSynchronizers.length} synchronizers, defaulting to ${defaultSynchronizerId}` + `Found ${synchronizers.length} synchronizers, defaulting to ${defaultSynchronizerId}` ) } diff --git a/yarn.lock b/yarn.lock index 69a2a735d..ab4c35044 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1726,6 +1726,30 @@ __metadata: languageName: unknown linkType: soft +"@canton-network/core-test-token@workspace:^, @canton-network/core-test-token@workspace:core/test-token-v1": + version: 0.0.0-use.local + resolution: "@canton-network/core-test-token@workspace:core/test-token-v1" + dependencies: + "@canton-network/core-ledger-client": "workspace:^" + "@canton-network/core-token-standard": "workspace:^" + "@canton-network/core-wallet-auth": "workspace:^" + "@daml/types": "npm:^3.5.0" + "@mojotech/json-type-validation": "npm:^3.1.0" + "@rollup/plugin-alias": "npm:^5.0.0" + "@rollup/plugin-commonjs": "npm:^29.0.0" + "@rollup/plugin-json": "npm:^6.1.0" + "@rollup/plugin-node-resolve": "npm:^16.0.3" + "@rollup/plugin-typescript": "npm:^12.3.0" + "@types/express": "npm:^5.0.6" + express: "npm:^5.2.1" + pino: "npm:^10.3.1" + rollup: "npm:^4.59.0" + rollup-plugin-dts: "npm:^6.3.0" + tslib: "npm:^2.8.1" + typescript: "npm:^5.9.3" + languageName: unknown + linkType: soft + "@canton-network/core-token-standard-service@workspace:^, @canton-network/core-token-standard-service@workspace:core/token-standard-service": version: 0.0.0-use.local resolution: "@canton-network/core-token-standard-service@workspace:core/token-standard-service" @@ -2576,7 +2600,7 @@ __metadata: languageName: node linkType: hard -"@daml/types@npm:^3.5.2": +"@daml/types@npm:^3.5.0, @daml/types@npm:^3.5.2": version: 3.5.2 resolution: "@daml/types@npm:3.5.2" dependencies: @@ -6344,7 +6368,7 @@ __metadata: languageName: node linkType: hard -"@rollup/plugin-alias@npm:^5.1.1": +"@rollup/plugin-alias@npm:^5.0.0, @rollup/plugin-alias@npm:^5.1.1": version: 5.1.1 resolution: "@rollup/plugin-alias@npm:5.1.1" peerDependencies: @@ -6356,7 +6380,7 @@ __metadata: languageName: node linkType: hard -"@rollup/plugin-commonjs@npm:^29.0.3": +"@rollup/plugin-commonjs@npm:^29.0.0, @rollup/plugin-commonjs@npm:^29.0.3": version: 29.0.3 resolution: "@rollup/plugin-commonjs@npm:29.0.3" dependencies: @@ -11722,10 +11746,13 @@ __metadata: version: 0.0.0-use.local resolution: "docs-wallet-integration-guide-examples@workspace:docs/wallet-integration-guide/examples" dependencies: + "@canton-network/core-amulet-service": "workspace:^" "@canton-network/core-ledger-client": "workspace:^" "@canton-network/core-ledger-client-types": "workspace:^" "@canton-network/core-ledger-proto": "workspace:^" "@canton-network/core-signing-lib": "workspace:^" + "@canton-network/core-test-token": "workspace:^" + "@canton-network/core-token-standard": "workspace:^" "@canton-network/core-tx-parser": "workspace:^" "@canton-network/core-types": "workspace:^" "@canton-network/core-wallet-auth": "workspace:^" @@ -17964,7 +17991,7 @@ __metadata: languageName: node linkType: hard -"rollup-plugin-dts@npm:^6.4.1": +"rollup-plugin-dts@npm:^6.3.0, rollup-plugin-dts@npm:^6.4.1": version: 6.4.1 resolution: "rollup-plugin-dts@npm:6.4.1" dependencies: @@ -18073,7 +18100,7 @@ __metadata: languageName: node linkType: hard -"rollup@npm:^4.62.2": +"rollup@npm:^4.59.0, rollup@npm:^4.62.2": version: 4.62.2 resolution: "rollup@npm:4.62.2" dependencies: