diff --git a/.github/actions/setup_canton/action.yml b/.github/actions/setup_canton/action.yml index ce7c697a1..b3b6edc88 100644 --- a/.github/actions/setup_canton/action.yml +++ b/.github/actions/setup_canton/action.yml @@ -7,6 +7,8 @@ inputs: canton_version: description: 'Canton version (required)' required: true + multi-sync: + description: 'Start localnet with --profile multi-sync' start_services: description: 'Whether to start canton services after setup' required: false @@ -60,6 +62,28 @@ runs: cat "$LOGFILE" exit 1 ' + # TODO (#1721): make multi-sync the default and remove the flag once multi-sync is fully supported and tested in the main scripts e2e tests, but for now we want to keep it as an option to avoid accidentally running multi-sync e2e tests without updating the main scripts e2e tests to cover multi-sync as well + - name: Start Localnet + if: inputs.instance == 'localnet' + shell: bash + run: | + MULTI_SYNC_FLAG="" + if [ "${{ inputs.multi-sync }}" = "true" ]; then + MULTI_SYNC_FLAG="--multi-sync" + fi + yarn start:localnet -- --network=${{ inputs.network }} $MULTI_SYNC_FLAG + + - name: Save Docker images to cache + if: ${{ inputs.instance == 'localnet' && steps.localnet-cache.outputs.cache-hit != 'true' }} + shell: bash + run: | + mkdir -p /tmp/docker-images + images=$(docker images --format "{{.Repository}}:{{.Tag}}" | grep -v "" || true) + if [ -n "$images" ]; then + echo "$images" | xargs -r docker save -o /tmp/docker-images/images.tar + else + echo "No Docker images found to save." + fi - name: Save Canton cache uses: ./.github/actions/save_cache_if_absent diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index e33806c40..067c3c891 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -379,7 +379,7 @@ jobs: run: yarn nx snippets docs-wallet-integration-guide-examples - uses: ./.github/actions/check_resources - + # TODO (#1721): make multi-sync the default and remove the flag once multi-sync is fully supported and tested in the main scripts e2e tests, but for now we want to keep it as an option to avoid accidentally running multi-sync e2e tests without updating the main scripts e2e tests to cover multi-sync as well - name: Stop Localnet (${{ matrix.network }}) if: always() run: yarn stop:localnet -- --network=${{ matrix.network }} @@ -431,7 +431,7 @@ jobs: run: yarn script:test:examples - uses: ./.github/actions/check_resources - + # TODO (#1721): make multi-sync the default and remove the flag once multi-sync is fully supported and tested in the main scripts e2e tests, but for now we want to keep it as an option to avoid accidentally running multi-sync e2e tests without updating the main scripts e2e tests to cover multi-sync as well - name: Stop Localnet (${{ matrix.network }}) if: always() run: yarn stop:localnet -- --network=${{ matrix.network }} @@ -453,10 +453,70 @@ jobs: name: docker-logs-scripts-${{ matrix.network }} path: logs/ + # TODO (#1721): remove multi-sync scripts e2e tests once multi-sync is fully supported and tested in the main scripts e2e tests + wallet-sdk-scripts-e2e-multi-sync: + name: wallet-sdk-scripts-e2e-multi-sync (${{ matrix.network }}) + runs-on: ubuntu-latest + needs: build + strategy: + fail-fast: false + matrix: + network: [devnet, mainnet] + + steps: + - name: Checkout + uses: actions/checkout@v6 + + - uses: ./.github/actions/setup_yarn + + - uses: ./.github/actions/setup_canton + with: + network: ${{ matrix.network }} + instance: localnet + multi-sync: 'true' + + - uses: ./.github/actions/check_resources + + - name: Build project + run: yarn build:all + + - name: Test multi-sync example script (${{ matrix.network }}) + env: + MAX_IO_LISTENERS: '50' + run: yarn script:test:examples:multi-sync + + - uses: ./.github/actions/check_resources + # TODO (#1721): make multi-sync the default and remove the flag once multi-sync is fully supported and tested in the main scripts e2e tests, but for now we want to keep it as an option to avoid accidentally running multi-sync e2e tests without updating the main scripts e2e tests to cover multi-sync as well + - name: Stop Localnet (${{ matrix.network }}) + if: always() + run: yarn stop:localnet -- --network=${{ matrix.network }} --multi-sync + + - name: Save container logs + if: failure() + run: | + #!/usr/bin/env bash + set -euo pipefail + mkdir -p logs + for c in $(docker ps -a --format '{{.Names}}'); do + docker logs "$c" &> "logs/$c.log" || true + done + + - name: Upload logs as artifacts + if: failure() + uses: actions/upload-artifact@v7 + with: + name: docker-logs-scripts-multi-sync-${{ matrix.network }} + path: logs/ + test-wallet-sdk-e2e: name: test-wallet-sdk-e2e runs-on: ubuntu-latest - needs: [wallet-sdk-snippets-e2e, wallet-sdk-scripts-e2e, wallet-sdk-pkg] + needs: [ + wallet-sdk-snippets-e2e, + wallet-sdk-scripts-e2e, + wallet-sdk-scripts-e2e-multi-sync, # TODO (#1721): remove multi-sync scripts e2e tests once multi-sync is fully supported and tested in the main scripts e2e tests, but for now we want to keep it as a gate to ensure multi-sync e2e tests are not accidentally skipped without updating the main scripts e2e tests to cover multi-sync as well + wallet-sdk-pkg, + ] if: always() steps: - name: Report wallet-sdk e2e execution @@ -469,6 +529,10 @@ jobs: echo "wallet-sdk scripts e2e did not succeed" exit 1 fi + if [ "${{ needs.wallet-sdk-scripts-e2e-multi-sync.result }}" != "success" ]; then + echo "wallet-sdk scripts e2e (multi-sync) did not succeed" + exit 1 + fi if [ "${{ needs.wallet-sdk-pkg.result }}" != "success" ]; then echo "wallet-sdk package validation did not succeed" exit 1 diff --git a/.github/workflows/examples-under-stress.yml b/.github/workflows/examples-under-stress.yml index 6de090880..32c555f46 100644 --- a/.github/workflows/examples-under-stress.yml +++ b/.github/workflows/examples-under-stress.yml @@ -88,7 +88,7 @@ jobs: run: yarn script:test:examples-stress - uses: ./.github/actions/check_resources - + # TODO (#1721): make multi-sync the default and remove the flag once multi-sync is fully supported and tested in the main scripts e2e tests, but for now we want to keep it as an option to avoid accidentally running multi-sync e2e tests without updating the main scripts e2e tests to cover multi-sync as well - name: Stop localnet (${{ github.event.inputs.network || 'devnet' }}) if: always() run: yarn stop:localnet -- --network=${{ github.event.inputs.network || 'devnet' }} diff --git a/.github/workflows/stress-tests.yml b/.github/workflows/stress-tests.yml index 04d18ee28..6413cc968 100644 --- a/.github/workflows/stress-tests.yml +++ b/.github/workflows/stress-tests.yml @@ -65,7 +65,7 @@ jobs: run: yarn script:test:stress-scripts - uses: ./.github/actions/check_resources - + # TODO (#1721): make multi-sync the default and remove the flag once multi-sync is fully supported and tested in the main scripts e2e tests, but for now we want to keep it as an option to avoid accidentally running multi-sync e2e tests without updating the main scripts e2e tests to cover multi-sync as well - name: Stop localnet (${{ 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 index d3731c3c1..5a9f45411 100644 --- a/canton/multi-sync/app-synchronizer.sc +++ b/canton/multi-sync/app-synchronizer.sc @@ -10,60 +10,60 @@ bootstrap.synchronizer( 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. +// Connect app-user and app-provider to the new synchronizer. +// app-user — global + app-synchronizer +// app-provider — global + app-synchronizer +// sv — global only (TradingApp is only an observer of Token Allocations; +// it learns about them when they are reassigned to global before settlement) +// +// The global domain is connected first (before this bootstrap script runs), +// so connectedSynchronizers[0] remains global for all participants — the +// default synchronizer selection is unaffected. `app-provider`.synchronizers.connect_local(`app-sequencer`, "app-synchronizer") +`app-user`.synchronizers.connect_local(`app-sequencer`, "app-synchronizer") -// Wait for app-provider to be active on app-synchronizer +// Wait for both participants to be active on app-synchronizer utils.retry_until_true { `app-provider`.synchronizers.active("app-synchronizer") } +utils.retry_until_true { + `app-user`.synchronizers.active("app-synchronizer") +} -// 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 +// Vet packages on app-synchronizer for all three participants. +// The Splice app already uploaded DARs and vetted them on global-domain. +// We replicate the vetting from the authorized store to app-synchronizer +// so that the synchronizer is fully functional. +val appSyncId = `app-provider`.synchronizers.list_connected() .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) +for (participant <- Seq(`app-provider`, `app-user`)) { + val vettedFromAuthorized = participant.topology.vetted_packages + .list(store = Some(TopologyStoreId.Authorized), filterParticipant = participant.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, -) + if (vettedFromAuthorized.nonEmpty) { + logger.info(s"Vetting ${vettedFromAuthorized.size} packages on app-synchronizer for ${participant.name}") + participant.topology.vetted_packages.propose_delta( + participant = participant.id, + store = appSyncId, + adds = vettedFromAuthorized.toSeq, + ) + } +} -// Wait for vetting to propagate on app-synchronizer +// Wait for vetting topology to propagate for app-provider and app-user 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 } +utils.retry_until_true { + val userVetted = `app-user`.topology.vetted_packages + .list(store = Some(appSyncId), filterParticipant = `app-user`.id.filterString) + userVetted.nonEmpty && userVetted.head.item.packages.nonEmpty +} -logger.info("app-synchronizer bootstrap with package vetting completed successfully") +logger.info("app-synchronizer bootstrap with package vetting completed successfully for app-provider and app-user") diff --git a/core/ledger-client/src/ledger-client.ts b/core/ledger-client/src/ledger-client.ts index 322b7c6e4..99417c55f 100644 --- a/core/ledger-client/src/ledger-client.ts +++ b/core/ledger-client/src/ledger-client.ts @@ -795,26 +795,6 @@ export class LedgerClient { return filter } - // Retrieve an (arbitrary) synchronizer id from the validator. - // 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]) { - throw new Error('No connected synchronizers found') - } - const synchronizerId = response.connectedSynchronizers[0].synchronizerId - if (response.connectedSynchronizers.length > 1) { - this.logger.warn( - `Found ${response.connectedSynchronizers.length} synchronizers, defaulting to ${synchronizerId}` - ) - } - this.synchronizerId = synchronizerId - return synchronizerId - } - public async postWithRetry( path: Path, body: PostRequest, diff --git a/core/wallet-ui-components/vite.config.ts.timestamp-1779195374723-99e90ad54c1fc.mjs b/core/wallet-ui-components/vite.config.ts.timestamp-1779195374723-99e90ad54c1fc.mjs new file mode 100644 index 000000000..b034523af --- /dev/null +++ b/core/wallet-ui-components/vite.config.ts.timestamp-1779195374723-99e90ad54c1fc.mjs @@ -0,0 +1,26 @@ +// Copyright (c) 2025-2026 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { defineConfig } from 'file:///mnt/extra-ssd/jarek/dev/splice-wallet-kernel/.yarn/__virtual__/vite-virtual-28a4e6acbc/6/home/jarek/.yarn/berry/cache/vite-npm-7.3.2-20decd81df-10c0.zip/node_modules/vite/dist/node/index.js' +import dts from 'file:///mnt/extra-ssd/jarek/dev/splice-wallet-kernel/.yarn/__virtual__/vite-plugin-dts-virtual-dcaad0a523/6/home/jarek/.yarn/berry/cache/vite-plugin-dts-npm-4.5.4-2445647687-10c0.zip/node_modules/vite-plugin-dts/dist/index.mjs' +var vite_config_default = defineConfig({ + build: { + emptyOutDir: false, + lib: { + entry: 'src/index.ts', + formats: ['es', 'cjs'], + fileName: (format) => (format === 'cjs' ? 'index.cjs' : 'index.js'), + cssFileName: 'index', + }, + rollupOptions: { + external: ['lit', 'bootstrap', '@popperjs/core'], + output: { + exports: 'auto', + }, + }, + sourcemap: true, + }, + plugins: [dts()], +}) +export { vite_config_default as default } +//# sourceMappingURL=data:application/json;base64,ewogICJ2ZXJzaW9uIjogMywKICAic291cmNlcyI6IFsidml0ZS5jb25maWcudHMiXSwKICAic291cmNlUm9vdCI6ICJmaWxlOi8vL21udC9leHRyYS1zc2QvamFyZWsvZGV2L3NwbGljZS13YWxsZXQta2VybmVsL2NvcmUvd2FsbGV0LXVpLWNvbXBvbmVudHMvIiwKICAic291cmNlc0NvbnRlbnQiOiBbImNvbnN0IF9fdml0ZV9pbmplY3RlZF9vcmlnaW5hbF9kaXJuYW1lID0gXCIvbW50L2V4dHJhLXNzZC9qYXJlay9kZXYvc3BsaWNlLXdhbGxldC1rZXJuZWwvY29yZS93YWxsZXQtdWktY29tcG9uZW50c1wiO2NvbnN0IF9fdml0ZV9pbmplY3RlZF9vcmlnaW5hbF9maWxlbmFtZSA9IFwiL21udC9leHRyYS1zc2QvamFyZWsvZGV2L3NwbGljZS13YWxsZXQta2VybmVsL2NvcmUvd2FsbGV0LXVpLWNvbXBvbmVudHMvdml0ZS5jb25maWcudHNcIjtjb25zdCBfX3ZpdGVfaW5qZWN0ZWRfb3JpZ2luYWxfaW1wb3J0X21ldGFfdXJsID0gXCJmaWxlOi8vL21udC9leHRyYS1zc2QvamFyZWsvZGV2L3NwbGljZS13YWxsZXQta2VybmVsL2NvcmUvd2FsbGV0LXVpLWNvbXBvbmVudHMvdml0ZS5jb25maWcudHNcIjsvLyBDb3B5cmlnaHQgKGMpIDIwMjUtMjAyNiBEaWdpdGFsIEFzc2V0IChTd2l0emVybGFuZCkgR21iSCBhbmQvb3IgaXRzIGFmZmlsaWF0ZXMuIEFsbCByaWdodHMgcmVzZXJ2ZWQuXG4vLyBTUERYLUxpY2Vuc2UtSWRlbnRpZmllcjogQXBhY2hlLTIuMFxuXG5pbXBvcnQgeyBkZWZpbmVDb25maWcgfSBmcm9tICd2aXRlJ1xuaW1wb3J0IGR0cyBmcm9tICd2aXRlLXBsdWdpbi1kdHMnXG5cbmV4cG9ydCBkZWZhdWx0IGRlZmluZUNvbmZpZyh7XG4gICAgYnVpbGQ6IHtcbiAgICAgICAgZW1wdHlPdXREaXI6IGZhbHNlLFxuICAgICAgICBsaWI6IHtcbiAgICAgICAgICAgIGVudHJ5OiAnc3JjL2luZGV4LnRzJyxcbiAgICAgICAgICAgIGZvcm1hdHM6IFsnZXMnLCAnY2pzJ10sXG4gICAgICAgICAgICBmaWxlTmFtZTogKGZvcm1hdCkgPT4gKGZvcm1hdCA9PT0gJ2NqcycgPyAnaW5kZXguY2pzJyA6ICdpbmRleC5qcycpLFxuICAgICAgICAgICAgY3NzRmlsZU5hbWU6ICdpbmRleCcsXG4gICAgICAgIH0sXG4gICAgICAgIHJvbGx1cE9wdGlvbnM6IHtcbiAgICAgICAgICAgIGV4dGVybmFsOiBbJ2xpdCcsICdib290c3RyYXAnLCAnQHBvcHBlcmpzL2NvcmUnXSxcbiAgICAgICAgICAgIG91dHB1dDoge1xuICAgICAgICAgICAgICAgIGV4cG9ydHM6ICdhdXRvJyxcbiAgICAgICAgICAgIH0sXG4gICAgICAgIH0sXG4gICAgICAgIHNvdXJjZW1hcDogdHJ1ZSxcbiAgICB9LFxuICAgIHBsdWdpbnM6IFtkdHMoKV0sXG59KVxuIl0sCiAgIm1hcHBpbmdzIjogIjtBQUdBLFNBQVMsb0JBQW9CO0FBQzdCLE9BQU8sU0FBUztBQUVoQixJQUFPLHNCQUFRLGFBQWE7QUFBQSxFQUN4QixPQUFPO0FBQUEsSUFDSCxhQUFhO0FBQUEsSUFDYixLQUFLO0FBQUEsTUFDRCxPQUFPO0FBQUEsTUFDUCxTQUFTLENBQUMsTUFBTSxLQUFLO0FBQUEsTUFDckIsVUFBVSxDQUFDLFdBQVksV0FBVyxRQUFRLGNBQWM7QUFBQSxNQUN4RCxhQUFhO0FBQUEsSUFDakI7QUFBQSxJQUNBLGVBQWU7QUFBQSxNQUNYLFVBQVUsQ0FBQyxPQUFPLGFBQWEsZ0JBQWdCO0FBQUEsTUFDL0MsUUFBUTtBQUFBLFFBQ0osU0FBUztBQUFBLE1BQ2I7QUFBQSxJQUNKO0FBQUEsSUFDQSxXQUFXO0FBQUEsRUFDZjtBQUFBLEVBQ0EsU0FBUyxDQUFDLElBQUksQ0FBQztBQUNuQixDQUFDOyIsCiAgIm5hbWVzIjogW10KfQo= diff --git a/docs/wallet-integration-guide/examples/package.json b/docs/wallet-integration-guide/examples/package.json index 7dd63abc3..1abae4c6a 100644 --- a/docs/wallet-integration-guide/examples/package.json +++ b/docs/wallet-integration-guide/examples/package.json @@ -26,6 +26,7 @@ "run-12": "tsx ./scripts/12-subscribe-to-events.ts | pino-pretty", "run-13": "tsx ./scripts/13-rewards-for-deposits/index.ts | pino-pretty", "run-14": "tsx ./scripts/14-offline-signing.ts | pino-pretty", + "run-15": "tsx ./scripts/15-multi-sync/index.ts | pino-pretty", "stress-run-01": "tsx ./scripts/stress/01-merge-utxos.ts | pino-pretty", "stress-run-02": "tsx ./scripts/stress/02-merge-utxos-delegate.ts | pino-pretty" }, diff --git a/docs/wallet-integration-guide/examples/scripts/03-parties.ts b/docs/wallet-integration-guide/examples/scripts/03-parties.ts index 9d381333c..22015f8b7 100644 --- a/docs/wallet-integration-guide/examples/scripts/03-parties.ts +++ b/docs/wallet-integration-guide/examples/scripts/03-parties.ts @@ -19,9 +19,7 @@ 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, - }) + .create(partyKeys.publicKey, { partyHint }) .sign(partyKeys.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 8ed4aee00..e1cc1a960 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 @@ -16,7 +16,7 @@ const sdk = await SDK.create({ }) const allocatedParties = await Promise.all( - ['v1-12-alice', 'v1-12-bob'].map((partyHint) => { + ['v1-12-alice', 'v1-12-bob'].map(async (partyHint) => { const partyKeys = sdk.keys.generate() return sdk.party.external .create(partyKeys.publicKey, { @@ -56,6 +56,7 @@ const charlieKeys = sdk.keys.generate() const charlie = await sdk.party.external .create(charlieKeys.publicKey, { partyHint: 'v1-12-charlie', + confirmingParticipantEndpoints: participantEndpoints, }) .sign(charlieKeys.privateKey) @@ -113,6 +114,7 @@ const observingCharlieKeys = sdk.keys.generate() const observingCharlie = await sdk.party.external .create(observingCharlieKeys.publicKey, { partyHint: 'v1-12-observingCharlie', + observingParticipantEndpoints: participantEndpoints, }) .sign(observingCharlieKeys.privateKey) 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..8b02e46db --- /dev/null +++ b/docs/wallet-integration-guide/examples/scripts/15-multi-sync/README.md @@ -0,0 +1,21 @@ +# 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 1: Fetch localnet bundle (first time or after a Splice version update) +yarn script:fetch:localnet + +# Step 2: Start localnet in multi-sync mode +yarn start:localnet -- --multi-sync + +# 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 -- --multi-sync +``` diff --git a/docs/wallet-integration-guide/examples/scripts/15-multi-sync/_config.ts b/docs/wallet-integration-guide/examples/scripts/15-multi-sync/_config.ts new file mode 100644 index 000000000..e37146711 --- /dev/null +++ b/docs/wallet-integration-guide/examples/scripts/15-multi-sync/_config.ts @@ -0,0 +1,24 @@ +// Copyright (c) 2025-2026 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +/** + * Multi-synchronizer localnet participant configuration. + * + * Port layout (PARTICIPANT_JSON_API_PORT_SUFFIX = 975): + * 2975 — app-user (P1): global + app-synchronizer + * 3975 — app-provider (P2): global + app-synchronizer + * 4975 — sv (P3): global only + * + */ + +// bob-participant JSON API (3 + PARTICIPANT_JSON_API_PORT_SUFFIX 975) +export const LOCALNET_BOB_LEDGER_URL = new URL('http://localhost:3975') + +// trading-app-participant JSON API (4 + PARTICIPANT_JSON_API_PORT_SUFFIX 975) +export const LOCALNET_TRADING_APP_LEDGER_URL = new URL('http://localhost:4975') + +// Party hint labels used when allocating parties +export const PARTY_HINT_ALICE = 'Alice' +export const PARTY_HINT_BOB = 'Bob' +export const PARTY_HINT_TRADING_APP = 'TradingApp' +export const PARTY_HINT_TOKEN_ADMIN = 'TokenAdmin' 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..32b4bb96a --- /dev/null +++ b/docs/wallet-integration-guide/examples/scripts/15-multi-sync/_setup.ts @@ -0,0 +1,244 @@ +// 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, + SDK, + type SDKInterface, + type SDKContext, + 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 { + TOKEN_NAMESPACE_CONFIG, + TOKEN_PROVIDER_CONFIG_DEFAULT, + resolveGlobalSynchronizerId, +} from '../utils/index.js' +import type { SynchronizerMap } from '../utils/index.js' +import { + LOCALNET_BOB_LEDGER_URL, + LOCALNET_TRADING_APP_LEDGER_URL, + PARTY_HINT_ALICE, + PARTY_HINT_BOB, + PARTY_HINT_TRADING_APP, + PARTY_HINT_TOKEN_ADMIN, +} from './_config.js' + +export type PartyInfo = Omit< + GenerateTransactionResponse, + 'topologyTransactions' +> & { + topologyTransactions?: string[] | undefined + keyPair: KeyPair +} + +export interface MultiSyncSetup { + p1Sdk: SDKInterface<'token'> + p2Sdk: SDKInterface<'token'> + p3Sdk: SDKInterface<'token'> + p1SdkCtx: SDKContext + p2SdkCtx: SDKContext + p3SdkCtx: SDKContext + tokenNamespaceP1: TokenNamespace + tokenNamespaceP2: TokenNamespace + alice: PartyInfo + bob: PartyInfo + tradingApp: PartyInfo + tokenAdmin: PartyInfo + globalSynchronizerId: string + appSynchronizerId: string + synchronizers: SynchronizerMap + scanProxy: ScanProxyClient + amuletAdmin: string +} + +/** + * Bootstraps a fresh multi-synchronizer environment: + * - Creates SDK instances for P1 (app-user), P2 (app-provider), P3 (sv) + * - Discovers global + app synchronizer IDs from P1 + * - Allocates alice (P1), bob (P2), tradingApp (P3) on global synchronizer + * - Registers alice and bob 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 { + // Create three SDK instances — one per participant node + const [p1Sdk, p2Sdk, p3Sdk] = await Promise.all([ + SDK.create({ + auth: TOKEN_PROVIDER_CONFIG_DEFAULT, + ledgerClientUrl: localNetStaticConfig.LOCALNET_APP_USER_LEDGER_URL, + token: TOKEN_NAMESPACE_CONFIG, + }), + SDK.create({ + auth: TOKEN_PROVIDER_CONFIG_DEFAULT, + ledgerClientUrl: LOCALNET_BOB_LEDGER_URL, + token: TOKEN_NAMESPACE_CONFIG, + }), + SDK.create({ + auth: TOKEN_PROVIDER_CONFIG_DEFAULT, + ledgerClientUrl: LOCALNET_TRADING_APP_LEDGER_URL, + token: TOKEN_NAMESPACE_CONFIG, + }), + ]) + + const p1SdkCtx = (p1Sdk.ledger as unknown as { sdkContext: SDKContext }) + .sdkContext + const p2SdkCtx = (p2Sdk.ledger as unknown as { sdkContext: SDKContext }) + .sdkContext + const p3SdkCtx = (p3Sdk.ledger as unknown as { sdkContext: SDKContext }) + .sdkContext + + // Discover synchronizer IDs from P1 (they are topology-wide, not per-participant) + const connectedSyncResponse = + await p1Sdk.ledger.state.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 with --multi-sync to enable it.' + ) + + logger.info( + `Connected synchronizers: ${allSynchronizers.map((s) => s.synchronizerAlias).join(', ')}` + ) + logger.info( + `Synchronizer IDs — global: ${globalSynchronizerId}, app: ${appSynchronizerId}` + ) + + const synchronizers: SynchronizerMap = { + globalSynchronizerId, + appSynchronizerId, + } + + // Allocate parties: alice on P1, bob on P2, tradingApp on P3, tokenAdmin on P2 (all on global synchronizer) + // tokenAdmin is on P2 (not P3/sv) because it operates on app-synchronizer, and sv is global-only. + const aliceKey = p1Sdk.keys.generate() + const bobKey = p1Sdk.keys.generate() + const tradingAppKey = p1Sdk.keys.generate() + const tokenAdminKey = p2Sdk.keys.generate() + + const [ + allocatedAlice, + allocatedBob, + allocatedTradingApp, + allocatedTokenAdmin, + ] = await Promise.all([ + p1Sdk.party.external + .create(aliceKey.publicKey, { + partyHint: PARTY_HINT_ALICE, + synchronizerId: globalSynchronizerId, + }) + .sign(aliceKey.privateKey) + .execute(), + p2Sdk.party.external + .create(bobKey.publicKey, { + partyHint: PARTY_HINT_BOB, + synchronizerId: globalSynchronizerId, + }) + .sign(bobKey.privateKey) + .execute(), + p3Sdk.party.external + .create(tradingAppKey.publicKey, { + partyHint: PARTY_HINT_TRADING_APP, + synchronizerId: globalSynchronizerId, + }) + .sign(tradingAppKey.privateKey) + .execute(), + p2Sdk.party.external + .create(tokenAdminKey.publicKey, { + partyHint: PARTY_HINT_TOKEN_ADMIN, + synchronizerId: globalSynchronizerId, + }) + .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 — alice: ${alice.partyId} (P1), bob: ${bob.partyId} (P2), tradingApp: ${tradingApp.partyId} (P3), tokenAdmin: ${tokenAdmin.partyId} (P2)` + ) + + // Register Alice, Bob, and TokenAdmin on app-synchronizer so they can transact there. + await Promise.all([ + p1Sdk.party.external + .create(alice.keyPair.publicKey, { + partyHint: alice.partyId.split('::')[0], + synchronizerId: appSynchronizerId, + }) + .sign(alice.keyPair.privateKey) + .execute({ grantUserRights: false }), + p2Sdk.party.external + .create(bob.keyPair.publicKey, { + partyHint: bob.partyId.split('::')[0], + synchronizerId: appSynchronizerId, + }) + .sign(bob.keyPair.privateKey) + .execute({ grantUserRights: false }), + // tokenAdmin must be registered via P2 (app-provider) — sv (P3) is global-only + p2Sdk.party.external + .create(tokenAdmin.keyPair.publicKey, { + partyHint: tokenAdmin.partyId.split('::')[0], + synchronizerId: appSynchronizerId, + }) + .sign(tokenAdmin.keyPair.privateKey) + .execute({ grantUserRights: false }), + ]) + logger.info('Alice, Bob, and TokenAdmin registered on app-synchronizer') + + // Connect scan proxy and discover Amulet admin + 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 { + p1Sdk, + p2Sdk, + p3Sdk, + p1SdkCtx, + p2SdkCtx, + p3SdkCtx, + tokenNamespaceP1: p1Sdk.token, + tokenNamespaceP2: p2Sdk.token, + alice, + bob, + tradingApp, + tokenAdmin, + globalSynchronizerId, + appSynchronizerId, + synchronizers, + scanProxy, + amuletAdmin, + } +} diff --git a/docs/wallet-integration-guide/examples/scripts/15-multi-sync/_trade_ops.ts b/docs/wallet-integration-guide/examples/scripts/15-multi-sync/_trade_ops.ts new file mode 100644 index 000000000..39ace2ea7 --- /dev/null +++ b/docs/wallet-integration-guide/examples/scripts/15-multi-sync/_trade_ops.ts @@ -0,0 +1,863 @@ +// 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 { ContractSpec } from '../utils/index.js' +import type { MultiSyncSetup } from './_setup.js' +import { + PARTY_HINT_ALICE, + PARTY_HINT_BOB, + PARTY_HINT_TRADING_APP, + PARTY_HINT_TOKEN_ADMIN, +} from './_config.js' + +// ── ACS contract entry (as returned by ledger.acs.read) ─────────────────────── + +interface AcsContractEntry { + contractId: string + templateId: string + createdEventBlob?: string + synchronizerId: string +} + +// ── Template / interface identifiers ───────────────────────────────────────── + +export const AMULET_TEMPLATE_ID = '#splice-amulet:Splice.Amulet:Amulet' +export const TEST_TOKEN_PREFIX = + '#splice-test-token-v1:Splice.Testing.Tokens.TestTokenV1' +export const TRADING_APP_PREFIX = + '#splice-token-test-trading-app:Splice.Testing.Apps.TradingApp' + +const TRANSFER_FACTORY_IFACE = + '#splice-api-token-transfer-instruction-v1:Splice.Api.Token.TransferInstructionV1:TransferFactory' +export function buildContractReadSpec(setup: MultiSyncSetup): ContractSpec[] { + const { p1Sdk, p2Sdk, p3Sdk, alice, bob, tradingApp, tokenAdmin } = setup + return [ + { + label: PARTY_HINT_ALICE, + sdk: p1Sdk, + templateIds: [ + AMULET_TEMPLATE_ID, + `${TEST_TOKEN_PREFIX}:Token`, + `${TRADING_APP_PREFIX}:OTCTradeProposal`, + `${TRADING_APP_PREFIX}:OTCTrade`, + ], + parties: [alice.partyId], + }, + { + label: PARTY_HINT_BOB, + sdk: p2Sdk, + templateIds: [AMULET_TEMPLATE_ID, `${TEST_TOKEN_PREFIX}:Token`], + parties: [bob.partyId], + }, + { + label: PARTY_HINT_TOKEN_ADMIN, + sdk: p2Sdk, + templateIds: [`${TEST_TOKEN_PREFIX}:TokenRules`], + parties: [tokenAdmin.partyId], + }, + { + label: PARTY_HINT_TRADING_APP, + sdk: p3Sdk, + templateIds: [ + `${TRADING_APP_PREFIX}:OTCTradeProposal`, + `${TRADING_APP_PREFIX}:OTCTrade`, + ], + parties: [tradingApp.partyId], + }, + ] +} + +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' + +const MS_30_MIN = 30 * 60 * 1000 +const MS_1_HOUR = 60 * 60 * 1000 +const MS_24_HOURS = 24 * 60 * 60 * 1000 + +export async function mintAmuletForAlice( + setup: MultiSyncSetup, + logger: Logger +): Promise { + const { p1Sdk, alice, globalSynchronizerId, scanProxy } = setup + const [amuletRulesContract, activeRoundContract] = await Promise.all([ + scanProxy.getAmuletRules(), + scanProxy.getActiveOpenMiningRound(), + ]) + if (!activeRoundContract) throw new Error('No active OpenMiningRound found') + const amuletRulesCid = amuletRulesContract.contract_id + const openMiningRoundCid = activeRoundContract.contract_id + + await p1Sdk.ledger + .prepare({ + partyId: alice.partyId, + commands: [ + { + ExerciseCommand: { + templateId: + '#splice-amulet:Splice.AmuletRules:AmuletRules', + contractId: amuletRulesCid, + choice: 'AmuletRules_DevNet_Tap', + choiceArgument: { + receiver: alice.partyId, + amount: ALICE_AMULET_TAP_AMOUNT, + openRound: openMiningRoundCid, + }, + }, + }, + ], + disclosedContracts: [ + { + templateId: amuletRulesContract.template_id, + contractId: amuletRulesCid, + createdEventBlob: amuletRulesContract.created_event_blob, + synchronizerId: globalSynchronizerId, + }, + { + templateId: activeRoundContract.template_id, + contractId: openMiningRoundCid, + createdEventBlob: activeRoundContract.created_event_blob, + synchronizerId: globalSynchronizerId, + }, + ], + 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 createTokenRulesAndMintForBob( + setup: MultiSyncSetup, + logger: Logger +): Promise { + const { p2Sdk, bob, tokenAdmin, globalSynchronizerId, appSynchronizerId } = + setup + + // tokenAdmin is hosted on P2; use p2Sdk for all tokenAdmin submissions + await Promise.all([ + p2Sdk.ledger + .prepare({ + partyId: tokenAdmin.partyId, + commands: { + CreateCommand: { + templateId: `${TEST_TOKEN_PREFIX}:TokenRules`, + createArguments: { admin: tokenAdmin.partyId }, + }, + }, + disclosedContracts: [], + synchronizerId: globalSynchronizerId, + }) + .sign(tokenAdmin.keyPair.privateKey) + .execute({ partyId: tokenAdmin.partyId }), + p2Sdk.ledger + .prepare({ + partyId: tokenAdmin.partyId, + commands: { + CreateCommand: { + templateId: `${TEST_TOKEN_PREFIX}:TokenRules`, + createArguments: { admin: tokenAdmin.partyId }, + }, + }, + disclosedContracts: [], + synchronizerId: appSynchronizerId, + }) + .sign(tokenAdmin.keyPair.privateKey) + .execute({ partyId: tokenAdmin.partyId }), + ]) + + // Mint Token on app-synchronizer via P2 (sv/P3 is global-only) + await p2Sdk.ledger + .prepare({ + partyId: tokenAdmin.partyId, + commands: [ + { + CreateCommand: { + templateId: `${TEST_TOKEN_PREFIX}:Token`, + createArguments: { + holding: { + owner: tokenAdmin.partyId, + instrumentId: { + admin: tokenAdmin.partyId, + id: 'TestToken', + }, + amount: BOB_TOKEN_MINT_AMOUNT, + lock: null, + meta: { values: {} }, + }, + }, + }, + }, + ], + disclosedContracts: [], + synchronizerId: appSynchronizerId, + }) + .sign(tokenAdmin.keyPair.privateKey) + .execute({ partyId: tokenAdmin.partyId }) + + // Read tokenAdmin's contracts via P2 (P2 is connected to both synchronizers) + const [tokenRulesContracts, adminTokenHoldings] = await Promise.all([ + p2Sdk.ledger.acs.read({ + templateIds: [`${TEST_TOKEN_PREFIX}:TokenRules`], + parties: [tokenAdmin.partyId], + filterByParty: true, + }), + p2Sdk.ledger.acs.read({ + templateIds: [`${TEST_TOKEN_PREFIX}:Token`], + parties: [tokenAdmin.partyId], + filterByParty: true, + }), + ]) + const appTokenRules = tokenRulesContracts.find( + (c) => c.synchronizerId === appSynchronizerId + ) + if (!appTokenRules) + throw new Error( + 'TokenRules not found on app synchronizer after creation' + ) + const adminTokenCid = adminTokenHoldings[0]?.contractId + if (!adminTokenCid) + throw new Error('TokenAdmin Token holding not found after mint') + + // Transfer Token to Bob on app-synchronizer via P2 (sv/P3 is global-only) + await p2Sdk.ledger + .prepare({ + partyId: tokenAdmin.partyId, + commands: [ + { + ExerciseCommand: { + templateId: TRANSFER_FACTORY_IFACE, + contractId: appTokenRules.contractId, + choice: 'TransferFactory_Transfer', + choiceArgument: { + expectedAdmin: tokenAdmin.partyId, + transfer: { + sender: tokenAdmin.partyId, + receiver: bob.partyId, + amount: BOB_TOKEN_MINT_AMOUNT, + instrumentId: { + admin: tokenAdmin.partyId, + id: 'TestToken', + }, + requestedAt: new Date(Date.now()).toISOString(), + executeBefore: new Date( + Date.now() + MS_24_HOURS + ).toISOString(), + inputHoldingCids: [adminTokenCid], + meta: { values: {} }, + }, + extraArgs: { + context: { values: {} }, + meta: { values: {} }, + }, + }, + }, + }, + ], + disclosedContracts: [], + synchronizerId: appSynchronizerId, + }) + .sign(tokenAdmin.keyPair.privateKey) + .execute({ partyId: tokenAdmin.partyId }) + + const transferOffers = await p2Sdk.ledger.acs.read({ + templateIds: [`${TEST_TOKEN_PREFIX}:TokenTransferOffer`], + parties: [bob.partyId], + filterByParty: true, + }) + const transferOfferCid = transferOffers[0]?.contractId + if (!transferOfferCid) + throw new Error('TokenTransferOffer not found for Bob') + + const transferInstructionIface = + '#splice-api-token-transfer-instruction-v1:Splice.Api.Token.TransferInstructionV1:TransferInstruction' + await p2Sdk.ledger + .prepare({ + partyId: bob.partyId, + commands: [ + { + ExerciseCommand: { + templateId: transferInstructionIface, + contractId: transferOfferCid, + choice: 'TransferInstruction_Accept', + choiceArgument: { + extraArgs: { + context: { values: {} }, + meta: { values: {} }, + }, + }, + }, + }, + ], + disclosedContracts: [], + 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` + ) +} + +export async function createAndInitiateOtcTrade( + setup: MultiSyncSetup, + transferLegs: Record, + logger: Logger +): Promise { + const { + p1Sdk, + p2Sdk, + p3Sdk, + alice, + bob, + tradingApp, + globalSynchronizerId, + } = setup + + const readProposalCid = async ( + sdk: typeof p1Sdk, + party: string + ): Promise => { + const contracts = await sdk.ledger.acs.read({ + templateIds: [`${TRADING_APP_PREFIX}:OTCTradeProposal`], + parties: [party], + filterByParty: true, + }) + if (!contracts.length) throw new Error('OTCTradeProposal not found') + return contracts[0].contractId + } + + await p1Sdk.ledger + .prepare({ + partyId: alice.partyId, + commands: { + CreateCommand: { + templateId: `${TRADING_APP_PREFIX}:OTCTradeProposal`, + createArguments: { + venue: tradingApp.partyId, + tradeCid: null, + 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 p2Sdk.ledger + .prepare({ + partyId: bob.partyId, + commands: [ + { + ExerciseCommand: { + templateId: `${TRADING_APP_PREFIX}:OTCTradeProposal`, + contractId: await readProposalCid(p2Sdk, bob.partyId), + choice: 'OTCTradeProposal_Accept', + choiceArgument: { 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 p3Sdk.ledger + .prepare({ + partyId: tradingApp.partyId, + commands: [ + { + ExerciseCommand: { + templateId: `${TRADING_APP_PREFIX}:OTCTradeProposal`, + contractId: await readProposalCid( + p3Sdk, + tradingApp.partyId + ), + choice: 'OTCTradeProposal_InitiateSettlement', + choiceArgument: { prepareUntil, settleBefore }, + }, + }, + ], + disclosedContracts: [], + synchronizerId: globalSynchronizerId, + }) + .sign(tradingApp.keyPair.privateKey) + .execute({ partyId: tradingApp.partyId }) + logger.info( + 'TradingApp: OTCTradeProposal_InitiateSettlement executed → OTCTrade created' + ) + + const otcTradeContracts = await p3Sdk.ledger.acs.read({ + templateIds: [`${TRADING_APP_PREFIX}:OTCTrade`], + parties: [tradingApp.partyId], + filterByParty: true, + }) + const otcTradeCid = otcTradeContracts[0]?.contractId + if (!otcTradeCid) + throw new Error('OTCTrade contract not found after initiation') + return otcTradeCid +} + +export async function allocateAmuletForAlice( + setup: MultiSyncSetup, + logger: Logger +): Promise { + const { + p1Sdk, + tokenNamespaceP1: tokenNamespaceP1, + alice, + globalSynchronizerId, + amuletAdmin, + } = setup + + const pendingRequests = await tokenNamespaceP1.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 p1Sdk.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 tokenNamespaceP1.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.href, + admin: amuletAdmin, + }, + inputUtxos: [amuletHoldingCid], + requestedAt: new Date().toISOString(), + }) + + await p1Sdk.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 +} + +export async function allocateTokenForBob( + setup: MultiSyncSetup, + logger: Logger +): Promise<{ legId: string }> { + const { p2Sdk, tokenNamespaceP2, bob, tokenAdmin, globalSynchronizerId } = + setup + + const pendingRequests = await tokenNamespaceP2.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, tokenRulesContracts] = await Promise.all([ + p2Sdk.ledger.acs.read({ + templateIds: [`${TEST_TOKEN_PREFIX}:Token`], + parties: [bob.partyId], + filterByParty: true, + }), + // Read tokenAdmin's TokenRules via P2 (P2 is connected to both synchronizers) + p2Sdk.ledger.acs.read({ + templateIds: [`${TEST_TOKEN_PREFIX}:TokenRules`], + parties: [tokenAdmin.partyId], + filterByParty: true, + }), + ]) + + const tokenHolding = tokenHoldings[0] + if (!tokenHolding) throw new Error('Token holding not found for Bob') + const tokenRulesOnGlobal = tokenRulesContracts.find( + (c) => c.synchronizerId === globalSynchronizerId + ) + if (!tokenRulesOnGlobal) + throw new Error('TokenRules not found on global synchronizer') + + // Explicitly reassign Bob's token from app-synchronizer to global before allocation. + // Canton requires the submitter to be a stakeholder of a contract already on the + // target synchronizer (SUBMITTER_ALWAYS_STAKEHOLDER policy). Without this step, + // Bob has no contracts on global, so the allocation submission would be rejected. + if (tokenHolding.synchronizerId !== globalSynchronizerId) { + await p2Sdk.ledger.internal.reassign({ + submitter: bob.partyId, + contractId: tokenHolding.contractId, + source: tokenHolding.synchronizerId, + target: globalSynchronizerId, + }) + } + + const [command, disclosedFromHelper] = + await tokenNamespaceP2.allocation.instruction.create({ + allocationSpecification: { + settlement: requestView.settlement, + transferLegId: legId, + transferLeg: requestView.transferLegs[legId], + }, + asset: { + id: 'TestToken', + displayName: 'TestToken', + symbol: 'TT', + registryUrl: 'http://unused.invalid', + admin: tokenAdmin.partyId, + }, + inputUtxos: [tokenHolding.contractId], + requestedAt: new Date(Date.now()).toISOString(), + prefetchedRegistryChoiceContext: { + factoryId: tokenRulesOnGlobal.contractId, + choiceContext: { + choiceContextData: {} as Record, + disclosedContracts: [], + }, + }, + }) + + await p2Sdk.ledger + .prepare({ + partyId: bob.partyId, + commands: [command], + disclosedContracts: [ + ...disclosedFromHelper, + { + templateId: tokenRulesOnGlobal.templateId, + contractId: tokenRulesOnGlobal.contractId, + createdEventBlob: tokenRulesOnGlobal.createdEventBlob!, + synchronizerId: tokenRulesOnGlobal.synchronizerId, + }, + ], + synchronizerId: globalSynchronizerId, + }) + .sign(bob.keyPair.privateKey) + .execute({ partyId: bob.partyId }) + + logger.info( + 'Bob: TestToken allocated for leg-1 (global synchronizer, single-party)' + ) + return { legId } +} + +export interface SettleParams { + otcTradeCid: string + legIdAlice: string + legIdBob: string + testTokenAllocationCid: string +} + +export async function settleOtcTrade( + setup: MultiSyncSetup, + params: SettleParams, + logger: Logger +): Promise { + const { + p3Sdk, + tokenNamespaceP1: tokenNamespaceP1, + alice, + tradingApp, + globalSynchronizerId, + } = setup + const { otcTradeCid, legIdAlice, legIdBob, testTokenAllocationCid } = params + + const allocationsAlice = await tokenNamespaceP1.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 tokenNamespaceP1.allocation.context.execute({ + allocationCid: amuletAllocation.contractId, + registryUrl: localNetStaticConfig.LOCALNET_REGISTRY_API_URL, + }) + + 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: { values: {} }, meta: { values: {} } }, + }, + } + + const disclosedContracts = (amuletExecCtx.disclosedContracts ?? []).map( + (c) => ({ ...c, synchronizerId: '' }) + ) + + await p3Sdk.ledger + .prepare({ + partyId: tradingApp.partyId, + commands: [ + { + ExerciseCommand: { + templateId: `${TRADING_APP_PREFIX}:OTCTrade`, + contractId: otcTradeCid, + choice: 'OTCTrade_Settle', + choiceArgument: { allocationsWithContext }, + }, + }, + ], + disclosedContracts, + synchronizerId: globalSynchronizerId, + }) + .sign(tradingApp.keyPair.privateKey) + .execute({ partyId: tradingApp.partyId }) + + logger.info( + `TradingApp: OTCTrade settled — ${TRADE_AMULET_AMOUNT} Amulet transferred to Bob, ${TRADE_TOKEN_AMOUNT} TestToken transferred to Alice` + ) +} + +export async function aliceSelfTransferToApp( + setup: MultiSyncSetup, + logger: Logger +): Promise { + const { p1Sdk, p2Sdk, alice, tokenAdmin, appSynchronizerId } = setup + + const [aliceTokens, tokenRulesContracts] = await Promise.all([ + p1Sdk.ledger.acs.read({ + templateIds: [`${TEST_TOKEN_PREFIX}:Token`], + parties: [alice.partyId], + filterByParty: true, + }), + // Read tokenAdmin's TokenRules via P2 (sv/P3 is global-only) + p2Sdk.ledger.acs.read({ + templateIds: [`${TEST_TOKEN_PREFIX}:TokenRules`], + parties: [tokenAdmin.partyId], + filterByParty: true, + }), + ]) + const aliceToken = aliceTokens[0] + if (!aliceToken) + throw new Error('Alice: Token holding not found after settlement') + const tokenRules = tokenRulesContracts.find( + (c) => c.synchronizerId === appSynchronizerId + ) + if (!tokenRules) throw new Error('TokenRules not found on app-synchronizer') + + // Reassign Alice's Token to app-synchronizer so she is a stakeholder there + // (required for SUBMITTER_ALWAYS_STAKEHOLDER rule on the target synchronizer) + if (aliceToken.synchronizerId !== appSynchronizerId) { + await p1Sdk.ledger.internal.reassign({ + submitter: alice.partyId, + contractId: aliceToken.contractId, + source: aliceToken.synchronizerId, + target: appSynchronizerId, + }) + } + + await p1Sdk.ledger + .prepare({ + partyId: alice.partyId, + commands: [ + { + ExerciseCommand: { + templateId: TRANSFER_FACTORY_IFACE, + contractId: tokenRules.contractId, + choice: 'TransferFactory_Transfer', + choiceArgument: { + expectedAdmin: tokenAdmin.partyId, + transfer: { + sender: alice.partyId, + receiver: alice.partyId, + amount: TRADE_TOKEN_AMOUNT, + instrumentId: { + admin: tokenAdmin.partyId, + id: 'TestToken', + }, + requestedAt: new Date(Date.now()).toISOString(), + executeBefore: new Date( + Date.now() + MS_24_HOURS + ).toISOString(), + inputHoldingCids: [aliceToken.contractId], + meta: { values: {} }, + }, + extraArgs: { + context: { values: {} }, + meta: { values: {} }, + }, + }, + }, + }, + ], + disclosedContracts: [ + { + templateId: tokenRules.templateId, + contractId: tokenRules.contractId, + createdEventBlob: tokenRules.createdEventBlob!, + synchronizerId: tokenRules.synchronizerId, + }, + // Alice's token is in her own ACS (she is a stakeholder) and has + // already been reassigned to app-synchronizer above, so no + // disclosure is needed for it. + ], + synchronizerId: appSynchronizerId, + }) + .sign(alice.keyPair.privateKey) + .execute({ partyId: alice.partyId }) + + // Mark as done — the TransferFactory_Transfer created a self-transfer offer (Alice→Alice) + logger.info( + `Alice: ${TRADE_TOKEN_AMOUNT} TestToken self-transferred on app-synchronizer` + ) +} + +export async function bobSelfTransferToApp( + setup: MultiSyncSetup, + logger: Logger +): Promise { + const { p2Sdk, bob, tokenAdmin, appSynchronizerId } = setup + + const [bobTokens, tokenRulesContracts] = await Promise.all([ + p2Sdk.ledger.acs.read({ + templateIds: [`${TEST_TOKEN_PREFIX}:Token`], + parties: [bob.partyId], + filterByParty: true, + }), + // Read tokenAdmin's TokenRules via P2 (sv/P3 is global-only) + p2Sdk.ledger.acs.read({ + templateIds: [`${TEST_TOKEN_PREFIX}:TokenRules`], + parties: [tokenAdmin.partyId], + filterByParty: true, + }), + ]) + + if (bobTokens.length === 0) { + logger.info('Bob: no TestToken holdings to self-transfer') + return + } + const tokenRules = tokenRulesContracts.find( + (c) => c.synchronizerId === appSynchronizerId + ) + if (!tokenRules) throw new Error('TokenRules not found on app-synchronizer') + + for (const token of bobTokens) { + const holdingAmount = ( + token as unknown as { + createArgument: { holding: { amount: string } } + } + ).createArgument?.holding?.amount + if (!holdingAmount) + throw new Error('Cannot read amount from Bob Token holding') + + // Reassign Bob's Token to app-synchronizer so he is a stakeholder there + if (token.synchronizerId !== appSynchronizerId) { + await p2Sdk.ledger.internal.reassign({ + submitter: bob.partyId, + contractId: token.contractId, + source: token.synchronizerId, + target: appSynchronizerId, + }) + } + + await p2Sdk.ledger + .prepare({ + partyId: bob.partyId, + commands: [ + { + ExerciseCommand: { + templateId: TRANSFER_FACTORY_IFACE, + contractId: tokenRules.contractId, + choice: 'TransferFactory_Transfer', + choiceArgument: { + expectedAdmin: tokenAdmin.partyId, + transfer: { + sender: bob.partyId, + receiver: bob.partyId, + amount: holdingAmount, + instrumentId: { + admin: tokenAdmin.partyId, + id: 'TestToken', + }, + requestedAt: new Date( + Date.now() + ).toISOString(), + executeBefore: new Date( + Date.now() + MS_24_HOURS + ).toISOString(), + inputHoldingCids: [token.contractId], + meta: { values: {} }, + }, + extraArgs: { + context: { values: {} }, + meta: { values: {} }, + }, + }, + }, + }, + ], + disclosedContracts: [ + { + templateId: tokenRules.templateId, + contractId: tokenRules.contractId, + createdEventBlob: tokenRules.createdEventBlob!, + synchronizerId: tokenRules.synchronizerId, + }, + // Bob's token is in his own ACS (he is a stakeholder) and has + // already been reassigned to app-synchronizer above, so no + // disclosure is needed for it. + ], + synchronizerId: appSynchronizerId, + }) + .sign(bob.keyPair.privateKey) + .execute({ partyId: bob.partyId }) + } + + logger.info( + `Bob: TestToken self-transferred on app-synchronizer ` + + `(Canton auto-reassigned Bob's Token from global → app)` + ) +} 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..4b0209b4f --- /dev/null +++ b/docs/wallet-integration-guide/examples/scripts/15-multi-sync/index.ts @@ -0,0 +1,102 @@ +import pino from 'pino' +import { logAllContracts } from '../utils/index.js' +import { setupMultiSyncTrade } from './_setup.js' +import { + TRADE_AMULET_AMOUNT, + TRADE_TOKEN_AMOUNT, + mintAmuletForAlice, + createTokenRulesAndMintForBob, + createAndInitiateOtcTrade, + allocateAmuletForAlice, + allocateTokenForBob, + settleOtcTrade, + aliceSelfTransferToApp, + bobSelfTransferToApp, + buildContractReadSpec, +} from './_trade_ops.js' + +// Multi-Synchronizer DvP: Alice pays 100 Amulet on global; Bob delivers 20 TestToken from app-sync. +// P1 = app-user (Alice), P2 = app-provider (Bob), P3 = sv (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 (P1, P2, P3) and discover global + app synchronizers +// Step 2: Vet DARs on both synchronizers for P1+P2; global only for P3 (sv is not connected to app-synchronizer) +// Step 3: Allocate parties for Alice (P1), Bob (P2), TradingApp (P3), and TokenAdmin (P3) +const setup = await setupMultiSyncTrade(logger) +const { tokenNamespaceP2, alice, bob, tokenAdmin, synchronizers, amuletAdmin } = + setup + +const allPartySpecs = buildContractReadSpec(setup) + +// ── 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, allPartySpecs) + +// ── 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, allPartySpecs) + +// ── 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, allPartySpecs) + +// ── Step 10a: Locate Bob's TestToken allocation ──────────────────────────────────── +const allocationsBob = await tokenNamespaceP2.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 + +// ── Step 10b: TradingApp settles the OTCTrade ───────────────────────────────── +await settleOtcTrade( + setup, + { otcTradeCid, legIdAlice, legIdBob, testTokenAllocationCid }, + logger +) +logger.info('Contracts after settlement:') +await logAllContracts(logger, synchronizers, allPartySpecs) + +// ── 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, allPartySpecs) 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..f27c97975 --- /dev/null +++ b/docs/wallet-integration-guide/examples/scripts/utils/acs-logger.ts @@ -0,0 +1,162 @@ +// 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 = { + label: string + sdk: SDKInterface + templateIds: string[] + 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, templateIds, parties }) => + sdk.ledger.acs.read({ templateIds, parties, filterByParty: true }) + ) + ) + + type Row = { + label: string + template: string + amount: string + cid: string + sync: string + } + const rows: Row[] = [] + const seenCids = new Set() + + const isHolding = (template: string): boolean => + template === 'Token' || template === 'Amulet' + + for (let i = 0; i < specs.length; i++) { + const { label } = specs[i] + const contracts = results[i] + if (contracts.length === 0) { + rows.push({ + label, + 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) + // For Token/Amulet rows, replace the participant label with the + // holding owner so the table reflects who actually owns the asset + // (not just whose ACS the contract appears in via signatory rules). + const rowLabel = isHolding(template) + ? shortenParty(extractOwner(c.createArgument)) || label + : label + 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/dar.ts b/docs/wallet-integration-guide/examples/scripts/utils/dar.ts new file mode 100644 index 000000000..cb4b38007 --- /dev/null +++ b/docs/wallet-integration-guide/examples/scripts/utils/dar.ts @@ -0,0 +1,22 @@ +// Copyright (c) 2025-2026 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +// TODO: replace this function with the usage of built-in upload() function after the latter one +// is fixed to support vetting of uploaded package on multiple synchronizers (currently it only vets on the default synchronizer, which is not sufficient for multi-synchronizer setups) +export async function vetDar( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ledgerProvider: any, + darBytes: Uint8Array | Buffer, + synchronizerId: string +): Promise { + await ledgerProvider.request({ + method: 'ledgerApi', + params: { + resource: '/v2/packages', + requestMethod: 'post', + query: { synchronizerId, vetAllPackages: true }, + body: darBytes, + headers: { 'Content-Type': 'application/octet-stream' }, + }, + }) +} diff --git a/docs/wallet-integration-guide/examples/scripts/utils/index.ts b/docs/wallet-integration-guide/examples/scripts/utils/index.ts index 1e15c1eee..acc59421e 100644 --- a/docs/wallet-integration-guide/examples/scripts/utils/index.ts +++ b/docs/wallet-integration-guide/examples/scripts/utils/index.ts @@ -9,12 +9,40 @@ import { AssetConfig, } from '@canton-network/wallet-sdk' +export { vetDar } from './dar.js' +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') ?? + synchronizers[0] + if (!global) throw new Error('No connected synchronizers found') + return global.synchronizerId +} + export const TOKEN_PROVIDER_CONFIG_DEFAULT: TokenProviderConfig = { method: 'self_signed', issuer: 'unsafe-auth', diff --git a/examples/ping/playwright.config.ts b/examples/ping/playwright.config.ts index d7f4d76bc..c911f56d8 100644 --- a/examples/ping/playwright.config.ts +++ b/examples/ping/playwright.config.ts @@ -31,7 +31,7 @@ export default defineConfig({ /* Opt out of parallel tests on CI. */ workers: 1, /* Reporter to use. See https://playwright.dev/docs/test-reporters */ - reporter: 'html', + reporter: process.env.CI ? 'html' : 'line', /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ use: { /* Base URL to use in actions like `await page.goto('/')`. */ diff --git a/examples/portfolio/playwright.config.ts b/examples/portfolio/playwright.config.ts index 2fd2fe5cd..a30b997da 100644 --- a/examples/portfolio/playwright.config.ts +++ b/examples/portfolio/playwright.config.ts @@ -25,7 +25,7 @@ export default defineConfig({ /* Opt out of parallel tests on CI. */ workers: 1, /* Reporter to use. See https://playwright.dev/docs/test-reporters */ - reporter: 'html', + reporter: process.env.CI ? 'html' : 'line', /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ use: { /* Base URL to use in actions like `await page.goto('/')`. */ diff --git a/package.json b/package.json index 058020791..25dcd0f60 100644 --- a/package.json +++ b/package.json @@ -38,6 +38,7 @@ "script:openrpc:titles": "tsx ./scripts/src/schema-title-validation.ts", "script:validate:package": "tsx ./scripts/src/package-and-verify-wallet-sdk.ts", "script:test:examples": "yarn node --trace-uncaught --enable-source-maps --import tsx ./scripts/src/test-example-scripts.ts", + "script:test:examples:multi-sync": "yarn node --trace-uncaught --enable-source-maps --import tsx ./scripts/src/test-multi-sync-scripts.ts", "script:test:examples-stress": "tsx ./scripts/src/test-examples-scripts-under-stress.ts", "script:test:stress-scripts": "tsx ./scripts/src/test-stress-scripts.ts", "script:release": "tsx ./scripts/src/release.ts", diff --git a/post-rebase-fixes.md b/post-rebase-fixes.md new file mode 100644 index 000000000..0471c24f2 --- /dev/null +++ b/post-rebase-fixes.md @@ -0,0 +1,100 @@ +# Post-Rebase Fix Report + +Branch: `jarekr/sdk_synchronizers` (merge of `wiktor/multisync-example`) +HEAD: `e1929f6c` + +--- + +## Summary + +After resolving rebase conflicts between `jarekr/sdk_synchronizers` and `wiktor/multisync-example`, six distinct bugs remained in the merged code. All were fixed. All 46 packages now compile cleanly, scripts 01–14 all pass, and the new multi-sync script 15 passes end-to-end. + +--- + +## Fixes + +### Fix 1 — Stale PnP manifest (missing `@canton-network/core-provider-dapp`) + +`sdk/wallet-sdk/package.json` had a new dependency added in wiktor's branch but `yarn install` was not run after the merge. The PnP manifest was stale and could not resolve the module at build time. + +**Fix:** Ran `yarn install` to regenerate `.pnp.cjs`. + +--- + +### Fix 2 — Stale re-export in `sdk.ts` (`resolveGlobalSynchronizerId`) + +**File:** `sdk/wallet-sdk/src/wallet/sdk.ts` + +Wiktor's commit `c76aa8b4` removed `resolveGlobalSynchronizerId` from `common.ts` and moved its logic into `State.globalSynchronizerId()`. During rebase conflict resolution the old `export { resolveGlobalSynchronizerId }` was incorrectly kept, causing a compile error. + +**Fix:** Removed the stale export line. + +--- + +### Fix 3 — Dead import in `utils/index.ts` (`resolveGlobalSynchronizerId`) + +**File:** `docs/wallet-integration-guide/examples/scripts/utils/index.ts` + +`getGlobalSynchronizerId` still imported and called `resolveGlobalSynchronizerId` from `@canton-network/wallet-sdk` (which was removed in wiktor's branch). The rebase left the old implementation. + +**Fix:** Removed the import; rewrote `getGlobalSynchronizerId` to delegate to `sdk.ledger.state.globalSynchronizerId()`. + +--- + +### Fix 4 — `TypeError: err.cause.includes is not a function` in `sdk.ts` + +**File:** `sdk/wallet-sdk/src/wallet/sdk.ts` + +A catch block in `SDK.create()` assumed `err.cause` is always a string and called `.includes()` on it directly. At runtime `err.cause` can be an `Error` object or `undefined`, causing a `TypeError` on every SDK instantiation when the ledger returns a non-auth error. + +**Fix:** Added runtime type guards — checks both `err.cause` and `err.message` via `typeof === 'string'` before joining and searching. + +--- + +### Fix 5 — `sv` participant (P3) used for `app-synchronizer` operations in script 15 + +**Files:** + +- `docs/wallet-integration-guide/examples/scripts/15-multi-sync/_setup.ts` +- `docs/wallet-integration-guide/examples/scripts/15-multi-sync/_trade_ops.ts` + +The canton bootstrap script (`canton/multi-sync/app-synchronizer.sc`) explicitly connects only `app-user` (P1) and `app-provider` (P2) to `app-synchronizer`. The `sv` participant (P3) is **global-only**. The merged test code routed all `tokenAdmin` operations through `p3Sdk`, causing `PACKAGE_SERVICE_NOT_CONNECTED_TO_SYNCHRONIZER`, `INVALID_ARGUMENT`, and `PERMISSION_DENIED` errors. + +**Fix:** + +- `_setup.ts`: Generate `tokenAdminKey` from `p2Sdk`; allocate `tokenAdmin` party via `p2Sdk`; register `tokenAdmin` on `app-synchronizer` via `p2Sdk`. +- `_trade_ops.ts`: Route all `tokenAdmin` ledger submissions and ACS reads to `p2Sdk`. P3 (`tradingApp`) operations remain on `global-domain` only. +- DAR vetting: P1+P2 vet on both synchronizers; P3 vets on global only. +- `buildContractReadSpec`: Use `p2Sdk` for `tokenAdmin`'s `TokenRules` reads. + +--- + +### Fix 6 — `SUBMITTER_ALWAYS_STAKEHOLDER` in self-transfer step (script 15, step 11) + +**File:** `docs/wallet-integration-guide/examples/scripts/15-multi-sync/_trade_ops.ts` + +After OTCTrade settlement, Alice's and Bob's TestToken holdings land on `global-domain`. Step 11 attempts to self-transfer them back to `app-synchronizer` via `TransferFactory_Transfer`. This failed because Canton requires the submitter to be a stakeholder on at least one contract **currently on the target synchronizer**. Since the tokens were still on global, neither Alice nor Bob had any contracts on `app-synchronizer` at submission time. + +**Fix:** Added explicit `ledger.internal.reassign` calls in `aliceSelfTransferToApp` and `bobSelfTransferToApp` to move each token from `global` to `app-synchronizer` before the self-transfer. + +--- + +## Test Results + +| Test suite | Result | +| --------------------------------------------------- | --------- | +| `yarn build:all` (46 packages) | ✅ Exit 0 | +| `yarn script:test:examples` (scripts 01–14 + utils) | ✅ Exit 0 | +| `yarn script:test:examples:multi-sync` (script 15) | ✅ Exit 0 | + +--- + +## Infrastructure Note + +Script 15 requires localnet started with the `--multi-sync` flag: + +``` +yarn tsx scripts/src/start-localnet.ts start --multi-sync +``` + +A plain `yarn start:localnet` starts without the `app-synchronizer`, and script 15 will immediately fail with "Expected at least 2 connected synchronizers". The CI job `wallet-sdk-scripts-e2e` already uses `setup_localnet` with `multi-sync: 'true'`. diff --git a/scripts/src/start-localnet.ts b/scripts/src/start-localnet.ts index 1f904b066..dd24896c2 100644 --- a/scripts/src/start-localnet.ts +++ b/scripts/src/start-localnet.ts @@ -4,10 +4,17 @@ import { execFileSync } from 'child_process' import fs from 'fs' import path from 'path' -import { getRepoRoot, getNetworkArg, SUPPORTED_VERSIONS } from './lib/utils.js' +import { + getRepoRoot, + getNetworkArg, + hasFlag, + SUPPORTED_VERSIONS, +} from './lib/utils.js' const args = process.argv.slice(2) const command = args[0] +const multiSync = hasFlag('multi-sync') + const rootDir = getRepoRoot() const LOCALNET_DIR = path.join(rootDir, '.localnet/docker-compose/localnet') const GENERATED_COMPOSE_OVERRIDE = path.join( @@ -16,32 +23,31 @@ 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' -) - +const LOCALNET_DARS_DIR = path.join(rootDir, '.localnet/dars') +// TODO (#1721): make multi-sync the default and remove the flag once multi-sync is fully supported and tested in the main scripts e2e tests, but for now we want to keep it as an option to avoid accidentally running multi-sync e2e tests without updating the main scripts e2e tests to cover multi-sync as well function ensureComposeOverride() { fs.mkdirSync(path.dirname(GENERATED_COMPOSE_OVERRIDE), { recursive: true }) - fs.writeFileSync( - GENERATED_COMPOSE_OVERRIDE, - [ - 'services:', - ' canton:', - ' environment:', - ' ADDITIONAL_CONFIG_MAX_COMMANDS_IN_FLIGHT: |-', - ` 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}`, + const lines = [ + 'services:', + ' canton:', + ' environment:', + ' ADDITIONAL_CONFIG_MAX_COMMANDS_IN_FLIGHT: |-', + ` 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}`, + ] + if (multiSync) { + lines.push( ' multi-sync-startup:', ' volumes:', - ` - ${CUSTOM_APP_SYNCHRONIZER_SC}:/app/app-synchronizer.sc`, - '', - ].join('\n'), - 'utf8' - ) + ` - ${LOCALNET_DARS_DIR}:/app/dars:ro` + ) + } + lines.push('') + fs.writeFileSync(GENERATED_COMPOSE_OVERRIDE, lines.join('\n'), 'utf8') } +// TODO (#1721): make multi-sync the default and remove the flag once multi-sync is fully supported and tested in the main scripts e2e tests, but for now we want to keep it as an option to avoid accidentally running multi-sync e2e tests without updating the main scripts e2e tests to cover multi-sync as well const composeBase = [ 'docker', 'compose', @@ -61,8 +67,7 @@ const composeBase = [ 'app-provider', '--profile', 'app-user', - '--profile', - 'multi-sync', + ...(multiSync ? ['--profile', 'multi-sync'] : []), ] const network = getNetworkArg() diff --git a/scripts/src/test-example-scripts.ts b/scripts/src/test-example-scripts.ts index 7da10e586..2f4a12b75 100644 --- a/scripts/src/test-example-scripts.ts +++ b/scripts/src/test-example-scripts.ts @@ -18,7 +18,11 @@ const dir = path.join( ) // do not run tests from these directory names; full name match -const EXCEPTIONS_DIR_NAMES = ['stress', '13-rewards-for-deposits'] +const EXCEPTIONS_DIR_NAMES = [ + 'stress', + '13-rewards-for-deposits', + '15-multi-sync', +] // do not run these tests; exceptions can be full filename or just any length subset of its starting characters const EXCEPTIONS_FILE_NAMES = ['_', 'utils', 'types.ts', 'upload-dars.ts'] diff --git a/scripts/src/test-multi-sync-scripts.ts b/scripts/src/test-multi-sync-scripts.ts new file mode 100644 index 000000000..1420069df --- /dev/null +++ b/scripts/src/test-multi-sync-scripts.ts @@ -0,0 +1,112 @@ +// Copyright (c) 2025-2026 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import fs from 'fs' +import path from 'path' +import { error, getRepoRoot, success } from './lib/utils.js' +import child_process from 'child_process' + +const maxIoListeners = Number.parseInt(process.env.MAX_IO_LISTENERS ?? '', 10) +if (Number.isFinite(maxIoListeners) && maxIoListeners > 0) { + process.stdout.setMaxListeners(maxIoListeners) + process.stderr.setMaxListeners(maxIoListeners) +} + +const dir = path.join( + getRepoRoot(), + 'docs/wallet-integration-guide/examples/scripts' +) + +// do not run these tests; exceptions can be full filename or just any length subset of its starting characters +const EXCEPTIONS_FILE_NAMES = ['_', 'utils', 'types.ts', 'upload-dars.ts'] + +function getMultiSyncScripts(): string[] { + const multiSyncDir = path.join(dir, '15-multi-sync') + return fs.readdirSync(multiSyncDir).flatMap((f) => { + if (!f.endsWith('.ts')) return [] + if (EXCEPTIONS_FILE_NAMES.find((e) => f.startsWith(e))) return [] + return [path.relative(dir, path.join(multiSyncDir, f))] + }) +} + +const scripts = getMultiSyncScripts() + +async function executeScript(name: string) { + console.log(success(`\n=== Executing script: ${name} ===`)) + await cmd('yarn', ['tsx', path.join(dir, name)]).then(() => { + console.log(success(`Script ${name} executed successfully`)) + }) + console.log(success(`=== Finished script: ${name} ===\n`)) +} + +async function cmd(bin: string, args: string[]): Promise { + const child = child_process.spawn(bin, args, { + stdio: ['ignore', 'pipe', 'pipe'], + }) + + const pretty = child_process.spawn('yarn', ['pino-pretty'], { + stdio: ['pipe', 'pipe', 'pipe'], + }) + + child.stdout.pipe(pretty.stdin) + + let logs = '' + child.stderr.on('data', (data: Buffer) => { + logs += data.toString() + }) + pretty.stdout.on('data', (data: Buffer) => { + logs += data.toString() + }) + pretty.stderr.on('data', (data: Buffer) => { + logs += data.toString() + }) + + const childCode = await new Promise((resolve) => { + child.on('close', (code) => resolve(code ?? 1)) + }) + pretty.stdin.end() + + await new Promise((resolve) => { + pretty.on('close', resolve) + }) + + if (childCode !== 0) { + throw Object.assign( + new Error(`Command failed: ${bin} ${args.join(' ')}`), + { logs } + ) + } + return logs +} + +const results: Array<{ + script: string + result: PromiseSettledResult +}> = [] + +for (const script of scripts) { + const result = await executeScript(script).then( + () => ({ + script, + result: { status: 'fulfilled', value: undefined } as const, + }), + (reason) => ({ + script, + result: { status: 'rejected', reason } as const, + }) + ) + results.push(result) +} + +const failedScripts = results.flatMap(({ script, result }) => + result.status === 'rejected' ? [{ script, result } as const] : [] +) + +if (failedScripts.length > 0) { + for (const { script, result } of failedScripts) { + const logs = (result.reason as { logs?: string }).logs ?? '' + if (logs) process.stdout.write(logs) + console.log(error(`=== Failed running script: ${script} ===\n`)) + } + process.exit(1) +} diff --git a/sdk/wallet-sdk/src/wallet/namespace/amulet/namespace.ts b/sdk/wallet-sdk/src/wallet/namespace/amulet/namespace.ts index 885fd7acc..a0e6526f6 100644 --- a/sdk/wallet-sdk/src/wallet/namespace/amulet/namespace.ts +++ b/sdk/wallet-sdk/src/wallet/namespace/amulet/namespace.ts @@ -15,6 +15,7 @@ import { TrafficNamespace } from './traffic.js' import { LedgerNamespace } from '../ledger/namespace.js' import { PreapprovalNamespace } from './preapproval.js' import { Decimal } from 'decimal.js' +import { resolveGlobalSynchronizerId } from '../state/client.js' const defaultMaxRetries = 10 const defaultDelayMs = 5000 @@ -82,15 +83,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], }) } @@ -120,9 +119,9 @@ export class AmuletNamespace { if (featuredAppRights) { return featuredAppRights } - const synchronizerId = - options.synchronizerId ?? - this.sdkContext.commonCtx.defaultSynchronizerId + const synchronizerId = await resolveGlobalSynchronizerId( + this.sdkContext.commonCtx.ledgerProvider + ) 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 c8a71f367..c1a351542 100644 --- a/sdk/wallet-sdk/src/wallet/namespace/amulet/preapproval.ts +++ b/sdk/wallet-sdk/src/wallet/namespace/amulet/preapproval.ts @@ -7,6 +7,7 @@ import { PreapprovalParties } from './types.js' import { LedgerNamespace } from '../ledger/namespace.js' import { fetchAmulet } from './namespace.js' import { SDKLogger } from '../../logger/logger.js' +import { resolveGlobalSynchronizerId } from '../state/client.js' const EMPTY_COMMAND_RESULT = [null, []] as const @@ -114,18 +115,13 @@ export class PreapprovalNamespace { parties: PreapprovalParties expiresAt: Date inputUtxos?: string[] - synchronizerId?: string }) { 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 - if (!synchronizerId) - this.ctx.commonCtx.error.throw({ - type: 'Unexpected', - message: 'Cannot obtain synchronizer id', - }) + const synchronizerId = await resolveGlobalSynchronizerId( + this.ctx.commonCtx.ledgerProvider + ) if ( !preapprovalStatus || diff --git a/sdk/wallet-sdk/src/wallet/namespace/amulet/traffic.ts b/sdk/wallet-sdk/src/wallet/namespace/amulet/traffic.ts index 4bcd2a651..d26b9ea46 100644 --- a/sdk/wallet-sdk/src/wallet/namespace/amulet/traffic.ts +++ b/sdk/wallet-sdk/src/wallet/namespace/amulet/traffic.ts @@ -5,16 +5,15 @@ import { PartyId } from '@canton-network/core-types' import { PreparedCommand } from '../transactions/types.js' import { Ops } from '@canton-network/core-provider-ledger' import { AmuletNamespaceConfig, fetchAmulet } from './namespace.js' +import { resolveGlobalSynchronizerId } from '../state/client.js' export class TrafficNamespace { constructor(private readonly sdkContext: AmuletNamespaceConfig) {} - async status( - params?: Partial<{ memberId?: string; synchronizerId?: string }> - ) { - const synchronizerId = - params?.synchronizerId || - this.sdkContext.commonCtx.defaultSynchronizerId + async status(params?: Partial<{ memberId?: string }>) { + const synchronizerId = await resolveGlobalSynchronizerId( + this.sdkContext.commonCtx.ledgerProvider + ) const memberId = params?.memberId ?? @@ -42,28 +41,25 @@ export class TrafficNamespace { memberId?: string inputUtxos: string[] migrationId?: number - synchronizerId?: string }): Promise { const { buyer, ccAmount, inputUtxos } = params const migrationId = params.migrationId ?? 0 const defaultAmulet = await fetchAmulet(this.sdkContext) - const memberId = + const [memberId, synchronizerId] = await Promise.all([ params.memberId ?? - ( - await this.sdkContext.commonCtx.ledgerProvider.request( - { + this.sdkContext.commonCtx.ledgerProvider + .request({ method: 'ledgerApi', params: { resource: '/v2/parties/participant-id', requestMethod: 'get', }, - } - ) - ).participantId - - const synchronizerId = - params.synchronizerId || - this.sdkContext.commonCtx.defaultSynchronizerId + }) + .then((r) => r.participantId), + resolveGlobalSynchronizerId( + this.sdkContext.commonCtx.ledgerProvider + ), + ]) const [command, dc] = await this.sdkContext.amuletService.buyMemberTraffic( diff --git a/sdk/wallet-sdk/src/wallet/namespace/amulet/types.ts b/sdk/wallet-sdk/src/wallet/namespace/amulet/types.ts index e97a1f5f7..4e86e6b31 100644 --- a/sdk/wallet-sdk/src/wallet/namespace/amulet/types.ts +++ b/sdk/wallet-sdk/src/wallet/namespace/amulet/types.ts @@ -41,7 +41,6 @@ export type LookupFeaturedAppRightsOptions = { } export type GrantFeaturedAppRightsOptions = { - synchronizerId?: string maxRetries?: number delayMs?: number } diff --git a/sdk/wallet-sdk/src/wallet/namespace/ledger/dar/client.ts b/sdk/wallet-sdk/src/wallet/namespace/ledger/dar/client.ts index c35d271ab..7179badda 100644 --- a/sdk/wallet-sdk/src/wallet/namespace/ledger/dar/client.ts +++ b/sdk/wallet-sdk/src/wallet/namespace/ledger/dar/client.ts @@ -7,6 +7,7 @@ import { Ops } from '@canton-network/core-provider-ledger' export class DarNamespace { constructor(private readonly sdkContext: SDKContext) {} + // TODO (#1712): add checking of vetting state also for vetting on provided sync async upload( darBytes: Uint8Array | Buffer, packageId: string, @@ -29,8 +30,7 @@ export class DarNamespace { resource: '/v2/packages', requestMethod: 'post', query: { - synchronizerId: - synchronizerId ?? this.sdkContext.defaultSynchronizerId, + ...(synchronizerId !== undefined && { synchronizerId }), vetAllPackages: vetAllPackages ?? true, }, body: darBytes as never, diff --git a/sdk/wallet-sdk/src/wallet/namespace/ledger/dar/index.ts b/sdk/wallet-sdk/src/wallet/namespace/ledger/dar/index.ts index 8fe87fcb6..d43f11611 100644 --- a/sdk/wallet-sdk/src/wallet/namespace/ledger/dar/index.ts +++ b/sdk/wallet-sdk/src/wallet/namespace/ledger/dar/index.ts @@ -29,8 +29,7 @@ export class DarService { resource: '/v2/packages', requestMethod: 'post', query: { - synchronizerId: - synchronizerId ?? this.sdkContext.defaultSynchronizerId, + ...(synchronizerId !== undefined && { synchronizerId }), vetAllPackages: vetAllPackages ?? true, }, body: darBytes as never, diff --git a/sdk/wallet-sdk/src/wallet/namespace/ledger/dar/vetting.ts b/sdk/wallet-sdk/src/wallet/namespace/ledger/dar/vetting.ts new file mode 100644 index 000000000..6dd518c50 --- /dev/null +++ b/sdk/wallet-sdk/src/wallet/namespace/ledger/dar/vetting.ts @@ -0,0 +1,46 @@ +// Copyright (c) 2025-2026 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { + AbstractLedgerProvider, + Ops, +} from '@canton-network/core-provider-ledger' + +/** + * Vet a DAR package on a specific synchronizer. + * + * Unlike {@link DarNamespace.upload}, this function always POSTs the DAR to + * the ledger API regardless of whether the package bytes have already been + * uploaded on another synchronizer. The server deduplicates the binary + * payload, but a POST is required for each synchronizer that should have the + * package vetted. Use this when the same package must be available on multiple + * synchronizers (e.g. global + app-synchronizer in a multi-synchronizer setup). + * + * Typical usage pattern: + * 1. Upload the DAR on the primary synchronizer with `sdk.ledger.dar.upload`. + * 2. Call `vetPackage` for each additional synchronizer that needs vetting. + * + * @param ledgerProvider - The ledger provider for the target participant node. + * Obtain via `(sdk.ledger as any).sdkContext.ledgerProvider`. + * @param darBytes - Raw DAR file bytes. + * @param synchronizerId - The synchronizer on which the package should be vetted. + * @param vetAllPackages - When true (default) all packages inside the DAR are + * vetted, not only the main dalf. Matches the behaviour of `dar.upload`. + */ +export async function vetPackage( + ledgerProvider: AbstractLedgerProvider, + darBytes: Uint8Array | Buffer, + synchronizerId: string, + vetAllPackages = true +): Promise { + await ledgerProvider.request({ + method: 'ledgerApi', + params: { + resource: '/v2/packages', + requestMethod: 'post', + query: { synchronizerId, vetAllPackages }, + body: darBytes as never, + headers: { 'Content-Type': 'application/octet-stream' }, + }, + }) +} diff --git a/sdk/wallet-sdk/src/wallet/namespace/ledger/internal/index.ts b/sdk/wallet-sdk/src/wallet/namespace/ledger/internal/index.ts index 851febd11..59cb778f5 100644 --- a/sdk/wallet-sdk/src/wallet/namespace/ledger/internal/index.ts +++ b/sdk/wallet-sdk/src/wallet/namespace/ledger/internal/index.ts @@ -22,15 +22,107 @@ type InternalOperationParams = Required< Omit, UnusedParams | RequiredParams> > +export interface ReassignParams { + submitter: string + contractId: string + source: string + target: string +} + 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 } = params + + // Phase 1: Unassign + const 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, + }, + }, + }, + } + ) + + const events = unassignResponse.reassignment?.events ?? [] + const unassignedEvent = events.find((e) => 'JsUnassignedEvent' in e) + if (!unassignedEvent || !('JsUnassignedEvent' in unassignedEvent)) { + throw new Error( + `No unassigned event returned for contract ${contractId} reassignment` + ) + } + const reassignmentId = + unassignedEvent.JsUnassignedEvent.value.reassignmentId + + // Phase 2: Assign + 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, + }, + }, + }, + }, + ], + }, + }, + }, + } + ) + } + async submit( args: InternalOperationParams ) { const { commands, - synchronizerId = this.ctx.defaultSynchronizerId, + synchronizerId, disclosedContracts = [], readAs = [], actAs, @@ -44,7 +136,7 @@ export class InternalLedgerNamespace { actAs, readAs, disclosedContracts, - synchronizerId, + ...(synchronizerId !== undefined && { synchronizerId }), packageIdSelectionPreference, } @@ -65,7 +157,7 @@ export class InternalLedgerNamespace { ) { const { commands, - synchronizerId = this.ctx.defaultSynchronizerId, + synchronizerId, disclosedContracts = [], readAs = [], actAs, @@ -80,7 +172,7 @@ export class InternalLedgerNamespace { actAs, readAs, disclosedContracts, - synchronizerId, + ...(synchronizerId !== undefined && { synchronizerId }), packageIdSelectionPreference, verboseHashing, } diff --git a/sdk/wallet-sdk/src/wallet/namespace/ledger/namespace.ts b/sdk/wallet-sdk/src/wallet/namespace/ledger/namespace.ts index d6b7bd904..c60c92da3 100644 --- a/sdk/wallet-sdk/src/wallet/namespace/ledger/namespace.ts +++ b/sdk/wallet-sdk/src/wallet/namespace/ledger/namespace.ts @@ -9,6 +9,7 @@ import { SignedTransaction } from '../transactions/signed.js' import { Ops } from '@canton-network/core-provider-ledger' import { DarNamespace } from './dar/client.js' import { AcsOptions } from '@canton-network/core-acs-reader' +import { State } from '../state/index.js' import { InternalLedgerNamespace } from './internal/index.js' import { PreparedTransactionNamespace } from './hash/namespace.js' @@ -16,11 +17,12 @@ export class LedgerNamespace { public readonly dar: DarNamespace public readonly internal: InternalLedgerNamespace public readonly preparedTransaction: PreparedTransactionNamespace - + public readonly state: State constructor(private readonly sdkContext: SDKContext) { this.dar = new DarNamespace(sdkContext) this.internal = new InternalLedgerNamespace(sdkContext) this.preparedTransaction = new PreparedTransactionNamespace(sdkContext) + this.state = new State(sdkContext) } public async ledgerEnd() { @@ -42,8 +44,7 @@ export class LedgerNamespace { */ prepare(options: PrepareOptions): PreparedTransaction { const preparePromise = async () => { - const synchronizerId = - options.synchronizerId || this.sdkContext.defaultSynchronizerId + const synchronizerId = options.synchronizerId const { partyId, @@ -59,7 +60,7 @@ export class LedgerNamespace { commandId, actAs: [partyId], disclosedContracts, - synchronizerId, + ...(synchronizerId !== undefined && { synchronizerId }), }) } 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..4958fa695 100644 --- a/sdk/wallet-sdk/src/wallet/namespace/party/external/service.ts +++ b/sdk/wallet-sdk/src/wallet/namespace/party/external/service.ts @@ -10,6 +10,7 @@ import { CreatePartyOptions } from './types.js' import { SDKLogger } from '../../../logger/index.js' import { LedgerProvider, Ops } from '@canton-network/core-provider-ledger' import { AuthTokenProvider } from '@canton-network/core-wallet-auth' +import { resolveGlobalSynchronizerId } from '../../state/client.js' export class ExternalPartyNamespace { private readonly logger: SDKLogger @@ -32,7 +33,8 @@ export class ExternalPartyNamespace { this.resolveParticipantUids( options?.confirmingParticipantEndpoints ?? [] ), - options?.synchronizerId || this.resolveSynchronizerId(), + options?.synchronizerId || + resolveGlobalSynchronizerId(this.ctx.ledgerProvider), ]).then( ([ observingParticipantUids, @@ -79,33 +81,6 @@ export class ExternalPartyNamespace { ) } - 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. 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..78afe7dc6 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,7 @@ import { Ops, } from '@canton-network/core-provider-ledger' import { AuthTokenProvider } from '@canton-network/core-wallet-auth' +import { resolveGlobalSynchronizerId } from '../../state/client.js' /** * Represents a signed party creation, ready to be allocated on the ledger. @@ -50,7 +51,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 } @@ -144,7 +152,9 @@ export class SignedPartyCreationService { } = options const ledgerProvider = defaultLedgerProvider ?? this.ctx.ledgerProvider try { - const synchronizerId = this.ctx.defaultSynchronizerId + const synchronizerId = + this.createPartyOptions?.synchronizerId ?? + (await resolveGlobalSynchronizerId(ledgerProvider)) await this.allocate( ledgerProvider, @@ -185,8 +195,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', diff --git a/sdk/wallet-sdk/src/wallet/namespace/party/internal/index.ts b/sdk/wallet-sdk/src/wallet/namespace/party/internal/index.ts index 0c910f083..ac25011e4 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/state/client.ts b/sdk/wallet-sdk/src/wallet/namespace/state/client.ts new file mode 100644 index 000000000..421f4275f --- /dev/null +++ b/sdk/wallet-sdk/src/wallet/namespace/state/client.ts @@ -0,0 +1,95 @@ +// Copyright (c) 2025-2026 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { SDKContext } from '../../sdk.js' +import { + AbstractLedgerProvider, + Ops, +} from '@canton-network/core-provider-ledger' +import { SDKLogger } from '../../logger/index.js' +import { v3_4 } from '@canton-network/core-ledger-client-types' + +export type ConnectedSynchronizersOptions = { + party?: string + participantId?: string + identityProviderId?: string +} + +export type ConnectedSynchronizer = + v3_4.components['schemas']['ConnectedSynchronizer'] + +/** + * Resolves the ID of the global synchronizer for the given ledger provider. + * + * Fetches the connected synchronizers list and selects the entry whose alias + * is `'global'`. Falls back to the first entry when no alias matches (e.g. + * single-synchronizer setups). + * + * @internal Used by SDK operations that need to route to the global synchronizer + * without requiring the caller to look it up first. + * @throws {Error} When no synchronizers are connected. + */ +export async function resolveGlobalSynchronizerId( + ledgerProvider: AbstractLedgerProvider +): Promise { + const result = + await ledgerProvider.request({ + method: 'ledgerApi', + params: { + resource: '/v2/state/connected-synchronizers', + requestMethod: 'get', + query: {}, + }, + }) + const synchronizers = result.connectedSynchronizers ?? [] + const global = + synchronizers.find((s) => s.synchronizerAlias === 'global') ?? + synchronizers[0] + if (!global) throw new Error('No connected synchronizers found') + return global.synchronizerId +} + +export class State { + private readonly logger: SDKLogger + + constructor(private readonly ctx: SDKContext) { + this.logger = ctx.logger.child({ namespace: 'State' }) + } + + /** + * Returns the list of connected synchronizers for the given party / participant. + * + * Calls GET /v2/state/connected-synchronizers with optional query parameters. + * + * @param options - Optional filters: party, participantId, identityProviderId. + */ + public async connectedSynchronizers( + options?: ConnectedSynchronizersOptions + ) { + this.logger.debug({ options }, 'Fetching connected synchronizers') + + const result = + await this.ctx.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, + }), + }, + }, + } + ) + + return result + } +} diff --git a/sdk/wallet-sdk/src/wallet/namespace/state/index.ts b/sdk/wallet-sdk/src/wallet/namespace/state/index.ts new file mode 100644 index 000000000..ccb7adf4e --- /dev/null +++ b/sdk/wallet-sdk/src/wallet/namespace/state/index.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 * from './client.js' 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/token/utxos/mergeDelegation.ts b/sdk/wallet-sdk/src/wallet/namespace/token/utxos/mergeDelegation.ts index 795eb828d..fbee3d330 100644 --- a/sdk/wallet-sdk/src/wallet/namespace/token/utxos/mergeDelegation.ts +++ b/sdk/wallet-sdk/src/wallet/namespace/token/utxos/mergeDelegation.ts @@ -8,6 +8,7 @@ import { ExerciseCommand, } from '@canton-network/core-token-standard-service' import { Holding, PrettyContract } from '@canton-network/core-tx-parser' +import { resolveGlobalSynchronizerId } from '../../state/client.js' import { WrappedCommand } from '../../ledger/types.js' import { PartyId } from '@canton-network/core-types' import { LedgerNamespace } from '../../ledger/index.js' @@ -22,7 +23,10 @@ export class MergeDelegationNamespace { this.ledger = new LedgerNamespace(ctx.commonCtx) } - async setup(synchronizerId: string = '') { + async setup() { + const synchronizerId = await resolveGlobalSynchronizerId( + this.ctx.commonCtx.ledgerProvider + ) const commands = [ { CreateCommand: { @@ -42,8 +46,11 @@ export class MergeDelegationNamespace { }) } - async approve(args: { owner: PartyId; synchronizerId?: string }) { - const { owner, synchronizerId = '' } = args + async approve(args: { owner: PartyId }) { + const { owner } = args + const synchronizerId = await resolveGlobalSynchronizerId( + this.ctx.commonCtx.ledgerProvider + ) const mergeDelegationProposals = await this.ledger.acs.read({ parties: [owner], @@ -84,11 +91,13 @@ export class MergeDelegationNamespace { async execute(args: { party: PartyId - synchronizerId?: string nodeLimit?: number inputUtxos?: PrettyContract[] }) { - const { party, nodeLimit = 200, inputUtxos, synchronizerId = '' } = args + const { party, nodeLimit = 200, inputUtxos } = args + const synchronizerId = await resolveGlobalSynchronizerId( + this.ctx.commonCtx.ledgerProvider + ) const utxos = inputUtxos ?? diff --git a/sdk/wallet-sdk/src/wallet/sdk.ts b/sdk/wallet-sdk/src/wallet/sdk.ts index 9138b4409..ea3f9a7a5 100644 --- a/sdk/wallet-sdk/src/wallet/sdk.ts +++ b/sdk/wallet-sdk/src/wallet/sdk.ts @@ -47,7 +47,6 @@ export type SDKContext = { userId: string logger: SDKLogger error: SDKErrorHandler - defaultSynchronizerId: string } export type OfflineSDKContext = { @@ -59,6 +58,8 @@ export type * 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 { vetPackage } from './namespace/ledger/dar/vetting.js' +export { ScanProxyClient } from '@canton-network/core-splice-client' export class SDK { static async create< @@ -98,9 +99,12 @@ export class SDK { .catch((err) => { if ( //this is only the cause if authentication is completely disabled on the ledger. - (err.cause as string).includes( - 'The submitted request is missing a user-id' - ) + [ + typeof err?.cause === 'string' ? err.cause : '', + typeof err?.message === 'string' ? err.message : '', + ] + .join(' ') + .includes('The submitted request is missing a user-id') ) { return undefined } else throw err @@ -122,11 +126,6 @@ export class SDK { }) } - const defaultSynchronizerId = await getDefaultSynchronizerId( - ledgerProvider, - logger - ) - const acsReader = new AcsReader(ledgerProvider) const ctx: SDKContext = { @@ -135,7 +134,6 @@ export class SDK { userId: userId!, logger, error, - defaultSynchronizerId, } const config = {} as Pick< @@ -168,31 +166,4 @@ export class SDK { } } -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/wallet-gateway/remote/src/dapp-api/controller.ts b/wallet-gateway/remote/src/dapp-api/controller.ts index f72e7a2fe..55cd78767 100644 --- a/wallet-gateway/remote/src/dapp-api/controller.ts +++ b/wallet-gateway/remote/src/dapp-api/controller.ts @@ -214,9 +214,11 @@ export const dappController = ( notifier.emit('txChanged', { status: 'pending', commandId }) - const synchronizerId = - network.synchronizerId ?? - (await ledgerClient.getSynchronizerId()) + const synchronizerId = network.synchronizerId + if (!synchronizerId) + throw new Error( + 'synchronizerId is not configured for this network — set it in the network settings' + ) const response = await prepareSubmission( context.userId, diff --git a/wallet-gateway/remote/src/ledger/party-allocation-service.ts b/wallet-gateway/remote/src/ledger/party-allocation-service.ts index 61487232d..247776e90 100644 --- a/wallet-gateway/remote/src/ledger/party-allocation-service.ts +++ b/wallet-gateway/remote/src/ledger/party-allocation-service.ts @@ -145,8 +145,11 @@ export class PartyAllocationService { hint: string, publicKey: string ): Promise { - const synchronizerId = - this.synchronizerId ?? (await this.ledgerClient.getSynchronizerId()) + const synchronizerId = this.synchronizerId + if (!synchronizerId) + throw new Error( + 'synchronizerId is not configured — set it in the network settings' + ) return this.ledgerClient.generateTopology( synchronizerId, publicKey, @@ -174,8 +177,11 @@ export class PartyAllocationService { signature: string, userId: string ): Promise { - const synchronizerId = - this.synchronizerId ?? (await this.ledgerClient.getSynchronizerId()) + const synchronizerId = this.synchronizerId + if (!synchronizerId) + throw new Error( + 'synchronizerId is not configured — set it in the network settings' + ) const res = await this.ledgerClient.allocateExternalParty( synchronizerId, transactions.map((transaction) => ({ @@ -217,9 +223,9 @@ export class PartyAllocationService { const res = await this.ledgerClient.postWithRetry('/v2/parties', { partyIdHint: hint, identityProviderId: '', - synchronizerId: - this.synchronizerId ?? - (await this.ledgerClient.getSynchronizerId()), + ...(this.synchronizerId !== undefined && { + synchronizerId: this.synchronizerId, + }), userId, }) @@ -240,8 +246,11 @@ export class PartyAllocationService { publicKey: string, signingCallback: SigningCbFn ): Promise { - const synchronizerId = - this.synchronizerId ?? (await this.ledgerClient.getSynchronizerId()) + const synchronizerId = this.synchronizerId + if (!synchronizerId) + throw new Error( + 'synchronizerId is not configured — set it in the network settings' + ) const namespace = this.createFingerprintFromKey(publicKey) const transactions = await this.generateTopologyTransactions( diff --git a/wallet-gateway/remote/src/ledger/transaction-service.ts b/wallet-gateway/remote/src/ledger/transaction-service.ts index cf3c45c4b..36f17a4e6 100644 --- a/wallet-gateway/remote/src/ledger/transaction-service.ts +++ b/wallet-gateway/remote/src/ledger/transaction-service.ts @@ -498,8 +498,11 @@ export class TransactionService { const { partyId } = executeParams const { commandId } = transaction - const synchronizerId = - network.synchronizerId ?? (await ledgerClient.getSynchronizerId()) + const synchronizerId = network.synchronizerId + if (!synchronizerId) + throw new Error( + 'synchronizerId is not configured for this network — set it in the network settings' + ) const prep = ledgerPrepareParams( userId,