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 e6c2e45e3..156ad3078 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -397,6 +397,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 @@ -458,6 +459,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 @@ -508,6 +510,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-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 12ddadbee..218c662fd 100644 --- a/core/ledger-client/src/ledger-client.ts +++ b/core/ledger-client/src/ledger-client.ts @@ -511,20 +511,31 @@ export class LedgerClient { return this.valueOrError(resp) } - // Retrieve an (arbitrary) synchronizer id from the validator. + // Retrieve the default synchronizer id from the validator. + // Prefers a synchronizer aliased 'global' or 'global-domain' over application-specific ones. // This synchronizer id is cached for the remainder of this object's life. public async getSynchronizerId(): Promise { if (this.synchronizerId) return this.synchronizerId const response = await this.getWithRetry( '/v2/state/connected-synchronizers' ) - if (!response.connectedSynchronizers?.[0]) { + const synchronizers = response.connectedSynchronizers + if (!synchronizers?.[0]) { throw new Error('No connected synchronizers found') } - const synchronizerId = response.connectedSynchronizers[0].synchronizerId - if (response.connectedSynchronizers.length > 1) { + const defaultEntry = + synchronizers.find((s) => s.synchronizerAlias === 'global') ?? + synchronizers.find( + (s) => s.synchronizerAlias === 'global-domain' + ) ?? + synchronizers.find( + (s) => s.synchronizerAlias !== 'app-synchronizer' + ) ?? + synchronizers[0] + const synchronizerId = defaultEntry.synchronizerId + if (synchronizers.length > 1) { this.logger.warn( - `Found ${response.connectedSynchronizers.length} synchronizers, defaulting to ${synchronizerId}` + `Found ${synchronizers.length} synchronizers, defaulting to ${synchronizerId}` ) } this.synchronizerId = synchronizerId diff --git a/core/test-token/.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..88619660c --- /dev/null +++ b/core/test-token/package.json @@ -0,0 +1,50 @@ +{ + "name": "@canton-network/core-test-token", + "version": "0.0.1", + "type": "module", + "description": "DAML codegen JS for splice-test-token-v1", + "license": "Apache-2.0", + "main": "dist/index.cjs", + "module": "dist/index.js", + "types": "dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "browser": "./dist/index.browser.js", + "import": "./dist/index.js", + "require": "./dist/index.cjs", + "default": "./dist/index.js" + } + }, + "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" + }, + "devDependencies": { + "@rollup/plugin-alias": "^5.0.0", + "@rollup/plugin-commonjs": "^29.0.0", + "@rollup/plugin-json": "^6.1.0", + "@rollup/plugin-node-resolve": "^16.0.3", + "@rollup/plugin-typescript": "^12.3.0", + "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/index.ts b/core/test-token/src/index.ts new file mode 100644 index 000000000..9c1760def --- /dev/null +++ b/core/test-token/src/index.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 + +import { Splice, packageId } from '@daml.js/splice-test-token-v1-1.0.0' +export { Splice, packageId } + +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/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/wallet-test-utils/src/otc-trade.ts b/core/wallet-test-utils/src/otc-trade.ts index b0c64279d..876f985dc 100644 --- a/core/wallet-test-utils/src/otc-trade.ts +++ b/core/wallet-test-utils/src/otc-trade.ts @@ -79,9 +79,18 @@ export class OTCTrade { PATH_TO_DAR_IN_LOCALNET ) + // Resolve the global synchronizer explicitly: the SDK no longer + // auto-selects one, and DAR upload cannot be autodetected when the + // participant is connected to multiple synchronizers. + const synchronizerId = await this.resolveGlobalSynchronizerId() + //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 @@ -207,6 +216,20 @@ export class OTCTrade { } } + private async resolveGlobalSynchronizerId(): Promise { + if (!this.sdk) throw new Error('SDK not initialized') + + const { connectedSynchronizers } = + await this.sdk.ledger.connectedSynchronizers({}) + const globalSynchronizer = (connectedSynchronizers ?? []).find( + (s) => s.synchronizerAlias === 'global' + ) + if (!globalSynchronizer) { + throw new Error('Global synchronizer not found') + } + return globalSynchronizer.synchronizerId + } + private async acceptProposal( approver: PartyId, approverName: string 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 40ecd497a..762d144e2 100644 --- a/docs/wallet-integration-guide/examples/package.json +++ b/docs/wallet-integration-guide/examples/package.json @@ -25,6 +25,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" @@ -39,10 +40,13 @@ "vitest": "^4.1.2" }, "dependencies": { + "@canton-network/core-amulet-service": "workspace:^", "@canton-network/core-ledger-client": "workspace:^", "@canton-network/core-ledger-client-types": "workspace:^", "@canton-network/core-ledger-proto": "workspace:^", "@canton-network/core-signing-lib": "workspace:^", + "@canton-network/core-test-token": "workspace:^", + "@canton-network/core-token-standard": "workspace:^", "@canton-network/core-tx-parser": "workspace:^", "@canton-network/core-types": "workspace:^", "@canton-network/core-wallet-auth": "workspace:^", diff --git a/docs/wallet-integration-guide/examples/scripts/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..baa1bbd80 --- /dev/null +++ b/docs/wallet-integration-guide/examples/scripts/15-multi-sync/_amulet_ops.ts @@ -0,0 +1,93 @@ +// Copyright (c) 2025-2026 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import type { Logger } from 'pino' +import { AMULET_TEMPLATE_ID } from '@canton-network/core-amulet-service' +import { localNetStaticConfig } from '@canton-network/wallet-sdk' +import type { MultiSyncSetup } from './_setup.js' +import { ALICE_AMULET_TAP_AMOUNT } from './_constants.js' + +export { AMULET_TEMPLATE_ID } + +export async function mintAmuletForAlice( + setup: MultiSyncSetup, + logger: Logger +): Promise { + const { appUserSdk, alice, globalSynchronizerId } = setup + const [aliceTapCreateCommand, aliceTapCreateDisclosedContracts] = + await appUserSdk.amulet.tap(alice.partyId, ALICE_AMULET_TAP_AMOUNT) + + await appUserSdk.ledger + .prepare({ + partyId: alice.partyId, + commands: aliceTapCreateCommand, + disclosedContracts: aliceTapCreateDisclosedContracts, + synchronizerId: globalSynchronizerId, + }) + .sign(alice.keyPair.privateKey) + .execute({ partyId: alice.partyId }) + + logger.info( + `Alice: Amulet minted (${ALICE_AMULET_TAP_AMOUNT}) on global synchronizer` + ) +} + +export async function allocateAmuletForAlice( + setup: MultiSyncSetup, + logger: Logger +): Promise { + const { + appUserSdk, + tokenNamespaceAppUser, + alice, + globalSynchronizerId, + amuletAdmin, + } = setup + + const pendingRequests = + await tokenNamespaceAppUser.allocation.request.pending(alice.partyId) + const requestView = pendingRequests[0].interfaceViewValue! + const legId = Object.keys(requestView.transferLegs).find( + (key) => requestView.transferLegs[key].sender === alice.partyId + )! + if (!legId) throw new Error('No transfer leg found for Alice') + + const amuletHoldings = await appUserSdk.ledger.acs.read({ + templateIds: [AMULET_TEMPLATE_ID], + parties: [alice.partyId], + filterByParty: true, + }) + const amuletHoldingCid = amuletHoldings[0]?.contractId + if (!amuletHoldingCid) throw new Error('Amulet holding not found for Alice') + + const [command, disclosedContracts] = + await tokenNamespaceAppUser.allocation.instruction.create({ + allocationSpecification: { + settlement: requestView.settlement, + transferLegId: legId, + transferLeg: requestView.transferLegs[legId], + }, + asset: { + id: 'Amulet', + displayName: 'Amulet', + symbol: 'CC', + registryUrl: localNetStaticConfig.LOCALNET_REGISTRY_API_URL, + admin: amuletAdmin, + }, + inputUtxos: [amuletHoldingCid], + requestedAt: new Date().toISOString(), + }) + + await appUserSdk.ledger + .prepare({ + partyId: alice.partyId, + commands: [command], + disclosedContracts, + synchronizerId: globalSynchronizerId, + }) + .sign(alice.keyPair.privateKey) + .execute({ partyId: alice.partyId }) + + logger.info('Alice: Amulet allocated for leg-0 (global synchronizer)') + return legId +} diff --git a/docs/wallet-integration-guide/examples/scripts/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..4aee0950e --- /dev/null +++ b/docs/wallet-integration-guide/examples/scripts/15-multi-sync/_setup.ts @@ -0,0 +1,243 @@ +// Copyright (c) 2025-2026 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import path from 'path' +import { fileURLToPath } from 'url' +import fs from 'fs/promises' +import type { Logger } from 'pino' +import { + localNetStaticConfig, + SDK, + type SDKInterface, + type TokenNamespace, +} from '@canton-network/wallet-sdk' +import type { KeyPair } from '@canton-network/core-signing-lib' +import type { GenerateTransactionResponse } from '@canton-network/core-ledger-client' +import { 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, + resolveGlobalSynchronizerId, +} from '../utils/index.js' +import type { SynchronizerMap } from '../utils/index.js' +import { TEST_TOKEN_REGISTRY_URL } from './_constants.js' + +// Token namespace config that also points the SDK at the local TestToken +// registry (in addition to the Amulet scan-proxy registry). This lets the +// wallet SDK resolve TestToken via the CIP-56 metadata API and fetch its +// transfer/allocation choice contexts over HTTP. +const TOKEN_NAMESPACE_CONFIG_WITH_REGISTRIES = { + ...TOKEN_NAMESPACE_CONFIG, + registries: [ + ...(TOKEN_NAMESPACE_CONFIG.registries as URL[]), + TEST_TOKEN_REGISTRY_URL, + ], +} + +export type PartyInfo = Omit< + GenerateTransactionResponse, + 'topologyTransactions' +> & { + topologyTransactions?: string[] | undefined + keyPair: KeyPair +} + +const 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'> + 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 [appUserSdk, appProviderSdk, svSdk] = await Promise.all([ + SDK.create({ + auth: TOKEN_PROVIDER_CONFIG_DEFAULT, + ledgerClientUrl: localNetStaticConfig.LOCALNET_APP_USER_LEDGER_URL, + amulet: AMULET_NAMESPACE_CONFIG, + token: TOKEN_NAMESPACE_CONFIG_WITH_REGISTRIES, + }), + SDK.create({ + auth: TOKEN_PROVIDER_CONFIG_DEFAULT, + ledgerClientUrl: + localNetStaticConfig.LOCALNET_APP_PROVIDER_LEDGER_URL, + token: TOKEN_NAMESPACE_CONFIG_WITH_REGISTRIES, + }), + SDK.create({ + auth: TOKEN_PROVIDER_CONFIG_DEFAULT, + ledgerClientUrl: localNetStaticConfig.LOCALNET_SV_LEDGER_URL, + token: TOKEN_NAMESPACE_CONFIG, + }), + ]) + + const connectedSyncResponse = + await 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 = resolveGlobalSynchronizerId(allSynchronizers) + const appSynchronizerId = allSynchronizers.find( + (s) => s.synchronizerAlias === 'app-synchronizer' + )?.synchronizerId + + if (!appSynchronizerId) + throw new Error( + 'App synchronizer not found — start localnet in multi-sync mode (the default; do not pass --no-multi-sync).' + ) + + logger.info( + `Connected synchronizers: ${allSynchronizers.map((s) => s.synchronizerAlias).join(', ')}` + ) + logger.info( + `Synchronizer IDs — global: ${globalSynchronizerId}, app: ${appSynchronizerId}` + ) + + const synchronizers: SynchronizerMap = { + globalSynchronizerId, + appSynchronizerId, + } + + const here = path.dirname(fileURLToPath(import.meta.url)) + const [testTokenV1Dar, tradingAppDar] = await Promise.all([ + 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) => + [appUserSdk, appProviderSdk].flatMap((sdk) => + [globalSynchronizerId, appSynchronizerId].map((sid) => + sdk.ledger.dar.vet(dar, sid) + ) + ) + ), + ...[testTokenV1Dar, tradingAppDar].map((dar) => + svSdk.ledger.dar.vet(dar, globalSynchronizerId) + ), + ]) + logger.info( + 'DARs vetted: app-user + 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, + 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..5856ec4ab --- /dev/null +++ b/docs/wallet-integration-guide/examples/scripts/15-multi-sync/_token_allocation.ts @@ -0,0 +1,83 @@ +// Copyright (c) 2025-2026 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import type { Logger } from 'pino' +import * as SpliceTestTokenV1 from '@canton-network/core-test-token' +import type { MultiSyncSetup } from './_setup.js' + +const TestTokenV1 = SpliceTestTokenV1.Splice.Testing.Tokens.TestTokenV1 + +export async function allocateTokenForBob( + setup: MultiSyncSetup, + logger: Logger +): Promise<{ legId: string }> { + const { + appProviderSdk, + tokenNamespaceAppProvider, + bob, + tokenAdmin, + globalSynchronizerId, + testTokenRegistryUrl, + } = setup + + const pendingRequests = + await tokenNamespaceAppProvider.allocation.request.pending(bob.partyId) + const requestView = pendingRequests[0].interfaceViewValue! + const legId = Object.keys(requestView.transferLegs).find( + (key) => requestView.transferLegs[key].sender === bob.partyId + )! + if (!legId) throw new Error('No transfer leg found for Bob') + + const tokenHoldings = await appProviderSdk.ledger.acs.read({ + templateIds: [TestTokenV1.Token.templateId], + parties: [bob.partyId], + filterByParty: true, + }) + + const tokenHolding = tokenHoldings[0] + if (!tokenHolding) throw new Error('Token holding not found for Bob') + + await appProviderSdk.ledger.internal.reassign({ + submitter: bob.partyId, + contractId: tokenHolding.contractId, + source: tokenHolding.synchronizerId, + target: globalSynchronizerId, + skipIfAlreadyOn: true, + }) + + // Fetch the AllocationFactory + choice context from the TestToken registry's + // allocation-instruction-v1 API. The registry returns the global-synchronizer + // TokenRules contract as the factory (disclosed in `disclosedFromHelper`). + const [command, disclosedFromHelper] = + await tokenNamespaceAppProvider.allocation.instruction.create({ + allocationSpecification: { + settlement: requestView.settlement, + transferLegId: legId, + transferLeg: requestView.transferLegs[legId], + }, + asset: { + id: 'TestToken', + displayName: 'TestToken', + symbol: 'TT', + registryUrl: testTokenRegistryUrl, + admin: tokenAdmin.partyId, + }, + inputUtxos: [tokenHolding.contractId], + requestedAt: new Date(Date.now()).toISOString(), + }) + + await appProviderSdk.ledger + .prepare({ + partyId: bob.partyId, + commands: [command], + disclosedContracts: disclosedFromHelper, + synchronizerId: globalSynchronizerId, + }) + .sign(bob.keyPair.privateKey) + .execute({ partyId: bob.partyId }) + + logger.info( + 'Bob: TestToken allocated for leg-1 (global synchronizer, single-party) via registry allocation-factory' + ) + return { legId } +} 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..558763291 --- /dev/null +++ b/docs/wallet-integration-guide/examples/scripts/15-multi-sync/_token_setup.ts @@ -0,0 +1,117 @@ +// Copyright (c) 2025-2026 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import type { Logger } from 'pino' +import { + buildCreateTokenRulesCommand, + buildMintTokenCommand, +} from '@canton-network/core-test-token' +import * as SpliceTestTokenV1 from '@canton-network/core-test-token' +import type { MultiSyncSetup } from './_setup.js' +import { BOB_TOKEN_MINT_AMOUNT } from './_constants.js' + +const TestTokenV1 = SpliceTestTokenV1.Splice.Testing.Tokens.TestTokenV1 + +export async function createTokenRulesAndMintForBob( + setup: MultiSyncSetup, + logger: Logger +): Promise { + const { + appProviderSdk, + tokenNamespaceAppProvider, + bob, + tokenAdmin, + globalSynchronizerId, + appSynchronizerId, + testTokenRegistryUrl, + } = setup + + await appProviderSdk.ledger.prepareAndExecuteOnSynchronizers( + { + partyId: tokenAdmin.partyId, + commands: buildCreateTokenRulesCommand(tokenAdmin.partyId), + disclosedContracts: [], + }, + [globalSynchronizerId, appSynchronizerId], + tokenAdmin.keyPair.privateKey + ) + + await appProviderSdk.ledger + .prepare({ + partyId: tokenAdmin.partyId, + commands: [ + buildMintTokenCommand({ + owner: tokenAdmin.partyId, + admin: tokenAdmin.partyId, + amount: BOB_TOKEN_MINT_AMOUNT, + }), + ], + disclosedContracts: [], + synchronizerId: appSynchronizerId, + }) + .sign(tokenAdmin.keyPair.privateKey) + .execute({ partyId: tokenAdmin.partyId }) + + const adminTokenHoldings = await appProviderSdk.ledger.acs.read({ + templateIds: [TestTokenV1.Token.templateId], + parties: [tokenAdmin.partyId], + filterByParty: true, + }) + const adminTokenCid = adminTokenHoldings[0]?.contractId + if (!adminTokenCid) + throw new Error('TokenAdmin Token holding not found after mint') + + // TokenAdmin offers the freshly-minted TestToken to Bob. The transfer factory + // and choice context come from the registry's transfer-instruction-v1 API + // (the TestToken registry is also resolved via the metadata-v1 API). + const [transferCommand, transferDisclosed] = + await tokenNamespaceAppProvider.transfer.create({ + sender: tokenAdmin.partyId, + recipient: bob.partyId, + amount: BOB_TOKEN_MINT_AMOUNT, + instrumentId: 'TestToken', + registryUrl: testTokenRegistryUrl, + inputUtxos: [adminTokenCid], + }) + + await appProviderSdk.ledger + .prepare({ + partyId: tokenAdmin.partyId, + commands: [transferCommand], + disclosedContracts: transferDisclosed, + synchronizerId: appSynchronizerId, + }) + .sign(tokenAdmin.keyPair.privateKey) + .execute({ partyId: tokenAdmin.partyId }) + + const transferOffers = await appProviderSdk.ledger.acs.read({ + templateIds: [TestTokenV1.TokenTransferOffer.templateId], + parties: [bob.partyId], + filterByParty: true, + }) + const transferOfferCid = transferOffers[0]?.contractId + if (!transferOfferCid) + throw new Error('TokenTransferOffer not found for Bob') + + // Bob accepts the transfer offer using the registry's transfer-instruction-v1 + // accept choice context. + const [acceptCommand, acceptDisclosed] = + await tokenNamespaceAppProvider.transfer.accept({ + transferInstructionCid: transferOfferCid, + registryUrl: testTokenRegistryUrl, + }) + + await appProviderSdk.ledger + .prepare({ + partyId: bob.partyId, + commands: [acceptCommand], + disclosedContracts: acceptDisclosed, + synchronizerId: appSynchronizerId, + }) + .sign(bob.keyPair.privateKey) + .execute({ partyId: bob.partyId }) + + 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..f4f258311 --- /dev/null +++ b/docs/wallet-integration-guide/examples/scripts/15-multi-sync/_token_transfer.ts @@ -0,0 +1,150 @@ +// Copyright (c) 2025-2026 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import type { Logger } from 'pino' +import * as SpliceTestTokenV1 from '@canton-network/core-test-token' +import type { Splice as SpliceTestTokenTypes } from '@canton-network/core-test-token' +import type { MultiSyncSetup } from './_setup.js' +import { TRADE_TOKEN_AMOUNT } from './_constants.js' + +const TestTokenV1 = SpliceTestTokenV1.Splice.Testing.Tokens.TestTokenV1 + +const TOKEN_POLL_TIMEOUT_MS = 30_000 +const TOKEN_POLL_INTERVAL_MS = 500 + +export async function aliceSelfTransferToApp( + setup: MultiSyncSetup, + logger: Logger +): Promise { + const { + appUserSdk, + tokenNamespaceAppUser, + alice, + appSynchronizerId, + testTokenRegistryUrl, + } = setup + + // The settlement is submitted by TradingApp (sv), so Alice's resulting Token + // holding propagates to her participant (app-user) asynchronously. Poll app-user until it + // becomes visible instead of reading once (cross-participant read-after-write). + const deadline = Date.now() + TOKEN_POLL_TIMEOUT_MS + let aliceToken + for (;;) { + const aliceTokens = await appUserSdk.ledger.acs.read({ + templateIds: [TestTokenV1.Token.templateId], + parties: [alice.partyId], + filterByParty: true, + }) + aliceToken = aliceTokens[0] + if (aliceToken) break + if (Date.now() >= deadline) + throw new Error('Alice: Token holding not found after settlement') + await new Promise((resolve) => + setTimeout(resolve, TOKEN_POLL_INTERVAL_MS) + ) + } + + // The settled holding lands on the global synchronizer; move it to the + // app-synchronizer before self-transferring there (mirrors Bob's flow). + if (aliceToken.synchronizerId !== appSynchronizerId) { + await appUserSdk.ledger.internal.reassign({ + submitter: alice.partyId, + contractId: aliceToken.contractId, + source: aliceToken.synchronizerId, + target: appSynchronizerId, + skipIfAlreadyOn: true, + }) + } + + const [transferCommand, transferDisclosed] = + await tokenNamespaceAppUser.transfer.create({ + sender: alice.partyId, + recipient: alice.partyId, + amount: TRADE_TOKEN_AMOUNT, + instrumentId: 'TestToken', + registryUrl: testTokenRegistryUrl, + inputUtxos: [aliceToken.contractId], + }) + + await appUserSdk.ledger + .prepare({ + partyId: alice.partyId, + commands: [transferCommand], + disclosedContracts: transferDisclosed, + synchronizerId: appSynchronizerId, + }) + .sign(alice.keyPair.privateKey) + .execute({ partyId: alice.partyId }) + + logger.info( + `Alice: ${TRADE_TOKEN_AMOUNT} TestToken self-transferred on app-synchronizer via registry transfer-factory` + ) +} + +export async function bobSelfTransferToApp( + setup: MultiSyncSetup, + logger: Logger +): Promise { + const { + appProviderSdk, + tokenNamespaceAppProvider, + bob, + appSynchronizerId, + testTokenRegistryUrl, + } = setup + + const bobTokens = await appProviderSdk.ledger.acs.read({ + templateIds: [TestTokenV1.Token.templateId], + parties: [bob.partyId], + filterByParty: true, + }) + + if (bobTokens.length === 0) { + logger.info('Bob: no TestToken holdings to self-transfer') + return + } + + for (const token of bobTokens) { + if (token.synchronizerId !== appSynchronizerId) { + await appProviderSdk.ledger.internal.reassign({ + submitter: bob.partyId, + contractId: token.contractId, + source: token.synchronizerId, + target: appSynchronizerId, + skipIfAlreadyOn: true, + }) + } + + const holdingAmount = ( + token as unknown as { + createArgument: SpliceTestTokenTypes.Testing.Tokens.TestTokenV1.Token + } + ).createArgument.holding.amount + if (!holdingAmount) + throw new Error('Cannot read amount from Bob Token holding') + + const [transferCommand, transferDisclosed] = + await tokenNamespaceAppProvider.transfer.create({ + sender: bob.partyId, + recipient: bob.partyId, + amount: holdingAmount, + instrumentId: 'TestToken', + registryUrl: testTokenRegistryUrl, + inputUtxos: [token.contractId], + }) + + await appProviderSdk.ledger + .prepare({ + partyId: bob.partyId, + commands: [transferCommand], + disclosedContracts: transferDisclosed, + synchronizerId: appSynchronizerId, + }) + .sign(bob.keyPair.privateKey) + .execute({ partyId: bob.partyId }) + } + + logger.info( + `Bob: TestToken self-transferred on app-synchronizer via registry transfer-factory` + ) +} diff --git a/docs/wallet-integration-guide/examples/scripts/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..6ec90f6b7 --- /dev/null +++ b/docs/wallet-integration-guide/examples/scripts/15-multi-sync/_trade_propose.ts @@ -0,0 +1,192 @@ +// Copyright (c) 2025-2026 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import type { Logger } from 'pino' +import type { SDKInterface } from '@canton-network/wallet-sdk' +import type { MultiSyncSetup } from './_setup.js' +import { TRADE_AMULET_AMOUNT, TRADE_TOKEN_AMOUNT } from './_constants.js' + +const OTC_TRADE_PROPOSAL_TEMPLATE_ID = + '#splice-token-test-trading-app:Splice.Testing.Apps.TradingApp:OTCTradeProposal' +const OTC_TRADE_TEMPLATE_ID = + '#splice-token-test-trading-app:Splice.Testing.Apps.TradingApp:OTCTrade' + +function buildOtcTradeProposalCommand(params: { + venue: string + transferLegs: Record + approvers: string[] + tradeCid?: string | null +}) { + return { + CreateCommand: { + templateId: OTC_TRADE_PROPOSAL_TEMPLATE_ID, + createArguments: { + venue: params.venue, + tradeCid: params.tradeCid ?? null, + transferLegs: params.transferLegs, + approvers: params.approvers, + }, + }, + } +} + +function buildAcceptOtcTradeCommand(params: { + proposalCid: string + approver: string +}) { + return { + ExerciseCommand: { + templateId: OTC_TRADE_PROPOSAL_TEMPLATE_ID, + contractId: params.proposalCid, + choice: 'OTCTradeProposal_Accept', + choiceArgument: { approver: params.approver }, + }, + } +} + +function buildInitiateSettlementCommand(params: { + proposalCid: string + prepareUntil: string + settleBefore: string +}) { + return { + ExerciseCommand: { + templateId: OTC_TRADE_PROPOSAL_TEMPLATE_ID, + contractId: params.proposalCid, + choice: 'OTCTradeProposal_InitiateSettlement', + choiceArgument: { + prepareUntil: params.prepareUntil, + settleBefore: params.settleBefore, + }, + }, + } +} + +const MS_30_MIN = 30 * 60 * 1000 +const MS_1_HOUR = 60 * 60 * 1000 + +const PROPOSAL_POLL_TIMEOUT_MS = 30_000 +const PROPOSAL_POLL_INTERVAL_MS = 500 + +export async function createAndInitiateOtcTrade( + setup: MultiSyncSetup, + transferLegs: Record, + logger: Logger +): Promise { + const { + appUserSdk, + appProviderSdk, + svSdk, + alice, + bob, + tradingApp, + globalSynchronizerId, + } = setup + + // The proposal is created on Alice's participant but read from other + // participants (Bob, TradingApp) + const readProposalCid = async ( + sdk: SDKInterface<'token'>, + party: string, + predicate: (approvers: string[]) => boolean = () => true + ): Promise => { + const deadline = Date.now() + PROPOSAL_POLL_TIMEOUT_MS + for (;;) { + const proposals = await sdk.ledger.acs.read({ + templateIds: [OTC_TRADE_PROPOSAL_TEMPLATE_ID], + parties: [party], + filterByParty: true, + }) + const match = proposals.find((proposal) => + predicate( + (( + proposal as unknown as { + createArgument?: { approvers?: string[] } + } + ).createArgument?.approvers ?? []) as string[] + ) + ) + if (match) return match.contractId + if (Date.now() >= deadline) { + throw new Error( + `OTCTradeProposal not visible to ${party} within ${PROPOSAL_POLL_TIMEOUT_MS}ms` + ) + } + await new Promise((resolve) => + setTimeout(resolve, PROPOSAL_POLL_INTERVAL_MS) + ) + } + } + + await appUserSdk.ledger + .prepare({ + partyId: alice.partyId, + commands: buildOtcTradeProposalCommand({ + venue: tradingApp.partyId, + transferLegs, + approvers: [alice.partyId], + }), + disclosedContracts: [], + synchronizerId: globalSynchronizerId, + }) + .sign(alice.keyPair.privateKey) + .execute({ partyId: alice.partyId }) + logger.info( + `Alice: OTCTradeProposal created (leg-0: ${TRADE_AMULET_AMOUNT} Amulet → Bob, leg-1: ${TRADE_TOKEN_AMOUNT} TestToken → Alice)` + ) + + await appProviderSdk.ledger + .prepare({ + partyId: bob.partyId, + commands: [ + buildAcceptOtcTradeCommand({ + proposalCid: await readProposalCid( + appProviderSdk, + bob.partyId + ), + approver: bob.partyId, + }), + ], + disclosedContracts: [], + synchronizerId: globalSynchronizerId, + }) + .sign(bob.keyPair.privateKey) + .execute({ partyId: bob.partyId }) + logger.info('Bob: OTCTradeProposal_Accept executed') + + const prepareUntil = new Date(Date.now() + MS_30_MIN).toISOString() + const settleBefore = new Date(Date.now() + MS_1_HOUR).toISOString() + + await svSdk.ledger + .prepare({ + partyId: tradingApp.partyId, + commands: [ + buildInitiateSettlementCommand({ + proposalCid: await readProposalCid( + svSdk, + tradingApp.partyId, + (approvers) => approvers.includes(bob.partyId) + ), + prepareUntil, + settleBefore, + }), + ], + disclosedContracts: [], + synchronizerId: globalSynchronizerId, + }) + .sign(tradingApp.keyPair.privateKey) + .execute({ partyId: tradingApp.partyId }) + logger.info( + 'TradingApp: OTCTradeProposal_InitiateSettlement executed → OTCTrade created' + ) + + const otcTradeContracts = await svSdk.ledger.acs.read({ + templateIds: [OTC_TRADE_TEMPLATE_ID], + parties: [tradingApp.partyId], + filterByParty: true, + }) + const otcTradeCid = otcTradeContracts[0]?.contractId + if (!otcTradeCid) + throw new Error('OTCTrade contract not found after initiation') + return otcTradeCid +} diff --git a/docs/wallet-integration-guide/examples/scripts/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..f254e140a --- /dev/null +++ b/docs/wallet-integration-guide/examples/scripts/15-multi-sync/_trade_settle.ts @@ -0,0 +1,238 @@ +// Copyright (c) 2025-2026 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import type { Logger } from 'pino' +import { localNetStaticConfig } from '@canton-network/wallet-sdk' +import type { LedgerCommonSchemas } from '@canton-network/core-ledger-client-types' +import type { MultiSyncSetup } from './_setup.js' +import { TRADE_AMULET_AMOUNT, TRADE_TOKEN_AMOUNT } from './_constants.js' + +const OTC_TRADE_TEMPLATE_ID = + '#splice-token-test-trading-app:Splice.Testing.Apps.TradingApp:OTCTrade' + +function buildSettleOtcTradeCommand(params: { + tradeCid: string + allocationsWithContext: Record +}) { + return { + ExerciseCommand: { + templateId: OTC_TRADE_TEMPLATE_ID, + contractId: params.tradeCid, + choice: 'OTCTrade_Settle', + choiceArgument: { + allocationsWithContext: params.allocationsWithContext, + }, + }, + } +} + +type DisclosedContract = LedgerCommonSchemas['DisclosedContract'] + +export interface SettleParams { + otcTradeCid: string + legIdAlice: string + legIdBob: string + testTokenAllocationCid: string + testTokenAllocationDisclosed: DisclosedContract +} + +/** Withdraws both allocations in parallel after a settlement failure, returning funds to each party. */ +async function withdrawAllocationsOnFailure( + setup: MultiSyncSetup, + amuletAllocationCid: string, + testTokenAllocationCid: string, + logger: Logger +): Promise { + const { + appUserSdk, + appProviderSdk, + tokenNamespaceAppUser, + tokenNamespaceAppProvider, + alice, + bob, + tokenAdmin, + globalSynchronizerId, + amuletAdmin, + testTokenRegistryUrl, + } = setup + + await Promise.all([ + (async () => { + const [cmd, disclosed] = + await tokenNamespaceAppUser.allocation.withdraw({ + allocationCid: amuletAllocationCid, + asset: { + id: 'Amulet', + displayName: 'Amulet', + symbol: 'CC', + registryUrl: + localNetStaticConfig.LOCALNET_REGISTRY_API_URL, + admin: amuletAdmin, + }, + }) + await appUserSdk.ledger + .prepare({ + partyId: alice.partyId, + commands: [cmd], + disclosedContracts: disclosed, + synchronizerId: globalSynchronizerId, + }) + .sign(alice.keyPair.privateKey) + .execute({ partyId: alice.partyId }) + logger.info('Alice: Amulet allocation withdrawn — funds returned') + })(), + (async () => { + const [cmd, disclosed] = + await tokenNamespaceAppProvider.allocation.withdraw({ + allocationCid: testTokenAllocationCid, + asset: { + id: 'TestToken', + displayName: 'TestToken', + symbol: 'TT', + registryUrl: testTokenRegistryUrl, + admin: tokenAdmin.partyId, + }, + }) + await appProviderSdk.ledger + .prepare({ + partyId: bob.partyId, + commands: [cmd], + disclosedContracts: disclosed, + synchronizerId: globalSynchronizerId, + }) + .sign(bob.keyPair.privateKey) + .execute({ partyId: bob.partyId }) + logger.info('Bob: TestToken allocation withdrawn — funds returned') + })(), + ]) +} + +export async function settleOtcTrade( + setup: MultiSyncSetup, + params: SettleParams, + logger: Logger +): Promise { + const { + svSdk, + tokenNamespaceAppUser, + tokenNamespaceAppProvider, + alice, + tradingApp, + globalSynchronizerId, + testTokenRegistryUrl, + } = setup + const { + otcTradeCid, + legIdAlice, + legIdBob, + testTokenAllocationCid, + testTokenAllocationDisclosed, + } = params + + const allocationsAlice = await tokenNamespaceAppUser.allocation.pending( + alice.partyId + ) + const amuletAllocation = allocationsAlice.find( + (a) => a.interfaceViewValue.allocation.transferLegId === legIdAlice + ) + if (!amuletAllocation) throw new Error('Amulet allocation not found') + + const amuletExecCtx = + await tokenNamespaceAppUser.allocation.context.execute({ + allocationCid: amuletAllocation.contractId, + registryUrl: localNetStaticConfig.LOCALNET_REGISTRY_API_URL, + }) + + // Fetch Bob's TestToken execute-transfer choice context from the registry's + // allocation-v1 API (instead of hard-coding an empty context). + const testTokenExecCtx = + await tokenNamespaceAppProvider.allocation.context.execute({ + allocationCid: testTokenAllocationCid, + registryUrl: testTokenRegistryUrl, + }) + + const allocationsWithContext = { + [legIdAlice]: { + _1: amuletAllocation.contractId, + _2: { + context: { + ...(amuletExecCtx.choiceContextData ?? {}), + values: + (amuletExecCtx.choiceContextData?.values as Record< + string, + unknown + >) ?? {}, + }, + meta: { values: {} }, + }, + }, + [legIdBob]: { + _1: testTokenAllocationCid, + _2: { + context: { + ...(testTokenExecCtx.choiceContextData ?? {}), + values: + (testTokenExecCtx.choiceContextData?.values as Record< + string, + unknown + >) ?? {}, + }, + meta: { values: {} }, + }, + }, + } + + const disclosedContracts = [ + ...(amuletExecCtx.disclosedContracts ?? []).map((c) => ({ + ...c, + synchronizerId: '', + })), + ...(testTokenExecCtx.disclosedContracts ?? []).map((c) => ({ + ...c, + synchronizerId: '', + })), + // Disclose Bob's TestToken allocation so the TradingApp's participant can + // resolve it without waiting for cross-participant ACS propagation. + testTokenAllocationDisclosed, + ] + + try { + await svSdk.ledger + .prepare({ + partyId: tradingApp.partyId, + commands: [ + buildSettleOtcTradeCommand({ + tradeCid: otcTradeCid, + allocationsWithContext, + }), + ], + disclosedContracts, + synchronizerId: globalSynchronizerId, + }) + .sign(tradingApp.keyPair.privateKey) + .execute({ partyId: tradingApp.partyId }) + } catch (settleError) { + logger.error( + { err: settleError }, + 'Settlement failed — withdrawing allocations to return funds' + ) + try { + await withdrawAllocationsOnFailure( + setup, + amuletAllocation.contractId, + testTokenAllocationCid, + logger + ) + } catch (compensationError) { + logger.error( + { err: compensationError }, + 'Compensation failed — manual intervention required to withdraw allocations' + ) + } + throw settleError + } + + logger.info( + `TradingApp: OTCTrade settled — ${TRADE_AMULET_AMOUNT} Amulet transferred to Bob, ${TRADE_TOKEN_AMOUNT} TestToken transferred to Alice` + ) +} diff --git a/docs/wallet-integration-guide/examples/scripts/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..62856642c 100644 --- a/docs/wallet-integration-guide/examples/scripts/utils/index.ts +++ b/docs/wallet-integration-guide/examples/scripts/utils/index.ts @@ -9,12 +9,57 @@ import { AssetConfig, } from '@canton-network/wallet-sdk' +export { syncAlias, logAllContracts } from './acs-logger.js' +export type { ContractReadSpec as ContractSpec } from './acs-logger.js' export function getActiveContractCid(entry: JSContractEntry) { if ('JsActiveContract' in entry) { return entry.JsActiveContract.createdEvent.contractId } } +/** Maps the two synchronizer roles used in multi-synchronizer setups. */ +export type SynchronizerMap = { + globalSynchronizerId: string + appSynchronizerId: string +} + +/** + * Resolve the global synchronizer ID from the list returned by the ledger API. + * + * Looks for the entry whose alias is `'global'`. Falls back to the first entry + * when no alias matches (e.g. single-synchronizer setups). + * + * @throws {Error} When the array is empty. + */ +export function resolveGlobalSynchronizerId( + synchronizers: Array<{ synchronizerAlias: string; synchronizerId: string }> +): string { + const global = synchronizers.find((s) => s.synchronizerAlias === 'global') + if (!global) throw new Error('Global synchronizer not found') + return global.synchronizerId +} + +/** + * Fetches connected synchronizers from the ledger API and returns the ID of the + * synchronizer aliased `'global'`. + * + * The wallet SDK no longer auto-selects a synchronizer, so client code (these + * examples) resolves it explicitly and passes it to SDK calls that require one. + */ +export async function getGlobalSynchronizerId(sdk: { + ledger: { + connectedSynchronizers(args: object): Promise<{ + connectedSynchronizers?: Array<{ + synchronizerAlias: string + synchronizerId: string + }> + }> + } +}): Promise { + const response = await sdk.ledger.connectedSynchronizers({}) + return resolveGlobalSynchronizerId(response.connectedSynchronizers ?? []) +} + 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..7222a6712 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,24 @@ async function beforeEachSetup() { asset: global.ASSET_CONFIG, }) + // ========= Resolve the synchronizer parties are hosted on ========= + const { connectedSynchronizers } = await sdk.ledger.connectedSynchronizers( + {} + ) + const globalSynchronizer = (connectedSynchronizers ?? []).find( + (s) => s.synchronizerAlias === 'global' + ) + if (!globalSynchronizer) throw new Error('Global synchronizer not found') + global.SYNCHRONIZER_ID = globalSynchronizer.synchronizerId + // ========= 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 +123,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 +152,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 +171,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..1b2292e7b 100644 --- a/sdk/wallet-sdk/src/config.ts +++ b/sdk/wallet-sdk/src/config.ts @@ -8,6 +8,8 @@ const LOCALNET_APP_VALIDATOR_URL = new URL( const LOCALNET_SCAN_API_URL = new URL('http://scan.localhost:4000/api/scan') const LOCALNET_APP_USER_LEDGER_URL = new URL('http://localhost:2975') +const LOCALNET_APP_PROVIDER_LEDGER_URL = new URL('http://localhost:3975') +const LOCALNET_SV_LEDGER_URL = new URL('http://localhost:4975') const LOCALNET_TOKEN_STANDARD_URL = new URL('http://localhost:5003') @@ -22,6 +24,8 @@ export const localNetStaticConfig = { LOCALNET_SCAN_API_URL, LOCALNET_REGISTRY_API_URL, LOCALNET_APP_USER_LEDGER_URL, + LOCALNET_APP_PROVIDER_LEDGER_URL, + LOCALNET_SV_LEDGER_URL, LOCALNET_TOKEN_STANDARD_URL, LOCALNET_USER_ID, } diff --git a/sdk/wallet-sdk/src/wallet/__test__/mocks.ts b/sdk/wallet-sdk/src/wallet/__test__/mocks.ts index 80f07d847..22535ad17 100644 --- a/sdk/wallet-sdk/src/wallet/__test__/mocks.ts +++ b/sdk/wallet-sdk/src/wallet/__test__/mocks.ts @@ -31,7 +31,6 @@ const ctx: SDKContext = { userId: 'userId', logger: mockLogger, error: mockErrorHandler, - defaultSynchronizerId: '', } export const mock = { diff --git a/sdk/wallet-sdk/src/wallet/namespace/amulet/amulet.test.ts b/sdk/wallet-sdk/src/wallet/namespace/amulet/amulet.test.ts index 4cb221a45..df22f1163 100644 --- a/sdk/wallet-sdk/src/wallet/namespace/amulet/amulet.test.ts +++ b/sdk/wallet-sdk/src/wallet/namespace/amulet/amulet.test.ts @@ -46,7 +46,6 @@ const mockAmuletService = { const config: AmuletNamespaceConfig = { commonCtx: { ...ctx, - defaultSynchronizerId: 'mock-synchronizer-id', logger: mockLogger, } as any, registry: { @@ -116,7 +115,9 @@ describe('AmuletNamespace', () => { ['dc-1'] as any, ]) - const result = await amuletNamespace.tapInternal('10000') + const result = await amuletNamespace.tapInternal('10000', { + synchronizerId: 'mock-synchronizer-id', + }) expect(amuletNamespace.tap).toHaveBeenCalledWith( config.validatorParty, @@ -125,7 +126,7 @@ describe('AmuletNamespace', () => { expect(mockSubmit).toHaveBeenCalledWith({ commands: [{ ExerciseCommand: tapCommand }], disclosedContracts: ['dc-1'], - synchronizerId: config.commonCtx.defaultSynchronizerId, + synchronizerId: 'mock-synchronizer-id', actAs: [config.validatorParty], }) expect(result).toStrictEqual({ diff --git a/sdk/wallet-sdk/src/wallet/namespace/amulet/namespace.ts b/sdk/wallet-sdk/src/wallet/namespace/amulet/namespace.ts index e1bd65b02..a0c8eb68f 100644 --- a/sdk/wallet-sdk/src/wallet/namespace/amulet/namespace.ts +++ b/sdk/wallet-sdk/src/wallet/namespace/amulet/namespace.ts @@ -88,15 +88,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], }) } @@ -126,9 +124,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.test.ts b/sdk/wallet-sdk/src/wallet/namespace/amulet/traffic.test.ts index 8f91a2589..ff27d2ab0 100644 --- a/sdk/wallet-sdk/src/wallet/namespace/amulet/traffic.test.ts +++ b/sdk/wallet-sdk/src/wallet/namespace/amulet/traffic.test.ts @@ -20,7 +20,6 @@ describe('TrafficNamespace', () => { config = { commonCtx: { ...mock.ctx, - defaultSynchronizerId: 'SYNCDEFAULT::123', } as any, amuletService: { getMemberTrafficStatus: vi.fn(), @@ -59,7 +58,7 @@ describe('TrafficNamespace', () => { }) }) - it('should fetch defaults if synchronizer/memberId id is not specified', async () => { + it('should fetch default memberId if not specified', async () => { vi.mocked( config.amuletService.getMemberTrafficStatus ).mockResolvedValue({ @@ -73,7 +72,9 @@ describe('TrafficNamespace', () => { participantId: 'PAR::234', }) - const result = await trafficNamespace.status() + const result = await trafficNamespace.status({ + synchronizerId: 'SYNC::123', + }) expect(mock.ledgerProvider.request).toHaveBeenCalledWith({ method: 'ledgerApi', @@ -84,7 +85,7 @@ describe('TrafficNamespace', () => { }) expect( config.amuletService.getMemberTrafficStatus - ).toHaveBeenCalledWith('SYNCDEFAULT::123', 'PAR::234') + ).toHaveBeenCalledWith('SYNC::123', 'PAR::234') expect(result).toStrictEqual({ traffic_status: { actual: { total_consumed: 0, total_limit: 1200000 }, @@ -140,7 +141,7 @@ describe('TrafficNamespace', () => { ]) }) - it('by member traffic with defaults params if not provided', async () => { + it('uses default memberId and migrationId when not provided', async () => { vi.mocked(fetchAmulet).mockResolvedValue({ admin: 'DSO::123', } as any) @@ -157,6 +158,7 @@ describe('TrafficNamespace', () => { buyer: buyer, ccAmount: 125, inputUtxos: utxos, + synchronizerId: 'SYNC::123', }) expect(mock.ledgerProvider.request).toHaveBeenCalledTimes(1) @@ -164,7 +166,7 @@ describe('TrafficNamespace', () => { 'DSO::123', buyer, 125, - 'SYNCDEFAULT::123', + 'SYNC::123', 'PAR::234', 0, utxos diff --git a/sdk/wallet-sdk/src/wallet/namespace/amulet/traffic.ts b/sdk/wallet-sdk/src/wallet/namespace/amulet/traffic.ts index 4bcd2a651..65a417935 100644 --- a/sdk/wallet-sdk/src/wallet/namespace/amulet/traffic.ts +++ b/sdk/wallet-sdk/src/wallet/namespace/amulet/traffic.ts @@ -12,9 +12,13 @@ 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) + this.sdkContext.commonCtx.error.throw({ + type: 'BadRequest', + message: + 'synchronizerId is required for traffic.status — pass the synchronizer ID explicitly', + }) const memberId = params?.memberId ?? @@ -46,6 +50,13 @@ export class TrafficNamespace { }): Promise { const { buyer, ccAmount, inputUtxos } = params const migrationId = params.migrationId ?? 0 + const synchronizerId = params.synchronizerId + if (!synchronizerId) + this.sdkContext.commonCtx.error.throw({ + type: 'BadRequest', + message: + 'synchronizerId is required for traffic.buy — pass the synchronizer ID explicitly', + }) const defaultAmulet = await fetchAmulet(this.sdkContext) const memberId = params.memberId ?? @@ -61,10 +72,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..eec5bd73b 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, @@ -54,4 +54,47 @@ export class DarNamespace { result.packageIds.includes(packageId) ) } + + /** + * Vets a DAR package on the specified synchronizer. + * + * Tolerates the case where a package with the same name+version is already + * vetted on the participant. + * @param darBytes - Raw DAR file bytes. + * @param synchronizerId - The synchronizer on which the package should be + * vetted. When omitted, the synchronizer is not constrained in the request. + */ + async vet( + darBytes: Uint8Array | Buffer, + synchronizerId?: string + ): Promise { + try { + await this.sdkContext.ledgerProvider.request({ + method: 'ledgerApi', + params: { + resource: '/v2/packages', + requestMethod: 'post', + query: { + ...(synchronizerId !== undefined && { synchronizerId }), + vetAllPackages: true, + }, + body: darBytes as never, + headers: { 'Content-Type': 'application/octet-stream' }, + }, + }) + } catch (e) { + const code = (e as { code?: string })?.code + const message = `${(e as { cause?: unknown })?.cause ?? (e as Error)?.message ?? e}` + if ( + code === 'KNOWN_PACKAGE_VERSION' || + message.includes('same name and version') + ) { + this.sdkContext.logger.warn( + 'A package with the same name+version is already vetted; reusing the existing package.' + ) + return + } + throw e + } + } } diff --git a/sdk/wallet-sdk/src/wallet/namespace/ledger/dar/index.ts b/sdk/wallet-sdk/src/wallet/namespace/ledger/dar/index.ts index 8fe87fcb6..d0ba9adeb 100644 --- a/sdk/wallet-sdk/src/wallet/namespace/ledger/dar/index.ts +++ b/sdk/wallet-sdk/src/wallet/namespace/ledger/dar/index.ts @@ -4,6 +4,8 @@ import { SDKContext } from '../../../sdk.js' import { Ops } from '@canton-network/core-provider-ledger' +// NOTE: unused duplicate of `DarNamespace` (see ./client.ts), which is the +// implementation actually wired into the ledger namespace. Kept as-is for now. export class DarService { constructor(private readonly sdkContext: SDKContext) {} @@ -29,8 +31,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/internal/namespace.ts b/sdk/wallet-sdk/src/wallet/namespace/ledger/internal/namespace.ts index 813c38408..72228baa7 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,146 @@ import { SDKContext } from '../../../sdk.js' import { v4 } from 'uuid' import { Ops } from '@canton-network/core-provider-ledger' -import { InternalOperationParams } from './types.js' +import { InternalOperationParams, ReassignParams } from './types.js' export class InternalLedgerNamespace { constructor(private readonly ctx: SDKContext) {} + /** + * Reassigns a contract from one synchronizer to another. + * Performs the two-phase Canton reassignment (Unassign → Assign) via + * `/v2/commands/submit-and-wait-for-reassignment`. + */ + async reassign(params: ReassignParams): Promise { + const { submitter, contractId, source, target, skipIfAlreadyOn } = + params + + if (skipIfAlreadyOn && source === target) { + return + } + + // Phase 1: Unassign + let unassignResponse: Awaited< + ReturnType< + typeof this.ctx.ledgerProvider.request + > + > + try { + unassignResponse = + await this.ctx.ledgerProvider.request( + { + method: 'ledgerApi', + params: { + resource: + '/v2/commands/submit-and-wait-for-reassignment', + requestMethod: 'post', + body: { + reassignmentCommands: { + commandId: v4(), + submitter, + commands: [ + { + command: { + UnassignCommand: { + value: { + contractId, + source, + target, + }, + }, + }, + }, + ], + }, + eventFormat: { + filtersByParty: { [submitter]: {} }, + verbose: false, + }, + }, + }, + } + ) + } catch (e: unknown) { + if ( + typeof e === 'object' && + e !== null && + 'code' in e && + (e as { code: string }).code === 'SUBMITTER_ALWAYS_STAKEHOLDER' + ) { + this.ctx.error.throw({ + message: + `Cannot reassign contract ${contractId} from ${source} to ${target}: ` + + `submitter "${submitter}" is not a stakeholder. ` + + `Only a stakeholder of the contract may initiate a reassignment.`, + type: 'CantonError', + originalError: e, + }) + } + throw e + } + + const events = unassignResponse.reassignment?.events ?? [] + const unassignedEvent = events.find((e) => 'JsUnassignedEvent' in e) + if (!unassignedEvent || !('JsUnassignedEvent' in unassignedEvent)) { + this.ctx.error.throw({ + message: `No unassigned event returned for contract ${contractId} reassignment`, + type: 'Unexpected', + }) + } + const reassignmentId = + unassignedEvent.JsUnassignedEvent.value.reassignmentId + + // Phase 2: Assign + try { + await this.ctx.ledgerProvider.request( + { + method: 'ledgerApi', + params: { + resource: + '/v2/commands/submit-and-wait-for-reassignment', + requestMethod: 'post', + body: { + reassignmentCommands: { + commandId: v4(), + submitter, + commands: [ + { + command: { + AssignCommand: { + value: { + reassignmentId, + source, + target, + }, + }, + }, + }, + ], + }, + }, + }, + } + ) + } catch (e) { + throw Object.assign( + new Error( + `Phase 2 (Assign) failed for contract ${contractId} ` + + `(reassignmentId: ${reassignmentId}). ` + + `The contract is in-flight on source synchronizer "${source}" and must be ` + + `assigned to "${target}" using the reassignmentId above.`, + { cause: e } + ), + { reassignmentId, source, target, contractId } + ) + } + } + public async submit( args: InternalOperationParams ) { const { commands, - synchronizerId = this.ctx.defaultSynchronizerId, + synchronizerId = '', disclosedContracts = [], readAs = [], actAs, @@ -49,7 +178,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..51c2a031e 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' @@ -25,6 +31,42 @@ export class LedgerNamespace { this.acsReader = new ACSReader(sdkContext.ledgerProvider) } + /** + * Returns connected synchronizers visible to the caller, optionally filtered + * by party, participant, or identity provider. + * + * Uses the Ledger API endpoint GET /v2/state/connected-synchronizers. + */ + public async connectedSynchronizers( + options?: ConnectedSynchronizersOptions + ) { + this.sdkContext.logger.debug( + { options }, + 'Fetching connected synchronizers' + ) + + return this.sdkContext.ledgerProvider.request( + { + method: 'ledgerApi', + params: { + resource: '/v2/state/connected-synchronizers', + requestMethod: 'get', + query: { + ...(options?.party !== undefined && { + party: options.party, + }), + ...(options?.participantId !== undefined && { + participantId: options.participantId, + }), + ...(options?.identityProviderId !== undefined && { + identityProviderId: options.identityProviderId, + }), + }, + }, + } + ) + } + public async ledgerEnd() { return ( await this.sdkContext.ledgerProvider.request( @@ -44,8 +86,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 +102,7 @@ export class LedgerNamespace { commandId, actAs: [partyId], disclosedContracts, - synchronizerId, + ...(synchronizerId !== undefined && { synchronizerId }), }) } @@ -217,6 +258,43 @@ export class LedgerNamespace { } }) }, + /** + * Queries the ACS and returns the first matching contract, throwing if none is found. + * @param options AcsOptions for querying the Active Contract Set (ACS). + * @throws {SDKError} When no matching contract is found. + */ + requireOne: async (options: AcsRequestOptions) => { + const contracts = await this.acs.read(options) + if (!contracts.length) { + this.sdkContext.error.throw({ + message: `Required contract not found (templateIds: ${options.templateIds?.join(', ')}, parties: ${options.parties?.join(', ')})`, + type: 'NotFound', + }) + } + return contracts[0] + }, + } + + /** + * Prepares, signs, and executes the same command set on multiple synchronizers in parallel. + * Equivalent to calling `prepare(...).sign(privateKey).execute({ partyId })` for each + * synchronizer, but without repeating the command payload. + * @param options - Command options without a synchronizerId (it is provided per-element) + * @param synchronizerIds - Synchronizers to submit to in parallel + * @param privateKey - Key used to sign each prepared transaction + */ + public async prepareAndExecuteOnSynchronizers( + options: Omit, + synchronizerIds: string[], + privateKey: PrivateKey + ): Promise { + await Promise.all( + synchronizerIds.map((synchronizerId) => + this.prepare({ ...options, synchronizerId }) + .sign(privateKey) + .execute({ partyId: options.partyId }) + ) + ) } /** diff --git a/sdk/wallet-sdk/src/wallet/namespace/ledger/types.ts b/sdk/wallet-sdk/src/wallet/namespace/ledger/types.ts index 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..49bce517e 100644 --- a/sdk/wallet-sdk/src/wallet/namespace/party/external/service.ts +++ b/sdk/wallet-sdk/src/wallet/namespace/party/external/service.ts @@ -32,41 +32,41 @@ export class ExternalPartyNamespace { this.resolveParticipantUids( options?.confirmingParticipantEndpoints ?? [] ), - options?.synchronizerId || this.resolveSynchronizerId(), - ]).then( - ([ - observingParticipantUids, - otherHostingParticipantUids, - synchronizerId, - ]) => - this.ctx.ledgerProvider.request( - { - method: 'ledgerApi', - params: { - resource: '/v2/parties/external/generate-topology', - body: { - synchronizer: synchronizerId, - partyHint: options?.partyHint ?? v4(), - publicKey: { - format: 'CRYPTO_KEY_FORMAT_RAW', - keyData: publicKey, - keySpec: 'SIGNING_KEY_SPEC_EC_CURVE25519', - }, - localParticipantObservationOnly: - options?.localParticipantObservationOnly ?? - false, - confirmationThreshold: - options?.confirmingThreshold ?? 1, - otherConfirmingParticipantUids: - otherHostingParticipantUids, - observingParticipantUids: - observingParticipantUids, + ]).then(([observingParticipantUids, otherHostingParticipantUids]) => { + const synchronizerId = options?.synchronizerId + if (!synchronizerId) + this.ctx.error.throw({ + message: + 'synchronizerId is required for party creation — pass it via options.synchronizerId', + type: 'BadRequest', + }) + return this.ctx.ledgerProvider.request( + { + method: 'ledgerApi', + params: { + resource: '/v2/parties/external/generate-topology', + body: { + synchronizer: synchronizerId, + partyHint: options?.partyHint ?? v4(), + publicKey: { + format: 'CRYPTO_KEY_FORMAT_RAW', + keyData: publicKey, + keySpec: 'SIGNING_KEY_SPEC_EC_CURVE25519', }, - requestMethod: 'post', + localParticipantObservationOnly: + options?.localParticipantObservationOnly ?? + false, + confirmationThreshold: + options?.confirmingThreshold ?? 1, + otherConfirmingParticipantUids: + otherHostingParticipantUids, + observingParticipantUids: observingParticipantUids, }, - } - ) - ) + requestMethod: 'post', + }, + } + ) + }) this.logger.debug('Prepared party creation successfully.') return new PreparedPartyCreationService( @@ -75,38 +75,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..e5f81df7b 100644 --- a/sdk/wallet-sdk/src/wallet/namespace/party/external/signed.ts +++ b/sdk/wallet-sdk/src/wallet/namespace/party/external/signed.ts @@ -17,6 +17,11 @@ import { Ops, } from '@canton-network/core-provider-ledger' import { AuthTokenProvider } from '@canton-network/core-wallet-auth' +import { + PrivateKey, + PublicKey, + signTransactionHash, +} from '@canton-network/core-signing-lib' /** * Represents a signed party creation, ready to be allocated on the ledger. @@ -26,7 +31,9 @@ export class SignedPartyCreationService { constructor( private readonly ctx: SDKContext, private readonly signedPartyPromise: Promise, - private readonly createPartyOptions?: CreatePartyOptions + private readonly createPartyOptions?: CreatePartyOptions, + private readonly publicKey?: PublicKey, + private readonly privateKey?: PrivateKey ) {} /** @@ -50,7 +57,14 @@ export class SignedPartyCreationService { type: 'SDKOperationUnsupported', }) - if (await this.checkIfPartyExists(party.partyId)) { + // When a specific synchronizerId is provided, check whether the party + // is already registered on that synchronizer (not just on the participant). + if ( + await this.checkIfPartyExists( + party.partyId, + this.createPartyOptions?.synchronizerId + ) + ) { this.ctx.logger.info('Party already created.') return party } @@ -78,6 +92,15 @@ export class SignedPartyCreationService { }) } + const additionalSynchronizerIds = + this.createPartyOptions?.additionalSynchronizerIds ?? [] + for (const synchronizerId of additionalSynchronizerIds) { + await this.registerOnAdditionalSynchronizer( + party.partyId, + synchronizerId + ) + } + const grantUserRights = options?.grantUserRights ?? true if (grantUserRights) { @@ -95,6 +118,81 @@ export class SignedPartyCreationService { return party } + /** + * Registers the party on an additional synchronizer without granting user rights. + * Used when additionalSynchronizerIds is provided in CreatePartyOptions. + * @param partyId - The party ID returned from primary allocation + * @param synchronizerId - The secondary synchronizer to register the party on + */ + private async registerOnAdditionalSynchronizer( + partyId: PartyId, + synchronizerId: string + ) { + if (!this.publicKey || !this.privateKey) { + this.ctx.error.throw({ + message: + 'Cannot register party on additional synchronizer: publicKey and privateKey must be provided (offline signing is not supported for additionalSynchronizerIds)', + type: 'BadRequest', + }) + } + + if (await this.checkIfPartyExists(partyId, synchronizerId)) { + this.ctx.logger.info( + `Party already registered on synchronizer ${synchronizerId}.` + ) + return + } + + const topology = + await this.ctx.ledgerProvider.request( + { + method: 'ledgerApi', + params: { + resource: '/v2/parties/external/generate-topology', + body: { + synchronizer: synchronizerId, + partyHint: this.createPartyOptions?.partyHint ?? '', + publicKey: { + format: 'CRYPTO_KEY_FORMAT_RAW', + keyData: this.publicKey, + keySpec: 'SIGNING_KEY_SPEC_EC_CURVE25519', + }, + localParticipantObservationOnly: false, + confirmationThreshold: 1, + otherConfirmingParticipantUids: [], + observingParticipantUids: [], + }, + requestMethod: 'post', + }, + } + ) + + const signature = signTransactionHash( + topology.multiHash, + this.privateKey + ) + + await this.allocate( + this.ctx.ledgerProvider, + synchronizerId, + topology.topologyTransactions!.map((transaction) => ({ + transaction, + })), + [ + { + format: 'SIGNATURE_FORMAT_CONCAT', + signature, + signedBy: topology.publicKeyFingerprint, + signingAlgorithmSpec: 'SIGNING_ALGORITHM_SPEC_ED25519', + }, + ] + ) + + this.ctx.logger.info( + `Party registered on additional synchronizer ${synchronizerId}.` + ) + } + /** * Allocates the prepared party to additional participant nodes. * Ensures the party topology is synchronized across confirming and observing participants. @@ -144,7 +242,13 @@ export class SignedPartyCreationService { } = options const ledgerProvider = defaultLedgerProvider ?? this.ctx.ledgerProvider try { - const synchronizerId = this.ctx.defaultSynchronizerId + const synchronizerId = this.createPartyOptions?.synchronizerId + if (!synchronizerId) + this.ctx.error.throw({ + type: 'BadRequest', + message: + 'synchronizerId is required for external party allocation — pass it via createPartyOptions.synchronizerId', + }) await this.allocate( ledgerProvider, @@ -185,8 +289,30 @@ export class SignedPartyCreationService { } } - private async checkIfPartyExists(partyId: PartyId): Promise { + private async checkIfPartyExists( + partyId: PartyId, + synchronizerId?: string + ): Promise { try { + if (synchronizerId) { + const response = + await this.ctx.ledgerProvider.request( + { + method: 'ledgerApi', + params: { + resource: '/v2/state/connected-synchronizers', + requestMethod: 'get', + query: { party: partyId }, + }, + } + ) + return ( + response.connectedSynchronizers?.some( + (s) => s.synchronizerId === synchronizerId + ) ?? false + ) + } + const party = await this.ctx.ledgerProvider.request({ method: 'ledgerApi', @@ -224,9 +350,10 @@ export class SignedPartyCreationService { } if (tries >= maxTries) { - throw new Error( - `timed out waiting for new party to appear after ${maxTries} tries` - ) + this.ctx.error.throw({ + message: `timed out waiting for new party to appear after ${maxTries} tries`, + type: 'Unexpected', + }) } const result = await this.grantRights(userId, { @@ -234,7 +361,10 @@ export class SignedPartyCreationService { }) if (!result.newlyGrantedRights) { - throw new Error('Failed to grant user rights') + this.ctx.error.throw({ + message: 'Failed to grant user rights', + type: 'Unexpected', + }) } return @@ -307,7 +437,10 @@ export class SignedPartyCreationService { }, }) if (!result.newlyGrantedRights) { - throw new Error('Failed to grant user rights') + this.ctx.error.throw({ + message: 'Failed to grant user rights', + type: 'Unexpected', + }) } return result @@ -320,9 +453,11 @@ export class SignedPartyCreationService { multiHashSignatures: MultiHashSignatures ): Promise { if (!onboardingTransactions || !multiHashSignatures) { - throw new Error( - 'onboardingTransactions and multiHashSignatures must be provided for party allocation' - ) + this.ctx.error.throw({ + message: + 'onboardingTransactions and multiHashSignatures must be provided for party allocation', + type: 'BadRequest', + }) } const resp = await ledgerProvider.request({ diff --git a/sdk/wallet-sdk/src/wallet/namespace/party/external/types.ts b/sdk/wallet-sdk/src/wallet/namespace/party/external/types.ts index 54f11b172..36391ae6b 100644 --- a/sdk/wallet-sdk/src/wallet/namespace/party/external/types.ts +++ b/sdk/wallet-sdk/src/wallet/namespace/party/external/types.ts @@ -9,6 +9,7 @@ export type CreatePartyOptions = Partial<{ partyHint: string confirmingThreshold: number synchronizerId: string + additionalSynchronizerIds: string[] confirmingParticipantEndpoints: ParticipantEndpointConfig[] observingParticipantEndpoints: ParticipantEndpointConfig[] localParticipantObservationOnly: boolean diff --git a/sdk/wallet-sdk/src/wallet/namespace/party/internal/index.ts b/sdk/wallet-sdk/src/wallet/namespace/party/internal/index.ts index 5e0f11b5e..ac2026ae6 100644 --- a/sdk/wallet-sdk/src/wallet/namespace/party/internal/index.ts +++ b/sdk/wallet-sdk/src/wallet/namespace/party/internal/index.ts @@ -50,9 +50,9 @@ export class InternalPartyNamespace { body: { partyIdHint: params.partyHint ?? v4(), identityProviderId: '', - synchronizerId: - params.synchronizerId ?? - this.ctx.defaultSynchronizerId, + ...(params.synchronizerId !== undefined && { + synchronizerId: params.synchronizerId, + }), userId: params.userId ?? this.ctx.userId, }, }, diff --git a/sdk/wallet-sdk/src/wallet/namespace/party/party.test.ts b/sdk/wallet-sdk/src/wallet/namespace/party/party.test.ts index b1fb58912..d2c6b7da3 100644 --- a/sdk/wallet-sdk/src/wallet/namespace/party/party.test.ts +++ b/sdk/wallet-sdk/src/wallet/namespace/party/party.test.ts @@ -295,7 +295,8 @@ describe('Party namespace', () => { Promise.resolve({ party: partyTransaction, signature: 'defaultSignature', - }) + }), + { synchronizerId: 'syncId' } ) }) @@ -372,16 +373,8 @@ describe('Party namespace', () => { }) }) - // Mock get connected synchronizers - ledgerProvider.request.mockResolvedValueOnce({ - connectedSynchronizers: [ - { - synchronizerId: 'syncId', - }, - ], - }) - party.external.create('publicKey', { + synchronizerId: 'syncId', observingParticipantEndpoints, confirmingParticipantEndpoints, }) @@ -400,14 +393,6 @@ describe('Party namespace', () => { } ) }) - expect(ledgerProvider.request).toHaveBeenNthCalledWith(6, { - method: 'ledgerApi', - params: { - resource: '/v2/state/connected-synchronizers', - requestMethod: 'get', - query: {}, - }, - }) }) }) @@ -458,24 +443,23 @@ describe('Party namespace', () => { describe('external.create.sign.execute', () => { it('should return the party if it is already existing', async () => { - // Mock checkIfPartyExists - party already exists + // Mock checkIfPartyExists - party already registered on the synchronizer ledgerProvider.request.mockResolvedValueOnce({ - partyDetails: [ + connectedSynchronizers: [ { - party: partyTransaction.partyId, + synchronizerId: 'syncId', }, ], - } satisfies LedgerCommonSchemas['GetPartiesResponse']) + }) const result = await signedParty.execute() - + // We check existence of party by connected-synchronizers call expect(ledgerProvider.request).toHaveBeenNthCalledWith(1, { method: 'ledgerApi', params: { - resource: '/v2/parties/{party}', + resource: '/v2/state/connected-synchronizers', requestMethod: 'get', - path: { party: partyTransaction.partyId }, - query: {}, + query: { party: partyTransaction.partyId }, }, }) @@ -513,7 +497,7 @@ describe('Party namespace', () => { resource: '/v2/parties/external/allocate', requestMethod: 'post', body: { - synchronizer: ctx.defaultSynchronizerId, + synchronizer: 'syncId', identityProviderId: '', onboardingTransactions: partyTransaction.topologyTransactions.map( @@ -560,7 +544,7 @@ describe('Party namespace', () => { resource: '/v2/parties/external/allocate', requestMethod: 'post', body: { - synchronizer: ctx.defaultSynchronizerId, + synchronizer: 'syncId', identityProviderId: '', onboardingTransactions: partyTransaction.topologyTransactions.map( 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/namespace/user/namespace.test.ts b/sdk/wallet-sdk/src/wallet/namespace/user/namespace.test.ts index cc152765c..d248a9724 100644 --- a/sdk/wallet-sdk/src/wallet/namespace/user/namespace.test.ts +++ b/sdk/wallet-sdk/src/wallet/namespace/user/namespace.test.ts @@ -19,7 +19,6 @@ const sdkContext = { userId: 'ledger-api-user', logger: new SDKLogger('console'), error: new SDKErrorHandler(logger), - defaultSynchronizerId: 'syncid', } describe('user namespace', () => { diff --git a/sdk/wallet-sdk/src/wallet/namespace/utils/utils.test.ts b/sdk/wallet-sdk/src/wallet/namespace/utils/utils.test.ts index c7ca0d303..2b3dacd17 100644 --- a/sdk/wallet-sdk/src/wallet/namespace/utils/utils.test.ts +++ b/sdk/wallet-sdk/src/wallet/namespace/utils/utils.test.ts @@ -20,7 +20,6 @@ const ctx: SDKContext = { userId: 'ledger-api-user', logger: new SDKLogger('console'), error: new SDKErrorHandler(new SDKLogger('console')), - defaultSynchronizerId: 'synchronizerId', } const amuletAsset = { diff --git a/sdk/wallet-sdk/src/wallet/sdk.ts b/sdk/wallet-sdk/src/wallet/sdk.ts index c7a63e07c..c1eb2d435 100644 --- a/sdk/wallet-sdk/src/wallet/sdk.ts +++ b/sdk/wallet-sdk/src/wallet/sdk.ts @@ -49,7 +49,6 @@ export type SDKContext = { userId: string logger: SDKLogger error: SDKErrorHandler - defaultSynchronizerId: string } export type OfflineSDKContext = { @@ -61,6 +60,7 @@ 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 { ScanProxyClient } from '@canton-network/core-splice-client' export class SDK { static async create< @@ -125,17 +125,11 @@ export class SDK { }) } - const defaultSynchronizerId = await getDefaultSynchronizerId( - ledgerProvider, - logger - ) - const ctx: SDKContext = { ledgerProvider, userId: userId!, logger, error, - defaultSynchronizerId, } const config = {} as Pick< @@ -167,32 +161,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 dd8dfd4f6..4ca2f3f2a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1843,6 +1843,24 @@ __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: + "@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" + rollup: "npm:^4.59.0" + rollup-plugin-dts: "npm:^6.3.0" + tslib: "npm:^2.8.1" + typescript: "npm:^5.9.3" + languageName: unknown + linkType: soft + "@canton-network/core-token-standard-service@workspace:^, @canton-network/core-token-standard-service@workspace:core/token-standard-service": version: 0.0.0-use.local resolution: "@canton-network/core-token-standard-service@workspace:core/token-standard-service" @@ -11707,10 +11725,13 @@ __metadata: version: 0.0.0-use.local resolution: "docs-wallet-integration-guide-examples@workspace:docs/wallet-integration-guide/examples" dependencies: + "@canton-network/core-amulet-service": "workspace:^" "@canton-network/core-ledger-client": "workspace:^" "@canton-network/core-ledger-client-types": "workspace:^" "@canton-network/core-ledger-proto": "workspace:^" "@canton-network/core-signing-lib": "workspace:^" + "@canton-network/core-test-token": "workspace:^" + "@canton-network/core-token-standard": "workspace:^" "@canton-network/core-tx-parser": "workspace:^" "@canton-network/core-types": "workspace:^" "@canton-network/core-wallet-auth": "workspace:^"