diff --git a/.github/actions/setup_localnet/artifacts/action.yml b/.github/actions/setup_localnet/artifacts/action.yml index 4592c3e6e..9dde764d6 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 a6dd02807..b10ca8977 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -389,6 +389,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 @@ -450,6 +451,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 @@ -500,6 +502,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 25f0d47e4..c3ab221d5 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/amulet-ops/package.json b/core/amulet-ops/package.json new file mode 100644 index 000000000..5db8aacaa --- /dev/null +++ b/core/amulet-ops/package.json @@ -0,0 +1,55 @@ +{ + "name": "@canton-network/core-amulet-ops", + "version": "0.0.1", + "type": "module", + "description": "SDK-level Amulet operations (tap/mint, allocate) built on the wallet SDK", + "license": "Apache-2.0", + "packageManager": "yarn@4.9.4", + "main": "dist/index.cjs", + "module": "dist/index.js", + "types": "dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "require": "./dist/index.cjs", + "default": "./dist/index.js" + } + }, + "scripts": { + "build": "tsup --onSuccess \"tsc\"", + "dev": "tsup --watch --onSuccess \"tsc\"", + "clean": "tsc -b --clean; rm -rf dist", + "flatpack": "yarn pack --out \"$FLATPACK_OUTDIR\"", + "test": "vitest run --project node --passWithNoTests", + "test:coverage": "vitest run --project node --coverage --passWithNoTests" + }, + "dependencies": { + "@canton-network/core-amulet-service": "workspace:^" + }, + "peerDependencies": { + "@canton-network/core-signing-lib": "workspace:^", + "@canton-network/wallet-sdk": "workspace:^", + "pino": "^10.3.1" + }, + "devDependencies": { + "@canton-network/core-signing-lib": "workspace:^", + "@canton-network/wallet-sdk": "workspace:^", + "@vitest/coverage-v8": "^4.1.2", + "pino": "^10.3.1", + "tsup": "^8.5.1", + "typescript": "^5.9.3", + "vitest": "^4.1.2" + }, + "files": [ + "dist/**" + ], + "publishConfig": { + "access": "public" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/canton-network/wallet.git", + "directory": "core/amulet-ops" + } +} diff --git a/core/amulet-ops/src/allocation.ts b/core/amulet-ops/src/allocation.ts new file mode 100644 index 000000000..43cfee1a2 --- /dev/null +++ b/core/amulet-ops/src/allocation.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 + +import type { Logger } from 'pino' +import type { SDKInterface } from '@canton-network/wallet-sdk' +import { AMULET_TEMPLATE_ID } from '@canton-network/core-amulet-service' +import type { SigningParty } from './tap.js' + +const AMULET_INSTRUMENT = { + id: 'Amulet', + displayName: 'Amulet', + symbol: 'CC', +} as const + +export interface AllocateAmuletParams { + sdk: SDKInterface<'token'> + sender: SigningParty + adminPartyId: string + registryUrl: URL + globalSynchronizerId: string + logger?: Logger +} + +/** + * Allocates the sender's Amulet holding against its leg of a pending token + * allocation request. + * + * Looks up the sender's transfer leg in the pending allocation request, reads its + * Amulet holding, builds the allocation instruction for the Amulet instrument, and + * submits it signed by the sender. + * + * @returns The transfer-leg id that was allocated. + */ +export async function allocateAmulet( + params: AllocateAmuletParams +): Promise { + const { + sdk, + sender, + adminPartyId, + registryUrl, + globalSynchronizerId, + logger, + } = params + const token = sdk.token + + const pendingRequests = await token.allocation.request.pending( + sender.partyId + ) + const requestView = pendingRequests[0].interfaceViewValue! + const legId = Object.keys(requestView.transferLegs).find( + (key) => requestView.transferLegs[key].sender === sender.partyId + )! + if (!legId) throw new Error('No transfer leg found for sender') + + const amuletHoldings = await sdk.ledger.acsReader.readJsContracts({ + templateIds: [AMULET_TEMPLATE_ID], + parties: [sender.partyId], + filterByParty: true, + }) + const amuletHoldingCid = amuletHoldings[0]?.contractId + if (!amuletHoldingCid) + throw new Error('Amulet holding not found for sender') + + const [command, disclosedContracts] = + await token.allocation.instruction.create({ + allocationSpecification: { + settlement: requestView.settlement, + transferLegId: legId, + transferLeg: requestView.transferLegs[legId], + }, + asset: { + id: AMULET_INSTRUMENT.id, + displayName: AMULET_INSTRUMENT.displayName, + symbol: AMULET_INSTRUMENT.symbol, + registryUrl, + admin: adminPartyId, + }, + inputUtxos: [amuletHoldingCid], + requestedAt: new Date().toISOString(), + }) + + await sdk.ledger + .prepare({ + partyId: sender.partyId, + commands: [command], + disclosedContracts, + synchronizerId: globalSynchronizerId, + }) + .sign(sender.privateKey) + .execute({ partyId: sender.partyId }) + + logger?.info('Amulet allocated for sender leg (global synchronizer)') + return legId +} diff --git a/core/amulet-ops/src/index.ts b/core/amulet-ops/src/index.ts new file mode 100644 index 000000000..3e0eb4cba --- /dev/null +++ b/core/amulet-ops/src/index.ts @@ -0,0 +1,8 @@ +// Copyright (c) 2025-2026 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +export { mintAmulet } from './tap.js' +export type { MintAmuletParams, SigningParty } from './tap.js' + +export { allocateAmulet } from './allocation.js' +export type { AllocateAmuletParams } from './allocation.js' diff --git a/core/amulet-ops/src/tap.ts b/core/amulet-ops/src/tap.ts new file mode 100644 index 000000000..b71b4ab0e --- /dev/null +++ b/core/amulet-ops/src/tap.ts @@ -0,0 +1,46 @@ +// 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 { PrivateKey } from '@canton-network/core-signing-lib' +import type { SDKInterface } from '@canton-network/wallet-sdk' + +export interface SigningParty { + partyId: string + privateKey: PrivateKey +} + +export interface MintAmuletParams { + sdk: SDKInterface<'amulet'> + receiver: SigningParty + amount: string + synchronizerId: string + logger?: Logger +} + +/** + * Taps (mints) `amount` Amulet into `receiver`'s wallet on `synchronizerId`. + * + * Builds the tap command via the SDK's `amulet` namespace, then prepares, signs, + * and executes it as a single-party submission by the receiver. + */ +export async function mintAmulet(params: MintAmuletParams): Promise { + const { sdk, receiver, amount, synchronizerId, logger } = params + + const [tapCommand, disclosedContracts] = await sdk.amulet.tap( + receiver.partyId, + amount + ) + + await sdk.ledger + .prepare({ + partyId: receiver.partyId, + commands: tapCommand, + disclosedContracts, + synchronizerId, + }) + .sign(receiver.privateKey) + .execute({ partyId: receiver.partyId }) + + logger?.info(`Amulet minted (${amount}) for receiver on synchronizer`) +} diff --git a/core/amulet-ops/tsconfig.json b/core/amulet-ops/tsconfig.json new file mode 100644 index 000000000..572eba58e --- /dev/null +++ b/core/amulet-ops/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.web.json", + "compilerOptions": { + "rootDir": "./src", + "outDir": "./dist" + }, + "include": ["src"] +} diff --git a/core/amulet-ops/tsup.config.ts b/core/amulet-ops/tsup.config.ts new file mode 100644 index 000000000..eede71ea8 --- /dev/null +++ b/core/amulet-ops/tsup.config.ts @@ -0,0 +1,11 @@ +// Copyright (c) 2025-2026 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { defineConfig } from 'tsup' +import { base } from '../../tsup.base' + +export default defineConfig({ + ...base, + entry: ['src/index.ts'], + platform: 'node', +}) diff --git a/core/amulet-ops/vitest.config.ts b/core/amulet-ops/vitest.config.ts new file mode 100644 index 000000000..67370d096 --- /dev/null +++ b/core/amulet-ops/vitest.config.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 + +import { defineConfig, defineProject } from 'vitest/config' + +export default defineConfig({ + test: { + coverage: { + include: ['src/**/*.ts'], + provider: 'v8', + reporter: ['text', 'html', 'lcov'], + thresholds: { + lines: 0, + functions: 0, + branches: 0, + statements: 0, + }, + }, + projects: [ + defineProject({ + test: { + name: 'node', + environment: 'node', + include: ['src/**/*.test.ts'], + }, + }), + ], + }, +}) diff --git a/core/amulet-service/src/index.ts b/core/amulet-service/src/index.ts index b3284dd0a..d382e07d5 100644 --- a/core/amulet-service/src/index.ts +++ b/core/amulet-service/src/index.ts @@ -1,4 +1,5 @@ // Copyright (c) 2025-2026 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. // SPDX-License-Identifier: Apache-2.0 +export * from './interface-ids.const.js' export * from './amulet-service.js' diff --git a/core/amulet-service/src/interface-ids.const.ts b/core/amulet-service/src/interface-ids.const.ts new file mode 100644 index 000000000..a0b826a34 --- /dev/null +++ b/core/amulet-service/src/interface-ids.const.ts @@ -0,0 +1,4 @@ +// Copyright (c) 2025-2026 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +export const AMULET_TEMPLATE_ID = '#splice-amulet:Splice.Amulet:Amulet' diff --git a/core/ledger-client/src/ledger-client.ts b/core/ledger-client/src/ledger-client.ts index 24601a2b7..bbe2017aa 100644 --- a/core/ledger-client/src/ledger-client.ts +++ b/core/ledger-client/src/ledger-client.ts @@ -505,20 +505,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/.gitignore b/core/test-token/.gitignore new file mode 100644 index 000000000..05f20ca38 --- /dev/null +++ b/core/test-token/.gitignore @@ -0,0 +1 @@ +.rollup.cache diff --git a/core/test-token/package.json b/core/test-token/package.json new file mode 100644 index 000000000..9bbe7e021 --- /dev/null +++ b/core/test-token/package.json @@ -0,0 +1,59 @@ +{ + "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", + "packageManager": "yarn@4.9.4", + "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" + } + }, + "scripts": { + "build": "yarn clean && rollup -c && yarn clean:types-tmp", + "clean:types-tmp": "rm -rf dist/types", + "clean": "rm -rf dist" + }, + "dependencies": { + "@daml/types": "^3.5.0", + "@mojotech/json-type-validation": "^3.1.0" + }, + "peerDependencies": { + "@canton-network/core-signing-lib": "workspace:^", + "@canton-network/wallet-sdk": "workspace:^", + "pino": "^10.3.1" + }, + "devDependencies": { + "@canton-network/core-signing-lib": "workspace:^", + "@canton-network/wallet-sdk": "workspace:^", + "@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", + "pino": "^10.3.1", + "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" + } +} diff --git a/core/test-token/rollup.config.js b/core/test-token/rollup.config.js new file mode 100644 index 000000000..a4c3b0208 --- /dev/null +++ b/core/test-token/rollup.config.js @@ -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 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)) + +// 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, + }, + }), + ], +} + +export default [codeEsm, codeCjs, codeBrowser, types] diff --git a/core/test-token/src/allocation.ts b/core/test-token/src/allocation.ts new file mode 100644 index 000000000..60d32b500 --- /dev/null +++ b/core/test-token/src/allocation.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 type { PrivateKey } from '@canton-network/core-signing-lib' +import type { SDKInterface } from '@canton-network/wallet-sdk' +import { Splice } from '@daml.js/splice-test-token-v1-1.0.0' + +const TestTokenV1 = Splice.Testing.Tokens.TestTokenV1 + +/** The instrument the TestToken DAR mints holdings for. */ +const TEST_TOKEN_INSTRUMENT = { + id: 'TestToken', + displayName: 'TestToken', + symbol: 'TT', +} as const + +export interface AllocateTestTokenParams { + /** SDK for the participant hosting the sender (must have the `token` namespace). */ + sdk: SDKInterface<'token'> + /** The party allocating its TestToken holding, plus the key used to sign. */ + sender: { partyId: string; privateKey: PrivateKey } + /** The party that administers the TestToken (the `TokenRules` admin). */ + adminPartyId: string + /** Synchronizer the TokenRules live on and the allocation is submitted to. */ + globalSynchronizerId: string + logger?: Logger +} + +/** + * Allocates the sender's TestToken holding against its leg of a pending token + * allocation request. + * + * Looks up the sender's transfer leg in the pending allocation request, reassigns + * its TestToken holding onto the target synchronizer (no-op if already there), + * builds the allocation instruction against the `TokenRules` on that synchronizer, + * and submits it signed by the sender. + * + * The TestToken-specific knowledge (the `Token` / `TokenRules` template IDs and the + * `TestToken`/`TT` instrument descriptor) lives here; everything else is supplied + * by the caller so the same flow works for any environment. + * + * @returns The transfer-leg id that was allocated. + */ +export async function allocateTestToken( + params: AllocateTestTokenParams +): Promise<{ legId: string }> { + const { sdk, sender, adminPartyId, globalSynchronizerId, logger } = params + const token = sdk.token + + const pendingRequests = await token.allocation.request.pending( + sender.partyId + ) + const requestView = pendingRequests[0].interfaceViewValue! + const legId = Object.keys(requestView.transferLegs).find( + (key) => requestView.transferLegs[key].sender === sender.partyId + )! + if (!legId) throw new Error('No transfer leg found for sender') + + const [tokenHoldings, tokenRulesContracts] = await Promise.all([ + sdk.ledger.acs.read({ + templateIds: [TestTokenV1.Token.templateId], + parties: [sender.partyId], + filterByParty: true, + }), + sdk.ledger.acs.read({ + templateIds: [TestTokenV1.TokenRules.templateId], + parties: [adminPartyId], + filterByParty: true, + }), + ]) + + const tokenHolding = tokenHoldings[0] + if (!tokenHolding) throw new Error('Token holding not found for sender') + const tokenRulesOnGlobal = tokenRulesContracts.find( + (c) => c.synchronizerId === globalSynchronizerId + ) + if (!tokenRulesOnGlobal) + throw new Error('TokenRules not found on global synchronizer') + + await sdk.ledger.internal.reassign({ + submitter: sender.partyId, + contractId: tokenHolding.contractId, + source: tokenHolding.synchronizerId, + target: globalSynchronizerId, + skipIfAlreadyOn: true, + }) + + const [command, disclosedFromHelper] = + await token.allocation.instruction.create({ + allocationSpecification: { + settlement: requestView.settlement, + transferLegId: legId, + transferLeg: requestView.transferLegs[legId], + }, + asset: { + id: TEST_TOKEN_INSTRUMENT.id, + displayName: TEST_TOKEN_INSTRUMENT.displayName, + symbol: TEST_TOKEN_INSTRUMENT.symbol, + registryUrl: new URL('http://unused.invalid'), + admin: adminPartyId, + }, + inputUtxos: [tokenHolding.contractId], + requestedAt: new Date(Date.now()).toISOString(), + prefetchedRegistryChoiceContext: { + factoryId: tokenRulesOnGlobal.contractId, + choiceContext: { + choiceContextData: {} as Record, + disclosedContracts: [], + }, + }, + }) + + await sdk.ledger + .prepare({ + partyId: sender.partyId, + commands: [command], + disclosedContracts: [ + ...disclosedFromHelper, + { + templateId: tokenRulesOnGlobal.templateId, + contractId: tokenRulesOnGlobal.contractId, + createdEventBlob: tokenRulesOnGlobal.createdEventBlob!, + synchronizerId: tokenRulesOnGlobal.synchronizerId, + }, + ], + synchronizerId: globalSynchronizerId, + }) + .sign(sender.privateKey) + .execute({ partyId: sender.partyId }) + + logger?.info( + 'TestToken allocated for sender leg (global synchronizer, single-party)' + ) + return { legId } +} diff --git a/core/test-token/src/commands.ts b/core/test-token/src/commands.ts new file mode 100644 index 000000000..91bacc7e4 --- /dev/null +++ b/core/test-token/src/commands.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 + +import { Splice } from '@daml.js/splice-test-token-v1-1.0.0' + +const T = Splice.Testing.Tokens.TestTokenV1 + +const TRANSFER_FACTORY_INTERFACE_ID = + '#splice-api-token-transfer-instruction-v1:Splice.Api.Token.TransferInstructionV1:TransferFactory' +const TRANSFER_INSTRUCTION_INTERFACE_ID = + '#splice-api-token-transfer-instruction-v1:Splice.Api.Token.TransferInstructionV1:TransferInstruction' + +/** 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: {} }, + }, + }, + }, + } +} + +/** Build an ExerciseCommand for TransferFactory_Transfer on a TokenRules contract. */ +export function buildTransferTokenCommand(params: { + tokenRulesCid: string + expectedAdmin: string + sender: string + receiver: string + amount: string + admin: string + inputHoldingCids: string[] + requestedAt: string + executeBefore: string +}) { + return { + ExerciseCommand: { + templateId: TRANSFER_FACTORY_INTERFACE_ID, + contractId: params.tokenRulesCid, + choice: 'TransferFactory_Transfer', + choiceArgument: { + expectedAdmin: params.expectedAdmin, + transfer: { + sender: params.sender, + receiver: params.receiver, + amount: params.amount, + instrumentId: { admin: params.admin, id: 'TestToken' }, + requestedAt: params.requestedAt, + executeBefore: params.executeBefore, + inputHoldingCids: params.inputHoldingCids, + meta: { values: {} }, + }, + extraArgs: { + context: { values: {} }, + meta: { values: {} }, + }, + }, + }, + } +} + +/** Build an ExerciseCommand that accepts a pending TransferInstruction (TokenTransferOffer). */ +export function buildAcceptTransferInstructionCommand(offerCid: string) { + return { + ExerciseCommand: { + templateId: TRANSFER_INSTRUCTION_INTERFACE_ID, + contractId: offerCid, + choice: 'TransferInstruction_Accept', + choiceArgument: { + extraArgs: { + context: { values: {} }, + meta: { values: {} }, + }, + }, + }, + } +} diff --git a/core/test-token/src/index.ts b/core/test-token/src/index.ts new file mode 100644 index 000000000..a3652b6a1 --- /dev/null +++ b/core/test-token/src/index.ts @@ -0,0 +1,28 @@ +// 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 } + +export { + buildCreateTokenRulesCommand, + buildMintTokenCommand, + buildTransferTokenCommand, + buildAcceptTransferInstructionCommand, +} from './commands.js' + +export { allocateTestToken } from './allocation.js' +export type { AllocateTestTokenParams } from './allocation.js' + +export { createTokenRules, mintTestToken } from './setup.js' +export type { + SigningParty, + CreateTokenRulesParams, + MintTestTokenParams, +} from './setup.js' + +export { selfTransferTestToken, selfTransferAllTestTokens } from './transfer.js' +export type { + SelfTransferTestTokenParams, + SelfTransferAllTestTokensParams, +} from './transfer.js' diff --git a/core/test-token/src/setup.ts b/core/test-token/src/setup.ts new file mode 100644 index 000000000..3b88f36bf --- /dev/null +++ b/core/test-token/src/setup.ts @@ -0,0 +1,160 @@ +// 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 { PrivateKey } from '@canton-network/core-signing-lib' +import type { SDKInterface } from '@canton-network/wallet-sdk' +import { Splice } from '@daml.js/splice-test-token-v1-1.0.0' +import { + buildCreateTokenRulesCommand, + buildMintTokenCommand, + buildTransferTokenCommand, + buildAcceptTransferInstructionCommand, +} from './commands.js' + +const TestTokenV1 = Splice.Testing.Tokens.TestTokenV1 + +/** Default validity window for a mint's transfer offer: 24 hours. */ +const DEFAULT_TRANSFER_VALIDITY_MS = 24 * 60 * 60 * 1000 + +export interface SigningParty { + partyId: string + privateKey: PrivateKey +} + +export interface CreateTokenRulesParams { + sdk: SDKInterface<'token'> + admin: SigningParty + synchronizerIds: string[] + logger?: Logger +} + +/** + * Creates a `TokenRules` contract administered by `admin` on each of the given + * synchronizers (prepared, signed, and executed in parallel per synchronizer). + */ +export async function createTokenRules( + params: CreateTokenRulesParams +): Promise { + const { sdk, admin, synchronizerIds, logger } = params + + await sdk.ledger.executeOnSynchronizers( + { + partyId: admin.partyId, + commands: buildCreateTokenRulesCommand(admin.partyId), + disclosedContracts: [], + }, + synchronizerIds, + admin.privateKey + ) + + logger?.info( + `TokenRules created on ${synchronizerIds.length} synchronizer(s)` + ) +} + +export interface MintTestTokenParams { + sdk: SDKInterface<'token'> + admin: SigningParty + receiver: SigningParty + amount: string + synchronizerId: string + transferValidityMs?: number + logger?: Logger +} + +/** + * Mints `amount` TestToken into `receiver`'s wallet on `synchronizerId`. + * + * The TestToken DAR has no direct mint-to-arbitrary-party choice, so this runs + * the full flow: `admin` mints a holding to itself, transfers it to `receiver` + * (creating a `TokenTransferOffer`), and `receiver` accepts the offer. + */ +export async function mintTestToken( + params: MintTestTokenParams +): Promise { + const { sdk, admin, receiver, amount, synchronizerId, logger } = params + const validityMs = params.transferValidityMs ?? DEFAULT_TRANSFER_VALIDITY_MS + + await sdk.ledger + .prepare({ + partyId: admin.partyId, + commands: [ + buildMintTokenCommand({ + owner: admin.partyId, + admin: admin.partyId, + amount, + }), + ], + disclosedContracts: [], + synchronizerId, + }) + .sign(admin.privateKey) + .execute({ partyId: admin.partyId }) + + const [tokenRulesContracts, adminTokenHoldings] = await Promise.all([ + sdk.ledger.acs.read({ + templateIds: [TestTokenV1.TokenRules.templateId], + parties: [admin.partyId], + filterByParty: true, + }), + sdk.ledger.acs.read({ + templateIds: [TestTokenV1.Token.templateId], + parties: [admin.partyId], + filterByParty: true, + }), + ]) + const tokenRules = tokenRulesContracts.find( + (c) => c.synchronizerId === synchronizerId + ) + if (!tokenRules) + throw new Error('TokenRules not found on synchronizer after creation') + const adminTokenCid = adminTokenHoldings[0]?.contractId + if (!adminTokenCid) + throw new Error('Admin Token holding not found after mint') + + await sdk.ledger + .prepare({ + partyId: admin.partyId, + commands: [ + buildTransferTokenCommand({ + tokenRulesCid: tokenRules.contractId, + expectedAdmin: admin.partyId, + sender: admin.partyId, + receiver: receiver.partyId, + amount, + admin: admin.partyId, + inputHoldingCids: [adminTokenCid], + requestedAt: new Date(Date.now()).toISOString(), + executeBefore: new Date( + Date.now() + validityMs + ).toISOString(), + }), + ], + disclosedContracts: [], + synchronizerId, + }) + .sign(admin.privateKey) + .execute({ partyId: admin.partyId }) + + const transferOffers = await sdk.ledger.acs.read({ + templateIds: [TestTokenV1.TokenTransferOffer.templateId], + parties: [receiver.partyId], + filterByParty: true, + }) + const transferOfferCid = transferOffers[0]?.contractId + if (!transferOfferCid) + throw new Error('TokenTransferOffer not found for receiver') + + await sdk.ledger + .prepare({ + partyId: receiver.partyId, + commands: [buildAcceptTransferInstructionCommand(transferOfferCid)], + disclosedContracts: [], + synchronizerId, + }) + .sign(receiver.privateKey) + .execute({ partyId: receiver.partyId }) + + logger?.info(`${amount} TestToken minted to receiver on synchronizer`) +} diff --git a/core/test-token/src/transfer.ts b/core/test-token/src/transfer.ts new file mode 100644 index 000000000..11836e324 --- /dev/null +++ b/core/test-token/src/transfer.ts @@ -0,0 +1,257 @@ +// 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 { Splice } from '@daml.js/splice-test-token-v1-1.0.0' +import { buildTransferTokenCommand } from './commands.js' +import type { SigningParty } from './setup.js' + +const TestTokenV1 = Splice.Testing.Tokens.TestTokenV1 + +const DEFAULT_TRANSFER_VALIDITY_MS = 24 * 60 * 60 * 1000 + +const DEFAULT_POLL_TIMEOUT_MS = 30_000 +const DEFAULT_POLL_INTERVAL_MS = 500 + +type TokenHolding = Awaited< + ReturnType['ledger']['acs']['read']> +>[number] + +/** Finds the `TokenRules` administered by `adminPartyId` on the given synchronizer. */ +async function findTokenRulesOnSynchronizer( + sdk: SDKInterface<'token'>, + adminPartyId: string, + synchronizerId: string +): Promise { + const contracts = await sdk.ledger.acs.read({ + templateIds: [TestTokenV1.TokenRules.templateId], + parties: [adminPartyId], + filterByParty: true, + }) + const tokenRules = contracts.find( + (c) => c.synchronizerId === synchronizerId + ) + if (!tokenRules) + throw new Error( + `TokenRules not found on synchronizer ${synchronizerId}` + ) + return tokenRules +} + +/** Reads the holding amount off a Token contract. */ +function readHoldingAmount(holding: TokenHolding): string { + const amount = ( + holding as unknown as { + createArgument: Splice.Testing.Tokens.TestTokenV1.Token + } + ).createArgument?.holding?.amount + if (!amount) throw new Error('Cannot read amount from Token holding') + return amount +} + +/** + * Reassigns a holding onto the target synchronizer (no-op if already there) and + * self-transfers `amount` of it via the admin's `TokenRules`. + */ +async function selfTransferHolding(args: { + sdk: SDKInterface<'token'> + owner: SigningParty + adminPartyId: string + synchronizerId: string + tokenRules: TokenHolding + holding: TokenHolding + amount: string + validityMs: number +}): Promise { + const { sdk, owner, adminPartyId, synchronizerId, tokenRules, holding } = + args + + if (holding.synchronizerId !== synchronizerId) { + await sdk.ledger.internal.reassign({ + submitter: owner.partyId, + contractId: holding.contractId, + source: holding.synchronizerId, + target: synchronizerId, + skipIfAlreadyOn: true, + }) + } + + await sdk.ledger + .prepare({ + partyId: owner.partyId, + commands: [ + buildTransferTokenCommand({ + tokenRulesCid: tokenRules.contractId, + expectedAdmin: adminPartyId, + sender: owner.partyId, + receiver: owner.partyId, + amount: args.amount, + admin: adminPartyId, + inputHoldingCids: [holding.contractId], + requestedAt: new Date(Date.now()).toISOString(), + executeBefore: new Date( + Date.now() + args.validityMs + ).toISOString(), + }), + ], + disclosedContracts: [ + { + templateId: tokenRules.templateId, + contractId: tokenRules.contractId, + createdEventBlob: tokenRules.createdEventBlob!, + synchronizerId: tokenRules.synchronizerId, + }, + ], + synchronizerId, + }) + .sign(owner.privateKey) + .execute({ partyId: owner.partyId }) +} + +/** Polls for the owner's first Token holding to appear, up to a timeout. */ +async function waitForFirstHolding( + sdk: SDKInterface<'token'>, + ownerPartyId: string, + timeoutMs: number, + intervalMs: number +): Promise { + const deadline = Date.now() + timeoutMs + for (;;) { + const holdings = await sdk.ledger.acs.read({ + templateIds: [TestTokenV1.Token.templateId], + parties: [ownerPartyId], + filterByParty: true, + }) + if (holdings[0]) return holdings[0] + if (Date.now() >= deadline) + throw new Error( + `Token holding not found for ${ownerPartyId} within ${timeoutMs}ms` + ) + await new Promise((resolve) => setTimeout(resolve, intervalMs)) + } +} + +export interface SelfTransferTestTokenParams { + sdk: SDKInterface<'token'> + owner: SigningParty + adminPartyId: string + adminSdk?: SDKInterface<'token'> + synchronizerId: string + amount: string + transferValidityMs?: number + waitForHolding?: { timeoutMs?: number; intervalMs?: number } | false + logger?: Logger +} + +/** + * Self-transfers `amount` of the owner's first TestToken holding onto + * `synchronizerId`, reassigning the holding there first if needed. + * + * By default it waits for the holding to appear (useful right after a + * settlement), then runs the transfer via the admin's `TokenRules`. + */ +export async function selfTransferTestToken( + params: SelfTransferTestTokenParams +): Promise { + const { sdk, owner, adminPartyId, synchronizerId, amount, logger } = params + const validityMs = params.transferValidityMs ?? DEFAULT_TRANSFER_VALIDITY_MS + + let holding: TokenHolding + if (params.waitForHolding === false) { + const holdings = await sdk.ledger.acs.read({ + templateIds: [TestTokenV1.Token.templateId], + parties: [owner.partyId], + filterByParty: true, + }) + if (!holdings[0]) + throw new Error(`Token holding not found for ${owner.partyId}`) + holding = holdings[0] + } else { + holding = await waitForFirstHolding( + sdk, + owner.partyId, + params.waitForHolding?.timeoutMs ?? DEFAULT_POLL_TIMEOUT_MS, + params.waitForHolding?.intervalMs ?? DEFAULT_POLL_INTERVAL_MS + ) + } + + const tokenRules = await findTokenRulesOnSynchronizer( + params.adminSdk ?? sdk, + adminPartyId, + synchronizerId + ) + + await selfTransferHolding({ + sdk, + owner, + adminPartyId, + synchronizerId, + tokenRules, + holding, + amount, + validityMs, + }) + + logger?.info( + `${amount} TestToken self-transferred on synchronizer ${synchronizerId}` + ) +} + +export interface SelfTransferAllTestTokensParams { + sdk: SDKInterface<'token'> + owner: SigningParty + adminPartyId: string + adminSdk?: SDKInterface<'token'> + synchronizerId: string + transferValidityMs?: number + logger?: Logger +} + +/** + * Self-transfers every TestToken holding the owner currently has onto + * `synchronizerId`, each at its full holding amount (reassigning to the target + * synchronizer first if needed). No-op when the owner has no holdings. + * + * @returns The number of holdings transferred. + */ +export async function selfTransferAllTestTokens( + params: SelfTransferAllTestTokensParams +): Promise { + const { sdk, owner, adminPartyId, synchronizerId, logger } = params + const validityMs = params.transferValidityMs ?? DEFAULT_TRANSFER_VALIDITY_MS + + const holdings = await sdk.ledger.acs.read({ + templateIds: [TestTokenV1.Token.templateId], + parties: [owner.partyId], + filterByParty: true, + }) + if (holdings.length === 0) { + logger?.info(`${owner.partyId}: no TestToken holdings to self-transfer`) + return 0 + } + + const tokenRules = await findTokenRulesOnSynchronizer( + params.adminSdk ?? sdk, + adminPartyId, + synchronizerId + ) + + for (const holding of holdings) { + await selfTransferHolding({ + sdk, + owner, + adminPartyId, + synchronizerId, + tokenRules, + holding, + amount: readHoldingAmount(holding), + validityMs, + }) + } + + logger?.info( + `${owner.partyId}: ${holdings.length} TestToken holding(s) self-transferred on synchronizer ${synchronizerId}` + ) + return holdings.length +} diff --git a/core/test-token/tsconfig.json b/core/test-token/tsconfig.json new file mode 100644 index 000000000..59119778a --- /dev/null +++ b/core/test-token/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/trading-app/package.json b/core/trading-app/package.json new file mode 100644 index 000000000..ae3a031cb --- /dev/null +++ b/core/trading-app/package.json @@ -0,0 +1,55 @@ +{ + "name": "@canton-network/core-trading-app", + "version": "0.0.1", + "type": "module", + "description": "OTC trading-app flows (propose, accept, initiate, settle) for the splice-token-test-trading-app", + "license": "Apache-2.0", + "packageManager": "yarn@4.9.4", + "main": "dist/index.cjs", + "module": "dist/index.js", + "types": "dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "require": "./dist/index.cjs", + "default": "./dist/index.js" + } + }, + "scripts": { + "build": "tsup --onSuccess \"tsc\"", + "dev": "tsup --watch --onSuccess \"tsc\"", + "clean": "tsc -b --clean; rm -rf dist", + "flatpack": "yarn pack --out \"$FLATPACK_OUTDIR\"", + "test": "vitest run --project node --passWithNoTests", + "test:coverage": "vitest run --project node --coverage --passWithNoTests" + }, + "dependencies": { + "@canton-network/core-ledger-client-types": "workspace:^" + }, + "peerDependencies": { + "@canton-network/core-signing-lib": "workspace:^", + "@canton-network/wallet-sdk": "workspace:^", + "pino": "^10.3.1" + }, + "devDependencies": { + "@canton-network/core-signing-lib": "workspace:^", + "@canton-network/wallet-sdk": "workspace:^", + "@vitest/coverage-v8": "^4.1.2", + "pino": "^10.3.1", + "tsup": "^8.5.1", + "typescript": "^5.9.3", + "vitest": "^4.1.2" + }, + "files": [ + "dist/**" + ], + "publishConfig": { + "access": "public" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/canton-network/wallet.git", + "directory": "core/trading-app" + } +} diff --git a/core/trading-app/src/commands.ts b/core/trading-app/src/commands.ts new file mode 100644 index 000000000..ab7dcaf91 --- /dev/null +++ b/core/trading-app/src/commands.ts @@ -0,0 +1,81 @@ +// Copyright (c) 2025-2026 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import type { PrivateKey } from '@canton-network/core-signing-lib' + +export interface SigningParty { + partyId: string + privateKey: PrivateKey +} + +export const OTC_TRADE_PROPOSAL_TEMPLATE_ID = + '#splice-token-test-trading-app:Splice.Testing.Apps.TradingApp:OTCTradeProposal' +export const OTC_TRADE_TEMPLATE_ID = + '#splice-token-test-trading-app:Splice.Testing.Apps.TradingApp:OTCTrade' + +export 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, + }, + }, + } +} + +export 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 }, + }, + } +} + +export 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, + }, + }, + } +} + +export function buildSettleOtcTradeCommand(params: { + tradeCid: string + allocationsWithContext: Record +}) { + return { + ExerciseCommand: { + templateId: OTC_TRADE_TEMPLATE_ID, + contractId: params.tradeCid, + choice: 'OTCTrade_Settle', + choiceArgument: { + allocationsWithContext: params.allocationsWithContext, + }, + }, + } +} diff --git a/core/trading-app/src/index.ts b/core/trading-app/src/index.ts new file mode 100644 index 000000000..bf20cae86 --- /dev/null +++ b/core/trading-app/src/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 + +export { + OTC_TRADE_PROPOSAL_TEMPLATE_ID, + OTC_TRADE_TEMPLATE_ID, + buildOtcTradeProposalCommand, + buildAcceptOtcTradeCommand, + buildInitiateSettlementCommand, + buildSettleOtcTradeCommand, +} from './commands.js' +export type { SigningParty } from './commands.js' + +export { createAndInitiateOtcTrade } from './propose.js' +export type { CreateAndInitiateOtcTradeParams } from './propose.js' + +export { settleOtcTrade } from './settle.js' +export type { + SettleOtcTradeParams, + ContextLeg, + DisclosedLeg, +} from './settle.js' + +export { withdrawAllocations } from './withdrawal.js' +export type { + WithdrawAllocationsParams, + AllocationWithdrawal, + AllocationWithdrawParams, +} from './withdrawal.js' diff --git a/core/trading-app/src/propose.ts b/core/trading-app/src/propose.ts new file mode 100644 index 000000000..8eeab0877 --- /dev/null +++ b/core/trading-app/src/propose.ts @@ -0,0 +1,158 @@ +// 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 { + OTC_TRADE_PROPOSAL_TEMPLATE_ID, + OTC_TRADE_TEMPLATE_ID, + buildOtcTradeProposalCommand, + buildAcceptOtcTradeCommand, + buildInitiateSettlementCommand, + type SigningParty, +} from './commands.js' + +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 interface CreateAndInitiateOtcTradeParams { + proposerSdk: SDKInterface<'token'> + proposer: SigningParty + acceptorSdk: SDKInterface<'token'> + acceptor: SigningParty + venueSdk: SDKInterface<'token'> + venue: SigningParty + transferLegs: Record + globalSynchronizerId: string + logger?: Logger +} + +/** + * Runs the full OTC trade initiation flow on the trading-app: + * 1. The proposer creates an `OTCTradeProposal` (itself as the sole initial approver). + * 2. The acceptor exercises `OTCTradeProposal_Accept`. + * 3. The venue exercises `OTCTradeProposal_InitiateSettlement`, producing an `OTCTrade`. + * + * @returns The contract id of the created `OTCTrade`. + */ +export async function createAndInitiateOtcTrade( + params: CreateAndInitiateOtcTradeParams +): Promise { + const { + proposerSdk, + proposer, + acceptorSdk, + acceptor, + venueSdk, + venue, + transferLegs, + globalSynchronizerId, + logger, + } = params + + 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.acsReader.readJsContracts({ + 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 proposerSdk.ledger + .prepare({ + partyId: proposer.partyId, + commands: buildOtcTradeProposalCommand({ + venue: venue.partyId, + transferLegs, + approvers: [proposer.partyId], + }), + disclosedContracts: [], + synchronizerId: globalSynchronizerId, + }) + .sign(proposer.privateKey) + .execute({ partyId: proposer.partyId }) + logger?.info('Proposer: OTCTradeProposal created') + + await acceptorSdk.ledger + .prepare({ + partyId: acceptor.partyId, + commands: [ + buildAcceptOtcTradeCommand({ + proposalCid: await readProposalCid( + acceptorSdk, + acceptor.partyId + ), + approver: acceptor.partyId, + }), + ], + disclosedContracts: [], + synchronizerId: globalSynchronizerId, + }) + .sign(acceptor.privateKey) + .execute({ partyId: acceptor.partyId }) + logger?.info('Acceptor: OTCTradeProposal_Accept executed') + + const prepareUntil = new Date(Date.now() + MS_30_MIN).toISOString() + const settleBefore = new Date(Date.now() + MS_1_HOUR).toISOString() + + await venueSdk.ledger + .prepare({ + partyId: venue.partyId, + commands: [ + buildInitiateSettlementCommand({ + proposalCid: await readProposalCid( + venueSdk, + venue.partyId, + (approvers) => approvers.includes(acceptor.partyId) + ), + prepareUntil, + settleBefore, + }), + ], + disclosedContracts: [], + synchronizerId: globalSynchronizerId, + }) + .sign(venue.privateKey) + .execute({ partyId: venue.partyId }) + logger?.info( + 'Venue: OTCTradeProposal_InitiateSettlement executed → OTCTrade created' + ) + + const otcTradeContracts = await venueSdk.ledger.acsReader.readJsContracts({ + templateIds: [OTC_TRADE_TEMPLATE_ID], + parties: [venue.partyId], + filterByParty: true, + }) + const otcTradeCid = otcTradeContracts[0]?.contractId + if (!otcTradeCid) + throw new Error('OTCTrade contract not found after initiation') + return otcTradeCid +} diff --git a/core/trading-app/src/settle.ts b/core/trading-app/src/settle.ts new file mode 100644 index 000000000..7b2b36269 --- /dev/null +++ b/core/trading-app/src/settle.ts @@ -0,0 +1,140 @@ +// 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, TokenNamespace } from '@canton-network/wallet-sdk' +import type { LedgerCommonSchemas } from '@canton-network/core-ledger-client-types' +import { buildSettleOtcTradeCommand, type SigningParty } from './commands.js' + +type DisclosedContract = LedgerCommonSchemas['DisclosedContract'] + +export interface ContextLeg { + tokenNamespace: TokenNamespace + ownerPartyId: string + legId: string + registryUrl: URL | string +} + +/** + * A settlement leg supplied pre-resolved: the allocation contract id plus the + * disclosed contract needed for the venue's participant to see it. + */ +export interface DisclosedLeg { + legId: string + allocationCid: string + disclosedContract: DisclosedContract +} + +export interface SettleOtcTradeParams { + venueSdk: SDKInterface<'token'> + venue: SigningParty + otcTradeCid: string + contextLeg: ContextLeg + disclosedLeg: DisclosedLeg + globalSynchronizerId: string + onSettlementFailure?: (contextLegAllocationCid: string) => Promise + logger?: Logger +} + +/** + * Settles a two-leg OTC trade by exercising `OTCTrade_Settle` on the venue's + * participant. One leg's allocation is located and its registry choice context + * resolved here; the other leg is supplied pre-disclosed by the caller. + * + * If settlement fails and `onSettlementFailure` is provided, it is invoked for + * compensation before the original error is re-thrown. + */ +export async function settleOtcTrade( + params: SettleOtcTradeParams +): Promise { + const { + venueSdk, + venue, + otcTradeCid, + contextLeg, + disclosedLeg, + globalSynchronizerId, + onSettlementFailure, + logger, + } = params + + const ownerAllocations = await contextLeg.tokenNamespace.allocation.pending( + contextLeg.ownerPartyId + ) + const contextAllocation = ownerAllocations.find( + (a) => + a.interfaceViewValue.allocation.transferLegId === contextLeg.legId + ) + if (!contextAllocation) + throw new Error('Allocation not found for context leg') + + const contextExecCtx = + await contextLeg.tokenNamespace.allocation.context.execute({ + allocationCid: contextAllocation.contractId, + registryUrl: contextLeg.registryUrl, + }) + + const allocationsWithContext = { + [contextLeg.legId]: { + _1: contextAllocation.contractId, + _2: { + context: { + ...(contextExecCtx.choiceContextData ?? {}), + values: + (contextExecCtx.choiceContextData?.values as Record< + string, + unknown + >) ?? {}, + }, + meta: { values: {} }, + }, + }, + [disclosedLeg.legId]: { + _1: disclosedLeg.allocationCid, + _2: { context: { values: {} }, meta: { values: {} } }, + }, + } + + const disclosedContracts = [ + ...(contextExecCtx.disclosedContracts ?? []).map((c) => ({ + ...c, + synchronizerId: '', + })), + disclosedLeg.disclosedContract, + ] + + try { + await venueSdk.ledger + .prepare({ + partyId: venue.partyId, + commands: [ + buildSettleOtcTradeCommand({ + tradeCid: otcTradeCid, + allocationsWithContext, + }), + ], + disclosedContracts, + synchronizerId: globalSynchronizerId, + }) + .sign(venue.privateKey) + .execute({ partyId: venue.partyId }) + } catch (settleError) { + logger?.error( + { err: settleError }, + 'Settlement failed — running compensation if provided' + ) + if (onSettlementFailure) { + try { + await onSettlementFailure(contextAllocation.contractId) + } catch (compensationError) { + logger?.error( + { err: compensationError }, + 'Compensation failed — manual intervention required to withdraw allocations' + ) + } + } + throw settleError + } + + logger?.info('Venue: OTCTrade settled — holdings transferred') +} diff --git a/core/trading-app/src/withdrawal.ts b/core/trading-app/src/withdrawal.ts new file mode 100644 index 000000000..958e2b999 --- /dev/null +++ b/core/trading-app/src/withdrawal.ts @@ -0,0 +1,54 @@ +// 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, TokenNamespace } from '@canton-network/wallet-sdk' +import type { SigningParty } from './commands.js' + +export type AllocationWithdrawParams = Parameters< + TokenNamespace['allocation']['withdraw'] +>[0] + +export interface AllocationWithdrawal { + sdk: SDKInterface<'token'> + owner: SigningParty + withdrawParams: AllocationWithdrawParams + logMessage?: string +} + +export interface WithdrawAllocationsParams { + withdrawals: AllocationWithdrawal[] + globalSynchronizerId: string + logger?: Logger +} + +/** + * Withdraws each allocation in parallel, returning the held funds to its owner. + * + * Useful as compensation when an OTC settlement fails: build one + * {@link AllocationWithdrawal} per locked allocation and the held holdings are + * released back to their respective parties. The asset descriptors are supplied + * by the caller, so this stays asset-agnostic. + */ +export async function withdrawAllocations( + params: WithdrawAllocationsParams +): Promise { + const { withdrawals, globalSynchronizerId, logger } = params + + await Promise.all( + withdrawals.map(async ({ sdk, owner, withdrawParams, logMessage }) => { + const [cmd, disclosed] = + await sdk.token.allocation.withdraw(withdrawParams) + await sdk.ledger + .prepare({ + partyId: owner.partyId, + commands: [cmd], + disclosedContracts: disclosed, + synchronizerId: globalSynchronizerId, + }) + .sign(owner.privateKey) + .execute({ partyId: owner.partyId }) + if (logMessage) logger?.info(logMessage) + }) + ) +} diff --git a/core/trading-app/tsconfig.json b/core/trading-app/tsconfig.json new file mode 100644 index 000000000..572eba58e --- /dev/null +++ b/core/trading-app/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.web.json", + "compilerOptions": { + "rootDir": "./src", + "outDir": "./dist" + }, + "include": ["src"] +} diff --git a/core/trading-app/tsup.config.ts b/core/trading-app/tsup.config.ts new file mode 100644 index 000000000..eede71ea8 --- /dev/null +++ b/core/trading-app/tsup.config.ts @@ -0,0 +1,11 @@ +// Copyright (c) 2025-2026 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { defineConfig } from 'tsup' +import { base } from '../../tsup.base' + +export default defineConfig({ + ...base, + entry: ['src/index.ts'], + platform: 'node', +}) diff --git a/core/trading-app/vitest.config.ts b/core/trading-app/vitest.config.ts new file mode 100644 index 000000000..67370d096 --- /dev/null +++ b/core/trading-app/vitest.config.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 + +import { defineConfig, defineProject } from 'vitest/config' + +export default defineConfig({ + test: { + coverage: { + include: ['src/**/*.ts'], + provider: 'v8', + reporter: ['text', 'html', 'lcov'], + thresholds: { + lines: 0, + functions: 0, + branches: 0, + statements: 0, + }, + }, + projects: [ + defineProject({ + test: { + name: 'node', + environment: 'node', + include: ['src/**/*.test.ts'], + }, + }), + ], + }, +}) diff --git a/core/wallet-test-utils/src/otc-trade.ts b/core/wallet-test-utils/src/otc-trade.ts index b0c64279d..0ad016c3a 100644 --- a/core/wallet-test-utils/src/otc-trade.ts +++ b/core/wallet-test-utils/src/otc-trade.ts @@ -13,9 +13,6 @@ import { localNetStaticConfig, } from '@canton-network/wallet-sdk' -// This example needs uploaded .dar for splice-token-test-trading-app -// It's in files of localnet, but it's not uploaded to participant, so we need to do this in the script -// Adjust if to your .localnet location const PATH_TO_LOCALNET = '../../../.localnet' const PATH_TO_DAR_IN_LOCALNET = '/dars/splice-token-test-trading-app-1.0.0.dar' const TRADING_APP_PACKAGE_ID = @@ -79,9 +76,15 @@ export class OTCTrade { PATH_TO_DAR_IN_LOCALNET ) + const synchronizerId = await this.sdk.ledger.getGlobalSynchronizerId() + //upload dar const darBytes = await fs.readFile(tradingDarPath) - await this.sdk.ledger.dar.upload(darBytes, TRADING_APP_PACKAGE_ID) + await this.sdk.ledger.dar.upload( + darBytes, + TRADING_APP_PACKAGE_ID, + synchronizerId + ) // Alice creates OTCTradeProposal 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 faff497b3..b0c106e4b 100644 --- a/docs/wallet-integration-guide/examples/package.json +++ b/docs/wallet-integration-guide/examples/package.json @@ -26,6 +26,7 @@ "run-12": "tsx ./scripts/12-subscribe-to-events.ts | pino-pretty", "run-13": "tsx ./scripts/13-rewards-for-deposits/index.ts | pino-pretty", "run-14": "tsx ./scripts/14-offline-signing.ts | pino-pretty", + "run-15": "tsx ./scripts/15-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" @@ -40,10 +41,15 @@ "vitest": "^4.1.2" }, "dependencies": { + "@canton-network/core-amulet-ops": "workspace:^", + "@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-trading-app": "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/01-init.ts b/docs/wallet-integration-guide/examples/scripts/01-init.ts index 6c03f6a6f..2d1f4e944 100644 --- a/docs/wallet-integration-guide/examples/scripts/01-init.ts +++ b/docs/wallet-integration-guide/examples/scripts/01-init.ts @@ -9,6 +9,7 @@ import { TOKEN_NAMESPACE_CONFIG, TOKEN_PROVIDER_CONFIG_DEFAULT, AMULET_NAMESPACE_CONFIG, + getGlobalSynchronizerId, } from './utils/index.js' const logger = pino({ name: 'v1-01-ping-localnet', level: 'info' }) @@ -20,11 +21,14 @@ const sdk = await SDK.create({ amulet: AMULET_NAMESPACE_CONFIG, }) +const globalSynchronizerId = await getGlobalSynchronizerId(sdk) + const senderKeys = sdk.keys.generate() const sender = await sdk.party.external .create(senderKeys.publicKey, { partyHint: 'v1-01-alice', + synchronizerId: globalSynchronizerId, }) .sign(senderKeys.privateKey) .execute() @@ -42,6 +46,7 @@ const receiverPartyCreation = sdk.party.external.create( receiverKeys.publicKey, { partyHint: 'v1-01-bob', + synchronizerId: globalSynchronizerId, } ) diff --git a/docs/wallet-integration-guide/examples/scripts/02-two-step-transfer/index.ts b/docs/wallet-integration-guide/examples/scripts/02-two-step-transfer/index.ts index ede7f712e..a1ac91f99 100644 --- a/docs/wallet-integration-guide/examples/scripts/02-two-step-transfer/index.ts +++ b/docs/wallet-integration-guide/examples/scripts/02-two-step-transfer/index.ts @@ -9,6 +9,7 @@ import { TOKEN_NAMESPACE_CONFIG, TOKEN_PROVIDER_CONFIG_DEFAULT, AMULET_NAMESPACE_CONFIG, + getGlobalSynchronizerId, } from '../utils/index.js' const logger = pino({ name: 'v1-02-two-step-transfer', level: 'info' }) @@ -20,11 +21,14 @@ const sdk = await SDK.create({ amulet: AMULET_NAMESPACE_CONFIG, }) +const globalSynchronizerId = await getGlobalSynchronizerId(sdk) + const senderKeys = sdk.keys.generate() const sender = await sdk.party.external .create(senderKeys.publicKey, { partyHint: 'v1-02-alice', + synchronizerId: globalSynchronizerId, }) .sign(senderKeys.privateKey) .execute() @@ -34,6 +38,7 @@ const receiverKeys = sdk.keys.generate() const receiver = await sdk.party.external .create(receiverKeys.publicKey, { partyHint: 'v1-02-bob', + synchronizerId: globalSynchronizerId, }) .sign(receiverKeys.privateKey) .execute() diff --git a/docs/wallet-integration-guide/examples/scripts/03-parties.ts b/docs/wallet-integration-guide/examples/scripts/03-parties.ts index 9d381333c..e5fc48e91 100644 --- a/docs/wallet-integration-guide/examples/scripts/03-parties.ts +++ b/docs/wallet-integration-guide/examples/scripts/03-parties.ts @@ -3,6 +3,7 @@ import { localNetStaticConfig, SDK } from '@canton-network/wallet-sdk' import { TOKEN_PROVIDER_CONFIG_DEFAULT, AMULET_NAMESPACE_CONFIG, + getGlobalSynchronizerId, } from './utils/index.js' const logger = pino({ name: 'v1-03-parties', level: 'info' }) @@ -15,12 +16,15 @@ const sdk = await SDK.create({ amulet: AMULET_NAMESPACE_CONFIG, }) +const globalSynchronizerId = await getGlobalSynchronizerId(sdk) + const allocatedParties = await Promise.all( ['v1-03-alice', 'v1-03-bob'].map((partyHint) => { const partyKeys = sdk.keys.generate() return sdk.party.external .create(partyKeys.publicKey, { partyHint, + synchronizerId: globalSynchronizerId, }) .sign(partyKeys.privateKey) .execute() @@ -43,7 +47,9 @@ if (!allocatedPartiesIds.isSubsetOf(new Set(listedParties))) { ) } -const featuredAppRights = await sdk.amulet.featuredApp.grant() +const featuredAppRights = await sdk.amulet.featuredApp.grant({ + synchronizerId: globalSynchronizerId, +}) if (!featuredAppRights) { throw new Error( @@ -69,6 +75,7 @@ const charlieKeys = sdk.keys.generate() const charlie = await sdk.party.external .create(charlieKeys.publicKey, { partyHint: 'v1-03-charlie', + synchronizerId: globalSynchronizerId, confirmingParticipantEndpoints: participantEndpoints, }) .sign(charlieKeys.privateKey) @@ -101,6 +108,7 @@ const observingCharlieKeys = sdk.keys.generate() const observingCharlie = await sdk.party.external .create(observingCharlieKeys.publicKey, { partyHint: 'v1-03-observingCharlie', + synchronizerId: globalSynchronizerId, observingParticipantEndpoints: participantEndpoints, }) .sign(observingCharlieKeys.privateKey) diff --git a/docs/wallet-integration-guide/examples/scripts/04-token-standard-allocation.ts b/docs/wallet-integration-guide/examples/scripts/04-token-standard-allocation.ts index 5b02a13a5..a777ab0d7 100644 --- a/docs/wallet-integration-guide/examples/scripts/04-token-standard-allocation.ts +++ b/docs/wallet-integration-guide/examples/scripts/04-token-standard-allocation.ts @@ -10,6 +10,7 @@ import { TOKEN_NAMESPACE_CONFIG, TOKEN_PROVIDER_CONFIG_DEFAULT, AMULET_NAMESPACE_CONFIG, + getGlobalSynchronizerId, } from './utils/index.js' const logger = pino({ name: 'v1-token-standard-allocation', level: 'info' }) @@ -43,9 +44,15 @@ const tradingDarPath = path.join( PATH_TO_DAR_IN_LOCALNET ) +const globalSynchronizerId = await getGlobalSynchronizerId(sdk) + //upload dar const darBytes = await fs.readFile(tradingDarPath) -await sdk.ledger.dar.upload(darBytes, TRADING_APP_PACKAGE_ID) +await sdk.ledger.dar.upload( + darBytes, + TRADING_APP_PACKAGE_ID, + globalSynchronizerId +) //allocate parties const allocatedParties = await Promise.all( @@ -54,6 +61,7 @@ const allocatedParties = await Promise.all( const party = await sdk.party.external .create(partyKeys.publicKey, { partyHint, + synchronizerId: globalSynchronizerId, }) .sign(partyKeys.privateKey) .execute() diff --git a/docs/wallet-integration-guide/examples/scripts/05-preapproval.ts b/docs/wallet-integration-guide/examples/scripts/05-preapproval.ts index 3534ee17a..674fe5e54 100644 --- a/docs/wallet-integration-guide/examples/scripts/05-preapproval.ts +++ b/docs/wallet-integration-guide/examples/scripts/05-preapproval.ts @@ -5,6 +5,7 @@ import { TOKEN_NAMESPACE_CONFIG, TOKEN_PROVIDER_CONFIG_DEFAULT, AMULET_NAMESPACE_CONFIG, + getGlobalSynchronizerId, } from './utils/index.js' const logger = pino({ name: 'v1-05-preapproval', level: 'info' }) @@ -16,6 +17,8 @@ const sdk = await SDK.create({ amulet: AMULET_NAMESPACE_CONFIG, }) +const globalSynchronizerId = await getGlobalSynchronizerId(sdk) + await sdk.amulet.tapInternal('1000') const aliceKeys = sdk.keys.generate() @@ -23,6 +26,7 @@ const aliceKeys = sdk.keys.generate() const alice = await sdk.party.external .create(aliceKeys.publicKey, { partyHint: 'v1-05-alice', + synchronizerId: globalSynchronizerId, }) .sign(aliceKeys.privateKey) .execute() @@ -46,6 +50,7 @@ const bobKeys = sdk.keys.generate() const bob = await sdk.party.external .create(bobKeys.publicKey, { partyHint: 'v1-05-bob', + synchronizerId: globalSynchronizerId, }) .sign(bobKeys.privateKey) .execute() @@ -169,6 +174,7 @@ await sdk.amulet.preapproval.renew({ receiver: bob.partyId, }, expiresAt: newExpiresAt, + synchronizerId: globalSynchronizerId, }) const fetchedStatusAfterRenew = await sdk.amulet.preapproval.fetchStatus( diff --git a/docs/wallet-integration-guide/examples/scripts/06-merge-utxos.ts b/docs/wallet-integration-guide/examples/scripts/06-merge-utxos.ts index 2aa38f1eb..91cccec25 100644 --- a/docs/wallet-integration-guide/examples/scripts/06-merge-utxos.ts +++ b/docs/wallet-integration-guide/examples/scripts/06-merge-utxos.ts @@ -4,6 +4,7 @@ import { TOKEN_NAMESPACE_CONFIG, TOKEN_PROVIDER_CONFIG_DEFAULT, AMULET_NAMESPACE_CONFIG, + getGlobalSynchronizerId, } from './utils/index.js' const logger = pino({ name: 'v1-06-merge-utxos', level: 'info' }) @@ -15,11 +16,14 @@ const sdk = await SDK.create({ amulet: AMULET_NAMESPACE_CONFIG, }) +const globalSynchronizerId = await getGlobalSynchronizerId(sdk) + const aliceKeys = sdk.keys.generate() const alice = await sdk.party.external .create(aliceKeys.publicKey, { partyHint: 'v1-06-alice', + synchronizerId: globalSynchronizerId, }) .sign(aliceKeys.privateKey) .execute() diff --git a/docs/wallet-integration-guide/examples/scripts/07-buy-member-traffic.ts b/docs/wallet-integration-guide/examples/scripts/07-buy-member-traffic.ts index f3458a0fc..a3f11b163 100644 --- a/docs/wallet-integration-guide/examples/scripts/07-buy-member-traffic.ts +++ b/docs/wallet-integration-guide/examples/scripts/07-buy-member-traffic.ts @@ -4,6 +4,7 @@ import { TOKEN_NAMESPACE_CONFIG, TOKEN_PROVIDER_CONFIG_DEFAULT, AMULET_NAMESPACE_CONFIG, + getGlobalSynchronizerId, } from './utils/index.js' const logger = pino({ name: 'v1-06-merge-utxos', level: 'info' }) @@ -14,11 +15,15 @@ const sdk = await SDK.create({ token: TOKEN_NAMESPACE_CONFIG, amulet: AMULET_NAMESPACE_CONFIG, }) + +const globalSynchronizerId = await getGlobalSynchronizerId(sdk) + const aliceKeys = sdk.keys.generate() const alice = await sdk.party.external .create(aliceKeys.publicKey, { partyHint: 'v1-07-alice', + synchronizerId: globalSynchronizerId, }) .sign(aliceKeys.privateKey) .execute() @@ -28,6 +33,7 @@ const bobKeys = sdk.keys.generate() const bob = await sdk.party.external .create(bobKeys.publicKey, { partyHint: 'v1-07-bob', + synchronizerId: globalSynchronizerId, }) .sign(bobKeys.privateKey) .execute() @@ -66,7 +72,9 @@ await sdk.ledger logger.info(`Tapped holdings for alice`) -const trafficStatusBeforePurchase = await sdk.amulet.traffic.status() +const trafficStatusBeforePurchase = await sdk.amulet.traffic.status({ + synchronizerId: globalSynchronizerId, +}) logger.info( `Traffic status before purchase: ${JSON.stringify(trafficStatusBeforePurchase)}` @@ -79,6 +87,7 @@ const [buyTrafficCommand, buyTrafficDisclosedContracts] = buyer: alice.partyId, ccAmount, inputUtxos: [], + synchronizerId: globalSynchronizerId, }) await sdk.ledger diff --git a/docs/wallet-integration-guide/examples/scripts/08-merge-delegation.ts b/docs/wallet-integration-guide/examples/scripts/08-merge-delegation.ts index c03aa2c12..fee75fb47 100644 --- a/docs/wallet-integration-guide/examples/scripts/08-merge-delegation.ts +++ b/docs/wallet-integration-guide/examples/scripts/08-merge-delegation.ts @@ -8,6 +8,7 @@ import { TOKEN_NAMESPACE_CONFIG, TOKEN_PROVIDER_CONFIG_DEFAULT, AMULET_NAMESPACE_CONFIG, + getGlobalSynchronizerId, } from './utils/index.js' const logger = pino({ name: 'v1-08-merge-delegation', level: 'info' }) @@ -36,10 +37,13 @@ const sdk = await SDK.create({ amulet: AMULET_NAMESPACE_CONFIG, }) +const synchronizerId = await getGlobalSynchronizerId(sdk) + const darBytes = await readFile(spliceUtilTokenStandardWalletDarPath) await sdk.ledger.dar.upload( darBytes, - SPLICE_UTIL_TOKEN_STANDARD_WALLET_PACKAGE_ID + SPLICE_UTIL_TOKEN_STANDARD_WALLET_PACKAGE_ID, + synchronizerId ) logger.info(`DAR ${PATH_TO_DAR_IN_LOCALNET} successfully uploaded`) @@ -49,6 +53,7 @@ const aliceKeys = sdk.keys.generate() const alice = await sdk.party.external .create(aliceKeys.publicKey, { partyHint: 'v1-08-alice', + synchronizerId, }) .sign(aliceKeys.privateKey) .execute() diff --git a/docs/wallet-integration-guide/examples/scripts/09-multi-user-setup.ts b/docs/wallet-integration-guide/examples/scripts/09-multi-user-setup.ts index 5e698beee..b6cce2a94 100644 --- a/docs/wallet-integration-guide/examples/scripts/09-multi-user-setup.ts +++ b/docs/wallet-integration-guide/examples/scripts/09-multi-user-setup.ts @@ -1,6 +1,9 @@ import { localNetStaticConfig, SDK } from '@canton-network/wallet-sdk' import { pino } from 'pino' -import { TOKEN_PROVIDER_CONFIG_DEFAULT } from './utils/index.js' +import { + TOKEN_PROVIDER_CONFIG_DEFAULT, + getGlobalSynchronizerId, +} from './utils/index.js' const logger = pino({ name: 'v1-multi-user-setup', level: 'info' }) logger.info('Operator sets up users and primary parties') @@ -10,16 +13,21 @@ const operatorSdk = await SDK.create({ ledgerClientUrl: localNetStaticConfig.LOCALNET_APP_USER_LEDGER_URL, }) +const globalSynchronizerId = await getGlobalSynchronizerId(operatorSdk) + const aliceInternal = await operatorSdk.party.internal.allocate({ partyHint: 'v1-09-alice', + synchronizerId: globalSynchronizerId, }) const bobInternal = await operatorSdk.party.internal.allocate({ partyHint: 'v1-09-bob', + synchronizerId: globalSynchronizerId, }) const masterPartyInternal = await operatorSdk.party.internal.allocate({ partyHint: 'v1-09-master', + synchronizerId: globalSynchronizerId, }) logger.info('Created the internal parties') @@ -90,6 +98,7 @@ const aliceKeyPair = aliceSdk.keys.generate() const aliceExternal = await aliceSdk.party.external .create(aliceKeyPair.publicKey, { partyHint: 'v1-09-alice', + synchronizerId: globalSynchronizerId, }) .sign(aliceKeyPair.privateKey) .execute() @@ -114,6 +123,7 @@ const bobKeyPair = bobSdk.keys.generate() const bobExternal = await bobSdk.party.external .create(bobKeyPair.publicKey, { partyHint: 'v1-09-bob', + synchronizerId: globalSynchronizerId, }) .sign(bobKeyPair.privateKey) .execute() diff --git a/docs/wallet-integration-guide/examples/scripts/10-init-with-ledger-provider.ts b/docs/wallet-integration-guide/examples/scripts/10-init-with-ledger-provider.ts index c8c9a6753..80d97414d 100644 --- a/docs/wallet-integration-guide/examples/scripts/10-init-with-ledger-provider.ts +++ b/docs/wallet-integration-guide/examples/scripts/10-init-with-ledger-provider.ts @@ -10,6 +10,7 @@ import { TOKEN_NAMESPACE_CONFIG, TOKEN_PROVIDER_CONFIG_DEFAULT, AMULET_NAMESPACE_CONFIG, + getGlobalSynchronizerId, } from './utils/index.js' import { AuthTokenProvider } from '@canton-network/core-wallet-auth' @@ -33,11 +34,15 @@ const sdkOptions = { } as const const sdk = await SDK.create(sdkOptions) + +const globalSynchronizerId = await getGlobalSynchronizerId(sdk) + const senderKeys = sdk.keys.generate() const sender = await sdk.party.external .create(senderKeys.publicKey, { partyHint: 'v1-10-alice', + synchronizerId: globalSynchronizerId, }) .sign(senderKeys.privateKey) .execute() @@ -50,6 +55,7 @@ const receiverPartyCreation = sdk.party.external.create( receiverKeys.publicKey, { partyHint: 'v1-10-bob', + synchronizerId: globalSynchronizerId, } ) diff --git a/docs/wallet-integration-guide/examples/scripts/11-hashing.ts b/docs/wallet-integration-guide/examples/scripts/11-hashing.ts index 760d2350e..b794bb01b 100644 --- a/docs/wallet-integration-guide/examples/scripts/11-hashing.ts +++ b/docs/wallet-integration-guide/examples/scripts/11-hashing.ts @@ -3,6 +3,7 @@ import { pino } from 'pino' import { TOKEN_PROVIDER_CONFIG_DEFAULT, AMULET_NAMESPACE_CONFIG, + getGlobalSynchronizerId, } from './utils/index.js' const logger = pino({ name: 'v1-11-hashing', level: 'info' }) @@ -13,11 +14,14 @@ const sdk = await SDK.create({ amulet: AMULET_NAMESPACE_CONFIG, }) +const globalSynchronizerId = await getGlobalSynchronizerId(sdk) + const senderKeys = sdk.keys.generate() const sender = await sdk.party.external .create(senderKeys.publicKey, { partyHint: 'v1-11-alice', + synchronizerId: globalSynchronizerId, }) .sign(senderKeys.privateKey) .execute() diff --git a/docs/wallet-integration-guide/examples/scripts/12-subscribe-to-events.ts b/docs/wallet-integration-guide/examples/scripts/12-subscribe-to-events.ts index 58f53077a..c7fac78a2 100644 --- a/docs/wallet-integration-guide/examples/scripts/12-subscribe-to-events.ts +++ b/docs/wallet-integration-guide/examples/scripts/12-subscribe-to-events.ts @@ -5,7 +5,10 @@ import { localNetStaticConfig, SDK, } from '@canton-network/wallet-sdk' -import { TOKEN_PROVIDER_CONFIG_DEFAULT } from './utils/index.js' +import { + TOKEN_PROVIDER_CONFIG_DEFAULT, + getGlobalSynchronizerId, +} from './utils/index.js' const logger = pino({ name: 'v1-12-subscribe-to-events', level: 'info' }) @@ -22,12 +25,15 @@ const sdk = await SDK.create({ }, }) +const globalSynchronizerId = await getGlobalSynchronizerId(sdk) + const allocatedParties = await Promise.all( ['v1-12-alice', 'v1-12-bob'].map((partyHint) => { const partyKeys = sdk.keys.generate() return sdk.party.external .create(partyKeys.publicKey, { partyHint, + synchronizerId: globalSynchronizerId, }) .sign(partyKeys.privateKey) .execute() @@ -63,6 +69,7 @@ const charlieKeys = sdk.keys.generate() const charlie = await sdk.party.external .create(charlieKeys.publicKey, { partyHint: 'v1-12-charlie', + synchronizerId: globalSynchronizerId, confirmingParticipantEndpoints: participantEndpoints, }) .sign(charlieKeys.privateKey) @@ -120,6 +127,7 @@ const observingCharlieKeys = sdk.keys.generate() const observingCharlie = await sdk.party.external .create(observingCharlieKeys.publicKey, { partyHint: 'v1-12-observingCharlie', + synchronizerId: globalSynchronizerId, observingParticipantEndpoints: participantEndpoints, }) .sign(observingCharlieKeys.privateKey) diff --git a/docs/wallet-integration-guide/examples/scripts/13-rewards-for-deposits/index.ts b/docs/wallet-integration-guide/examples/scripts/13-rewards-for-deposits/index.ts index 63ad93ae5..3ef328531 100644 --- a/docs/wallet-integration-guide/examples/scripts/13-rewards-for-deposits/index.ts +++ b/docs/wallet-integration-guide/examples/scripts/13-rewards-for-deposits/index.ts @@ -4,6 +4,7 @@ import { TOKEN_NAMESPACE_CONFIG, TOKEN_PROVIDER_CONFIG_DEFAULT, AMULET_NAMESPACE_CONFIG, + getGlobalSynchronizerId, } from '../utils/index.js' import path from 'node:path' import { fileURLToPath } from 'node:url' @@ -31,12 +32,15 @@ const sdk = await SDK.create({ amulet: AMULET_NAMESPACE_CONFIG, }) +const globalSynchronizerId = await getGlobalSynchronizerId(sdk) + const aliceKeys = sdk.keys.generate() const treasuryKeys = sdk.keys.generate() const alice = await sdk.party.external .create(aliceKeys.publicKey, { partyHint: 'v1-13-alice', + synchronizerId: globalSynchronizerId, }) .sign(aliceKeys.privateKey) .execute() @@ -44,6 +48,7 @@ const alice = await sdk.party.external const treasury = await sdk.party.external .create(treasuryKeys.publicKey, { partyHint: 'v1-13-treasury', + synchronizerId: globalSynchronizerId, }) .sign(treasuryKeys.privateKey) .execute() @@ -57,7 +62,11 @@ const spliceUtilFeaturedAppProxyDarPath = path.join( ) const darBytes = await fs.readFile(spliceUtilFeaturedAppProxyDarPath) -await sdk.ledger.dar.upload(darBytes, SPLICE_UTIL_PROXY_PACKAGE_ID) +await sdk.ledger.dar.upload( + darBytes, + SPLICE_UTIL_PROXY_PACKAGE_ID, + globalSynchronizerId +) const transferPreApprovalProposal = await sdk.amulet.preapproval.command.create( { @@ -77,7 +86,9 @@ await sdk.ledger partyId: alice.partyId, }) -const featuredAppRight = await sdk.amulet.featuredApp.grant() +const featuredAppRight = await sdk.amulet.featuredApp.grant({ + synchronizerId: globalSynchronizerId, +}) logger.info(featuredAppRight, 'Featured app rights:') if (!featuredAppRight) throw Error('featuredAppRightCid is undefined') diff --git a/docs/wallet-integration-guide/examples/scripts/14-offline-signing.ts b/docs/wallet-integration-guide/examples/scripts/14-offline-signing.ts index 025bf7ce6..3ff0e35ad 100644 --- a/docs/wallet-integration-guide/examples/scripts/14-offline-signing.ts +++ b/docs/wallet-integration-guide/examples/scripts/14-offline-signing.ts @@ -8,6 +8,7 @@ import { TOKEN_NAMESPACE_CONFIG, TOKEN_PROVIDER_CONFIG_DEFAULT, AMULET_NAMESPACE_CONFIG, + getGlobalSynchronizerId, } from './utils/index.js' const onlineLogger = pino({ name: '14-online-localnet', level: 'info' }) @@ -20,6 +21,8 @@ const onlineSDK = await SDK.create({ token: TOKEN_NAMESPACE_CONFIG, }) +const globalSynchronizerId = await getGlobalSynchronizerId(onlineSDK) + onlineLogger.info(`Online sdk initialized.`) const offlineSdk = SDK.createOffline() @@ -40,6 +43,7 @@ const senderPartyPrepared = onlineSDK.party.external.create( keyPairSender.publicKey, { partyHint: 'v1-14-alice', + synchronizerId: globalSynchronizerId, } ) @@ -94,6 +98,7 @@ const receiverPartyPrepared = onlineSDK.party.external.create( keyPairReceiver.publicKey, { partyHint: 'v1-14-bob', + synchronizerId: globalSynchronizerId, } ) diff --git a/docs/wallet-integration-guide/examples/scripts/15-multi-sync/README.md b/docs/wallet-integration-guide/examples/scripts/15-multi-sync/README.md new file mode 100644 index 000000000..35b3a0a60 --- /dev/null +++ b/docs/wallet-integration-guide/examples/scripts/15-multi-sync/README.md @@ -0,0 +1,84 @@ +# Example 15: Multi-Synchronizer DvP Trade + +This example implements a Delivery vs Payment (DvP) flow across two synchronizers: Amulet on the global synchronizer and a Token instrument on a private app-synchronizer, settled via the OTC Trading App using only single-party submissions. + +## 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-15 + +# 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 **sv** participant. + +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. + +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. + +```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 │ │ TradingApp │ + │ │ │ TokenAdmin │ │ │ + └──────┬───────┘ └──────┬───────┘ └──────────────┘ + │ │ + │ 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) | TestTokenV1, trading-app, (Amulet\*) | TestTokenV1, trading-app | +| **app-provider** (Bob, TokenAdmin) | TestTokenV1, trading-app, (Amulet\*) | TestTokenV1, trading-app | +| **sv** (TradingApp) | TestTokenV1, trading-app, (Amulet\*) | — _(not connected)_ | + +DARs referenced above: + +- **TestTokenV1** = `splice-test-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/15-multi-sync/_amulet_ops.ts b/docs/wallet-integration-guide/examples/scripts/15-multi-sync/_amulet_ops.ts new file mode 100644 index 000000000..4f4172a49 --- /dev/null +++ b/docs/wallet-integration-guide/examples/scripts/15-multi-sync/_amulet_ops.ts @@ -0,0 +1,45 @@ +// 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 { mintAmulet, allocateAmulet } from '@canton-network/core-amulet-ops' +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 { appUserSdk, alice, globalSynchronizerId } = setup + + await mintAmulet({ + sdk: appUserSdk, + receiver: { + partyId: alice.partyId, + privateKey: alice.keyPair.privateKey, + }, + amount: ALICE_AMULET_TAP_AMOUNT, + synchronizerId: globalSynchronizerId, + logger, + }) +} + +export async function allocateAmuletForAlice( + setup: MultiSyncSetup, + logger: Logger +): Promise { + const { appUserSdk, alice, globalSynchronizerId, amuletAdmin } = setup + + return allocateAmulet({ + sdk: appUserSdk, + sender: { + partyId: alice.partyId, + privateKey: alice.keyPair.privateKey, + }, + adminPartyId: amuletAdmin, + registryUrl: localNetStaticConfig.LOCALNET_REGISTRY_API_URL, + globalSynchronizerId, + logger, + }) +} diff --git a/docs/wallet-integration-guide/examples/scripts/15-multi-sync/_constants.ts b/docs/wallet-integration-guide/examples/scripts/15-multi-sync/_constants.ts new file mode 100644 index 000000000..b9155a2bc --- /dev/null +++ b/docs/wallet-integration-guide/examples/scripts/15-multi-sync/_constants.ts @@ -0,0 +1,17 @@ +// 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 (see ./_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/15-multi-sync/_registry/features/allocation-instruction/handlers.ts b/docs/wallet-integration-guide/examples/scripts/15-multi-sync/_registry/features/allocation-instruction/handlers.ts new file mode 100644 index 000000000..2f64bc474 --- /dev/null +++ b/docs/wallet-integration-guide/examples/scripts/15-multi-sync/_registry/features/allocation-instruction/handlers.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 + +/** + * TestToken implementation of the allocation-instruction-v1 API + * (api-specs/splice/0.6.1/allocation-instruction-v1.yaml). + * + * The allocation factory return live `TokenRules` contract on the + * *global synchronizer* + */ + +import type { + FactoryWithChoiceContext, + AllocationInstructionHandlers, + GetFactoryRequest, +} from '../../types.js' +import type { TokenRulesContract } from '../../ledger.js' + +export interface AllocationInstructionHandlerContext { + getTokenRules: ( + synchronizerId?: string + ) => Promise + globalSynchronizerId: string +} + +export function createAllocationInstructionHandlers( + ctx: AllocationInstructionHandlerContext +): AllocationInstructionHandlers { + return { + getAllocationFactory: async ( + _req: GetFactoryRequest + ): Promise => { + const tokenRules = await ctx.getTokenRules(ctx.globalSynchronizerId) + if (!tokenRules) return null + return { + factoryId: tokenRules.contractId, + choiceContext: { + choiceContextData: { values: {} }, + disclosedContracts: [ + { + templateId: tokenRules.templateId, + contractId: tokenRules.contractId, + createdEventBlob: tokenRules.createdEventBlob, + synchronizerId: tokenRules.synchronizerId, + }, + ], + }, + } + }, + } +} diff --git a/docs/wallet-integration-guide/examples/scripts/15-multi-sync/_registry/features/allocation/handlers.ts b/docs/wallet-integration-guide/examples/scripts/15-multi-sync/_registry/features/allocation/handlers.ts new file mode 100644 index 000000000..7fec385e6 --- /dev/null +++ b/docs/wallet-integration-guide/examples/scripts/15-multi-sync/_registry/features/allocation/handlers.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 implementation of the allocation-v1 API + * (api-specs/splice/0.6.1/allocation-v1.yaml). + * + * All allocation choice-context endpoints return an empty context — the + * TestToken `Allocation_ExecuteTransfer`, `Allocation_Withdraw` and + * `Allocation_Cancel` choices read no additional contracts. + */ + +import type { ChoiceContext, AllocationHandlers } from '../../types.js' + +export function createAllocationHandlers(): AllocationHandlers { + const emptyContext: ChoiceContext = { + choiceContextData: { values: {} }, + disclosedContracts: [], + } + + return { + getAllocationTransferContext: async (): Promise => + emptyContext, + getAllocationWithdrawContext: async (): Promise => + emptyContext, + getAllocationCancelContext: async (): Promise => + emptyContext, + } +} diff --git a/docs/wallet-integration-guide/examples/scripts/15-multi-sync/_registry/features/metadata/handlers.ts b/docs/wallet-integration-guide/examples/scripts/15-multi-sync/_registry/features/metadata/handlers.ts new file mode 100644 index 000000000..800eef504 --- /dev/null +++ b/docs/wallet-integration-guide/examples/scripts/15-multi-sync/_registry/features/metadata/handlers.ts @@ -0,0 +1,56 @@ +// 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, + MetadataHandlers, + SupportedApis, +} from '../../types.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 { + tokenAdminPartyId: string + instrumentId: string +} + +export function createMetadataHandlers( + ctx: MetadataHandlerContext +): MetadataHandlers { + const instrument: Instrument = { + id: ctx.instrumentId, + name: 'TestToken', + symbol: 'TT', + decimals: 10, + supportedApis: SUPPORTED_APIS, + } + + return { + getRegistryInfo: (): GetRegistryInfoResponse => ({ + adminId: ctx.tokenAdminPartyId, + supportedApis: SUPPORTED_APIS, + }), + + listInstruments: (): ListInstrumentsResponse => ({ + instruments: [instrument], + }), + + getInstrument: ({ instrumentId }): Instrument | null => + instrumentId === ctx.instrumentId ? instrument : null, + } +} diff --git a/docs/wallet-integration-guide/examples/scripts/15-multi-sync/_registry/features/transfer/handlers.ts b/docs/wallet-integration-guide/examples/scripts/15-multi-sync/_registry/features/transfer/handlers.ts new file mode 100644 index 000000000..bc1a849ee --- /dev/null +++ b/docs/wallet-integration-guide/examples/scripts/15-multi-sync/_registry/features/transfer/handlers.ts @@ -0,0 +1,96 @@ +// 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 app-synchronizer + * — TestToken transfers (mint → Bob, and the 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, + TransferHandlers, + GetFactoryRequest, +} from '../../types.js' +import type { TokenRulesContract } from '../../ledger.js' + +export interface TransferHandlerContext { + getTokenRules: ( + synchronizerId?: string + ) => Promise + appSynchronizerId: string +} + +export function createTransferHandlers( + ctx: TransferHandlerContext +): TransferHandlers { + return { + getTransferFactory: async ( + req: GetFactoryRequest + ): Promise => { + const transfer = req.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.appSynchronizerId) + if (!tokenRules) + throw new Error( + `getTransferFactory: TokenRules not found on app synchronizer ${ctx.appSynchronizerId}` + ) + return { + factoryId: tokenRules.contractId, + transferKind, + choiceContext: { + choiceContextData: { values: {} }, + disclosedContracts: [ + { + templateId: tokenRules.templateId, + contractId: tokenRules.contractId, + createdEventBlob: tokenRules.createdEventBlob, + synchronizerId: tokenRules.synchronizerId, + }, + ], + }, + } + }, + + getTransferInstructionAcceptContext: + async (): Promise => ({ + choiceContextData: { values: {} }, + disclosedContracts: [], + }), + + getTransferInstructionRejectContext: + async (): Promise => ({ + choiceContextData: { values: {} }, + disclosedContracts: [], + }), + + getTransferInstructionWithdrawContext: + async (): Promise => ({ + choiceContextData: { values: {} }, + disclosedContracts: [], + }), + } +} diff --git a/docs/wallet-integration-guide/examples/scripts/15-multi-sync/_registry/http/router.ts b/docs/wallet-integration-guide/examples/scripts/15-multi-sync/_registry/http/router.ts new file mode 100644 index 000000000..367a2ab80 --- /dev/null +++ b/docs/wallet-integration-guide/examples/scripts/15-multi-sync/_registry/http/router.ts @@ -0,0 +1,99 @@ +// Copyright (c) 2025-2026 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +/** + * Minimal path-parameter router for the TestToken registry server. + * + * Provides `createRouter()` for route registration / matching, and two + * standalone helpers (`respond`, `readBody`) that every feature slice shares. + */ + +import type { IncomingMessage, ServerResponse } from 'node:http' + +export type RouteHandler = ( + req: IncomingMessage, + res: ServerResponse, + body: unknown, + params: Record +) => Promise + +interface Route { + method: string + /** Literal path segments or `:paramName` placeholders. */ + pattern: string[] + handler: RouteHandler +} + +export interface Router { + route: (method: string, pattern: string, handler: RouteHandler) => void + matchRoute: ( + method: string, + pathname: string + ) => { handler: RouteHandler; params: Record } | null +} + +export function createRouter(): Router { + const routes: Route[] = [] + + function route( + method: string, + pattern: string, + handler: RouteHandler + ): void { + routes.push({ method, pattern: pattern.split('/'), handler }) + } + + function matchRoute( + method: string, + pathname: string + ): { handler: RouteHandler; params: Record } | null { + const segments = pathname.split('/') + for (const r of routes) { + if (r.method !== method) continue + if (r.pattern.length !== segments.length) continue + const params: Record = {} + let ok = true + for (let i = 0; i < r.pattern.length; i++) { + const p = r.pattern[i]! + if (p.startsWith(':')) { + params[p.slice(1)] = decodeURIComponent(segments[i]!) + } else if (p !== segments[i]) { + ok = false + break + } + } + if (ok) return { handler: r.handler, params } + } + return null + } + + return { route, matchRoute } +} + +export function respond( + res: ServerResponse, + status: number, + body: unknown +): void { + const payload = JSON.stringify(body) + res.writeHead(status, { + 'Content-Type': 'application/json', + 'Content-Length': Buffer.byteLength(payload), + }) + res.end(payload) +} + +export async function readBody(req: IncomingMessage): Promise { + return new Promise((resolve, reject) => { + let raw = '' + req.on('data', (chunk: Buffer) => (raw += chunk.toString())) + req.on('end', () => { + try { + resolve(raw.length ? JSON.parse(raw) : {}) + } catch { + reject(new Error('Invalid JSON body')) + } + }) + req.on('error', reject) + }) +} diff --git a/docs/wallet-integration-guide/examples/scripts/15-multi-sync/_registry/index.ts b/docs/wallet-integration-guide/examples/scripts/15-multi-sync/_registry/index.ts new file mode 100644 index 000000000..d72432d3b --- /dev/null +++ b/docs/wallet-integration-guide/examples/scripts/15-multi-sync/_registry/index.ts @@ -0,0 +1,311 @@ +// Copyright (c) 2025-2026 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +/** + * TestToken Registry — entry point. + * + * Wires the HTTP router, all feature-slice route handlers, and the ledger client + * into a single `startRegistry()` factory that is called from the example's + * initialization phase once the tokenAdmin party ID is known. + * + * Implements all four Token Standard off-ledger registry APIs: + * api-specs/splice/0.6.1/token-metadata-v1.yaml + * api-specs/splice/0.6.1/transfer-instruction-v1.yaml + * api-specs/splice/0.6.1/allocation-instruction-v1.yaml + * api-specs/splice/0.6.1/allocation-v1.yaml + */ + +import { + createServer, + type IncomingMessage, + type Server, + type ServerResponse, +} from 'node:http' +import type { Logger } from 'pino' +import type { LedgerClient } from '@canton-network/core-ledger-client' +import { buildLedgerClient, readTokenRules } from './ledger.js' +import type { TokenRulesContract } from './ledger.js' +import { createRouter, respond, readBody } from './http/router.js' +import type { GetFactoryRequest, GetChoiceContextRequest } from './types.js' +import { createMetadataHandlers } from './features/metadata/handlers.js' +import { createTransferHandlers } from './features/transfer/handlers.js' +import { createAllocationInstructionHandlers } from './features/allocation-instruction/handlers.js' +import { createAllocationHandlers } from './features/allocation/handlers.js' + +// ── static instrument metadata ───────────────────────────────────────────── +const TEST_TOKEN_INSTRUMENT_ID = 'TestToken' + +// ── Route table (source of truth: api-specs/splice/0.6.1/) ──────────────── +interface RouteEntry { + method: string + pattern: string + operationId: string + nullable?: boolean +} + +const ROUTES: RouteEntry[] = [ + // token-metadata-v1 + { + method: 'GET', + pattern: '/registry/metadata/v1/info', + operationId: 'getRegistryInfo', + }, + { + method: 'GET', + pattern: '/registry/metadata/v1/instruments', + operationId: 'listInstruments', + }, + { + method: 'GET', + pattern: '/registry/metadata/v1/instruments/:instrumentId', + operationId: 'getInstrument', + nullable: true, + }, + // transfer-instruction-v1 + { + method: 'POST', + pattern: '/registry/transfer-instruction/v1/transfer-factory', + operationId: 'getTransferFactory', + nullable: true, + }, + { + method: 'POST', + pattern: + '/registry/transfer-instruction/v1/:transferInstructionId/choice-contexts/accept', + operationId: 'getTransferInstructionAcceptContext', + }, + { + method: 'POST', + pattern: + '/registry/transfer-instruction/v1/:transferInstructionId/choice-contexts/reject', + operationId: 'getTransferInstructionRejectContext', + }, + { + method: 'POST', + pattern: + '/registry/transfer-instruction/v1/:transferInstructionId/choice-contexts/withdraw', + operationId: 'getTransferInstructionWithdrawContext', + }, + // allocation-instruction-v1 + { + method: 'POST', + pattern: '/registry/allocation-instruction/v1/allocation-factory', + operationId: 'getAllocationFactory', + nullable: true, + }, + // allocation-v1 + { + method: 'POST', + pattern: + '/registry/allocations/v1/:allocationId/choice-contexts/execute-transfer', + operationId: 'getAllocationTransferContext', + }, + { + method: 'POST', + pattern: + '/registry/allocations/v1/:allocationId/choice-contexts/withdraw', + operationId: 'getAllocationWithdrawContext', + }, + { + method: 'POST', + pattern: + '/registry/allocations/v1/:allocationId/choice-contexts/cancel', + operationId: 'getAllocationCancelContext', + }, +] + +export interface RegistryConfig { + tokenAdminPartyId: string + port: number + ledgerUrl: URL + logger: Logger + globalSynchronizerId: string + appSynchronizerId: string +} + +export interface RegistryHandle { + stop(): Promise +} + +/** + * Starts the TestToken registry HTTP server. + * + * @param config - Runtime configuration (party ID, port, ledger URL, logger). + * @returns A handle with a `stop()` method for graceful shutdown. + */ +export async function startRegistry( + config: RegistryConfig +): Promise { + const { + tokenAdminPartyId, + port, + ledgerUrl, + logger, + globalSynchronizerId, + appSynchronizerId, + } = config + + const ledgerClient: LedgerClient = buildLedgerClient(ledgerUrl, logger) + + async function getTokenRules( + synchronizerId?: string + ): Promise { + const all = await readTokenRules( + ledgerClient, + tokenAdminPartyId, + logger + ) + if (all.length === 0) return null + if (!synchronizerId) return all[0]! + return all.find((c) => c.synchronizerId === synchronizerId) ?? all[0]! + } + + const metadata = createMetadataHandlers({ + tokenAdminPartyId, + instrumentId: TEST_TOKEN_INSTRUMENT_ID, + }) + const transfer = createTransferHandlers({ + getTokenRules, + appSynchronizerId, + }) + const allocInstr = createAllocationInstructionHandlers({ + getTokenRules, + globalSynchronizerId, + }) + const alloc = createAllocationHandlers() + + // Dispatch map: operationId → (params, body) → Promise + type DispatchFn = ( + params: Record, + body: unknown + ) => Promise + const dispatch = new Map([ + // Metadata + ['getRegistryInfo', async () => metadata.getRegistryInfo()], + ['listInstruments', async () => metadata.listInstruments()], + [ + 'getInstrument', + async (p) => + metadata.getInstrument({ instrumentId: p['instrumentId']! }), + ], + // Transfer + [ + 'getTransferFactory', + async (_, b) => transfer.getTransferFactory(b as GetFactoryRequest), + ], + [ + 'getTransferInstructionAcceptContext', + async (p, b) => + transfer.getTransferInstructionAcceptContext( + { transferInstructionId: p['transferInstructionId']! }, + b as GetChoiceContextRequest + ), + ], + [ + 'getTransferInstructionRejectContext', + async (p, b) => + transfer.getTransferInstructionRejectContext( + { transferInstructionId: p['transferInstructionId']! }, + b as GetChoiceContextRequest + ), + ], + [ + 'getTransferInstructionWithdrawContext', + async (p, b) => + transfer.getTransferInstructionWithdrawContext( + { transferInstructionId: p['transferInstructionId']! }, + b as GetChoiceContextRequest + ), + ], + // Allocation Instruction + [ + 'getAllocationFactory', + async (_, b) => + allocInstr.getAllocationFactory(b as GetFactoryRequest), + ], + // Allocation + [ + 'getAllocationTransferContext', + async (p, b) => + alloc.getAllocationTransferContext( + { allocationId: p['allocationId']! }, + b as GetChoiceContextRequest + ), + ], + [ + 'getAllocationWithdrawContext', + async (p, b) => + alloc.getAllocationWithdrawContext( + { allocationId: p['allocationId']! }, + b as GetChoiceContextRequest + ), + ], + [ + 'getAllocationCancelContext', + async (p, b) => + alloc.getAllocationCancelContext( + { allocationId: p['allocationId']! }, + b as GetChoiceContextRequest + ), + ], + ]) + + const { route, matchRoute } = createRouter() + for (const { method, pattern, operationId, nullable = false } of ROUTES) { + route(method, pattern, async (_req, res, body, params) => { + const fn = dispatch.get(operationId)! + const result = await fn(params, body) + if (nullable && result === null) { + respond(res, 404, { error: `${operationId}: not found` }) + } else { + respond(res, 200, result) + } + }) + } + + const server: Server = createServer( + async (req: IncomingMessage, res: ServerResponse) => { + const url = new URL(req.url ?? '/', 'http://localhost') + const method = req.method?.toUpperCase() ?? 'GET' + const pathname = url.pathname + + logger.debug({ method, pathname }, 'incoming registry request') + + try { + const match = matchRoute(method, pathname) + if (!match) { + respond(res, 404, { + error: `${method} ${pathname} not found`, + }) + return + } + const body = + method === 'POST' || method === 'PUT' + ? await readBody(req) + : {} + await match.handler(req, res, body, match.params) + } catch (err) { + logger.error(err, 'registry request handler error') + if (!res.headersSent) { + respond(res, 500, { + error: err instanceof Error ? err.message : String(err), + }) + } + } + } + ) + + await new Promise((resolve) => server.listen(port, resolve)) + + logger.info( + { port, tokenAdminPartyId, ledgerUrl: ledgerUrl.href }, + 'TestToken registry server started' + ) + + return { + stop: () => + new Promise((resolve, reject) => + server.close((err) => (err ? reject(err) : resolve())) + ), + } +} diff --git a/docs/wallet-integration-guide/examples/scripts/15-multi-sync/_registry/ledger.ts b/docs/wallet-integration-guide/examples/scripts/15-multi-sync/_registry/ledger.ts new file mode 100644 index 000000000..f7ab1e54a --- /dev/null +++ b/docs/wallet-integration-guide/examples/scripts/15-multi-sync/_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 `TokenRules` contracts visible to the tokenAdmin party from the + * app-provider participant (P2). P2 hosts tokenAdmin on both synchronizers + * (global + app), so it returns the TokenRules contract for either one. 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 `tokenAdminPartyId` from the configured + * participant. Caches results for a short TTL. + */ +export async function readTokenRules( + client: LedgerClient, + tokenAdminPartyId: string, + logger: Logger +): Promise { + const now = Date.now() + if (cache && now < cache.expireAt) { + logger.debug('TokenRules cache hit') + return cache.contracts + } + + logger.debug('Fetching TokenRules from ledger ACS…') + + // `get` 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: { + [tokenAdminPartyId]: { + 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/docs/wallet-integration-guide/examples/scripts/15-multi-sync/_registry/types.ts b/docs/wallet-integration-guide/examples/scripts/15-multi-sync/_registry/types.ts new file mode 100644 index 000000000..e5a6eb71c --- /dev/null +++ b/docs/wallet-integration-guide/examples/scripts/15-multi-sync/_registry/types.ts @@ -0,0 +1,121 @@ +// 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'] + +/** Request body for getTransferFactory and getAllocationFactory. */ +export type GetFactoryRequest = + transferInstructionRegistryTypes['schemas']['GetFactoryRequest'] + +/** Request body for the transfer-instruction and allocation choice-context endpoints. */ +export type GetChoiceContextRequest = + transferInstructionRegistryTypes['schemas']['GetChoiceContextRequest'] + +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[] +} + +// ── token-metadata-v1 ────────────────────────────────────────────────────── + +export interface MetadataHandlers { + getRegistryInfo(): + | GetRegistryInfoResponse + | Promise + listInstruments(query?: { + pageSize?: number + pageToken?: string + }): ListInstrumentsResponse | Promise + getInstrument(path: { + instrumentId: string + }): Instrument | null | Promise +} + +// ── transfer-instruction-v1 ──────────────────────────────────────────────── + +export interface TransferFactoryWithChoiceContext { + factoryId: string + transferKind: 'self' | 'direct' | 'offer' + choiceContext: ChoiceContext +} + +export interface TransferHandlers { + getTransferFactory( + body: GetFactoryRequest + ): + | TransferFactoryWithChoiceContext + | null + | Promise + getTransferInstructionAcceptContext( + path: { transferInstructionId: string }, + body: GetChoiceContextRequest + ): ChoiceContext | Promise + getTransferInstructionRejectContext( + path: { transferInstructionId: string }, + body: GetChoiceContextRequest + ): ChoiceContext | Promise + getTransferInstructionWithdrawContext( + path: { transferInstructionId: string }, + body: GetChoiceContextRequest + ): ChoiceContext | Promise +} + +// ── allocation-instruction-v1 ────────────────────────────────────────────── + +export interface FactoryWithChoiceContext { + factoryId: string + choiceContext: ChoiceContext +} + +export interface AllocationInstructionHandlers { + getAllocationFactory( + body: GetFactoryRequest + ): + | FactoryWithChoiceContext + | null + | Promise +} + +// ── allocation-v1 ────────────────────────────────────────────────────────── + +export interface AllocationHandlers { + getAllocationTransferContext( + path: { allocationId: string }, + body: GetChoiceContextRequest + ): ChoiceContext | Promise + getAllocationWithdrawContext( + path: { allocationId: string }, + body: GetChoiceContextRequest + ): ChoiceContext | Promise + getAllocationCancelContext( + path: { allocationId: string }, + body: GetChoiceContextRequest + ): ChoiceContext | Promise +} diff --git a/docs/wallet-integration-guide/examples/scripts/15-multi-sync/_setup.ts b/docs/wallet-integration-guide/examples/scripts/15-multi-sync/_setup.ts new file mode 100644 index 000000000..8d5b858a5 --- /dev/null +++ b/docs/wallet-integration-guide/examples/scripts/15-multi-sync/_setup.ts @@ -0,0 +1,257 @@ +// 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 { + createLocalNetSdks, + localNetStaticConfig, + type SDKInterface, + type SDKContext, + type TokenNamespace, + vetPackageIdempotent, +} from '@canton-network/wallet-sdk' +import type { KeyPair } from '@canton-network/core-signing-lib' +import type { GenerateTransactionResponse } from '@canton-network/core-ledger-client' +import { ScanProxyClient } from '@canton-network/wallet-sdk' +import { AuthTokenProvider } from '@canton-network/core-wallet-auth' +import { + AMULET_NAMESPACE_CONFIG, + TOKEN_NAMESPACE_CONFIG, + TOKEN_PROVIDER_CONFIG_DEFAULT, +} 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 TEST_TOKEN_V1_DAR = + '../../../../../damljs/splice-test-token-v1/.daml/dist/splice-test-token-v1-1.0.0.dar' +const LOCALNET_PATH = '../../../../../.localnet' +const TRADING_APP_DAR_LOCALNET = '/dars/splice-token-test-trading-app-1.0.0.dar' + +export interface MultiSyncSetup { + appUserSdk: SDKInterface<'token' | 'amulet'> + appProviderSdk: SDKInterface<'token'> + svSdk: SDKInterface<'token'> + appUserCtx: SDKContext + appProviderCtx: SDKContext + svCtx: SDKContext + tokenNamespaceAppUser: TokenNamespace + tokenNamespaceAppProvider: TokenNamespace + alice: PartyInfo + bob: PartyInfo + tradingApp: PartyInfo + tokenAdmin: PartyInfo + globalSynchronizerId: string + appSynchronizerId: string + synchronizers: SynchronizerMap + scanProxy: ScanProxyClient + amuletAdmin: string + testTokenRegistryUrl: URL +} + +/** + * Bootstraps a fresh multi-synchronizer environment: + * - Creates SDK instances for the app-user, app-provider, and sv participants + * - Discovers global + app synchronizer IDs from the app-user participant + * - Allocates alice (app-user), bob (app-provider), tradingApp (sv), tokenAdmin (app-provider) on global synchronizer + * while simultaneously registering alice, bob, and tokenAdmin on app-synchronizer + * - tradingApp is global-only + * - Connects the scan proxy and returns the Amulet admin party ID + */ +export async function setupMultiSyncTrade( + logger: Logger +): Promise { + const { + appUser: appUserSdk, + appProvider: appProviderSdk, + sv: svSdk, + } = await createLocalNetSdks({ + appUser: { + amulet: AMULET_NAMESPACE_CONFIG, + token: TOKEN_NAMESPACE_CONFIG, + }, + appProvider: { token: TOKEN_NAMESPACE_CONFIG }, + sv: { token: TOKEN_NAMESPACE_CONFIG }, + }) + + const appUserCtx = ( + appUserSdk.ledger as unknown as { sdkContext: SDKContext } + ).sdkContext + const appProviderCtx = ( + appProviderSdk.ledger as unknown as { sdkContext: SDKContext } + ).sdkContext + const svCtx = (svSdk.ledger as unknown as { sdkContext: SDKContext }) + .sdkContext + + const connectedSyncResponse = + await appUserSdk.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 = + await appUserSdk.ledger.getGlobalSynchronizerId() + const appSynchronizerId = + await appUserSdk.ledger.getSynchronizerIdByAlias('app-synchronizer') + + 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([ + fs.readFile(path.join(here, TEST_TOKEN_V1_DAR)), + fs.readFile(path.join(here, LOCALNET_PATH, TRADING_APP_DAR_LOCALNET)), + ]) + + await Promise.all([ + // app-user + app-provider vet both DARs on the global and app synchronizers. + ...[testTokenV1Dar, tradingAppDar].flatMap((dar) => + [appUserCtx, appProviderCtx].flatMap((ctx) => + [globalSynchronizerId, appSynchronizerId].map((sid) => + vetPackageIdempotent(ctx.ledgerProvider, dar, sid, logger) + ) + ) + ), + ...[testTokenV1Dar, tradingAppDar].map((dar) => + vetPackageIdempotent( + svCtx.ledgerProvider, + dar, + globalSynchronizerId, + logger + ) + ), + ]) + logger.info( + 'DARs vetted: app-user + app-provider have TestTokenV1 + trading-app on both synchronizers; sv has both on global only' + ) + + const aliceKey = appUserSdk.keys.generate() + const bobKey = appUserSdk.keys.generate() + const tradingAppKey = appUserSdk.keys.generate() + const tokenAdminKey = appProviderSdk.keys.generate() + + const [ + allocatedAlice, + allocatedBob, + allocatedTradingApp, + allocatedTokenAdmin, + ] = await Promise.all([ + appUserSdk.party.external + .create(aliceKey.publicKey, { + partyHint: 'Alice', + synchronizerId: globalSynchronizerId, + additionalSynchronizerIds: [appSynchronizerId], + }) + .sign(aliceKey.privateKey) + .execute(), + appProviderSdk.party.external + .create(bobKey.publicKey, { + partyHint: 'Bob', + synchronizerId: globalSynchronizerId, + additionalSynchronizerIds: [appSynchronizerId], + }) + .sign(bobKey.privateKey) + .execute(), + svSdk.party.external + .create(tradingAppKey.publicKey, { + partyHint: 'TradingApp', + synchronizerId: globalSynchronizerId, + }) + .sign(tradingAppKey.privateKey) + .execute(), + appProviderSdk.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} (sv), tokenAdmin: ${tokenAdmin.partyId} (app-provider)` + ) + + const auth = new AuthTokenProvider(TOKEN_PROVIDER_CONFIG_DEFAULT, logger) + const scanProxy = new ScanProxyClient( + localNetStaticConfig.LOCALNET_APP_VALIDATOR_URL, + logger, + auth + ) + const amuletRules = await scanProxy.getAmuletRules() + const amuletAdmin = (amuletRules.payload as Record)[ + 'dso' + ] as string + logger.info(`Amulet asset discovered — admin: ${amuletAdmin}`) + + return { + appUserSdk, + appProviderSdk, + svSdk, + appUserCtx, + appProviderCtx, + svCtx, + tokenNamespaceAppUser: appUserSdk.token, + tokenNamespaceAppProvider: appProviderSdk.token, + alice, + bob, + tradingApp, + tokenAdmin, + globalSynchronizerId, + appSynchronizerId, + synchronizers, + scanProxy, + amuletAdmin, + testTokenRegistryUrl: TEST_TOKEN_REGISTRY_URL, + } +} diff --git a/docs/wallet-integration-guide/examples/scripts/15-multi-sync/_token_allocation.ts b/docs/wallet-integration-guide/examples/scripts/15-multi-sync/_token_allocation.ts new file mode 100644 index 000000000..b273808c3 --- /dev/null +++ b/docs/wallet-integration-guide/examples/scripts/15-multi-sync/_token_allocation.ts @@ -0,0 +1,21 @@ +// 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 { allocateTestToken } from '@canton-network/core-test-token' +import type { MultiSyncSetup } from './_setup.js' + +export async function allocateTokenForBob( + setup: MultiSyncSetup, + logger: Logger +): Promise<{ legId: string }> { + const { appProviderSdk, bob, tokenAdmin, globalSynchronizerId } = setup + + return allocateTestToken({ + sdk: appProviderSdk, + sender: { partyId: bob.partyId, privateKey: bob.keyPair.privateKey }, + adminPartyId: tokenAdmin.partyId, + globalSynchronizerId, + logger, + }) +} diff --git a/docs/wallet-integration-guide/examples/scripts/15-multi-sync/_token_setup.ts b/docs/wallet-integration-guide/examples/scripts/15-multi-sync/_token_setup.ts new file mode 100644 index 000000000..167fa5e0d --- /dev/null +++ b/docs/wallet-integration-guide/examples/scripts/15-multi-sync/_token_setup.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 + +import type { Logger } from 'pino' +import { + createTokenRules, + mintTestToken, +} from '@canton-network/core-test-token' +import type { MultiSyncSetup } from './_setup.js' +import { BOB_TOKEN_MINT_AMOUNT } from './_constants.js' + +export async function createTokenRulesAndMintForBob( + setup: MultiSyncSetup, + logger: Logger +): Promise { + const { + appProviderSdk, + tokenNamespaceAppProvider, + bob, + tokenAdmin, + globalSynchronizerId, + appSynchronizerId, + testTokenRegistryUrl, + } = setup + + const admin = { + partyId: tokenAdmin.partyId, + privateKey: tokenAdmin.keyPair.privateKey, + } + + await createTokenRules({ + sdk: appProviderSdk, + admin, + synchronizerIds: [globalSynchronizerId, appSynchronizerId], + }) + + await mintTestToken({ + sdk: appProviderSdk, + admin, + receiver: { + partyId: bob.partyId, + privateKey: bob.keyPair.privateKey, + }, + amount: BOB_TOKEN_MINT_AMOUNT, + synchronizerId: appSynchronizerId, + }) + + logger.info( + `TokenAdmin: TokenRules created on global + app synchronizers; Bob: ${BOB_TOKEN_MINT_AMOUNT} TestToken minted on app-synchronizer via registry transfer-factory` + ) +} diff --git a/docs/wallet-integration-guide/examples/scripts/15-multi-sync/_token_transfer.ts b/docs/wallet-integration-guide/examples/scripts/15-multi-sync/_token_transfer.ts new file mode 100644 index 000000000..f255b71a4 --- /dev/null +++ b/docs/wallet-integration-guide/examples/scripts/15-multi-sync/_token_transfer.ts @@ -0,0 +1,46 @@ +// 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 { + selfTransferTestToken, + selfTransferAllTestTokens, +} from '@canton-network/core-test-token' +import type { MultiSyncSetup } from './_setup.js' +import { TRADE_TOKEN_AMOUNT } from './_constants.js' + +export async function aliceSelfTransferToApp( + setup: MultiSyncSetup, + logger: Logger +): Promise { + const { appUserSdk, appProviderSdk, alice, tokenAdmin, appSynchronizerId } = + setup + + await selfTransferTestToken({ + sdk: appUserSdk, + owner: { + partyId: alice.partyId, + privateKey: alice.keyPair.privateKey, + }, + adminPartyId: tokenAdmin.partyId, + adminSdk: appProviderSdk, + synchronizerId: appSynchronizerId, + amount: TRADE_TOKEN_AMOUNT, + logger, + }) +} + +export async function bobSelfTransferToApp( + setup: MultiSyncSetup, + logger: Logger +): Promise { + const { appProviderSdk, bob, tokenAdmin, appSynchronizerId } = setup + + await selfTransferAllTestTokens({ + sdk: appProviderSdk, + owner: { partyId: bob.partyId, privateKey: bob.keyPair.privateKey }, + adminPartyId: tokenAdmin.partyId, + synchronizerId: appSynchronizerId, + logger, + }) +} diff --git a/docs/wallet-integration-guide/examples/scripts/15-multi-sync/_trade_propose.ts b/docs/wallet-integration-guide/examples/scripts/15-multi-sync/_trade_propose.ts new file mode 100644 index 000000000..bbb73f1f4 --- /dev/null +++ b/docs/wallet-integration-guide/examples/scripts/15-multi-sync/_trade_propose.ts @@ -0,0 +1,41 @@ +// 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 { createAndInitiateOtcTrade as createAndInitiateOtcTradeCore } from '@canton-network/core-trading-app' +import type { MultiSyncSetup } from './_setup.js' + +/** Adapts the example's {@link MultiSyncSetup} to the trading-app OTC trade flow. */ +export async function createAndInitiateOtcTrade( + setup: MultiSyncSetup, + transferLegs: Record, + logger: Logger +): Promise { + const { + appUserSdk, + appProviderSdk, + svSdk, + alice, + bob, + tradingApp, + globalSynchronizerId, + } = setup + + return createAndInitiateOtcTradeCore({ + proposerSdk: appUserSdk, + proposer: { + partyId: alice.partyId, + privateKey: alice.keyPair.privateKey, + }, + acceptorSdk: appProviderSdk, + acceptor: { partyId: bob.partyId, privateKey: bob.keyPair.privateKey }, + venueSdk: svSdk, + venue: { + partyId: tradingApp.partyId, + privateKey: tradingApp.keyPair.privateKey, + }, + transferLegs, + globalSynchronizerId, + logger, + }) +} diff --git a/docs/wallet-integration-guide/examples/scripts/15-multi-sync/_trade_settle.ts b/docs/wallet-integration-guide/examples/scripts/15-multi-sync/_trade_settle.ts new file mode 100644 index 000000000..f3e82be59 --- /dev/null +++ b/docs/wallet-integration-guide/examples/scripts/15-multi-sync/_trade_settle.ts @@ -0,0 +1,121 @@ +// 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 { + settleOtcTrade as settleOtcTradeCore, + withdrawAllocations, +} from '@canton-network/core-trading-app' +import type { LedgerCommonSchemas } from '@canton-network/core-ledger-client-types' +import type { MultiSyncSetup } from './_setup.js' + +type DisclosedContract = LedgerCommonSchemas['DisclosedContract'] + +export interface SettleParams { + otcTradeCid: string + legIdAlice: string + legIdBob: string + testTokenAllocationCid: string + testTokenAllocationDisclosed: DisclosedContract +} + +/** Adapts the example's {@link MultiSyncSetup} to the trading-app OTC settlement flow. */ +export async function settleOtcTrade( + setup: MultiSyncSetup, + params: SettleParams, + logger: Logger +): Promise { + const { + appUserSdk, + appProviderSdk, + svSdk, + tokenNamespaceAppUser, + alice, + bob, + tradingApp, + tokenAdmin, + globalSynchronizerId, + amuletAdmin, + testTokenRegistryUrl, + } = setup + const { + otcTradeCid, + legIdAlice, + legIdBob, + testTokenAllocationCid, + testTokenAllocationDisclosed, + } = params + + await settleOtcTradeCore({ + venueSdk: svSdk, + venue: { + partyId: tradingApp.partyId, + privateKey: tradingApp.keyPair.privateKey, + }, + otcTradeCid, + contextLeg: { + tokenNamespace: tokenNamespaceAppUser, + ownerPartyId: alice.partyId, + legId: legIdAlice, + registryUrl: localNetStaticConfig.LOCALNET_REGISTRY_API_URL, + }, + disclosedLeg: { + legId: legIdBob, + allocationCid: testTokenAllocationCid, + disclosedContract: testTokenAllocationDisclosed, + }, + globalSynchronizerId, + onSettlementFailure: (amuletAllocationCid) => + withdrawAllocations({ + globalSynchronizerId, + logger, + withdrawals: [ + { + sdk: appUserSdk, + owner: { + partyId: alice.partyId, + privateKey: alice.keyPair.privateKey, + }, + withdrawParams: { + allocationCid: amuletAllocationCid, + asset: { + id: 'Amulet', + displayName: 'Amulet', + symbol: 'CC', + registryUrl: + localNetStaticConfig.LOCALNET_REGISTRY_API_URL, + admin: amuletAdmin, + }, + }, + logMessage: + 'Alice: Amulet allocation withdrawn — funds returned', + }, + { + sdk: appProviderSdk, + owner: { + partyId: bob.partyId, + privateKey: bob.keyPair.privateKey, + }, + withdrawParams: { + allocationCid: testTokenAllocationCid, + asset: { + id: 'TestToken', + displayName: 'TestToken', + symbol: 'TT', + registryUrl: new URL('http://unused.invalid'), + admin: tokenAdmin.partyId, + }, + prefetchedRegistryChoiceContext: { + choiceContextData: { values: {} as never }, + disclosedContracts: [], + }, + }, + logMessage: + 'Bob: TestToken allocation withdrawn — funds returned', + }, + ], + }), + logger, + }) +} diff --git a/docs/wallet-integration-guide/examples/scripts/15-multi-sync/index.ts b/docs/wallet-integration-guide/examples/scripts/15-multi-sync/index.ts new file mode 100644 index 000000000..74891270a --- /dev/null +++ b/docs/wallet-integration-guide/examples/scripts/15-multi-sync/index.ts @@ -0,0 +1,184 @@ +import pino from 'pino' +import { localNetStaticConfig } from '@canton-network/wallet-sdk' +import { logAllContracts } from '../utils/index.js' +import { setupMultiSyncTrade } from './_setup.js' +import { startRegistry } from './_registry/index.js' +import { + TEST_TOKEN_REGISTRY_PORT, + TRADE_AMULET_AMOUNT, + TRADE_TOKEN_AMOUNT, +} from './_constants.js' +import { mintAmuletForAlice, allocateAmuletForAlice } from './_amulet_ops.js' +import { createTokenRulesAndMintForBob } from './_token_setup.js' +import { allocateTokenForBob } from './_token_allocation.js' +import { + aliceSelfTransferToApp, + bobSelfTransferToApp, +} from './_token_transfer.js' +import { createAndInitiateOtcTrade } from './_trade_propose.js' +import { settleOtcTrade } from './_trade_settle.js' + +// Multi-Synchronizer DvP: Alice pays 100 Amulet on global; Bob delivers 20 TestToken from app-sync. +// app-user participant hosts Alice, app-provider hosts Bob (+ TokenAdmin), sv hosts TradingApp. + +const logger = pino({ name: 'v1-15-multi-sync-trade', level: 'info' }) + +// ── Setup: create SDKs, discover synchronizers, vet DARs, allocate parties ─── +// Step 1: Create SDKs for all 3 participants (app-user, app-provider, sv) 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 (sv), and TokenAdmin (app-provider) +const setup = await setupMultiSyncTrade(logger) +const { + appUserSdk, + appProviderSdk, + svSdk, + tokenNamespaceAppProvider, + alice, + bob, + tradingApp, + tokenAdmin, + synchronizers, + amuletAdmin, +} = setup + +// ── Start the TestToken registry (CIP-56 off-ledger APIs) ─────────────────── +const registry = await startRegistry({ + tokenAdminPartyId: tokenAdmin.partyId, + port: TEST_TOKEN_REGISTRY_PORT, + ledgerUrl: localNetStaticConfig.LOCALNET_APP_PROVIDER_LEDGER_URL, + globalSynchronizerId: setup.globalSynchronizerId, + appSynchronizerId: setup.appSynchronizerId, + logger, +}) + +// ── Steps 4–5: Init holdings ──────────────────────────────────────────────── +// Step 4: Mint Amulet for Alice (global synchronizer) +// Steps 5a–5e: TokenAdmin creates TokenRules on global + app, self-mints Token, +// offers to Bob via TransferFactory_Transfer; Bob accepts via +// TransferInstruction_Accept — all single-party submissions +await Promise.all([ + mintAmuletForAlice(setup, logger), + createTokenRulesAndMintForBob(setup, logger), +]) + +logger.info('Contracts after setup:') +await logAllContracts(logger, synchronizers, [ + { sdk: appUserSdk, parties: [alice.partyId] }, + { sdk: appProviderSdk, parties: [bob.partyId] }, + { sdk: appProviderSdk, parties: [tokenAdmin.partyId] }, + { sdk: svSdk, 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: appUserSdk, parties: [alice.partyId] }, + { sdk: appProviderSdk, parties: [bob.partyId] }, + { sdk: appProviderSdk, parties: [tokenAdmin.partyId] }, + { sdk: svSdk, 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: appUserSdk, parties: [alice.partyId] }, + { sdk: appProviderSdk, parties: [bob.partyId] }, + { sdk: appProviderSdk, parties: [tokenAdmin.partyId] }, + { sdk: svSdk, parties: [tradingApp.partyId] }, +]) +// ── Step 10a: Locate Bob's TestToken allocation ──────────────────────────────────── +const allocationsBob = await tokenNamespaceAppProvider.allocation.pending( + bob.partyId +) +const testTokenAllocation = allocationsBob.find( + (a) => a.interfaceViewValue.allocation.transferLegId === legIdBob +) +if (!testTokenAllocation) throw new Error('TestToken allocation not found') +const testTokenAllocationCid = testTokenAllocation.contractId +// Disclose Bob's TestToken allocation to the TradingApp (sv participant): the +// allocation is created on the app-provider participant, so it may not yet be in the sv's ACS +// when settlement runs. Disclosing it makes settlement independent of cross- +// participant propagation timing. +const testTokenAllocationDisclosed = { + templateId: testTokenAllocation.activeContract.createdEvent.templateId, + contractId: testTokenAllocation.contractId, + createdEventBlob: + testTokenAllocation.activeContract.createdEvent.createdEventBlob!, + synchronizerId: '', +} + +// ── Step 10b: TradingApp settles the OTCTrade ───────────────────────────────── +try { + await settleOtcTrade( + setup, + { + otcTradeCid, + legIdAlice, + legIdBob, + testTokenAllocationCid, + testTokenAllocationDisclosed, + }, + logger + ) +} catch (e) { + logger.error( + { err: e }, + 'Settlement failed — compensation applied, funds returned' + ) + await bobSelfTransferToApp(setup, logger) + logger.info('Contracts after settlement failure (compensation applied):') + await logAllContracts(logger, synchronizers, [ + { sdk: appUserSdk, parties: [alice.partyId] }, + { sdk: appProviderSdk, parties: [bob.partyId] }, + { sdk: appProviderSdk, parties: [tokenAdmin.partyId] }, + { sdk: svSdk, parties: [tradingApp.partyId] }, + ]) + await registry.stop() + process.exit(1) +} +logger.info('Contracts after settlement:') +await logAllContracts(logger, synchronizers, [ + { sdk: appUserSdk, parties: [alice.partyId] }, + { sdk: appProviderSdk, parties: [bob.partyId] }, + { sdk: appProviderSdk, parties: [tokenAdmin.partyId] }, + { sdk: svSdk, parties: [tradingApp.partyId] }, +]) +// ── Step 11: Self-transfer TestTokens back to app-synchronizer ───────────────── +await Promise.all([ + aliceSelfTransferToApp(setup, logger), + bobSelfTransferToApp(setup, logger), +]) +logger.info('Final contract state:') +await logAllContracts(logger, synchronizers, [ + { sdk: appUserSdk, parties: [alice.partyId] }, + { sdk: appProviderSdk, parties: [bob.partyId] }, + { sdk: appProviderSdk, parties: [tokenAdmin.partyId] }, + { sdk: svSdk, parties: [tradingApp.partyId] }, +]) + +await registry.stop() +process.exit(0) diff --git a/docs/wallet-integration-guide/examples/scripts/stress/01-merge-utxos.ts b/docs/wallet-integration-guide/examples/scripts/stress/01-merge-utxos.ts index 267794a44..e4923ac6b 100644 --- a/docs/wallet-integration-guide/examples/scripts/stress/01-merge-utxos.ts +++ b/docs/wallet-integration-guide/examples/scripts/stress/01-merge-utxos.ts @@ -4,6 +4,7 @@ import { TOKEN_NAMESPACE_CONFIG, TOKEN_PROVIDER_CONFIG_DEFAULT, AMULET_NAMESPACE_CONFIG, + getGlobalSynchronizerId, } from '../utils/index.js' import { batchTap } from './utils.js' import Decimal from 'decimal.js' @@ -19,9 +20,12 @@ const sdk = await SDK.create({ const aliceKeys = sdk.keys.generate() +const globalSynchronizerId = await getGlobalSynchronizerId(sdk) + const alice = await sdk.party.external .create(aliceKeys.publicKey, { partyHint: 'v1-06-alice', + synchronizerId: globalSynchronizerId, }) .sign(aliceKeys.privateKey) .execute() diff --git a/docs/wallet-integration-guide/examples/scripts/stress/02-merge-utxos-delegate.ts b/docs/wallet-integration-guide/examples/scripts/stress/02-merge-utxos-delegate.ts index 1ddd13e2e..7141bb0cf 100644 --- a/docs/wallet-integration-guide/examples/scripts/stress/02-merge-utxos-delegate.ts +++ b/docs/wallet-integration-guide/examples/scripts/stress/02-merge-utxos-delegate.ts @@ -8,6 +8,7 @@ import { TOKEN_NAMESPACE_CONFIG, TOKEN_PROVIDER_CONFIG_DEFAULT, AMULET_NAMESPACE_CONFIG, + getGlobalSynchronizerId, } from '../utils/index.js' import { batchTap } from './utils.js' import Decimal from 'decimal.js' @@ -37,10 +38,13 @@ const sdk = await SDK.create({ amulet: AMULET_NAMESPACE_CONFIG, }) +const synchronizerId = await getGlobalSynchronizerId(sdk) + const darBytes = await readFile(spliceUtilTokenStandardWalletDarPath) await sdk.ledger.dar.upload( darBytes, - SPLICE_UTIL_TOKEN_STANDARD_WALLET_PACKAGE_ID + SPLICE_UTIL_TOKEN_STANDARD_WALLET_PACKAGE_ID, + synchronizerId ) logger.info(`DAR ${PATH_TO_DAR_IN_LOCALNET} successfully uploaded`) 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 1e15c1eee..bfe25e278 100644 --- a/docs/wallet-integration-guide/examples/scripts/utils/index.ts +++ b/docs/wallet-integration-guide/examples/scripts/utils/index.ts @@ -9,12 +9,29 @@ 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 +} + +/** + * Returns the ID of the synchronizer aliased `'global'`. + */ +export async function getGlobalSynchronizerId(sdk: { + ledger: { getGlobalSynchronizerId(): Promise } +}): Promise { + return sdk.ledger.getGlobalSynchronizerId() +} + export const TOKEN_PROVIDER_CONFIG_DEFAULT: TokenProviderConfig = { method: 'self_signed', issuer: 'unsafe-auth', diff --git a/docs/wallet-integration-guide/examples/scripts/utils/upload-dars.ts b/docs/wallet-integration-guide/examples/scripts/utils/upload-dars.ts index d37fb8908..d3620f002 100644 --- a/docs/wallet-integration-guide/examples/scripts/utils/upload-dars.ts +++ b/docs/wallet-integration-guide/examples/scripts/utils/upload-dars.ts @@ -3,7 +3,10 @@ import { localNetStaticConfig, SDK } from '@canton-network/wallet-sdk' import path from 'path' import { fileURLToPath } from 'url' import fs from 'fs/promises' -import { TOKEN_PROVIDER_CONFIG_DEFAULT } from './index.js' +import { + getGlobalSynchronizerId, + TOKEN_PROVIDER_CONFIG_DEFAULT, +} from './index.js' /* This script is so that the CI can run all the scripts in parallel @@ -28,6 +31,8 @@ const TRADING_APP_PACKAGE_ID = const here = path.dirname(fileURLToPath(import.meta.url)) +const synchronizerId = await getGlobalSynchronizerId(sdk) + const tradingDarPath = path.join( here, PATH_TO_LOCALNET, @@ -36,7 +41,7 @@ const tradingDarPath = path.join( //upload dar const darBytes = await fs.readFile(tradingDarPath) -await sdk.ledger.dar.upload(darBytes, TRADING_APP_PACKAGE_ID) +await sdk.ledger.dar.upload(darBytes, TRADING_APP_PACKAGE_ID, synchronizerId) const PATH_TO_TOKEN_STANDARD_DAR_IN_LOCALNET = '/dars/splice-util-token-standard-wallet-1.0.0.dar' @@ -55,7 +60,8 @@ const tokenStandardDarBytes = await fs.readFile( ) await sdk.ledger.dar.upload( tokenStandardDarBytes, - SPLICE_UTIL_TOKEN_STANDARD_WALLET_PACKAGE_ID + SPLICE_UTIL_TOKEN_STANDARD_WALLET_PACKAGE_ID, + synchronizerId ) logger.info('upload dars completed') diff --git a/docs/wallet-integration-guide/examples/snippets/allocate-party-without-rights.ts b/docs/wallet-integration-guide/examples/snippets/allocate-party-without-rights.ts index 0a9e4a93b..24c4f863e 100644 --- a/docs/wallet-integration-guide/examples/snippets/allocate-party-without-rights.ts +++ b/docs/wallet-integration-guide/examples/snippets/allocate-party-without-rights.ts @@ -9,7 +9,10 @@ export default async function () { const key = sdk.keys.generate() const party = await sdk.party.external - .create(key.publicKey, { partyHint: 'my-party-without-rights' }) + .create(key.publicKey, { + partyHint: 'my-party-without-rights', + synchronizerId: global.SYNCHRONIZER_ID, + }) .sign(key.privateKey) .execute({ grantUserRights: false }) //do not grant user actAs and readAs for the party } diff --git a/docs/wallet-integration-guide/examples/snippets/allocate-party.ts b/docs/wallet-integration-guide/examples/snippets/allocate-party.ts index c8a5c916e..83a735bcc 100644 --- a/docs/wallet-integration-guide/examples/snippets/allocate-party.ts +++ b/docs/wallet-integration-guide/examples/snippets/allocate-party.ts @@ -41,7 +41,10 @@ export default async function () { const partyHint = 'my-wallet-1' await sdk.party.external - .create(key.publicKey, { partyHint }) + .create(key.publicKey, { + partyHint, + synchronizerId: global.SYNCHRONIZER_ID, + }) .sign(key.privateKey) .execute() } diff --git a/docs/wallet-integration-guide/examples/snippets/config-template.ts b/docs/wallet-integration-guide/examples/snippets/config-template.ts index 8c4a49bd9..b96b29c2e 100644 --- a/docs/wallet-integration-guide/examples/snippets/config-template.ts +++ b/docs/wallet-integration-guide/examples/snippets/config-template.ts @@ -36,7 +36,7 @@ export default async function () { await sdk.token.utxos.list({ partyId: myParty }) - await sdk.amulet.traffic.status() + await sdk.amulet.traffic.status({ synchronizerId: global.SYNCHRONIZER_ID }) // OR, you can defer loading config by calling .extend() @@ -80,5 +80,7 @@ export default async function () { // Now both token and amulet are available await fullyExtendedSDK.token.utxos.list({ partyId: myParty }) - await fullyExtendedSDK.amulet.traffic.status() + await fullyExtendedSDK.amulet.traffic.status({ + synchronizerId: global.SYNCHRONIZER_ID, + }) } diff --git a/docs/wallet-integration-guide/examples/snippets/create-topology-transactions.ts b/docs/wallet-integration-guide/examples/snippets/create-topology-transactions.ts index 23711ae41..45d0f8793 100644 --- a/docs/wallet-integration-guide/examples/snippets/create-topology-transactions.ts +++ b/docs/wallet-integration-guide/examples/snippets/create-topology-transactions.ts @@ -13,6 +13,7 @@ export default async function () { const prepared = sdk.party.external.create(key.publicKey, { partyHint, + synchronizerId: global.SYNCHRONIZER_ID, }) await prepared.topology() diff --git a/docs/wallet-integration-guide/examples/snippets/setupTests.ts b/docs/wallet-integration-guide/examples/snippets/setupTests.ts index 0c7b4ba3a..7dc757ff5 100644 --- a/docs/wallet-integration-guide/examples/snippets/setupTests.ts +++ b/docs/wallet-integration-guide/examples/snippets/setupTests.ts @@ -26,6 +26,8 @@ declare global { var VALIDATOR_OPERATOR_PARTY: PartyId + var SYNCHRONIZER_ID: string + var EXISTING_TOPOLOGY: { multiHash: string partyId: string @@ -95,12 +97,17 @@ async function beforeEachSetup() { asset: global.ASSET_CONFIG, }) + // ========= Resolve the synchronizer parties are hosted on ========= + global.SYNCHRONIZER_ID = await sdk.ledger.getGlobalSynchronizerId() + // ========= Setup Existing Party 1 ========= global.EXISTING_PARTY_1_KEYS = sdk.keys.generate() global.EXISTING_PARTY_1 = ( await sdk.party.external - .create(global.EXISTING_PARTY_1_KEYS.publicKey, {}) + .create(global.EXISTING_PARTY_1_KEYS.publicKey, { + synchronizerId: global.SYNCHRONIZER_ID, + }) .sign(global.EXISTING_PARTY_1_KEYS.privateKey) .execute() ).partyId @@ -109,7 +116,9 @@ async function beforeEachSetup() { global.EXISTING_PARTY_2_KEYS = sdk.keys.generate() global.EXISTING_PARTY_2 = ( await sdk.party.external - .create(global.EXISTING_PARTY_2_KEYS.publicKey, {}) + .create(global.EXISTING_PARTY_2_KEYS.publicKey, { + synchronizerId: global.SYNCHRONIZER_ID, + }) .sign(global.EXISTING_PARTY_2_KEYS.privateKey) .execute() ).partyId @@ -136,6 +145,7 @@ async function beforeEachSetup() { global.EXISTING_TOPOLOGY = await sdk.party.external .create(global.EXISTING_PARTY_1_KEYS.publicKey, { partyHint: 'my-party', + synchronizerId: global.SYNCHRONIZER_ID, }) .sign(global.EXISTING_PARTY_1_KEYS.privateKey) .execute() @@ -154,7 +164,9 @@ async function beforeEachSetup() { global.EXISTING_PARTY_WITH_PREAPPROVAL_KEYS = sdk.keys.generate() global.EXISTING_PARTY_WITH_PREAPPROVAL = ( await sdk.party.external - .create(global.EXISTING_PARTY_WITH_PREAPPROVAL_KEYS.publicKey, {}) + .create(global.EXISTING_PARTY_WITH_PREAPPROVAL_KEYS.publicKey, { + synchronizerId: global.SYNCHRONIZER_ID, + }) .sign(global.EXISTING_PARTY_WITH_PREAPPROVAL_KEYS.privateKey) .execute() ).partyId diff --git a/docs/wallet-integration-guide/examples/snippets/submit-signed-topology-transaction.ts b/docs/wallet-integration-guide/examples/snippets/submit-signed-topology-transaction.ts index 0184827b9..c3eb04127 100644 --- a/docs/wallet-integration-guide/examples/snippets/submit-signed-topology-transaction.ts +++ b/docs/wallet-integration-guide/examples/snippets/submit-signed-topology-transaction.ts @@ -16,6 +16,7 @@ export default async function () { await sdk.party.external .create(keys.publicKey, { partyHint: 'snippet-party-hint', + synchronizerId: global.SYNCHRONIZER_ID, }) .sign(keys.privateKey) .execute() @@ -27,6 +28,7 @@ export default async function () { offlineSigningKeys.publicKey, { partyHint: 'offline-signing-party', + synchronizerId: global.SYNCHRONIZER_ID, } ) 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/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/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..66989ffa5 100644 --- a/sdk/wallet-sdk/src/config.ts +++ b/sdk/wallet-sdk/src/config.ts @@ -1,6 +1,8 @@ // Copyright (c) 2025-2026 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. // SPDX-License-Identifier: Apache-2.0 +import { TokenProviderConfig } from '@canton-network/core-wallet-auth' + const LOCALNET_APP_VALIDATOR_URL = new URL( 'http://localhost:2000/api/validator' ) @@ -8,6 +10,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 +26,19 @@ 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, } + +export const localNetDefaultAuth: TokenProviderConfig = { + method: 'self_signed', + issuer: 'unsafe-auth', + credentials: { + clientId: LOCALNET_USER_ID, + clientSecret: 'unsafe', + audience: 'https://canton.network.global', + scope: '', + }, +} diff --git a/sdk/wallet-sdk/src/wallet/index.ts b/sdk/wallet-sdk/src/wallet/index.ts index cc6fc5301..a6471e5f4 100644 --- a/sdk/wallet-sdk/src/wallet/index.ts +++ b/sdk/wallet-sdk/src/wallet/index.ts @@ -2,3 +2,4 @@ // SPDX-License-Identifier: Apache-2.0 export * from './sdk.js' +export * from './localnet.js' diff --git a/sdk/wallet-sdk/src/wallet/localnet.ts b/sdk/wallet-sdk/src/wallet/localnet.ts new file mode 100644 index 000000000..0faa7d830 --- /dev/null +++ b/sdk/wallet-sdk/src/wallet/localnet.ts @@ -0,0 +1,90 @@ +// Copyright (c) 2025-2026 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { TokenProviderConfig } from '@canton-network/core-wallet-auth' +import { localNetDefaultAuth, localNetStaticConfig } from '../config.js' +import { SDK } from './sdk.js' +import { AllowedLogAdapters } from './logger/types.js' +import { + ExtendedSDKOptions, + GetExtendedKeys, + SDKInterface, +} from './init/types/sdk.js' + +export type LocalNetParticipant = 'app-user' | 'app-provider' | 'sv' + +const LOCALNET_LEDGER_URLS: Record = { + 'app-user': localNetStaticConfig.LOCALNET_APP_USER_LEDGER_URL, + 'app-provider': localNetStaticConfig.LOCALNET_APP_PROVIDER_LEDGER_URL, + sv: localNetStaticConfig.LOCALNET_SV_LEDGER_URL, +} + +export interface LocalNetSdkOptions { + auth?: TokenProviderConfig + logAdapter?: AllowedLogAdapters +} + +function createLocalNetSdk>( + participant: LocalNetParticipant, + extensions: Ext, + options?: LocalNetSdkOptions +): Promise>> { + return SDK.create({ + auth: options?.auth ?? localNetDefaultAuth, + ledgerClientUrl: LOCALNET_LEDGER_URLS[participant], + ...(options?.logAdapter !== undefined && { + logAdapter: options.logAdapter, + }), + ...extensions, + }) as unknown as Promise>> +} + +/** + * Creates SDKs for all three LocalNet participants (app-user, app-provider, sv) + * in parallel, filling in each participant's ledger URL and the LocalNet default + * auth so callers only supply the namespace extensions they need per participant. + * + * @example + * const { appUser, appProvider, sv } = await createLocalNetSdks({ + * appUser: { amulet: amuletConfig, token: tokenConfig }, + * appProvider: { token: tokenConfig }, + * sv: { token: tokenConfig }, + * }) + * + * @param participants - Namespace extensions (`amulet`, `token`, `asset`, + * `events`) for each participant; each returned SDK is typed with exactly the + * namespaces provided for it. + * @param options - Optional shared overrides for `auth` (defaults to the LocalNet + * self-signed auth) and the log adapter. + */ +export async function createLocalNetSdks< + AppUser extends Partial = Record, + AppProvider extends Partial = Record, + Sv extends Partial = Record, +>( + participants: { + appUser?: AppUser + appProvider?: AppProvider + sv?: Sv + } = {}, + options?: LocalNetSdkOptions +): Promise<{ + appUser: SDKInterface> + appProvider: SDKInterface> + sv: SDKInterface> +}> { + const [appUser, appProvider, sv] = await Promise.all([ + createLocalNetSdk( + 'app-user', + participants.appUser ?? ({} as AppUser), + options + ), + createLocalNetSdk( + 'app-provider', + participants.appProvider ?? ({} as AppProvider), + options + ), + createLocalNetSdk('sv', participants.sv ?? ({} as Sv), options), + ]) + return { appUser, appProvider, sv } +} diff --git a/sdk/wallet-sdk/src/wallet/namespace/amulet/namespace.ts b/sdk/wallet-sdk/src/wallet/namespace/amulet/namespace.ts index 88a3a8d4b..b3b298178 100644 --- a/sdk/wallet-sdk/src/wallet/namespace/amulet/namespace.ts +++ b/sdk/wallet-sdk/src/wallet/namespace/amulet/namespace.ts @@ -86,15 +86,13 @@ export class AmuletNamespace { options?: { partyId?: PartyId; synchronizerId?: string } ) { const partyId = options?.partyId ?? this.sdkContext.validatorParty - const synchronizerId = - options?.synchronizerId ?? - this.sdkContext.commonCtx.defaultSynchronizerId + const synchronizerId = options?.synchronizerId const [tapCommand, disclosedContracts] = await this.tap(partyId, amount) return await this.ledger.internal.submit({ commands: [tapCommand], disclosedContracts, - synchronizerId, + ...(synchronizerId !== undefined && { synchronizerId }), actAs: [partyId], }) } @@ -124,9 +122,13 @@ export class AmuletNamespace { if (featuredAppRights) { return featuredAppRights } - const synchronizerId = - options.synchronizerId ?? - this.sdkContext.commonCtx.defaultSynchronizerId + const synchronizerId = options.synchronizerId + if (!synchronizerId) + this.sdkContext.commonCtx.error.throw({ + type: 'BadRequest', + message: + 'synchronizerId is required for featuredApp.grant — pass the synchronizer ID explicitly', + }) const [featuredAppCommand, dc] = await this.sdkContext.amuletService.selfGrantFeatureAppRight( diff --git a/sdk/wallet-sdk/src/wallet/namespace/amulet/preapproval.ts b/sdk/wallet-sdk/src/wallet/namespace/amulet/preapproval.ts index fe31ed9a0..86333e5f1 100644 --- a/sdk/wallet-sdk/src/wallet/namespace/amulet/preapproval.ts +++ b/sdk/wallet-sdk/src/wallet/namespace/amulet/preapproval.ts @@ -116,12 +116,12 @@ export class PreapprovalNamespace { const { parties, inputUtxos, expiresAt } = args const preapprovalStatus = await this.fetchStatus(parties.receiver) const provider = parties?.provider ?? this.ctx.validatorParty - const synchronizerId = - args.synchronizerId ?? this.ctx.commonCtx.defaultSynchronizerId + const synchronizerId = args.synchronizerId if (!synchronizerId) this.ctx.commonCtx.error.throw({ - type: 'Unexpected', - message: 'Cannot obtain synchronizer id', + type: 'BadRequest', + message: + 'synchronizerId is required for preapproval.renew — pass the synchronizer ID explicitly', }) if ( diff --git a/sdk/wallet-sdk/src/wallet/namespace/amulet/traffic.ts b/sdk/wallet-sdk/src/wallet/namespace/amulet/traffic.ts index 4bcd2a651..895eb8da4 100644 --- a/sdk/wallet-sdk/src/wallet/namespace/amulet/traffic.ts +++ b/sdk/wallet-sdk/src/wallet/namespace/amulet/traffic.ts @@ -12,9 +12,11 @@ export class TrafficNamespace { async status( params?: Partial<{ memberId?: string; synchronizerId?: string }> ) { - const synchronizerId = - params?.synchronizerId || - this.sdkContext.commonCtx.defaultSynchronizerId + const synchronizerId = params?.synchronizerId + if (!synchronizerId) + throw new Error( + 'synchronizerId is required for traffic.status — pass the synchronizer ID explicitly' + ) const memberId = params?.memberId ?? @@ -46,6 +48,11 @@ export class TrafficNamespace { }): Promise { const { buyer, ccAmount, inputUtxos } = params const migrationId = params.migrationId ?? 0 + const synchronizerId = params.synchronizerId + if (!synchronizerId) + throw new Error( + 'synchronizerId is required for traffic.buy — pass the synchronizer ID explicitly' + ) const defaultAmulet = await fetchAmulet(this.sdkContext) const memberId = params.memberId ?? @@ -61,10 +68,6 @@ export class TrafficNamespace { ) ).participantId - const synchronizerId = - params.synchronizerId || - this.sdkContext.commonCtx.defaultSynchronizerId - const [command, dc] = await this.sdkContext.amuletService.buyMemberTraffic( defaultAmulet.admin, diff --git a/sdk/wallet-sdk/src/wallet/namespace/ledger/dar/client.ts b/sdk/wallet-sdk/src/wallet/namespace/ledger/dar/client.ts index c35d271ab..7179badda 100644 --- a/sdk/wallet-sdk/src/wallet/namespace/ledger/dar/client.ts +++ b/sdk/wallet-sdk/src/wallet/namespace/ledger/dar/client.ts @@ -7,6 +7,7 @@ import { Ops } from '@canton-network/core-provider-ledger' export class DarNamespace { constructor(private readonly sdkContext: SDKContext) {} + // TODO (#1712): add checking of vetting state also for vetting on provided sync async upload( darBytes: Uint8Array | Buffer, packageId: string, @@ -29,8 +30,7 @@ export class DarNamespace { resource: '/v2/packages', requestMethod: 'post', query: { - synchronizerId: - synchronizerId ?? this.sdkContext.defaultSynchronizerId, + ...(synchronizerId !== undefined && { synchronizerId }), vetAllPackages: vetAllPackages ?? true, }, body: darBytes as never, 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 8fe87fcb6..d43f11611 100644 --- a/sdk/wallet-sdk/src/wallet/namespace/ledger/dar/index.ts +++ b/sdk/wallet-sdk/src/wallet/namespace/ledger/dar/index.ts @@ -29,8 +29,7 @@ export class DarService { resource: '/v2/packages', requestMethod: 'post', query: { - synchronizerId: - synchronizerId ?? this.sdkContext.defaultSynchronizerId, + ...(synchronizerId !== undefined && { synchronizerId }), vetAllPackages: vetAllPackages ?? true, }, body: darBytes as never, diff --git a/sdk/wallet-sdk/src/wallet/namespace/ledger/dar/vetting.ts b/sdk/wallet-sdk/src/wallet/namespace/ledger/dar/vetting.ts new file mode 100644 index 000000000..84d2aa4ef --- /dev/null +++ b/sdk/wallet-sdk/src/wallet/namespace/ledger/dar/vetting.ts @@ -0,0 +1,72 @@ +// Copyright (c) 2025-2026 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { + AbstractLedgerProvider, + Ops, +} from '@canton-network/core-provider-ledger' + +/** + * Vets a DAR package on the specified synchronizer. + * + * @param ledgerProvider - The ledger provider for the target participant node. + * Obtain via `(sdk.ledger as any).sdkContext.ledgerProvider`. + * @param darBytes - Raw DAR file bytes. + * @param synchronizerId - The synchronizer on which the package should be vetted. + */ +export async function vetDar( + ledgerProvider: AbstractLedgerProvider, + darBytes: Uint8Array | Buffer, + synchronizerId: string +): Promise { + await ledgerProvider.request({ + method: 'ledgerApi', + params: { + resource: '/v2/packages', + requestMethod: 'post', + query: { synchronizerId, vetAllPackages: true }, + body: darBytes as never, + headers: { 'Content-Type': 'application/octet-stream' }, + }, + }) +} + +/** + * Like {@link vetDar}, but tolerates the case where a package with the same + * name+version is already vetted on the participant. + * + * On a persistent network, a previous build of a DAR (e.g. `splice-test-token-v1`) + * may already be vetted. Re-vetting after rebuilding the DAR produces a different + * package hash for the same name+version, which Canton rejects with + * `KNOWN_PACKAGE_VERSION`. Since the already-vetted package is resolved by + * package-name at command-submission time, it is safe to reuse it and continue. + * + * @param ledgerProvider - The ledger provider for the target participant node. + * @param darBytes - Raw DAR file bytes. + * @param synchronizerId - The synchronizer on which the package should be vetted. + * @param logger - Optional logger; a warning is emitted when an existing package + * is reused. + */ +export async function vetDarIdempotent( + ledgerProvider: AbstractLedgerProvider, + darBytes: Uint8Array | Buffer, + synchronizerId: string, + logger?: { warn(message: string): void } +): Promise { + try { + await vetDar(ledgerProvider, darBytes, synchronizerId) + } 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') + ) { + 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/index.ts b/sdk/wallet-sdk/src/wallet/namespace/ledger/index.ts index 8dace158d..a9b667b59 100644 --- a/sdk/wallet-sdk/src/wallet/namespace/ledger/index.ts +++ b/sdk/wallet-sdk/src/wallet/namespace/ledger/index.ts @@ -3,3 +3,4 @@ export * from './namespace.js' export * from './types.js' +export * from './synchronizer-cache.js' 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..a00f50180 100644 --- a/sdk/wallet-sdk/src/wallet/namespace/ledger/internal/namespace.ts +++ b/sdk/wallet-sdk/src/wallet/namespace/ledger/internal/namespace.ts @@ -4,17 +4,145 @@ 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)) { + throw new Error( + `No unassigned event returned for contract ${contractId} reassignment` + ) + } + 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 ) { const { commands, - synchronizerId = this.ctx.defaultSynchronizerId, + synchronizerId = '', disclosedContracts = [], readAs = [], actAs, @@ -49,7 +177,7 @@ export class InternalLedgerNamespace { ) { const { commands, - synchronizerId = this.ctx.defaultSynchronizerId, + synchronizerId = '', disclosedContracts = [], readAs = [], actAs, 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 6a85b6bc9..fc71c3088 100644 --- a/sdk/wallet-sdk/src/wallet/namespace/ledger/internal/types.ts +++ b/sdk/wallet-sdk/src/wallet/namespace/ledger/internal/types.ts @@ -37,3 +37,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 43ae6c662..91e443019 100644 --- a/sdk/wallet-sdk/src/wallet/namespace/ledger/namespace.ts +++ b/sdk/wallet-sdk/src/wallet/namespace/ledger/namespace.ts @@ -3,7 +3,13 @@ import { LedgerTypes, SDKContext } from '../../sdk.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' @@ -11,6 +17,7 @@ import { DarNamespace } from './dar/client.js' import { InternalLedgerNamespace } from './internal/index.js' import { PreparedTransactionNamespace } from './hash/namespace.js' import { AcsOptions, ACSReader } from '@canton-network/core-acs-reader' +import { ConnectedSynchronizer } from './synchronizer-cache.js' export class LedgerNamespace { public readonly dar: DarNamespace @@ -25,6 +32,77 @@ export class LedgerNamespace { this.acsReader = new ACSReader(sdkContext.ledgerProvider) } + /** + * Returns connected synchronizers visible to the caller, optionally filtered + * by party, participant, or identity provider. Reads connected synchronizers from the cache by default, but can be forced to re-fetch from the Ledger API with `opts.refresh = true`. + */ + public async connectedSynchronizers( + options?: ConnectedSynchronizersOptions, + extraOptions?: { refresh?: boolean } + ) { + return { + connectedSynchronizers: await this.sdkContext.synchronizers.list( + options, + extraOptions + ), + } + } + + /** + * Re-fetches the connected synchronizers from the Ledger API and updates the + * cache. + */ + public async refreshSynchronizers(): Promise { + return this.sdkContext.synchronizers.refresh() + } + + /** + * Adds connected synchronizers to the cache + */ + public addConnectedSynchronizers( + ...synchronizers: ConnectedSynchronizer[] + ): void { + this.sdkContext.synchronizers.add(...synchronizers) + } + + /** + * Resolves the ID of the synchronizer with the given alias from the + * synchronizers connected to the caller. Returns `undefined` when no + * synchronizer with that alias is connected, leaving it to the caller to + * decide how a missing synchronizer should be handled. + */ + public async getSynchronizerIdByAlias( + alias: string, + options?: ConnectedSynchronizersOptions + ): Promise { + return this.sdkContext.synchronizers.resolveSynchronizerIdByAlias( + alias, + options + ) + } + + /** + * Resolves the ID of the synchronizer aliased `'global'` from the + * synchronizers connected to the caller. + * + * @throws {Error} When no synchronizer aliased `'global'` is connected. + */ + public async getGlobalSynchronizerId( + options?: ConnectedSynchronizersOptions + ): Promise { + const synchronizerId = + await this.sdkContext.synchronizers.resolveGlobalSynchronizerId( + options + ) + if (!synchronizerId) { + this.sdkContext.error.throw({ + message: 'Global synchronizer not found', + type: 'SDKOperationUnsupported', + }) + } + return synchronizerId + } + public async ledgerEnd() { return ( await this.sdkContext.ledgerProvider.request( @@ -44,8 +122,7 @@ export class LedgerNamespace { */ public prepare(options: PrepareOptions): PreparedTransaction { const preparePromise = async () => { - const synchronizerId = - options.synchronizerId || this.sdkContext.defaultSynchronizerId + const synchronizerId = options.synchronizerId const { partyId, @@ -61,7 +138,7 @@ export class LedgerNamespace { commandId, actAs: [partyId], disclosedContracts, - synchronizerId, + ...(synchronizerId !== undefined && { synchronizerId }), }) } @@ -217,6 +294,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 {Error} When no matching contract is found. + */ + requireOne: async (options: AcsRequestOptions) => { + const contracts = await this.acs.read(options) + if (!contracts.length) { + throw new Error( + `Required contract not found (templateIds: ${options.templateIds?.join(', ')}, parties: ${options.parties?.join(', ')})` + ) + } + 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 executeOnSynchronizers( + 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/synchronizer-cache.ts b/sdk/wallet-sdk/src/wallet/namespace/ledger/synchronizer-cache.ts new file mode 100644 index 000000000..1f51feb3b --- /dev/null +++ b/sdk/wallet-sdk/src/wallet/namespace/ledger/synchronizer-cache.ts @@ -0,0 +1,287 @@ +// Copyright (c) 2025-2026 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { + AbstractLedgerProvider, + Ops, +} from '@canton-network/core-provider-ledger' +import { SDKLogger } from '../../logger/logger.js' +import { ConnectedSynchronizersOptions } from './types.js' +import type { SDKContext } from '../../sdk.js' + +/** + * Resolves the synchronizer an operation should target: the explicit + * `synchronizerId` when given, otherwise the global synchronizer as a fallback. + * + * @throws When no `synchronizerId` is provided and no global synchronizer is + * connected to fall back to. + */ +export const resolveSynchronizerIdOrGlobal = async ( + ctx: SDKContext, + synchronizerId?: string +): Promise => { + const resolved = + synchronizerId ?? + (await ctx.synchronizers.resolveGlobalSynchronizerId()) + if (!resolved) { + ctx.error.throw({ + message: + 'No synchronizerId provided and no global synchronizer is connected to fall back to', + type: 'SDKOperationUnsupported', + }) + } + return resolved +} + +export type ConnectedSynchronizer = NonNullable< + Ops.GetV2StateConnectedSynchronizers['ledgerApi']['result']['connectedSynchronizers'] +>[number] + +/** + * A connected synchronizer plus the parties / participants / identity providers + * it has been observed connected to. + */ +export type CachedSynchronizer = { + synchronizerId: string + synchronizerAlias: string + parties: string[] + participantIds: string[] + identityProviderIds: string[] +} + +/** Adds a value to a list if not already present. */ +const addUnique = (list: string[], value: string): void => { + if (!list.includes(value)) list.push(value) +} + +/** + * Whether a synchronizer satisfies the given query scope. A scope dimension that + * is `undefined` matches anything; a defined dimension matches only when the + * synchronizer was observed connected under that value. + */ +const matchesScope = ( + synchronizer: CachedSynchronizer, + options?: ConnectedSynchronizersOptions +): boolean => + (options?.party === undefined || + synchronizer.parties.includes(options.party)) && + (options?.participantId === undefined || + synchronizer.participantIds.includes(options.participantId)) && + (options?.identityProviderId === undefined || + synchronizer.identityProviderIds.includes(options.identityProviderId)) + +/** Whether two query scopes target the same party / participant / idp. */ +const sameScope = ( + a: ConnectedSynchronizersOptions, + b: ConnectedSynchronizersOptions +): boolean => + a.party === b.party && + a.participantId === b.participantId && + a.identityProviderId === b.identityProviderId + +/** + * Caches the synchronizers connected to the participant so the SDK reads them + * from the Ledger API once per scope + * + * - Entries are keyed by `synchronizerId` (one shared by several parties is a + * single entry); its membership lists record every scope it was seen under. + * - Reads filter entries by {@link matchesScope}. + * - Fetched scopes are tracked separately so a genuinely empty scope counts as a + * cache hit instead of being re-queried every call. + * - {@link add} / {@link connect} merge in a newly known synchronizer; + * {@link refresh} rebuilds by re-fetching every scope seen so far. + */ +export class SynchronizerCache { + private cache = new Map() + private fetchedScopes: ConnectedSynchronizersOptions[] = [] + + constructor( + private readonly ledgerProvider: AbstractLedgerProvider, + private readonly logger: SDKLogger + ) {} + + /** + * Merges a synchronizer returned by the Ledger API into its entry, recording + * the scope it was fetched under in the entry's membership lists. + */ + private addOrUpdate( + synchronizer: ConnectedSynchronizer, + options?: ConnectedSynchronizersOptions + ): void { + const entry = this.cache.get(synchronizer.synchronizerId) ?? { + synchronizerId: synchronizer.synchronizerId, + synchronizerAlias: synchronizer.synchronizerAlias, + parties: [], + participantIds: [], + identityProviderIds: [], + } + entry.synchronizerAlias = synchronizer.synchronizerAlias + if (options?.party !== undefined) + addUnique(entry.parties, options.party) + if (options?.participantId !== undefined) + addUnique(entry.participantIds, options.participantId) + if (options?.identityProviderId !== undefined) + addUnique(entry.identityProviderIds, options.identityProviderId) + this.cache.set(synchronizer.synchronizerId, entry) + } + + /** The cached synchronizers matching the given scope. */ + private matching( + options?: ConnectedSynchronizersOptions + ): CachedSynchronizer[] { + return [...this.cache.values()].filter((s) => matchesScope(s, options)) + } + + /** Whether the given scope has already been fetched from the Ledger API. */ + private isScopeFetched(options?: ConnectedSynchronizersOptions): boolean { + return this.fetchedScopes.some((s) => sameScope(s, options ?? {})) + } + + /** Records that the given scope has been fetched. */ + private markFetched(options?: ConnectedSynchronizersOptions): void { + if (!this.isScopeFetched(options)) { + this.fetchedScopes.push({ + ...(options?.party !== undefined && { party: options.party }), + ...(options?.participantId !== undefined && { + participantId: options.participantId, + }), + ...(options?.identityProviderId !== undefined && { + identityProviderId: options.identityProviderId, + }), + }) + } + } + + /** + * Fetches a scope's synchronizers from the Ledger API, merges each into its + * entry, and marks the scope fetched. + */ + private async fetch( + options?: ConnectedSynchronizersOptions + ): Promise { + this.logger.debug({ options }, 'Fetching connected synchronizers') + const response = + await this.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, + }), + }, + }, + } + ) + for (const synchronizer of response.connectedSynchronizers ?? []) { + this.addOrUpdate(synchronizer, options) + } + this.markFetched(options) + return this.matching(options) + } + + /** + * Returns the synchronizers for the given scope, fetching from the Ledger + * API on the first request for that scope (or when `refresh` is set) and + * serving subsequent requests from the cache. + */ + public async list( + options?: ConnectedSynchronizersOptions, + extraOptions?: { refresh?: boolean } + ): Promise { + if (extraOptions?.refresh || !this.isScopeFetched(options)) { + return this.fetch(options) + } + return this.matching(options) + } + + /** + * Resolves the ID of the synchronizer with the given alias from the cache, + * re-fetching once if it is not yet present (in case a synchronizer was + * connected after the SDK was initialized). Returns `undefined` when no + * synchronizer with that alias is connected. + */ + public async resolveSynchronizerIdByAlias( + alias: string, + options?: ConnectedSynchronizersOptions + ): Promise { + const findByAlias = (synchronizers: CachedSynchronizer[]) => + synchronizers.find((s) => s.synchronizerAlias === alias) + + let match = findByAlias(await this.list(options)) + if (!match) { + match = findByAlias(await this.list(options, { refresh: true })) + } + return match?.synchronizerId + } + + /** + * Resolves the ID of the synchronizer aliased `'global'` from the cache, + * re-fetching once if it is not yet present (in case a synchronizer was + * connected after the SDK was initialized). Returns `undefined` when no + * global synchronizer is connected + * + */ + public async resolveGlobalSynchronizerId( + options?: ConnectedSynchronizersOptions + ): Promise { + return this.resolveSynchronizerIdByAlias('global', options) + } + + /** + * Rebuilds the cache by re-fetching every scope queried so far from the + * Ledger API. + */ + public async refresh(): Promise { + const scopes = this.fetchedScopes + this.cache = new Map() + this.fetchedScopes = [] + await Promise.all(scopes.map((scope) => this.fetch(scope))) + } + + /** + * Adds already-known connected synchronizers to the cache + */ + public add(...synchronizers: ConnectedSynchronizer[]): void { + for (const synchronizer of synchronizers) { + this.addOrUpdate(synchronizer) + } + } + + /** + * Records that the given scope (a party and/or participant) is now connected + * to a synchronizer, updating its membership lists in place. + * + */ + public connect( + synchronizerId: string, + scope: ConnectedSynchronizersOptions, + synchronizerAlias?: string + ): void { + const entry = this.cache.get(synchronizerId) ?? { + synchronizerId, + synchronizerAlias: synchronizerAlias ?? '', + parties: [], + participantIds: [], + identityProviderIds: [], + } + if (synchronizerAlias !== undefined) { + entry.synchronizerAlias = synchronizerAlias + } + if (scope.party !== undefined) addUnique(entry.parties, scope.party) + if (scope.participantId !== undefined) + addUnique(entry.participantIds, scope.participantId) + if (scope.identityProviderId !== undefined) + addUnique(entry.identityProviderIds, scope.identityProviderId) + this.cache.set(synchronizerId, entry) + this.markFetched(scope) + } +} diff --git a/sdk/wallet-sdk/src/wallet/namespace/ledger/types.ts b/sdk/wallet-sdk/src/wallet/namespace/ledger/types.ts index 9f4c9c3ff..26b471ecc 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: LedgerTypes['ExerciseCommand'] CreateCommand: LedgerTypes['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..9cd8c6757 100644 --- a/sdk/wallet-sdk/src/wallet/namespace/party/external/service.ts +++ b/sdk/wallet-sdk/src/wallet/namespace/party/external/service.ts @@ -10,6 +10,7 @@ import { CreatePartyOptions } from './types.js' import { SDKLogger } from '../../../logger/index.js' import { LedgerProvider, Ops } from '@canton-network/core-provider-ledger' import { AuthTokenProvider } from '@canton-network/core-wallet-auth' +import { resolveSynchronizerIdOrGlobal } from '../../ledger/synchronizer-cache.js' export class ExternalPartyNamespace { private readonly logger: SDKLogger @@ -32,20 +33,25 @@ export class ExternalPartyNamespace { this.resolveParticipantUids( options?.confirmingParticipantEndpoints ?? [] ), - options?.synchronizerId || this.resolveSynchronizerId(), + options?.synchronizerId, ]).then( - ([ + async ([ observingParticipantUids, otherHostingParticipantUids, synchronizerId, - ]) => - this.ctx.ledgerProvider.request( + ]) => { + const resolvedSynchronizerId = + await resolveSynchronizerIdOrGlobal( + this.ctx, + synchronizerId + ) + return this.ctx.ledgerProvider.request( { method: 'ledgerApi', params: { resource: '/v2/parties/external/generate-topology', body: { - synchronizer: synchronizerId, + synchronizer: resolvedSynchronizerId, partyHint: options?.partyHint ?? v4(), publicKey: { format: 'CRYPTO_KEY_FORMAT_RAW', @@ -66,6 +72,7 @@ export class ExternalPartyNamespace { }, } ) + } ) this.logger.debug('Prepared party creation successfully.') @@ -75,38 +82,11 @@ 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 - } - /** * Retrieves participant IDs from the given endpoints by querying their ledger API. * @param hostingParticipantConfigs - Participant endpoint configurations to query 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..9d51e359c 100644 --- a/sdk/wallet-sdk/src/wallet/namespace/party/external/signed.ts +++ b/sdk/wallet-sdk/src/wallet/namespace/party/external/signed.ts @@ -16,7 +16,13 @@ import { LedgerProvider, Ops, } from '@canton-network/core-provider-ledger' +import { resolveSynchronizerIdOrGlobal } from '../../ledger/synchronizer-cache.js' 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 +32,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 +58,9 @@ export class SignedPartyCreationService { type: 'SDKOperationUnsupported', }) - if (await this.checkIfPartyExists(party.partyId)) { + const synchronizerId = await this.resolveSynchronizerId() + + if (await this.checkIfPartyExists(party.partyId, synchronizerId)) { this.ctx.logger.info('Party already created.') return party } @@ -62,10 +72,15 @@ export class SignedPartyCreationService { await this.executeAllocateParty({ ...executeOptions, + synchronizerId, withErrorHandling: true, expectHeavyLoad: Boolean(options?.expectHeavyLoad), }) + this.ctx.synchronizers.connect(synchronizerId, { + party: party.partyId, + }) + const endpointConfig = [ ...(this.createPartyOptions?.confirmingParticipantEndpoints ?? []), ...(this.createPartyOptions?.observingParticipantEndpoints ?? []), @@ -75,9 +90,19 @@ export class SignedPartyCreationService { await this.allocateExternalPartyForAdditionalParticipants({ ...executeOptions, endpointConfig, + synchronizerId, }) } + 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 +120,80 @@ 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) { + throw new Error( + 'Cannot register party on additional synchronizer: publicKey and privateKey must be provided (offline signing is not supported for additionalSynchronizerIds)' + ) + } + + 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.synchronizers.connect(synchronizerId, { party: partyId }) + + 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. @@ -103,9 +202,10 @@ export class SignedPartyCreationService { private async allocateExternalPartyForAdditionalParticipants( options: { endpointConfig: ParticipantEndpointConfig[] + synchronizerId: string } & ExecuteOptions ) { - const { endpointConfig, party, signature } = options + const { endpointConfig, party, signature, synchronizerId } = options for (const endpoint of endpointConfig) { const defaultLedgerProvider = new LedgerProvider({ baseUrl: endpoint.url, @@ -119,10 +219,25 @@ export class SignedPartyCreationService { defaultLedgerProvider, party, signature, + synchronizerId, }) } } + /** + * Resolves the synchronizer the party should be allocated on: the one given + * in {@link CreatePartyOptions}, or the global synchronizer as a fallback + * when none was provided. + * @throws {Error} When no synchronizerId is provided and no global + * synchronizer is connected to fall back to. + */ + private resolveSynchronizerId(): Promise { + return resolveSynchronizerIdOrGlobal( + this.ctx, + this.createPartyOptions?.synchronizerId + ) + } + /** * Performs the actual party allocation transaction on a ledger client. * Includes error handling for timeout scenarios when heavy load is expected. @@ -130,6 +245,7 @@ export class SignedPartyCreationService { */ private async executeAllocateParty( options: { + synchronizerId: string withErrorHandling?: boolean expectHeavyLoad?: boolean defaultLedgerProvider?: AbstractLedgerProvider @@ -138,14 +254,13 @@ export class SignedPartyCreationService { const { party, signature, + synchronizerId, withErrorHandling, expectHeavyLoad, defaultLedgerProvider, } = options const ledgerProvider = defaultLedgerProvider ?? this.ctx.ledgerProvider try { - const synchronizerId = this.ctx.defaultSynchronizerId - await this.allocate( ledgerProvider, synchronizerId, @@ -185,8 +300,19 @@ export class SignedPartyCreationService { } } - private async checkIfPartyExists(partyId: PartyId): Promise { + private async checkIfPartyExists( + partyId: PartyId, + synchronizerId?: string + ): Promise { try { + if (synchronizerId) { + const connectedSynchronizers = + await this.ctx.synchronizers.list({ party: partyId }) + return connectedSynchronizers.some( + (s) => s.synchronizerId === synchronizerId + ) + } + const party = await this.ctx.ledgerProvider.request({ method: 'ledgerApi', 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/internal/index.ts b/sdk/wallet-sdk/src/wallet/namespace/party/internal/index.ts index 0c910f083..5df82527c 100644 --- a/sdk/wallet-sdk/src/wallet/namespace/party/internal/index.ts +++ b/sdk/wallet-sdk/src/wallet/namespace/party/internal/index.ts @@ -6,6 +6,7 @@ import { SDKContext } from '../../../sdk.js' import { v4 } from 'uuid' import { PartyId } from '@canton-network/core-types' import { SDKLogger } from '../../../logger/logger.js' +import { resolveSynchronizerIdOrGlobal } from '../../ledger/synchronizer-cache.js' export class InternalPartyNamespace { private readonly logger: SDKLogger @@ -24,6 +25,11 @@ export class InternalPartyNamespace { userId?: string } = {} ): Promise { + const synchronizerId = await resolveSynchronizerIdOrGlobal( + this.ctx, + params.synchronizerId + ) + if (params.partyHint) { const pIdFingerprint = await this.getParticipantIdFingerprint() @@ -50,9 +56,7 @@ export class InternalPartyNamespace { body: { partyIdHint: params.partyHint ?? v4(), identityProviderId: '', - synchronizerId: - params.synchronizerId ?? - this.ctx.defaultSynchronizerId, + synchronizerId, userId: params.userId ?? this.ctx.userId, }, }, @@ -64,6 +68,9 @@ export class InternalPartyNamespace { type: 'CantonError', }) } + this.ctx.synchronizers.connect(synchronizerId, { + party: allocatedParty.partyDetails.party, + }) return allocatedParty.partyDetails.party } 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/namespace/token/transfer/proxyDelegation.ts b/sdk/wallet-sdk/src/wallet/namespace/token/transfer/proxyDelegation.ts index b906508a7..5359230c7 100644 --- a/sdk/wallet-sdk/src/wallet/namespace/token/transfer/proxyDelegation.ts +++ b/sdk/wallet-sdk/src/wallet/namespace/token/transfer/proxyDelegation.ts @@ -138,7 +138,6 @@ export class ProxyDelegationNamespace { templateId: featuredAppRight.template_id, contractId: featuredAppRight.contract_id, createdEventBlob: featuredAppRight.created_event_blob, - synchronizerId: this.ctx.commonCtx.defaultSynchronizerId, } } } diff --git a/sdk/wallet-sdk/src/wallet/sdk.ts b/sdk/wallet-sdk/src/wallet/sdk.ts index c7a63e07c..b77404fb7 100644 --- a/sdk/wallet-sdk/src/wallet/sdk.ts +++ b/sdk/wallet-sdk/src/wallet/sdk.ts @@ -27,6 +27,7 @@ import { } from '@canton-network/core-ledger-client-types' import { AllowedLogAdapters } from './logger/types.js' import { DappLedgerRpc } from '@canton-network/core-provider-dapp' +import { SynchronizerCache } from './namespace/ledger/synchronizer-cache.js' export * from './namespace/asset/index.js' export type * from './namespace/token/index.js' export type * from './namespace/amulet/index.js' @@ -49,7 +50,7 @@ export type SDKContext = { userId: string logger: SDKLogger error: SDKErrorHandler - defaultSynchronizerId: string + synchronizers: SynchronizerCache } export type OfflineSDKContext = { @@ -61,6 +62,11 @@ export * from './init/index.js' export { PrepareOptions, ExecuteOptions } from './namespace/ledger/index.js' export * from './namespace/transactions/prepared.js' export * from './namespace/transactions/signed.js' +export { + vetDar as vetPackage, + vetDarIdempotent as vetPackageIdempotent, +} from './namespace/ledger/dar/vetting.js' +export { ScanProxyClient } from '@canton-network/core-splice-client' export class SDK { static async create< @@ -125,17 +131,15 @@ export class SDK { }) } - const defaultSynchronizerId = await getDefaultSynchronizerId( - ledgerProvider, - logger - ) + const synchronizers = new SynchronizerCache(ledgerProvider, logger) + await synchronizers.list() const ctx: SDKContext = { ledgerProvider, userId: userId!, logger, error, - defaultSynchronizerId, + synchronizers, } const config = {} as Pick< @@ -167,32 +171,3 @@ export class SDK { return new OfflineInitializedSDK({ logger, error }) } } - -async function getDefaultSynchronizerId( - provider: AbstractLedgerProvider, - logger: SDKLogger -) { - const connectedSynchronizers = - await provider.request({ - method: 'ledgerApi', - params: { - resource: '/v2/state/connected-synchronizers', - requestMethod: 'get', - query: {}, - }, - }) - - if (!connectedSynchronizers.connectedSynchronizers?.[0]) { - throw new Error('No connected synchronizers found') - } - - const defaultSynchronizerId = - connectedSynchronizers.connectedSynchronizers[0].synchronizerId - if (connectedSynchronizers.connectedSynchronizers.length > 1) { - logger.warn( - `Found ${connectedSynchronizers.connectedSynchronizers.length} synchronizers, defaulting to ${defaultSynchronizerId}` - ) - } - - return defaultSynchronizerId -} diff --git a/yarn.lock b/yarn.lock index f66744421..e34d699bc 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1488,6 +1488,25 @@ __metadata: languageName: unknown linkType: soft +"@canton-network/core-amulet-ops@workspace:^, @canton-network/core-amulet-ops@workspace:core/amulet-ops": + version: 0.0.0-use.local + resolution: "@canton-network/core-amulet-ops@workspace:core/amulet-ops" + dependencies: + "@canton-network/core-amulet-service": "workspace:^" + "@canton-network/core-signing-lib": "workspace:^" + "@canton-network/wallet-sdk": "workspace:^" + "@vitest/coverage-v8": "npm:^4.1.2" + pino: "npm:^10.3.1" + tsup: "npm:^8.5.1" + typescript: "npm:^5.9.3" + vitest: "npm:^4.1.2" + peerDependencies: + "@canton-network/core-signing-lib": "workspace:^" + "@canton-network/wallet-sdk": "workspace:^" + pino: ^10.3.1 + languageName: unknown + linkType: soft + "@canton-network/core-amulet-service@workspace:^, @canton-network/core-amulet-service@workspace:core/amulet-service": version: 0.0.0-use.local resolution: "@canton-network/core-amulet-service@workspace:core/amulet-service" @@ -1879,6 +1898,31 @@ __metadata: languageName: unknown linkType: soft +"@canton-network/core-test-token@workspace:^, @canton-network/core-test-token@workspace:core/test-token": + version: 0.0.0-use.local + resolution: "@canton-network/core-test-token@workspace:core/test-token" + dependencies: + "@canton-network/core-signing-lib": "workspace:^" + "@canton-network/wallet-sdk": "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" + 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" + peerDependencies: + "@canton-network/core-signing-lib": "workspace:^" + "@canton-network/wallet-sdk": "workspace:^" + pino: ^10.3.1 + 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" @@ -1932,6 +1976,25 @@ __metadata: languageName: unknown linkType: soft +"@canton-network/core-trading-app@workspace:^, @canton-network/core-trading-app@workspace:core/trading-app": + version: 0.0.0-use.local + resolution: "@canton-network/core-trading-app@workspace:core/trading-app" + dependencies: + "@canton-network/core-ledger-client-types": "workspace:^" + "@canton-network/core-signing-lib": "workspace:^" + "@canton-network/wallet-sdk": "workspace:^" + "@vitest/coverage-v8": "npm:^4.1.2" + pino: "npm:^10.3.1" + tsup: "npm:^8.5.1" + typescript: "npm:^5.9.3" + vitest: "npm:^4.1.2" + peerDependencies: + "@canton-network/core-signing-lib": "workspace:^" + "@canton-network/wallet-sdk": "workspace:^" + pino: ^10.3.1 + languageName: unknown + linkType: soft + "@canton-network/core-tx-parser@workspace:^, @canton-network/core-tx-parser@workspace:core/tx-parser": version: 0.0.0-use.local resolution: "@canton-network/core-tx-parser@workspace:core/tx-parser" @@ -12473,10 +12536,15 @@ __metadata: version: 0.0.0-use.local resolution: "docs-wallet-integration-guide-examples@workspace:docs/wallet-integration-guide/examples" dependencies: + "@canton-network/core-amulet-ops": "workspace:^" + "@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-trading-app": "workspace:^" "@canton-network/core-tx-parser": "workspace:^" "@canton-network/core-types": "workspace:^" "@canton-network/core-wallet-auth": "workspace:^"