From 9c8bf74aa2da079cbbdf84d87d2a0c3f059bdf46 Mon Sep 17 00:00:00 2001 From: jarekr-da Date: Thu, 9 Jul 2026 12:08:38 +0200 Subject: [PATCH 01/23] feat(wallet-sdk): add multi-synchronizer DvP example (run-17) Rebase the wiktor/multisync-example branch onto origin/main. The branch's tangled history (79 commits including 12 main-merges) is collapsed into this net change set applied on top of current main; the original commits remain on origin/wiktor/multisync-example and in a local backup tag. Includes the multi-sync DvP example (run-17), the core-test-token-v1 registry package and splice-test-token-v1 DAR, and wallet-sdk multi-sync support. Non-trivial conflict resolutions: - ledger/dar namespace: origin/main independently introduced DarNamespace in dar/index.ts (with dar.test.ts). Folded the branch's extra vet() method into it and removed the now-redundant dar/client.ts duplicate. - examples/package.json: main's existing run-15/run-16 no-validator-url examples are preserved unchanged; the new multi-sync example is added as run-17 (scripts/17-multi-sync). Signed-off-by: jarekr-da --- .../setup_localnet/artifacts/action.yml | 6 +- .github/workflows/build.yml | 17 + .github/workflows/stress-tests.yml | 1 - canton/multi-sync/app-synchronizer.sc | 69 ---- core/ledger-client/src/ledger-client.ts | 21 +- core/test-token-v1/README.md | 19 + core/test-token-v1/package.json | 68 ++++ core/test-token-v1/rollup.config.js | 261 +++++++++++++ core/test-token-v1/src/index.ts | 39 ++ .../allocation-instruction/handlers.ts | 50 +++ .../registry/features/allocation/handlers.ts | 62 ++++ .../registry/features/metadata/handlers.ts | 57 +++ .../registry/features/transfer/handlers.ts | 98 +++++ .../generated-server/registry-server.ts | 95 +++++ .../src/registry/http/openapi-router.ts | 156 ++++++++ core/test-token-v1/src/registry/index.ts | 211 +++++++++++ core/test-token-v1/src/registry/ledger.ts | 150 ++++++++ core/test-token-v1/src/registry/types.ts | 51 +++ core/test-token-v1/src/setup/index.ts | 29 ++ core/test-token-v1/tsconfig.json | 60 +++ .../src/token-standard-client.ts | 7 + damljs/splice-test-token-v1/.gitignore | 5 + damljs/splice-test-token-v1/daml.yaml | 23 ++ .../Splice/Testing/Tokens/Internal/Utils.daml | 77 ++++ .../Splice/Testing/Tokens/TestTokenV1.daml | 306 ++++++++++++++++ .../examples/package.json | 4 + .../examples/scripts/17-multi-sync/README.md | 94 +++++ .../scripts/17-multi-sync/_amulet_ops.ts | 93 +++++ .../scripts/17-multi-sync/_constants.ts | 18 + .../examples/scripts/17-multi-sync/_setup.ts | 260 +++++++++++++ .../17-multi-sync/_token_allocation.ts | 105 ++++++ .../scripts/17-multi-sync/_token_setup.ts | 136 +++++++ .../scripts/17-multi-sync/_token_transfer.ts | 150 ++++++++ .../scripts/17-multi-sync/_trade_propose.ts | 189 ++++++++++ .../scripts/17-multi-sync/_trade_settle.ts | 346 ++++++++++++++++++ .../examples/scripts/17-multi-sync/index.ts | 196 ++++++++++ .../examples/scripts/utils/acs-logger.ts | 154 ++++++++ .../examples/scripts/utils/index.ts | 24 ++ scripts/src/generate-featured-dars.ts | 29 +- scripts/src/generate-openapi-clients.ts | 6 +- scripts/src/lib/daml-codegen.ts | 31 +- scripts/src/lib/generate-registry-server.ts | 157 ++++++++ scripts/src/start-localnet.ts | 11 +- sdk/wallet-sdk/src/config.ts | 4 + .../src/wallet/namespace/ledger/dar/index.ts | 45 +++ .../namespace/ledger/internal/namespace.ts | 131 ++++++- .../wallet/namespace/ledger/internal/types.ts | 8 + .../src/wallet/namespace/ledger/namespace.ts | 81 +++- .../src/wallet/namespace/ledger/types.ts | 6 + .../namespace/party/external/prepared.ts | 8 +- .../namespace/party/external/service.ts | 31 +- .../wallet/namespace/party/external/signed.ts | 155 +++++++- .../wallet/namespace/party/external/types.ts | 1 + .../src/wallet/namespace/party/party.test.ts | 17 - .../namespace/token/allocation/service.ts | 4 +- sdk/wallet-sdk/src/wallet/sdk.ts | 31 +- yarn.lock | 37 +- 57 files changed, 4304 insertions(+), 196 deletions(-) delete mode 100644 canton/multi-sync/app-synchronizer.sc create mode 100644 core/test-token-v1/README.md create mode 100644 core/test-token-v1/package.json create mode 100644 core/test-token-v1/rollup.config.js create mode 100644 core/test-token-v1/src/index.ts create mode 100644 core/test-token-v1/src/registry/features/allocation-instruction/handlers.ts create mode 100644 core/test-token-v1/src/registry/features/allocation/handlers.ts create mode 100644 core/test-token-v1/src/registry/features/metadata/handlers.ts create mode 100644 core/test-token-v1/src/registry/features/transfer/handlers.ts create mode 100644 core/test-token-v1/src/registry/generated-server/registry-server.ts create mode 100644 core/test-token-v1/src/registry/http/openapi-router.ts create mode 100644 core/test-token-v1/src/registry/index.ts create mode 100644 core/test-token-v1/src/registry/ledger.ts create mode 100644 core/test-token-v1/src/registry/types.ts create mode 100644 core/test-token-v1/src/setup/index.ts create mode 100644 core/test-token-v1/tsconfig.json create mode 100644 damljs/splice-test-token-v1/.gitignore create mode 100644 damljs/splice-test-token-v1/daml.yaml create mode 100644 damljs/splice-test-token-v1/daml/Splice/Testing/Tokens/Internal/Utils.daml create mode 100644 damljs/splice-test-token-v1/daml/Splice/Testing/Tokens/TestTokenV1.daml create mode 100644 docs/wallet-integration-guide/examples/scripts/17-multi-sync/README.md create mode 100644 docs/wallet-integration-guide/examples/scripts/17-multi-sync/_amulet_ops.ts create mode 100644 docs/wallet-integration-guide/examples/scripts/17-multi-sync/_constants.ts create mode 100644 docs/wallet-integration-guide/examples/scripts/17-multi-sync/_setup.ts create mode 100644 docs/wallet-integration-guide/examples/scripts/17-multi-sync/_token_allocation.ts create mode 100644 docs/wallet-integration-guide/examples/scripts/17-multi-sync/_token_setup.ts create mode 100644 docs/wallet-integration-guide/examples/scripts/17-multi-sync/_token_transfer.ts create mode 100644 docs/wallet-integration-guide/examples/scripts/17-multi-sync/_trade_propose.ts create mode 100644 docs/wallet-integration-guide/examples/scripts/17-multi-sync/_trade_settle.ts create mode 100644 docs/wallet-integration-guide/examples/scripts/17-multi-sync/index.ts create mode 100644 docs/wallet-integration-guide/examples/scripts/utils/acs-logger.ts create mode 100644 scripts/src/lib/generate-registry-server.ts 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..ec7bf570c --- /dev/null +++ b/core/test-token-v1/src/registry/features/transfer/handlers.ts @@ -0,0 +1,98 @@ +// 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..ce470221e --- /dev/null +++ b/damljs/splice-test-token-v1/.gitignore @@ -0,0 +1,5 @@ +* +!.gitignore +!daml.yaml +!daml/ +!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..e875e3cc9 --- /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 is deployed to a private / local `app-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** — app-user, hosted on the **app-user** participant. Holds Amulet, buys `TestToken`. +- **Bob** — app-provider, 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 APP: TestTokenV1, trading-app — 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..2b556220e --- /dev/null +++ b/docs/wallet-integration-guide/examples/scripts/17-multi-sync/_amulet_ops.ts @@ -0,0 +1,93 @@ +// 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, + aliceTokenNamespace, + alice, + globalSynchronizerId, + amuletAdmin, + } = setup + + const pendingRequests = + await aliceTokenNamespace.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 aliceTokenNamespace.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 aliceTokenNamespace.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/_setup.ts b/docs/wallet-integration-guide/examples/scripts/17-multi-sync/_setup.ts new file mode 100644 index 000000000..a26948f3f --- /dev/null +++ b/docs/wallet-integration-guide/examples/scripts/17-multi-sync/_setup.ts @@ -0,0 +1,260 @@ +// 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, + type TokenNamespace, +} 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 { SynchronizerMap } 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 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'> + svSdk: SDKInterface<'token'> + aliceTokenNamespace: TokenNamespace + bobTokenNamespace: TokenNamespace + tokenAdminTokenNamespace: TokenNamespace + alice: PartyInfo + bob: PartyInfo + tradingApp: PartyInfo + tokenAdmin: PartyInfo + globalSynchronizerId: string + appSynchronizerId: string + synchronizers: SynchronizerMap + amuletAdmin: string + testTokenRegistryUrl: URL +} + +/** + * Bootstraps a fresh multi-synchronizer environment: + * - Creates one SDK instance per party (alice, tradingApp 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) on global synchronizer + * while simultaneously registering alice, bob, tradingApp, and tokenAdmin 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, 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_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: SynchronizerMap = { + 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.vet(dar, sid) + ) + ) + ), + ...[testTokenV1Dar, tradingAppDar].map((dar) => + svSdk.ledger.dar.vet(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 [ + allocatedAlice, + allocatedBob, + allocatedTradingApp, + allocatedTokenAdmin, + ] = 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(), + ]) + + const alice: PartyInfo = { ...allocatedAlice, keyPair: aliceKey } + const bob: PartyInfo = { ...allocatedBob, keyPair: bobKey } + const tradingApp: PartyInfo = { + ...allocatedTradingApp, + keyPair: tradingAppKey, + } + const tokenAdmin: PartyInfo = { + ...allocatedTokenAdmin, + keyPair: tokenAdminKey, + } + + 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)` + ) + + const { admin: amuletAdmin } = await aliceSdk.asset.find('Amulet') + logger.info(`Amulet asset discovered — admin: ${amuletAdmin}`) + + return { + aliceSdk, + tradingAppSdk, + bobSdk, + tokenAdminSdk, + svSdk, + aliceTokenNamespace: aliceSdk.token, + bobTokenNamespace: bobSdk.token, + tokenAdminTokenNamespace: tokenAdminSdk.token, + alice, + bob, + tradingApp, + tokenAdmin, + globalSynchronizerId, + appSynchronizerId, + synchronizers, + amuletAdmin, + testTokenRegistryUrl: TEST_TOKEN_REGISTRY_URL, + } +} 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..2c380793b --- /dev/null +++ b/docs/wallet-integration-guide/examples/scripts/17-multi-sync/_token_allocation.ts @@ -0,0 +1,105 @@ +// 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, + bobTokenNamespace, + bob, + tokenAdmin, + appSynchronizerId, + testTokenRegistryUrl, + } = setup + + const pendingRequests = await bobTokenNamespace.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.acs.read({ + 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 bobTokenNamespace.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.acs.read({ + 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..063af1f3d --- /dev/null +++ b/docs/wallet-integration-guide/examples/scripts/17-multi-sync/_token_setup.ts @@ -0,0 +1,136 @@ +// 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, + bobTokenNamespace, + tokenAdminTokenNamespace, + bob, + tokenAdmin, + appSynchronizerId, + testTokenRegistryUrl, + } = setup + + 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.acs.read({ + 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 tokenAdminTokenNamespace.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.acs.read({ + 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 bobTokenNamespace.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..8c9063de6 --- /dev/null +++ b/docs/wallet-integration-guide/examples/scripts/17-multi-sync/_token_transfer.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 + +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 aliceSelfTransferToApp( + setup: MultiSyncSetup, + logger: Logger +): Promise { + const { + aliceSdk, + aliceTokenNamespace, + alice, + appSynchronizerId, + testTokenRegistryUrl, + } = setup + + // 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.acs.read({ + 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 self-transferring there (mirrors Bob's flow). + if (aliceToken.synchronizerId !== appSynchronizerId) { + await aliceSdk.ledger.internal.reassign({ + submitter: alice.partyId, + contractId: aliceToken.contractId, + source: aliceToken.synchronizerId, + target: appSynchronizerId, + skipIfAlreadyOn: true, + }) + } + + const [transferCommand, transferDisclosed] = + await aliceTokenNamespace.transfer.create({ + sender: alice.partyId, + recipient: alice.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 }) + + logger.info( + `Alice: ${TRADE_TOKEN_AMOUNT} TestToken self-transferred on app-synchronizer via registry transfer-factory` + ) +} + +export async function bobSelfTransferToApp( + setup: MultiSyncSetup, + logger: Logger +): Promise { + const { + bobSdk, + bobTokenNamespace, + bob, + appSynchronizerId, + testTokenRegistryUrl, + } = setup + + const bobTokens = await bobSdk.ledger.acs.read({ + templateIds: [TestTokenV1.Token.templateId], + parties: [bob.partyId], + filterByParty: true, + }) + + if (bobTokens.length === 0) { + logger.info('Bob: no TestToken holdings to self-transfer') + return + } + + for (const token of bobTokens) { + if (token.synchronizerId !== appSynchronizerId) { + await bobSdk.ledger.internal.reassign({ + submitter: bob.partyId, + contractId: token.contractId, + source: token.synchronizerId, + target: appSynchronizerId, + skipIfAlreadyOn: true, + }) + } + + 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 bobTokenNamespace.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..659f98a5f --- /dev/null +++ b/docs/wallet-integration-guide/examples/scripts/17-multi-sync/_trade_propose.ts @@ -0,0 +1,189 @@ +// 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' + +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 = async ( + sdk: SDKInterface<'token'>, + party: string, + predicate: (approvers: string[]) => boolean = () => true + ): Promise => { + const deadline = Date.now() + PROPOSAL_POLL_TIMEOUT_MS + for (;;) { + const proposals = await sdk.ledger.acs.read({ + templateIds: [OTC_TRADE_PROPOSAL_TEMPLATE_ID], + parties: [party], + filterByParty: true, + }) + const match = proposals.find((proposal) => + predicate( + (( + proposal as unknown as { + createArgument?: { approvers?: string[] } + } + ).createArgument?.approvers ?? []) as string[] + ) + ) + if (match) return match.contractId + if (Date.now() >= deadline) { + throw new Error( + `OTCTradeProposal not visible to ${party} within ${PROPOSAL_POLL_TIMEOUT_MS}ms` + ) + } + await new Promise((resolve) => + setTimeout(resolve, PROPOSAL_POLL_INTERVAL_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.acs.read({ + 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..6b47bf540 --- /dev/null +++ b/docs/wallet-integration-guide/examples/scripts/17-multi-sync/_trade_settle.ts @@ -0,0 +1,346 @@ +// 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 +} + +export interface ScannedAllocations { + amuletAllocationCid: string + testTokenAllocationCid: string +} + +/** + * Polls the TradingApp's ACS until both the Amulet (leg-0) and TestToken (leg-1) + * allocations have propagated to the TradingApp's settlement participant, then + * returns their contract IDs. + */ +export async function scanAllocations( + setup: MultiSyncSetup, + legIds: { legIdAlice: string; legIdBob: string }, + logger: Logger +): Promise { + const { tradingAppSdk, tradingApp } = setup + const { legIdAlice, legIdBob } = legIds + + const MAX_ATTEMPTS = 30 + const RETRY_INTERVAL_MS = 1000 + + for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) { + const allocations = await tradingAppSdk.token.allocation.pending( + tradingApp.partyId + ) + const amuletAllocation = allocations.find( + (a) => a.interfaceViewValue.allocation.transferLegId === legIdAlice + ) + const testTokenAllocation = allocations.find( + (a) => a.interfaceViewValue.allocation.transferLegId === legIdBob + ) + + if (amuletAllocation && testTokenAllocation) { + logger.info( + 'TradingApp: both Amulet and TestToken allocations are visible — ready to settle' + ) + return { + amuletAllocationCid: amuletAllocation.contractId, + testTokenAllocationCid: testTokenAllocation.contractId, + } + } + + logger.info( + `TradingApp: waiting for allocations to propagate (attempt ${attempt}/${MAX_ATTEMPTS}; amulet=${Boolean( + amuletAllocation + )}, testToken=${Boolean(testTokenAllocation)})` + ) + await new Promise((resolve) => setTimeout(resolve, RETRY_INTERVAL_MS)) + } + + throw new Error( + 'Allocations did not propagate to the TradingApp participant in time' + ) +} + +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, + testTokenRegistryUrl, + } = setup + const { + otcTradeCid, + legIdAlice, + legIdBob, + amuletAllocationCid, + testTokenAllocationCid, + } = params + + const tokenNamespace = tradingAppSdk.token + // 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, + testTokenRegistryUrl, + } = setup + const { + otcTradeCid, + legIdAlice, + legIdBob, + amuletAllocationCid, + testTokenAllocationCid, + } = params + + const tokenNamespace = tradingAppSdk.token + 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..84809200a --- /dev/null +++ b/docs/wallet-integration-guide/examples/scripts/17-multi-sync/index.ts @@ -0,0 +1,196 @@ +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 { + aliceSelfTransferToApp, + bobSelfTransferToApp, +} from './_token_transfer.js' +import { createAndInitiateOtcTrade } from './_trade_propose.js' +import { scanAllocations, 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, + bobTokenNamespace, + alice, + bob, + tradingApp, + tokenAdmin, + 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 bobTokenNamespace.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 ── +await bobSdk.ledger.internal.reassign({ + submitter: bob.partyId, + contractId: testTokenAllocation.contractId, + source: synchronizers.appSynchronizerId, + target: synchronizers.globalSynchronizerId, + skipIfAlreadyOn: true, +}) +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 { amuletAllocationCid, testTokenAllocationCid } = await scanAllocations( + setup, + { legIdAlice, legIdBob }, + logger +) + +// ── 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 self-transfers her received TestToken back to app-synchronizer ─ +await aliceSelfTransferToApp(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] }, +]) + +await registry.stop() +process.exit(0) 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..aa8861cf4 --- /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 { SynchronizerMap } from './index.js' + +export type ContractReadSpec = { + sdk: SDKInterface + parties: string[] +} + +/** Resolve a synchronizer ID to a logical role alias */ +export function syncAlias( + syncId: string, + synchronizers: SynchronizerMap +): 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: SynchronizerMap, + specs: ContractReadSpec[] +): Promise { + const results = await Promise.all( + specs.map(({ sdk, parties }) => + sdk.ledger.acs.read({ 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..29e94cba2 100644 --- a/docs/wallet-integration-guide/examples/scripts/utils/index.ts +++ b/docs/wallet-integration-guide/examples/scripts/utils/index.ts @@ -9,12 +9,36 @@ import { AssetConfig, } from '@canton-network/wallet-sdk' +export { syncAlias, 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 SynchronizerMap = { + 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'`. Falls back to the first entry + * when no alias matches (e.g. single-synchronizer setups). + * + * @throws {Error} When the array is empty. + */ +export function resolveGlobalSynchronizerId( + synchronizers: Array<{ synchronizerAlias: string; synchronizerId: string }> +): 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..e634ca269 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 vet( + 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..e7f1465b3 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,140 @@ 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`. + */ + async reassign(params: ReassignParams): Promise { + const { submitter, contractId, source, target, skipIfAlreadyOn } = + params + + if (skipIfAlreadyOn && source === target) { + return + } + + // 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, + }) + } + 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..09e01700f 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,11 @@ export type InternalOperationParams = UnusedParams & RequiredParamsFor > > + +export interface ReassignParams { + submitter: string + contractId: string + source: string + target: string + skipIfAlreadyOn?: boolean +} diff --git a/sdk/wallet-sdk/src/wallet/namespace/ledger/namespace.ts b/sdk/wallet-sdk/src/wallet/namespace/ledger/namespace.ts index e8230c696..59fd6ab86 100644 --- a/sdk/wallet-sdk/src/wallet/namespace/ledger/namespace.ts +++ b/sdk/wallet-sdk/src/wallet/namespace/ledger/namespace.ts @@ -4,7 +4,13 @@ import type { LedgerCommonSchemas } from '@canton-network/core-ledger-client-types' import type { SDKContext } from '../../init/types/context.js' import { v4 } from 'uuid' -import { PrepareOptions, ExecuteOptions, AcsRequestOptions } from './types.js' +import { + PrepareOptions, + ExecuteOptions, + AcsRequestOptions, + ConnectedSynchronizersOptions, +} from './types.js' +import { PrivateKey } from '@canton-network/core-signing-lib' import { PreparedTransaction } from '../transactions/prepared.js' import { SignedTransaction } from '../transactions/signed.js' import { Ops } from '@canton-network/core-provider-ledger' @@ -23,6 +29,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?: ConnectedSynchronizersOptions + ) { + 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 +254,42 @@ 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. + */ + requireOne: async (options: AcsRequestOptions) => { + const contracts = await this.acs.read(options) + if (!contracts.length) { + this.sdkContext.error.throw({ + message: `Required contract not found (templateIds: ${options.templateIds?.join(', ')}, parties: ${options.parties?.join(', ')})`, + type: 'NotFound', + }) + } + return contracts[0] + }, + } + + /** + * Prepares, signs, and executes the same command set on multiple synchronizers in parallel. + * Equivalent to calling `prepare(...).sign(privateKey).execute({ partyId })` for each + * synchronizer, but without repeating the command payload. + * @param options - Command options without a synchronizerId (it is provided per-element) + * @param synchronizerIds - Synchronizers to submit to in parallel + * @param privateKey - Key used to sign each prepared transaction + */ + public async prepareAndExecuteOnSynchronizers( + options: Omit, + synchronizerIds: string[], + privateKey: PrivateKey + ): Promise { + await Promise.all( + synchronizerIds.map((synchronizerId) => + this.prepare({ ...options, synchronizerId }) + .sign(privateKey) + .execute({ partyId: options.partyId }) + ) + ) } } diff --git a/sdk/wallet-sdk/src/wallet/namespace/ledger/types.ts b/sdk/wallet-sdk/src/wallet/namespace/ledger/types.ts index f1e5eb3ff..141b1c36b 100644 --- a/sdk/wallet-sdk/src/wallet/namespace/ledger/types.ts +++ b/sdk/wallet-sdk/src/wallet/namespace/ledger/types.ts @@ -18,6 +18,12 @@ export type ExecuteOptions = { partyId: PartyId } +export type ConnectedSynchronizersOptions = { + party?: string + participantId?: string + identityProviderId?: string +} + 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/service.ts b/sdk/wallet-sdk/src/wallet/namespace/token/allocation/service.ts index 322c52d65..817986dec 100644 --- a/sdk/wallet-sdk/src/wallet/namespace/token/allocation/service.ts +++ b/sdk/wallet-sdk/src/wallet/namespace/token/allocation/service.ts @@ -51,7 +51,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 +62,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/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: From fb7d01e36307e50974b41a2cfff7814f57516c4c Mon Sep 17 00:00:00 2001 From: jarekr-da Date: Thu, 9 Jul 2026 13:03:18 +0200 Subject: [PATCH 02/23] review: added todo for reassign Signed-off-by: jarekr-da --- .../src/wallet/namespace/ledger/internal/namespace.ts | 2 ++ 1 file changed, 2 insertions(+) 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 e7f1465b3..89ef98d77 100644 --- a/sdk/wallet-sdk/src/wallet/namespace/ledger/internal/namespace.ts +++ b/sdk/wallet-sdk/src/wallet/namespace/ledger/internal/namespace.ts @@ -13,6 +13,8 @@ export class InternalLedgerNamespace { * 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 (i2097) */ async reassign(params: ReassignParams): Promise { const { submitter, contractId, source, target, skipIfAlreadyOn } = From 8ba2342e0404f1538d8754ea0eb1bc135bb9ffc5 Mon Sep 17 00:00:00 2001 From: jarekr-da Date: Thu, 9 Jul 2026 13:10:42 +0200 Subject: [PATCH 03/23] review: added todo for reassign Signed-off-by: jarekr-da --- .../examples/scripts/17-multi-sync/_token_transfer.ts | 2 ++ .../examples/scripts/17-multi-sync/index.ts | 1 + .../src/wallet/namespace/ledger/internal/namespace.ts | 2 +- 3 files changed, 4 insertions(+), 1 deletion(-) 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 index 8c9063de6..3b36404f4 100644 --- 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 @@ -46,6 +46,7 @@ export async function aliceSelfTransferToApp( // The settled holding lands on the global synchronizer; move it to the // app-synchronizer before self-transferring there (mirrors Bob's flow). + // TODO #2097 remove after bugfix in canton if (aliceToken.synchronizerId !== appSynchronizerId) { await aliceSdk.ledger.internal.reassign({ submitter: alice.partyId, @@ -106,6 +107,7 @@ export async function bobSelfTransferToApp( 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, 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 index 84809200a..9c962cee3 100644 --- a/docs/wallet-integration-guide/examples/scripts/17-multi-sync/index.ts +++ b/docs/wallet-integration-guide/examples/scripts/17-multi-sync/index.ts @@ -125,6 +125,7 @@ const testTokenAllocation = allocationsBob.find( ) 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, 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 89ef98d77..6fd1558da 100644 --- a/sdk/wallet-sdk/src/wallet/namespace/ledger/internal/namespace.ts +++ b/sdk/wallet-sdk/src/wallet/namespace/ledger/internal/namespace.ts @@ -14,7 +14,7 @@ export class InternalLedgerNamespace { * Performs the two-phase Canton reassignment (Unassign → Assign) via * `/v2/commands/submit-and-wait-for-reassignment`. * - * TODO (i2097) + * TODO #2097 */ async reassign(params: ReassignParams): Promise { const { submitter, contractId, source, target, skipIfAlreadyOn } = From 2c8215bdf330e165599bc052e59c4e4b3cdae020 Mon Sep 17 00:00:00 2001 From: jarekr-da Date: Thu, 9 Jul 2026 13:20:00 +0200 Subject: [PATCH 04/23] review: improved polling code Signed-off-by: jarekr-da --- .../scripts/17-multi-sync/_trade_propose.ts | 53 +++++++++---------- 1 file changed, 25 insertions(+), 28 deletions(-) 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 index 659f98a5f..72c5d5df6 100644 --- 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 @@ -5,6 +5,7 @@ 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' @@ -85,38 +86,34 @@ export async function createAndInitiateOtcTrade( // The proposal is created on Alice's participant but read from other // participants (Bob, TradingApp) - const readProposalCid = async ( + const readProposalCid = ( sdk: SDKInterface<'token'>, party: string, predicate: (approvers: string[]) => boolean = () => true - ): Promise => { - const deadline = Date.now() + PROPOSAL_POLL_TIMEOUT_MS - for (;;) { - const proposals = await sdk.ledger.acs.read({ - templateIds: [OTC_TRADE_PROPOSAL_TEMPLATE_ID], - parties: [party], - filterByParty: true, - }) - const match = proposals.find((proposal) => - predicate( - (( - proposal as unknown as { - createArgument?: { approvers?: string[] } - } - ).createArgument?.approvers ?? []) as string[] - ) - ) - if (match) return match.contractId - if (Date.now() >= deadline) { - throw new Error( - `OTCTradeProposal not visible to ${party} within ${PROPOSAL_POLL_TIMEOUT_MS}ms` - ) + ): Promise => + pollUntil( + async () => { + const proposals = await sdk.ledger.acs.read({ + 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 new Promise((resolve) => - setTimeout(resolve, PROPOSAL_POLL_INTERVAL_MS) - ) - } - } + ) await aliceSdk.ledger .prepare({ From 744a66a15c5c658b6ee5b3a5497c6e557b1c52d3 Mon Sep 17 00:00:00 2001 From: jarekr-da Date: Thu, 9 Jul 2026 13:47:20 +0200 Subject: [PATCH 05/23] Update docs/wallet-integration-guide/examples/scripts/17-multi-sync/README.md Co-authored-by: Simon Meier Signed-off-by: jarekr-da --- .../examples/scripts/17-multi-sync/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 index e875e3cc9..cd14bb24e 100644 --- a/docs/wallet-integration-guide/examples/scripts/17-multi-sync/README.md +++ b/docs/wallet-integration-guide/examples/scripts/17-multi-sync/README.md @@ -27,7 +27,7 @@ yarn stop:localnet # Example details -The goal here is to show an exchange operation with a custom token (`TestToken`) that is deployed to a private / local `app-synchronizer`. +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. From 2ea6206f160b7a3b1492b536a3e6161a4fbead67 Mon Sep 17 00:00:00 2001 From: jarekr-da Date: Thu, 9 Jul 2026 13:50:32 +0200 Subject: [PATCH 06/23] review: allocations scanner added Signed-off-by: jarekr-da --- .../scripts/17-multi-sync/_trade_settle.ts | 55 --------------- .../examples/scripts/17-multi-sync/index.ts | 13 ++-- .../token/allocation/allocation.test.ts | 70 ++++++++++++++++++- .../namespace/token/allocation/service.ts | 61 ++++++++++++++++ .../namespace/token/allocation/types.ts | 12 ++++ 5 files changed, 149 insertions(+), 62 deletions(-) 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 index 6b47bf540..2e0f87086 100644 --- 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 @@ -49,61 +49,6 @@ export interface SettleParams { testTokenAllocationCid: string } -export interface ScannedAllocations { - amuletAllocationCid: string - testTokenAllocationCid: string -} - -/** - * Polls the TradingApp's ACS until both the Amulet (leg-0) and TestToken (leg-1) - * allocations have propagated to the TradingApp's settlement participant, then - * returns their contract IDs. - */ -export async function scanAllocations( - setup: MultiSyncSetup, - legIds: { legIdAlice: string; legIdBob: string }, - logger: Logger -): Promise { - const { tradingAppSdk, tradingApp } = setup - const { legIdAlice, legIdBob } = legIds - - const MAX_ATTEMPTS = 30 - const RETRY_INTERVAL_MS = 1000 - - for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) { - const allocations = await tradingAppSdk.token.allocation.pending( - tradingApp.partyId - ) - const amuletAllocation = allocations.find( - (a) => a.interfaceViewValue.allocation.transferLegId === legIdAlice - ) - const testTokenAllocation = allocations.find( - (a) => a.interfaceViewValue.allocation.transferLegId === legIdBob - ) - - if (amuletAllocation && testTokenAllocation) { - logger.info( - 'TradingApp: both Amulet and TestToken allocations are visible — ready to settle' - ) - return { - amuletAllocationCid: amuletAllocation.contractId, - testTokenAllocationCid: testTokenAllocation.contractId, - } - } - - logger.info( - `TradingApp: waiting for allocations to propagate (attempt ${attempt}/${MAX_ATTEMPTS}; amulet=${Boolean( - amuletAllocation - )}, testToken=${Boolean(testTokenAllocation)})` - ) - await new Promise((resolve) => setTimeout(resolve, RETRY_INTERVAL_MS)) - } - - throw new Error( - 'Allocations did not propagate to the TradingApp participant in time' - ) -} - interface CancelParams { otcTradeCid: string legIdAlice: string 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 index 9c962cee3..4189c961b 100644 --- a/docs/wallet-integration-guide/examples/scripts/17-multi-sync/index.ts +++ b/docs/wallet-integration-guide/examples/scripts/17-multi-sync/index.ts @@ -16,7 +16,7 @@ import { bobSelfTransferToApp, } from './_token_transfer.js' import { createAndInitiateOtcTrade } from './_trade_propose.js' -import { scanAllocations, settleOtcTrade } from './_trade_settle.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 @@ -141,11 +141,12 @@ logger.info( // 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 { amuletAllocationCid, testTokenAllocationCid } = await scanAllocations( - setup, - { legIdAlice, legIdBob }, - logger -) +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 { 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 817986dec..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 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 +} From caa8e0e7a8715174fec1090db0c2f7d6880c2142 Mon Sep 17 00:00:00 2001 From: jarekr-da Date: Thu, 9 Jul 2026 13:52:53 +0200 Subject: [PATCH 07/23] review: fixed readme Signed-off-by: jarekr-da --- .../examples/scripts/17-multi-sync/README.md | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) 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 index cd14bb24e..3c97bddd9 100644 --- a/docs/wallet-integration-guide/examples/scripts/17-multi-sync/README.md +++ b/docs/wallet-integration-guide/examples/scripts/17-multi-sync/README.md @@ -33,15 +33,15 @@ provided that at some point the contracts are re-assigned (automatically or expl The parties in the example are: -- **Alice** — app-user, hosted on the **app-user** participant. Holds Amulet, buys `TestToken`. -- **Bob** — app-provider, 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). +- **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. +- **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 From 2b13181529ef8d4aa8f6c7669db0663a94e156da Mon Sep 17 00:00:00 2001 From: jarekr-da Date: Thu, 9 Jul 2026 13:56:48 +0200 Subject: [PATCH 08/23] Update docs/wallet-integration-guide/examples/scripts/17-multi-sync/README.md Co-authored-by: Simon Meier Signed-off-by: jarekr-da --- .../examples/scripts/17-multi-sync/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 index 3c97bddd9..4c87f8853 100644 --- a/docs/wallet-integration-guide/examples/scripts/17-multi-sync/README.md +++ b/docs/wallet-integration-guide/examples/scripts/17-multi-sync/README.md @@ -73,7 +73,7 @@ GLOBAL synchronizer — Amulet* · leg-0: Alice --100 CC--> Bob │ TradingApp │ │ TokenAdmin │ │ parties) │ └──────┬───────┘ └──────┬───────┘ └──────────────┘ │ │ - │ vetted on APP: TestTokenV1, trading-app — app-user & app-provider only (sv not connected) + │ vetted on PRIVATE: TestTokenV1, — app-user & app-provider only (sv not connected) │ │ ══════════╧═══════════════════════╧═════════════════════════════════════════ APP synchronizer — TestToken · leg-1: Bob --20 TT--> Alice From f190d7dd8879d8a302986039c82c09a89232a1b2 Mon Sep 17 00:00:00 2001 From: jarekr-da Date: Thu, 9 Jul 2026 14:09:44 +0200 Subject: [PATCH 09/23] review: vat( method renamed Signed-off-by: jarekr-da --- .../examples/scripts/17-multi-sync/_setup.ts | 4 ++-- sdk/wallet-sdk/src/wallet/namespace/ledger/dar/index.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) 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 index a26948f3f..0c5f1c0c9 100644 --- a/docs/wallet-integration-guide/examples/scripts/17-multi-sync/_setup.ts +++ b/docs/wallet-integration-guide/examples/scripts/17-multi-sync/_setup.ts @@ -163,12 +163,12 @@ export async function setupMultiSyncTrade( ...[testTokenV1Dar, tradingAppDar].flatMap((dar) => [aliceSdk, bobSdk].flatMap((sdk) => [globalSynchronizerId, appSynchronizerId].map((sid) => - sdk.ledger.dar.vet(dar, sid) + sdk.ledger.dar.uploadAndVet(dar, sid) ) ) ), ...[testTokenV1Dar, tradingAppDar].map((dar) => - svSdk.ledger.dar.vet(dar, globalSynchronizerId) + svSdk.ledger.dar.uploadAndVet(dar, globalSynchronizerId) ), ]) logger.info( 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 e634ca269..8a8447210 100644 --- a/sdk/wallet-sdk/src/wallet/namespace/ledger/dar/index.ts +++ b/sdk/wallet-sdk/src/wallet/namespace/ledger/dar/index.ts @@ -64,7 +64,7 @@ export class DarNamespace { * @param synchronizerId - The synchronizer on which the package should be * vetted. Defaults to the SDK's configured synchronizer. */ - async vet( + async uploadAndVet( darBytes: Uint8Array | Buffer, synchronizerId?: string ): Promise { From d6d7a94a8592f1a5a903a064564ce89c7d56adb8 Mon Sep 17 00:00:00 2001 From: jarekr-da Date: Thu, 9 Jul 2026 14:25:03 +0200 Subject: [PATCH 10/23] review: removed redundant param Signed-off-by: jarekr-da --- .../examples/scripts/17-multi-sync/_token_transfer.ts | 2 -- .../examples/scripts/17-multi-sync/index.ts | 1 - .../src/wallet/namespace/ledger/internal/namespace.ts | 7 +------ .../src/wallet/namespace/ledger/internal/types.ts | 1 - 4 files changed, 1 insertion(+), 10 deletions(-) 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 index 3b36404f4..a3ae69880 100644 --- 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 @@ -53,7 +53,6 @@ export async function aliceSelfTransferToApp( contractId: aliceToken.contractId, source: aliceToken.synchronizerId, target: appSynchronizerId, - skipIfAlreadyOn: true, }) } @@ -113,7 +112,6 @@ export async function bobSelfTransferToApp( contractId: token.contractId, source: token.synchronizerId, target: appSynchronizerId, - skipIfAlreadyOn: true, }) } 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 index 4189c961b..ad9abfd3d 100644 --- a/docs/wallet-integration-guide/examples/scripts/17-multi-sync/index.ts +++ b/docs/wallet-integration-guide/examples/scripts/17-multi-sync/index.ts @@ -131,7 +131,6 @@ await bobSdk.ledger.internal.reassign({ contractId: testTokenAllocation.contractId, source: synchronizers.appSynchronizerId, target: synchronizers.globalSynchronizerId, - skipIfAlreadyOn: true, }) logger.info( 'Bob: TestToken allocation reassigned app-synchronizer → global ahead of settlement' 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 6fd1558da..1debe57d6 100644 --- a/sdk/wallet-sdk/src/wallet/namespace/ledger/internal/namespace.ts +++ b/sdk/wallet-sdk/src/wallet/namespace/ledger/internal/namespace.ts @@ -17,12 +17,7 @@ export class InternalLedgerNamespace { * TODO #2097 */ async reassign(params: ReassignParams): Promise { - const { submitter, contractId, source, target, skipIfAlreadyOn } = - params - - if (skipIfAlreadyOn && source === target) { - return - } + const { submitter, contractId, source, target } = params // Phase 1: Unassign let unassignResponse: Awaited< 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 09e01700f..f968214d7 100644 --- a/sdk/wallet-sdk/src/wallet/namespace/ledger/internal/types.ts +++ b/sdk/wallet-sdk/src/wallet/namespace/ledger/internal/types.ts @@ -40,5 +40,4 @@ export interface ReassignParams { contractId: string source: string target: string - skipIfAlreadyOn?: boolean } From 7557e702dd7b27420c7107fc80e44ae8c655381e Mon Sep 17 00:00:00 2001 From: jarekr-da Date: Thu, 9 Jul 2026 16:41:42 +0200 Subject: [PATCH 11/23] review: reassign TODO added Signed-off-by: jarekr-da --- .../src/wallet/namespace/ledger/internal/namespace.ts | 2 ++ 1 file changed, 2 insertions(+) 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 1debe57d6..bf9936cb0 100644 --- a/sdk/wallet-sdk/src/wallet/namespace/ledger/internal/namespace.ts +++ b/sdk/wallet-sdk/src/wallet/namespace/ledger/internal/namespace.ts @@ -76,6 +76,8 @@ export class InternalLedgerNamespace { originalError: e, }) } + + // TODO #2103 implement proper compensation algorigthms for reassign throw e } From bbebb081b86fc6dd38deebb484bed5ab1b73063f Mon Sep 17 00:00:00 2001 From: jarekr-da Date: Thu, 9 Jul 2026 17:04:19 +0200 Subject: [PATCH 12/23] review: charlie added Signed-off-by: jarekr-da --- .../examples/scripts/17-multi-sync/_setup.ts | 43 ++++++++++++++----- .../scripts/17-multi-sync/_token_transfer.ts | 37 ++++++++++++++-- .../examples/scripts/17-multi-sync/index.ts | 9 ++-- 3 files changed, 72 insertions(+), 17 deletions(-) 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 index 0c5f1c0c9..fffcaa122 100644 --- a/docs/wallet-integration-guide/examples/scripts/17-multi-sync/_setup.ts +++ b/docs/wallet-integration-guide/examples/scripts/17-multi-sync/_setup.ts @@ -48,22 +48,25 @@ 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 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. + // 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'> aliceTokenNamespace: TokenNamespace bobTokenNamespace: TokenNamespace tokenAdminTokenNamespace: TokenNamespace + charlieTokenNamespace: TokenNamespace alice: PartyInfo bob: PartyInfo tradingApp: PartyInfo tokenAdmin: PartyInfo + charlie: PartyInfo globalSynchronizerId: string appSynchronizerId: string synchronizers: SynchronizerMap @@ -73,18 +76,18 @@ export interface MultiSyncSetup { /** * Bootstraps a fresh multi-synchronizer environment: - * - Creates one SDK instance per party (alice, tradingApp on the app-user - * participant; bob, tokenAdmin on the app-provider participant) plus an sv SDK + * - 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) on global synchronizer - * while simultaneously registering alice, bob, tradingApp, and tokenAdmin on app-synchronizer + * - 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, bobSdk, tokenAdminSdk, svSdk] = + const [aliceSdk, tradingAppSdk, charlieSdk, bobSdk, tokenAdminSdk, svSdk] = await Promise.all([ SDK.create({ auth: TOKEN_PROVIDER_CONFIG_DEFAULT, @@ -100,6 +103,12 @@ export async function setupMultiSyncTrade( 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: @@ -179,12 +188,14 @@ export async function setupMultiSyncTrade( 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, { @@ -218,6 +229,14 @@ export async function setupMultiSyncTrade( }) .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 } @@ -230,9 +249,10 @@ export async function setupMultiSyncTrade( ...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)` + `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') @@ -243,14 +263,17 @@ export async function setupMultiSyncTrade( tradingAppSdk, bobSdk, tokenAdminSdk, + charlieSdk, svSdk, aliceTokenNamespace: aliceSdk.token, bobTokenNamespace: bobSdk.token, tokenAdminTokenNamespace: tokenAdminSdk.token, + charlieTokenNamespace: charlieSdk.token, alice, bob, tradingApp, tokenAdmin, + charlie, globalSynchronizerId, appSynchronizerId, synchronizers, 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 index a3ae69880..b6545f11d 100644 --- 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 @@ -12,14 +12,17 @@ const TestTokenV1 = SpliceTestTokenV1.Splice.Testing.Tokens.TestTokenV1 const TOKEN_POLL_TIMEOUT_MS = 30_000 const TOKEN_POLL_INTERVAL_MS = 500 -export async function aliceSelfTransferToApp( +export async function aliceTransferToCharlie( setup: MultiSyncSetup, logger: Logger ): Promise { const { aliceSdk, aliceTokenNamespace, + charlieSdk, + charlieTokenNamespace, alice, + charlie, appSynchronizerId, testTokenRegistryUrl, } = setup @@ -45,7 +48,7 @@ export async function aliceSelfTransferToApp( } // The settled holding lands on the global synchronizer; move it to the - // app-synchronizer before self-transferring there (mirrors Bob's flow). + // 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({ @@ -56,10 +59,11 @@ export async function aliceSelfTransferToApp( }) } + // Alice offers her freshly-received TestToken to Charlie via the registry's const [transferCommand, transferDisclosed] = await aliceTokenNamespace.transfer.create({ sender: alice.partyId, - recipient: alice.partyId, + recipient: charlie.partyId, amount: TRADE_TOKEN_AMOUNT, instrumentId: 'TestToken', registryUrl: testTokenRegistryUrl, @@ -76,8 +80,33 @@ export async function aliceSelfTransferToApp( .sign(alice.keyPair.privateKey) .execute({ partyId: alice.partyId }) + const transferOffers = await charlieSdk.ledger.acs.read({ + 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 charlieTokenNamespace.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 self-transferred on app-synchronizer via registry transfer-factory` + `Alice: ${TRADE_TOKEN_AMOUNT} TestToken transferred to Charlie on app-synchronizer via registry transfer-factory` ) } 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 index ad9abfd3d..8eaabd246 100644 --- a/docs/wallet-integration-guide/examples/scripts/17-multi-sync/index.ts +++ b/docs/wallet-integration-guide/examples/scripts/17-multi-sync/index.ts @@ -12,7 +12,7 @@ import { mintAmuletForAlice, allocateAmuletForAlice } from './_amulet_ops.js' import { createTokenRules, mintAndTransferTokenToBob } from './_token_setup.js' import { allocateTokenForBob } from './_token_allocation.js' import { - aliceSelfTransferToApp, + aliceTransferToCharlie, bobSelfTransferToApp, } from './_token_transfer.js' import { createAndInitiateOtcTrade } from './_trade_propose.js' @@ -34,11 +34,13 @@ const { bobSdk, tradingAppSdk, tokenAdminSdk, + charlieSdk, bobTokenNamespace, alice, bob, tradingApp, tokenAdmin, + charlie, synchronizers, amuletAdmin, } = setup @@ -182,8 +184,8 @@ await logAllContracts(logger, synchronizers, [ { sdk: tokenAdminSdk, parties: [tokenAdmin.partyId] }, { sdk: tradingAppSdk, parties: [tradingApp.partyId] }, ]) -// ── Step 11: Alice self-transfers her received TestToken back to app-synchronizer ─ -await aliceSelfTransferToApp(setup, logger) +// ── 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, [ @@ -191,6 +193,7 @@ await logAllContracts(logger, synchronizers, [ { sdk: bobSdk, parties: [bob.partyId] }, { sdk: tokenAdminSdk, parties: [tokenAdmin.partyId] }, { sdk: tradingAppSdk, parties: [tradingApp.partyId] }, + { sdk: charlieSdk, parties: [charlie.partyId] }, ]) await registry.stop() From 476d9a2d79e04a32773e89bfb08c9110df4f2aaf Mon Sep 17 00:00:00 2001 From: jarekr-da Date: Thu, 9 Jul 2026 19:14:01 +0200 Subject: [PATCH 13/23] review: removed "namespaces" from example and registry Signed-off-by: jarekr-da --- .../scripts/17-multi-sync/_amulet_ops.ts | 17 ++++----- .../examples/scripts/17-multi-sync/_poll.ts | 32 +++++++++++++++++ .../examples/scripts/17-multi-sync/_setup.ts | 11 ------ .../17-multi-sync/_token_allocation.ts | 19 +++++----- .../scripts/17-multi-sync/_token_setup.ts | 26 +++++++------- .../scripts/17-multi-sync/_token_transfer.ts | 34 ++++++++---------- .../scripts/17-multi-sync/_trade_settle.ts | 20 +++++------ .../examples/scripts/17-multi-sync/index.ts | 3 +- .../src/wallet/namespace/token/namespace.ts | 35 ++++++++++++++++++- 9 files changed, 115 insertions(+), 82 deletions(-) create mode 100644 docs/wallet-integration-guide/examples/scripts/17-multi-sync/_poll.ts 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 index 2b556220e..36384f26a 100644 --- 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 @@ -33,23 +33,18 @@ export async function allocateAmuletForAlice( setup: MultiSyncSetup, logger: Logger ): Promise { - const { - aliceSdk, - aliceTokenNamespace, - alice, - globalSynchronizerId, - amuletAdmin, - } = setup + const { aliceSdk, alice, globalSynchronizerId, amuletAdmin } = setup - const pendingRequests = - await aliceTokenNamespace.allocation.request.pending(alice.partyId) + 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 aliceTokenNamespace.utxos.list({ + const amuletHoldings = await aliceSdk.token.utxos.list({ partyId: alice.partyId, includeLocked: false, }) @@ -61,7 +56,7 @@ export async function allocateAmuletForAlice( if (!amuletHoldingCid) throw new Error('Amulet holding not found for Alice') const [command, disclosedContracts] = - await aliceTokenNamespace.allocation.instruction.create({ + await aliceSdk.token.allocation.instruction.create({ allocationSpecification: { settlement: requestView.settlement, transferLegId: legId, 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 index fffcaa122..7b8916173 100644 --- a/docs/wallet-integration-guide/examples/scripts/17-multi-sync/_setup.ts +++ b/docs/wallet-integration-guide/examples/scripts/17-multi-sync/_setup.ts @@ -9,7 +9,6 @@ import { localNetStaticConfig, SDK, type SDKInterface, - type TokenNamespace, } from '@canton-network/wallet-sdk' import type { KeyPair } from '@canton-network/core-signing-lib' import type { GenerateTransactionResponse } from '@canton-network/core-ledger-client' @@ -58,10 +57,6 @@ export interface MultiSyncSetup { tokenAdminSdk: SDKInterface<'token'> charlieSdk: SDKInterface<'token'> svSdk: SDKInterface<'token'> - aliceTokenNamespace: TokenNamespace - bobTokenNamespace: TokenNamespace - tokenAdminTokenNamespace: TokenNamespace - charlieTokenNamespace: TokenNamespace alice: PartyInfo bob: PartyInfo tradingApp: PartyInfo @@ -71,7 +66,6 @@ export interface MultiSyncSetup { appSynchronizerId: string synchronizers: SynchronizerMap amuletAdmin: string - testTokenRegistryUrl: URL } /** @@ -265,10 +259,6 @@ export async function setupMultiSyncTrade( tokenAdminSdk, charlieSdk, svSdk, - aliceTokenNamespace: aliceSdk.token, - bobTokenNamespace: bobSdk.token, - tokenAdminTokenNamespace: tokenAdminSdk.token, - charlieTokenNamespace: charlieSdk.token, alice, bob, tradingApp, @@ -278,6 +268,5 @@ export async function setupMultiSyncTrade( appSynchronizerId, synchronizers, amuletAdmin, - testTokenRegistryUrl: TEST_TOKEN_REGISTRY_URL, } } 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 index 2c380793b..fc64c1dd1 100644 --- 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 @@ -11,17 +11,14 @@ export async function allocateTokenForBob( setup: MultiSyncSetup, logger: Logger ): Promise<{ legId: string }> { - const { - bobSdk, - tokenAdminSdk, - bobTokenNamespace, - bob, - tokenAdmin, - appSynchronizerId, - testTokenRegistryUrl, - } = setup + const { bobSdk, tokenAdminSdk, bob, tokenAdmin, appSynchronizerId } = setup - const pendingRequests = await bobTokenNamespace.allocation.request.pending( + // 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! @@ -43,7 +40,7 @@ export async function allocateTokenForBob( // allocation-instruction-v1 API. The registry returns the global-synchronizer // TokenRules contract as the factory (disclosed in `disclosedFromHelper`). const [command, disclosedFromHelper] = - await bobTokenNamespace.allocation.instruction.create({ + await bobSdk.token.allocation.instruction.create({ allocationSpecification: { settlement: requestView.settlement, transferLegId: 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 index 063af1f3d..2505038c2 100644 --- 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 @@ -44,16 +44,13 @@ export async function mintAndTransferTokenToBob( setup: MultiSyncSetup, logger: Logger ): Promise { - const { - bobSdk, - tokenAdminSdk, - bobTokenNamespace, - tokenAdminTokenNamespace, - bob, - tokenAdmin, - appSynchronizerId, - testTokenRegistryUrl, - } = setup + 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({ @@ -84,7 +81,7 @@ export async function mintAndTransferTokenToBob( // 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 tokenAdminTokenNamespace.transfer.create({ + await tokenAdminSdk.token.transfer.create({ sender: tokenAdmin.partyId, recipient: bob.partyId, amount: BOB_TOKEN_MINT_AMOUNT, @@ -114,11 +111,12 @@ export async function mintAndTransferTokenToBob( // Bob accepts the transfer offer using the registry's transfer-instruction-v1 // accept choice context. - const [acceptCommand, acceptDisclosed] = - await bobTokenNamespace.transfer.accept({ + const [acceptCommand, acceptDisclosed] = await bobSdk.token.transfer.accept( + { transferInstructionCid: transferOfferCid, registryUrl: testTokenRegistryUrl, - }) + } + ) await bobSdk.ledger .prepare({ 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 index b6545f11d..c1215a2a0 100644 --- 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 @@ -16,16 +16,12 @@ export async function aliceTransferToCharlie( setup: MultiSyncSetup, logger: Logger ): Promise { - const { - aliceSdk, - aliceTokenNamespace, - charlieSdk, - charlieTokenNamespace, - alice, - charlie, - appSynchronizerId, - testTokenRegistryUrl, - } = setup + 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 @@ -61,7 +57,7 @@ export async function aliceTransferToCharlie( // Alice offers her freshly-received TestToken to Charlie via the registry's const [transferCommand, transferDisclosed] = - await aliceTokenNamespace.transfer.create({ + await aliceSdk.token.transfer.create({ sender: alice.partyId, recipient: charlie.partyId, amount: TRADE_TOKEN_AMOUNT, @@ -90,7 +86,7 @@ export async function aliceTransferToCharlie( throw new Error('TokenTransferOffer not found for Charlie') const [acceptCommand, acceptDisclosed] = - await charlieTokenNamespace.transfer.accept({ + await charlieSdk.token.transfer.accept({ transferInstructionCid: transferOfferCid, registryUrl: testTokenRegistryUrl, }) @@ -114,13 +110,7 @@ export async function bobSelfTransferToApp( setup: MultiSyncSetup, logger: Logger ): Promise { - const { - bobSdk, - bobTokenNamespace, - bob, - appSynchronizerId, - testTokenRegistryUrl, - } = setup + const { bobSdk, bob, appSynchronizerId } = setup const bobTokens = await bobSdk.ledger.acs.read({ templateIds: [TestTokenV1.Token.templateId], @@ -133,6 +123,10 @@ export async function bobSelfTransferToApp( 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 @@ -153,7 +147,7 @@ export async function bobSelfTransferToApp( throw new Error('Cannot read amount from Bob Token holding') const [transferCommand, transferDisclosed] = - await bobTokenNamespace.transfer.create({ + await bobSdk.token.transfer.create({ sender: bob.partyId, recipient: bob.partyId, amount: holdingAmount, 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 index 2e0f87086..386275af2 100644 --- 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 @@ -68,12 +68,7 @@ async function cancelAllocationsOnFailure( params: CancelParams, logger: Logger ): Promise { - const { - tradingAppSdk, - tradingApp, - globalSynchronizerId, - testTokenRegistryUrl, - } = setup + const { tradingAppSdk, tradingApp, globalSynchronizerId } = setup const { otcTradeCid, legIdAlice, @@ -83,6 +78,9 @@ async function cancelAllocationsOnFailure( } = 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). @@ -164,12 +162,7 @@ export async function settleOtcTrade( params: SettleParams, logger: Logger ): Promise { - const { - tradingAppSdk, - tradingApp, - globalSynchronizerId, - testTokenRegistryUrl, - } = setup + const { tradingAppSdk, tradingApp, globalSynchronizerId } = setup const { otcTradeCid, legIdAlice, @@ -179,6 +172,9 @@ export async function settleOtcTrade( } = 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, 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 index 8eaabd246..3d71913a6 100644 --- a/docs/wallet-integration-guide/examples/scripts/17-multi-sync/index.ts +++ b/docs/wallet-integration-guide/examples/scripts/17-multi-sync/index.ts @@ -35,7 +35,6 @@ const { tradingAppSdk, tokenAdminSdk, charlieSdk, - bobTokenNamespace, alice, bob, tradingApp, @@ -121,7 +120,7 @@ await logAllContracts(logger, synchronizers, [ { sdk: tradingAppSdk, parties: [tradingApp.partyId] }, ]) // ── Step 10a: Locate Bob's TestToken allocation ──────────────────────────────────── -const allocationsBob = await bobTokenNamespace.allocation.pending(bob.partyId) +const allocationsBob = await bobSdk.token.allocation.pending(bob.partyId) const testTokenAllocation = allocationsBob.find( (a) => a.interfaceViewValue.allocation.transferLegId === legIdBob ) 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 + ) + } } From aed03930ad7c618d1b320e00fe59d8874b76f928 Mon Sep 17 00:00:00 2001 From: jarekr-da Date: Mon, 13 Jul 2026 09:49:30 +0200 Subject: [PATCH 14/23] review: process exit removed Signed-off-by: jarekr-da --- .../examples/scripts/17-multi-sync/index.ts | 1 - 1 file changed, 1 deletion(-) 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 index 3d71913a6..ea363057e 100644 --- a/docs/wallet-integration-guide/examples/scripts/17-multi-sync/index.ts +++ b/docs/wallet-integration-guide/examples/scripts/17-multi-sync/index.ts @@ -196,4 +196,3 @@ await logAllContracts(logger, synchronizers, [ ]) await registry.stop() -process.exit(0) From 30624ffcfd4664a7fde20b03d722c686481bc2ee Mon Sep 17 00:00:00 2001 From: jarekr-da Date: Mon, 13 Jul 2026 11:16:09 +0200 Subject: [PATCH 15/23] review: switched gitignore Signed-off-by: jarekr-da --- damljs/splice-test-token-v1/.gitignore | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/damljs/splice-test-token-v1/.gitignore b/damljs/splice-test-token-v1/.gitignore index ce470221e..ec8a629cd 100644 --- a/damljs/splice-test-token-v1/.gitignore +++ b/damljs/splice-test-token-v1/.gitignore @@ -1,5 +1,3 @@ * -!.gitignore -!daml.yaml -!daml/ -!daml/** +.daml/ + From d20d0f9d76dc061c9fb24de3fc02c55faca397ba Mon Sep 17 00:00:00 2001 From: jarekr-da Date: Mon, 13 Jul 2026 15:25:08 +0200 Subject: [PATCH 16/23] review: removed unused method Signed-off-by: jarekr-da --- .../src/wallet/namespace/ledger/namespace.ts | 19 ++----------------- .../src/wallet/namespace/ledger/types.ts | 7 ------- 2 files changed, 2 insertions(+), 24 deletions(-) diff --git a/sdk/wallet-sdk/src/wallet/namespace/ledger/namespace.ts b/sdk/wallet-sdk/src/wallet/namespace/ledger/namespace.ts index 59fd6ab86..152ac6e54 100644 --- a/sdk/wallet-sdk/src/wallet/namespace/ledger/namespace.ts +++ b/sdk/wallet-sdk/src/wallet/namespace/ledger/namespace.ts @@ -4,12 +4,7 @@ import type { LedgerCommonSchemas } from '@canton-network/core-ledger-client-types' import type { SDKContext } from '../../init/types/context.js' import { v4 } from 'uuid' -import { - PrepareOptions, - ExecuteOptions, - AcsRequestOptions, - ConnectedSynchronizersOptions, -} from './types.js' +import { PrepareOptions, ExecuteOptions, AcsRequestOptions } from './types.js' import { PrivateKey } from '@canton-network/core-signing-lib' import { PreparedTransaction } from '../transactions/prepared.js' import { SignedTransaction } from '../transactions/signed.js' @@ -36,7 +31,7 @@ export class LedgerNamespace { * Uses the Ledger API endpoint GET /v2/state/connected-synchronizers. */ public async connectedSynchronizers( - options?: ConnectedSynchronizersOptions + options?: Ops.GetV2StateConnectedSynchronizers['ledgerApi']['params']['query'] ) { this.sdkContext.logger.debug( { options }, @@ -259,16 +254,6 @@ export class LedgerNamespace { * @param options AcsOptions for querying the Active Contract Set (ACS). * @throws {SDKError} When no matching contract is found. */ - requireOne: async (options: AcsRequestOptions) => { - const contracts = await this.acs.read(options) - if (!contracts.length) { - this.sdkContext.error.throw({ - message: `Required contract not found (templateIds: ${options.templateIds?.join(', ')}, parties: ${options.parties?.join(', ')})`, - type: 'NotFound', - }) - } - return contracts[0] - }, } /** diff --git a/sdk/wallet-sdk/src/wallet/namespace/ledger/types.ts b/sdk/wallet-sdk/src/wallet/namespace/ledger/types.ts index 141b1c36b..a168af2e3 100644 --- a/sdk/wallet-sdk/src/wallet/namespace/ledger/types.ts +++ b/sdk/wallet-sdk/src/wallet/namespace/ledger/types.ts @@ -17,13 +17,6 @@ export type ExecuteOptions = { submissionId?: string partyId: PartyId } - -export type ConnectedSynchronizersOptions = { - party?: string - participantId?: string - identityProviderId?: string -} - export type RawCommandMap = { ExerciseCommand: LedgerCommonSchemas['ExerciseCommand'] CreateCommand: LedgerCommonSchemas['CreateCommand'] From f617d0a5dcf6579c92b4c662b86781a1e8678c8a Mon Sep 17 00:00:00 2001 From: jarekr-da Date: Mon, 13 Jul 2026 16:28:17 +0200 Subject: [PATCH 17/23] review: removed unused method Signed-off-by: jarekr-da --- .../src/wallet/namespace/ledger/namespace.ts | 23 ------------------- 1 file changed, 23 deletions(-) diff --git a/sdk/wallet-sdk/src/wallet/namespace/ledger/namespace.ts b/sdk/wallet-sdk/src/wallet/namespace/ledger/namespace.ts index 152ac6e54..357bbde7a 100644 --- a/sdk/wallet-sdk/src/wallet/namespace/ledger/namespace.ts +++ b/sdk/wallet-sdk/src/wallet/namespace/ledger/namespace.ts @@ -5,7 +5,6 @@ import type { LedgerCommonSchemas } from '@canton-network/core-ledger-client-typ import type { SDKContext } from '../../init/types/context.js' import { v4 } from 'uuid' import { PrepareOptions, ExecuteOptions, AcsRequestOptions } from './types.js' -import { PrivateKey } from '@canton-network/core-signing-lib' import { PreparedTransaction } from '../transactions/prepared.js' import { SignedTransaction } from '../transactions/signed.js' import { Ops } from '@canton-network/core-provider-ledger' @@ -255,26 +254,4 @@ export class LedgerNamespace { * @throws {SDKError} When no matching contract is found. */ } - - /** - * Prepares, signs, and executes the same command set on multiple synchronizers in parallel. - * Equivalent to calling `prepare(...).sign(privateKey).execute({ partyId })` for each - * synchronizer, but without repeating the command payload. - * @param options - Command options without a synchronizerId (it is provided per-element) - * @param synchronizerIds - Synchronizers to submit to in parallel - * @param privateKey - Key used to sign each prepared transaction - */ - public async prepareAndExecuteOnSynchronizers( - options: Omit, - synchronizerIds: string[], - privateKey: PrivateKey - ): Promise { - await Promise.all( - synchronizerIds.map((synchronizerId) => - this.prepare({ ...options, synchronizerId }) - .sign(privateKey) - .execute({ partyId: options.partyId }) - ) - ) - } } From 378553363984c7a3418b3352282f4c33c2eb1981 Mon Sep 17 00:00:00 2001 From: jarekr-da Date: Mon, 13 Jul 2026 16:32:00 +0200 Subject: [PATCH 18/23] review: removed empty arg Signed-off-by: jarekr-da --- .../examples/scripts/17-multi-sync/_setup.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) 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 index 7b8916173..c75964c98 100644 --- a/docs/wallet-integration-guide/examples/scripts/17-multi-sync/_setup.ts +++ b/docs/wallet-integration-guide/examples/scripts/17-multi-sync/_setup.ts @@ -122,9 +122,7 @@ export async function setupMultiSyncTrade( }), ]) - const connectedSyncResponse = await aliceSdk.ledger.connectedSynchronizers( - {} - ) + const connectedSyncResponse = await aliceSdk.ledger.connectedSynchronizers() const allSynchronizers = connectedSyncResponse.connectedSynchronizers ?? [] if (allSynchronizers.length < 2) throw new Error( From c5260bb2b70dc8d8e3362ca89a38dbeeae6c73cf Mon Sep 17 00:00:00 2001 From: jarekr-da Date: Mon, 13 Jul 2026 16:37:29 +0200 Subject: [PATCH 19/23] review: rename Signed-off-by: jarekr-da --- .../examples/scripts/17-multi-sync/_setup.ts | 6 +++--- .../examples/scripts/utils/acs-logger.ts | 6 +++--- .../examples/scripts/utils/index.ts | 2 +- 3 files changed, 7 insertions(+), 7 deletions(-) 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 index c75964c98..441d46acb 100644 --- a/docs/wallet-integration-guide/examples/scripts/17-multi-sync/_setup.ts +++ b/docs/wallet-integration-guide/examples/scripts/17-multi-sync/_setup.ts @@ -20,7 +20,7 @@ import { TOKEN_PROVIDER_CONFIG_DEFAULT, resolveGlobalSynchronizerId, } from '../utils/index.js' -import type { SynchronizerMap } 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 @@ -64,7 +64,7 @@ export interface MultiSyncSetup { charlie: PartyInfo globalSynchronizerId: string appSynchronizerId: string - synchronizers: SynchronizerMap + synchronizers: KnownSynchronizers amuletAdmin: string } @@ -146,7 +146,7 @@ export async function setupMultiSyncTrade( `Synchronizer IDs — global: ${globalSynchronizerId}, app: ${appSynchronizerId}` ) - const synchronizers: SynchronizerMap = { + const synchronizers: KnownSynchronizers = { globalSynchronizerId, appSynchronizerId, } diff --git a/docs/wallet-integration-guide/examples/scripts/utils/acs-logger.ts b/docs/wallet-integration-guide/examples/scripts/utils/acs-logger.ts index aa8861cf4..924198033 100644 --- a/docs/wallet-integration-guide/examples/scripts/utils/acs-logger.ts +++ b/docs/wallet-integration-guide/examples/scripts/utils/acs-logger.ts @@ -3,7 +3,7 @@ import type { SDKInterface } from '@canton-network/wallet-sdk' import type { Logger } from 'pino' -import type { SynchronizerMap } from './index.js' +import type { KnownSynchronizers } from './index.js' export type ContractReadSpec = { sdk: SDKInterface @@ -13,7 +13,7 @@ export type ContractReadSpec = { /** Resolve a synchronizer ID to a logical role alias */ export function syncAlias( syncId: string, - synchronizers: SynchronizerMap + synchronizers: KnownSynchronizers ): string { if (syncId === synchronizers.globalSynchronizerId) return 'global' if (syncId === synchronizers.appSynchronizerId) return 'app-synchronizer' @@ -27,7 +27,7 @@ export function syncAlias( */ export async function logAllContracts( logger: Logger, - synchronizers: SynchronizerMap, + synchronizers: KnownSynchronizers, specs: ContractReadSpec[] ): Promise { const results = await Promise.all( diff --git a/docs/wallet-integration-guide/examples/scripts/utils/index.ts b/docs/wallet-integration-guide/examples/scripts/utils/index.ts index 29e94cba2..f047ee39e 100644 --- a/docs/wallet-integration-guide/examples/scripts/utils/index.ts +++ b/docs/wallet-integration-guide/examples/scripts/utils/index.ts @@ -18,7 +18,7 @@ export function getActiveContractCid(entry: JSContractEntry) { } /** Maps the two synchronizer roles used in multi-synchronizer setups. */ -export type SynchronizerMap = { +export type KnownSynchronizers = { globalSynchronizerId: string appSynchronizerId: string } From 1ca9342bee7bd8717189097ff1d00701f3aa5d36 Mon Sep 17 00:00:00 2001 From: jarekr-da Date: Mon, 13 Jul 2026 17:32:05 +0200 Subject: [PATCH 20/23] review: changed type Signed-off-by: jarekr-da --- .../examples/scripts/utils/index.ts | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/docs/wallet-integration-guide/examples/scripts/utils/index.ts b/docs/wallet-integration-guide/examples/scripts/utils/index.ts index f047ee39e..306c7b09f 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, @@ -26,13 +27,18 @@ export type KnownSynchronizers = { /** * Resolve the global synchronizer ID from the list returned by the ledger API. * - * Looks for the entry whose alias is `'global'`. Falls back to the first entry - * when no alias matches (e.g. single-synchronizer setups). + * 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 the array is empty. + * @throws {Error} When no entry with alias `'global'` is present. */ export function resolveGlobalSynchronizerId( - synchronizers: Array<{ synchronizerAlias: string; synchronizerId: string }> + 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') From 0f6b31e98b68a6a5302d2313856801fcf944b483 Mon Sep 17 00:00:00 2001 From: jarekr-da Date: Mon, 13 Jul 2026 17:43:17 +0200 Subject: [PATCH 21/23] review: syncAlias not exported anymore Signed-off-by: jarekr-da --- .../examples/scripts/utils/acs-logger.ts | 5 +---- .../wallet-integration-guide/examples/scripts/utils/index.ts | 2 +- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/docs/wallet-integration-guide/examples/scripts/utils/acs-logger.ts b/docs/wallet-integration-guide/examples/scripts/utils/acs-logger.ts index 924198033..a94571b69 100644 --- a/docs/wallet-integration-guide/examples/scripts/utils/acs-logger.ts +++ b/docs/wallet-integration-guide/examples/scripts/utils/acs-logger.ts @@ -11,10 +11,7 @@ export type ContractReadSpec = { } /** Resolve a synchronizer ID to a logical role alias */ -export function syncAlias( - syncId: string, - synchronizers: KnownSynchronizers -): string { +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}`) diff --git a/docs/wallet-integration-guide/examples/scripts/utils/index.ts b/docs/wallet-integration-guide/examples/scripts/utils/index.ts index 306c7b09f..24191f08f 100644 --- a/docs/wallet-integration-guide/examples/scripts/utils/index.ts +++ b/docs/wallet-integration-guide/examples/scripts/utils/index.ts @@ -10,7 +10,7 @@ import { AssetConfig, } from '@canton-network/wallet-sdk' -export { syncAlias, logAllContracts } from './acs-logger.js' +export { logAllContracts } from './acs-logger.js' export type { ContractReadSpec as ContractSpec } from './acs-logger.js' export function getActiveContractCid(entry: JSContractEntry) { if ('JsActiveContract' in entry) { From 936a1504f46f71b18988fd953b8960646692e517 Mon Sep 17 00:00:00 2001 From: jarekr-da Date: Mon, 13 Jul 2026 20:33:14 +0200 Subject: [PATCH 22/23] review: discarded use of acs.read - changed acs-logger Signed-off-by: jarekr-da --- .../17-multi-sync/_token_allocation.ts | 4 +-- .../scripts/17-multi-sync/_token_setup.ts | 13 +++++----- .../scripts/17-multi-sync/_token_transfer.ts | 25 +++++++++++-------- .../scripts/17-multi-sync/_trade_propose.ts | 22 ++++++++-------- .../examples/scripts/utils/acs-logger.ts | 5 +++- 5 files changed, 39 insertions(+), 30 deletions(-) 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 index fc64c1dd1..dd50a9e14 100644 --- 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 @@ -27,7 +27,7 @@ export async function allocateTokenForBob( )! if (!legId) throw new Error('No transfer leg found for Bob') - const tokenHoldings = await bobSdk.ledger.acs.read({ + const tokenHoldings = await bobSdk.ledger.acsReader.raw.readJsContracts({ templateIds: [TestTokenV1.Token.templateId], parties: [bob.partyId], filterByParty: true, @@ -58,7 +58,7 @@ export async function allocateTokenForBob( }) const appTokenRules = ( - await tokenAdminSdk.ledger.acs.read({ + await tokenAdminSdk.ledger.acsReader.raw.readJsContracts({ templateIds: [TestTokenV1.TokenRules.templateId], parties: [tokenAdmin.partyId], filterByParty: true, 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 index 2505038c2..0cecaa513 100644 --- 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 @@ -68,11 +68,12 @@ export async function mintAndTransferTokenToBob( .sign(tokenAdmin.keyPair.privateKey) .execute({ partyId: tokenAdmin.partyId }) - const adminTokenHoldings = await tokenAdminSdk.ledger.acs.read({ - templateIds: [TestTokenV1.Token.templateId], - parties: [tokenAdmin.partyId], - filterByParty: true, - }) + 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') @@ -100,7 +101,7 @@ export async function mintAndTransferTokenToBob( .sign(tokenAdmin.keyPair.privateKey) .execute({ partyId: tokenAdmin.partyId }) - const transferOffers = await bobSdk.ledger.acs.read({ + const transferOffers = await bobSdk.ledger.acsReader.raw.readJsContracts({ templateIds: [TestTokenV1.TokenTransferOffer.templateId], parties: [bob.partyId], filterByParty: true, 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 index c1215a2a0..9b5ecd00e 100644 --- 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 @@ -29,11 +29,13 @@ export async function aliceTransferToCharlie( const deadline = Date.now() + TOKEN_POLL_TIMEOUT_MS let aliceToken for (;;) { - const aliceTokens = await aliceSdk.ledger.acs.read({ - templateIds: [TestTokenV1.Token.templateId], - parties: [alice.partyId], - filterByParty: true, - }) + 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) @@ -76,11 +78,12 @@ export async function aliceTransferToCharlie( .sign(alice.keyPair.privateKey) .execute({ partyId: alice.partyId }) - const transferOffers = await charlieSdk.ledger.acs.read({ - templateIds: [TestTokenV1.TokenTransferOffer.templateId], - parties: [charlie.partyId], - filterByParty: true, - }) + 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') @@ -112,7 +115,7 @@ export async function bobSelfTransferToApp( ): Promise { const { bobSdk, bob, appSynchronizerId } = setup - const bobTokens = await bobSdk.ledger.acs.read({ + const bobTokens = await bobSdk.ledger.acsReader.raw.readJsContracts({ templateIds: [TestTokenV1.Token.templateId], parties: [bob.partyId], filterByParty: true, 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 index 72c5d5df6..15d5650a8 100644 --- 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 @@ -93,11 +93,12 @@ export async function createAndInitiateOtcTrade( ): Promise => pollUntil( async () => { - const proposals = await sdk.ledger.acs.read({ - templateIds: [OTC_TRADE_PROPOSAL_TEMPLATE_ID], - parties: [party], - filterByParty: true, - }) + const proposals = + await sdk.ledger.acsReader.raw.readJsContracts({ + templateIds: [OTC_TRADE_PROPOSAL_TEMPLATE_ID], + parties: [party], + filterByParty: true, + }) return proposals.find((proposal) => predicate( (( @@ -174,11 +175,12 @@ export async function createAndInitiateOtcTrade( 'TradingApp: OTCTradeProposal_InitiateSettlement executed → OTCTrade created' ) - const otcTradeContracts = await tradingAppSdk.ledger.acs.read({ - templateIds: [OTC_TRADE_TEMPLATE_ID], - parties: [tradingApp.partyId], - filterByParty: true, - }) + 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') diff --git a/docs/wallet-integration-guide/examples/scripts/utils/acs-logger.ts b/docs/wallet-integration-guide/examples/scripts/utils/acs-logger.ts index a94571b69..28ee0ca86 100644 --- a/docs/wallet-integration-guide/examples/scripts/utils/acs-logger.ts +++ b/docs/wallet-integration-guide/examples/scripts/utils/acs-logger.ts @@ -29,7 +29,10 @@ export async function logAllContracts( ): Promise { const results = await Promise.all( specs.map(({ sdk, parties }) => - sdk.ledger.acs.read({ parties, filterByParty: true }) + sdk.ledger.acsReader.raw.readJsContracts({ + parties, + filterByParty: true, + }) ) ) From 25935a869c3a4917ee63e99801e0166bd5473a01 Mon Sep 17 00:00:00 2001 From: jarekr-da Date: Tue, 14 Jul 2026 10:00:23 +0200 Subject: [PATCH 23/23] review: ci - style fix Signed-off-by: jarekr-da --- core/test-token-v1/src/registry/features/transfer/handlers.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/core/test-token-v1/src/registry/features/transfer/handlers.ts b/core/test-token-v1/src/registry/features/transfer/handlers.ts index ec7bf570c..c6809a436 100644 --- a/core/test-token-v1/src/registry/features/transfer/handlers.ts +++ b/core/test-token-v1/src/registry/features/transfer/handlers.ts @@ -37,8 +37,7 @@ export function createTransferHandlers( body, }): Promise => { const transfer = body.choiceArguments?.['transfer'] as - | Record - | undefined + Record | undefined if (transfer === undefined) throw new Error( 'getTransferFactory: missing "transfer" choice argument'