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..bc3a428d6 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,17 +453,92 @@ 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, e2e-affected] + if: needs.e2e-affected.outputs.affected_wallet_sdk == 'true' + 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: [ + e2e-affected, + 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 run: | - if [ "${{ needs.wallet-sdk-snippets-e2e.result }}" != "success" ]; then - echo "wallet-sdk snippets e2e did not succeed" - exit 1 + if [ "${{ needs.e2e-affected.outputs.affected_wallet_sdk }}" = "true" ]; then + if [ "${{ needs.wallet-sdk-snippets-e2e.result }}" != "success" ]; then + echo "wallet-sdk snippets e2e was scheduled but did not succeed" + exit 1 + fi + if [ "${{ needs.wallet-sdk-scripts-e2e.result }}" != "success" ]; then + echo "wallet-sdk scripts e2e was scheduled but did not succeed" + exit 1 + fi + if [ "${{ needs.wallet-sdk-scripts-e2e-multi-sync.result }}" != "success" ]; then + echo "wallet-sdk scripts e2e (multi-sync) was scheduled but did not succeed" + exit 1 + fi + echo "all wallet-sdk-e2e jobs passed" + else + echo "wallet-sdk e2e skipped (no affected wallet-sdk dependencies)" fi if [ "${{ needs.wallet-sdk-scripts-e2e.result }}" != "success" ]; then echo "wallet-sdk scripts e2e did not succeed" 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/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/15-multi-sync/README.md b/docs/wallet-integration-guide/examples/scripts/15-multi-sync/README.md new file mode 100644 index 000000000..309f0e62c --- /dev/null +++ b/docs/wallet-integration-guide/examples/scripts/15-multi-sync/README.md @@ -0,0 +1,250 @@ +# Example 15: Multi-Synchronizer DvP Trade + +## Overview + +This example implements a Delivery vs Payment (DvP) flow across multiple +synchronizers. It demonstrates how to orchestrate a trade between Amulet +(on a global synchronizer) and a Token instrument (on a private/app +synchronizer) using the OTC Trading App. + +Complete workflow covered: + +- SDK initialization with multiple synchronizers +- Party allocation and registration across synchronizers +- Parallel asset minting (Amulet on global, Token on app-synchronizer) +- Multi-synchronizer trade settlement using only single-party submissions +- Canton auto-reassignment via disclosed contracts (no explicit `ledger.internal.reassign`) +- Canton disclosure-based authorization for cross-signatory contract creation + +## Prerequisites + +### 1. Download the localnet bundle (first time only) + +If you have never run localnet before, or after a Splice version update: + +```bash +yarn script:fetch:localnet +``` + +For mainnet network variant: + +```bash +yarn script:fetch:localnet -- --network=mainnet +``` + +This populates `.localnet/docker-compose/` and `.localnet/dars/`. + +The DARs required by this example come from two locations: + +| DAR file | Location | Purpose | +| ----------------------------------------- | ------------------------ | ---------------------------------------------------------------------------------- | +| `splice-token-test-trading-app-1.0.0.dar` | `.localnet/dars/` | `OTCTrade` and `OTCTradeAllocationRequest` templates for orchestrating the trade | +| `splice-test-token-v1-1.0.0.dar` | `scripts/15-multi-sync/` | `Token` and `TokenRules` templates — the custom instrument on the app-synchronizer | + +`splice-token-test-trading-app-1.0.0.dar` is fetched by `yarn script:fetch:localnet`. +`splice-test-token-v1-1.0.0.dar` is bundled directly in the script directory. + +## Running Locally + +All commands are run from the **repository root** unless noted otherwise. + +### Full end-to-end (start → run → stop) + +All `yarn start:localnet`, `yarn stop:localnet`, `yarn script:*` commands must be +run from the **repository root** (`splice-wallet-kernel/`). +The example script itself (`yarn run-15`) must be run from the +`docs/wallet-integration-guide/examples/` subdirectory. + +```bash +# ── From the repository root ────────────────────────────────────────────────── + +# Step 1: Fetch localnet bundle (first time or after a Splice version update) +yarn script:fetch:localnet +# For mainnet variant: +# yarn script:fetch:localnet -- --network=mainnet + +# Step 2: Start localnet in multi-sync mode +# This spins up 16 containers: the standard 14 localnet containers plus +# multi-sync-startup (runs the app-synchronizer.sc bootstrap script, then exits) +# and multi-sync-ready (health-gate container). +yarn start:localnet -- --multi-sync +# For mainnet variant: +# yarn start:localnet -- --network=mainnet --multi-sync + +# Step 3: Wait until all containers are healthy +# multi-sync-startup will appear as "Exited (0)" — that is expected and correct. +# All other containers should show "(healthy)" before you proceed. +docker ps --format "table {{.Names}}\t{{.Status}}" + +# ── From docs/wallet-integration-guide/examples/ ────────────────────────────── + +# Step 4: Run the example +cd docs/wallet-integration-guide/examples +yarn run-15 + +# ── From the repository root ────────────────────────────────────────────────── + +# Step 5: Stop the multi-sync localnet when done +cd - # return to repository root +yarn stop:localnet -- --multi-sync +# For mainnet variant: +# yarn stop:localnet -- --network=mainnet --multi-sync +``` + +Alternatively, run the example from the repository root using the workspace shorthand: + +```bash +yarn workspace docs-wallet-integration-guide-examples run-15 +``` + +### Quick run (multi-sync localnet already running) + +From `docs/wallet-integration-guide/examples/`: + +```bash +cd docs/wallet-integration-guide/examples +yarn run-15 +``` + +Or from the repository root: + +```bash +yarn workspace docs-wallet-integration-guide-examples run-15 +``` + +### Run via the dedicated multi-sync test suite + +This is the same flow used in CI for the `wallet-sdk-scripts-e2e-multi-sync` job. +All commands run from the **repository root**. + +```bash +# Step 1: Start multi-sync localnet +yarn start:localnet -- --multi-sync +# For mainnet variant: +# yarn start:localnet -- --network=mainnet --multi-sync + +# Step 2: Run the multi-sync test suite (runs example 15 only) +yarn script:test:examples:multi-sync + +# Step 3: Stop when done +yarn stop:localnet -- --multi-sync +``` + +### Run as part of the full example test suite + +All commands run from the **repository root**. + +```bash +# Ensure DARs are downloaded and multi-sync localnet is running (steps 1–3 above), +# then run the full suite (examples 01–14 + 15): +yarn script:test:examples +``` + +If `splice-token-test-trading-app-1.0.0.dar` is missing from `.localnet/dars/`, run +`yarn script:fetch:localnet` from the repository root. + +### Expected output + +``` +[v1-15-multi-sync-trade] Connected synchronizers: global-synchronizer, app-synchronizer +[v1-15-multi-sync-trade] Synchronizer IDs — global: ..., app: ... +[v1-15-multi-sync-trade] DARs vetted: P1+P2+P3 on both synchronizers +[v1-15-multi-sync-trade] Parties allocated — alice: ... (P1), bob: ... (P2), tradingApp: ... (P3), tokenAdmin: ... (P3) +[v1-15-multi-sync-trade] Alice, Bob, and TokenAdmin registered on app-synchronizer +[v1-15-multi-sync-trade] Amulet asset discovered — admin: ... +[v1-15-multi-sync-trade] Alice: Amulet minted (2000000) on global synchronizer +[v1-15-multi-sync-trade] TokenAdmin: TokenRules created on global + app synchronizers; Bob: 500 TestToken minted on app-synchronizer +[v1-15-multi-sync-trade] Alice: OTCTradeProposal created (leg-0: 100 Amulet → Bob, leg-1: 20 TestToken → Alice) +[v1-15-multi-sync-trade] Bob: OTCTradeProposal_Accept executed +[v1-15-multi-sync-trade] TradingApp: OTCTradeProposal_InitiateSettlement executed → OTCTrade created +[v1-15-multi-sync-trade] Alice: Amulet allocated for leg-0 (global synchronizer) +[v1-15-multi-sync-trade] Bob: TestToken allocated for leg-1 (global synchronizer, single-party) +[v1-15-multi-sync-trade] TradingApp: OTCTrade settled — 100 Amulet transferred to Bob, 20 TestToken transferred to Alice +[v1-15-multi-sync-trade] Bob: TestToken self-transferred on app-synchronizer (Canton auto-reassigned Bob's Token from global → app) +[v1-15-multi-sync-trade] Alice: 20 TestToken self-transferred on app-synchronizer (Canton auto-reassigned Alice's Token from global → app) +[v1-15-multi-sync-trade] Final contract state: +``` + +> **Note:** Steps 8 (Alice allocates Amulet) and 9 (Bob allocates TestToken) run in parallel, +> as do Alice's and Bob's self-transfers in step 11, so those log lines may appear in either order. + +## How it Works + +| Step | Who | What | Synchronizer | +| ---- | ----------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------- | +| 1 | — | Create SDKs (P1, P2, P3) and discover synchronizers | global + app | +| 2 | — | Vet DARs: P1+P2+P3 on both synchronizers | global + app | +| 3 | — | Allocate parties (Alice/P1, Bob/P2, TradingApp/P3, TokenAdmin/P3) | global | +| 4 | Alice | Mint 2,000,000 Amulet for Alice | global | +| 5a | TokenAdmin | Create `TokenRules` on global synchronizer (single-party) | global | +| 5b | TokenAdmin | Create `TokenRules` on app-synchronizer (single-party, parallel with 5a) | app | +| 5c | TokenAdmin | Create `Token` (owner=TokenAdmin) on app-synchronizer — single-party because owner=admin=TokenAdmin | app | +| 5d | TokenAdmin | `TransferFactory_Transfer` on app `TokenRules` → `TokenTransferOffer` to Bob — single-party (sender=TokenAdmin) | app | +| 5e | Bob | `TransferInstruction_Accept` → `Token` (owner=Bob, admin=TokenAdmin) on app-synchronizer — single-party (Bob is receiver/controller) | app | +| 6a | Alice | Create `OTCTradeProposal` (2 legs) | global | +| 6b | Bob | `OTCTradeProposal_Accept` | global | +| 6c | Trading App | `OTCTradeProposal_InitiateSettlement` → `OTCTrade` created | global | +| 7 | — | Read `OTCTrade` contract ID | global | +| 8 | Alice | `AllocationFactory_Allocate` (Amulet, leg-0) — single-party | global | +| 9 | Bob | `AllocationFactory_Allocate` (TestToken, leg-1), disclosing global `TokenRules`; Canton auto-reassigns Bob's `Token` from app→global because P2 lacks TokenAdmin's authorization locally (TokenAdmin is on P3) | app → global (auto) | +| 10a | — | Locate Bob's TestToken allocation | global | +| 10b | Trading App | `OTCTrade_Settle` — single-party TradingApp submission | global | +| 11 | Alice | `TransferFactory_Transfer` self-transfer; Canton auto-reassigns Alice's `Token` to app-synchronizer (parallel with Bob's step 11) | global → app (auto) | +| 11 | Bob | `TransferFactory_Transfer` self-transfer; Canton auto-reassigns Bob's `Token` to app-synchronizer (parallel with Alice's step 11) | global → app (auto) | + +## Troubleshooting + +### `Required DAR not found` + +Verify the DAR files are present in their expected locations: + +```bash +# Trading-app DAR — fetched into .localnet/dars/ by yarn script:fetch:localnet +ls -la .localnet/dars/splice-token-test-trading-app-1.0.0.dar + +# Test-token DAR — bundled in the script directory +ls -la docs/wallet-integration-guide/examples/scripts/15-multi-sync/splice-test-token-v1-1.0.0.dar +``` + +### `App synchronizer not found (alias: app-synchronizer)` + +This error means the `app-user` participant is not connected to the app-synchronizer. +The `app-synchronizer.sc` bootstrap script must connect `app-provider`, `app-user`, +and `sv` to the app-synchronizer. Check that you are using the current version of +that file (it should reference all three participants). + +Check that the `multi-sync-startup` bootstrap container ran to completion: + +```bash +docker logs $(docker ps -a --filter name=multi-sync-startup --format "{{.ID}}") +``` + +The last line should read: + +``` +app-synchronizer bootstrap with package vetting completed successfully for app-provider, app-user and sv +``` + +If localnet was started with an older version of the bootstrap script, restart it: + +```bash +yarn stop:localnet -- --multi-sync +yarn start:localnet -- --multi-sync +``` + +### `No connected synchronizers found` + +Localnet may still be initialising. Wait until all containers show `(healthy)`: + +```bash +docker ps --format "table {{.Names}}\t{{.Status}}" +``` + +### Docker containers not starting + +Ensure Docker Desktop has enough resources (≥ 8 GB RAM, ≥ 4 CPUs recommended). +Check current usage: + +```bash +docker stats --no-stream +``` 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..4bbb9e456 --- /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 + app-synchronizer + * + */ + +// 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..5e7b4e4db --- /dev/null +++ b/docs/wallet-integration-guide/examples/scripts/15-multi-sync/_setup.ts @@ -0,0 +1,284 @@ +// Copyright (c) 2025-2026 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import path from 'path' +import { fileURLToPath } from 'url' +import fs from 'fs/promises' +import type { Logger } from 'pino' +import { + localNetStaticConfig, + SDK, + type SDKInterface, + type 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, + vetDar, +} 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 +} + +const DARS_PATH = '../../../../../.localnet/dars' +const TRADING_APP_DAR = 'splice-token-test-trading-app-1.0.0.dar' +const TEST_TOKEN_V1_DAR = 'splice-test-token-v1-1.0.0.dar' + +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 = await p1Sdk.ledger.state.globalSynchronizerId() + const appSynchronizerId = allSynchronizers.find( + (s) => s.synchronizerAlias === 'app-synchronizer' + )?.synchronizerId + + if (!globalSynchronizerId) throw new Error('Global synchronizer not found') + 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, + } + + // Load DARs bundled alongside this script and vet on all participants × both synchronizers. + const here = path.dirname(fileURLToPath(import.meta.url)) + const darsDir = path.join(here, DARS_PATH) + for (const [darPath, darName] of [ + [path.join(darsDir, TRADING_APP_DAR), TRADING_APP_DAR], + [path.join(here, TEST_TOKEN_V1_DAR), TEST_TOKEN_V1_DAR], + ] as [string, string][]) { + try { + await fs.stat(darPath) + } catch { + throw new Error( + `Required DAR not found: ${darPath}\n` + + ` "${darName}" must be present in .localnet/dars/.` + ) + } + } + + const [tradingAppDar, testTokenV1Dar] = await Promise.all([ + fs.readFile(path.join(darsDir, TRADING_APP_DAR)), + fs.readFile(path.join(here, TEST_TOKEN_V1_DAR)), + ]) + + // P1, P2 and P3 vet DARs on both synchronizers + await Promise.all( + [p1SdkCtx, p2SdkCtx, p3SdkCtx].flatMap((ctx) => + [globalSynchronizerId, appSynchronizerId].flatMap((sid) => + [tradingAppDar, testTokenV1Dar].map((dar) => + vetDar(ctx.ledgerProvider, dar, sid) + ) + ) + ) + ) + logger.info('DARs vetted: P1+P2+P3 on both synchronizers') + + // Allocate parties: alice on P1, bob on P2, tradingApp on P3, tokenAdmin on P2 (all on global synchronizer) + const aliceKey = p1Sdk.keys.generate() + const bobKey = p1Sdk.keys.generate() + const tradingAppKey = p1Sdk.keys.generate() + const tokenAdminKey = p3Sdk.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(), + p3Sdk.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} (P3)` + ) + + // 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 }), + p3Sdk.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..02d6e5cff --- /dev/null +++ b/docs/wallet-integration-guide/examples/scripts/15-multi-sync/_trade_ops.ts @@ -0,0 +1,837 @@ +// 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: p3Sdk, + 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, + p3Sdk, + bob, + tokenAdmin, + globalSynchronizerId, + appSynchronizerId, + } = setup + + await Promise.all([ + p3Sdk.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 }), + p3Sdk.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 }), + ]) + + await p3Sdk.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 }) + + const [tokenRulesContracts, adminTokenHoldings] = await Promise.all([ + p3Sdk.ledger.acs.read({ + templateIds: [`${TEST_TOKEN_PREFIX}:TokenRules`], + parties: [tokenAdmin.partyId], + filterByParty: true, + }), + p3Sdk.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') + + await p3Sdk.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, + p3Sdk, + 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, + }), + p3Sdk.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') + + 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, p3Sdk, alice, tokenAdmin, appSynchronizerId } = setup + + const [aliceTokens, tokenRulesContracts] = await Promise.all([ + p1Sdk.ledger.acs.read({ + templateIds: [`${TEST_TOKEN_PREFIX}:Token`], + parties: [alice.partyId], + filterByParty: true, + }), + p3Sdk.ledger.acs.read({ + templateIds: [`${TEST_TOKEN_PREFIX}:TokenRules`], + parties: [tokenAdmin.partyId], + filterByParty: true, + }), + ]) + const aliceTokenCid = aliceTokens[0]?.contractId + if (!aliceTokenCid) + 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') + + 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: [aliceTokenCid], + meta: { values: {} }, + }, + extraArgs: { + context: { values: {} }, + meta: { values: {} }, + }, + }, + }, + }, + ], + disclosedContracts: [ + { + templateId: tokenRules.templateId, + contractId: tokenRules.contractId, + createdEventBlob: tokenRules.createdEventBlob!, + synchronizerId: tokenRules.synchronizerId, + }, + ], + synchronizerId: appSynchronizerId, + }) + .sign(alice.keyPair.privateKey) + .execute({ partyId: alice.partyId }) + + logger.info( + `Alice: ${TRADE_TOKEN_AMOUNT} TestToken self-transferred on app-synchronizer ` + + `(Canton auto-reassigned Alice's Token from global → app)` + ) +} + +export async function bobSelfTransferToApp( + setup: MultiSyncSetup, + logger: Logger +): Promise { + const { p2Sdk, p3Sdk, bob, tokenAdmin, appSynchronizerId } = setup + + const [bobTokens, tokenRulesContracts] = await Promise.all([ + p2Sdk.ledger.acs.read({ + templateIds: [`${TEST_TOKEN_PREFIX}:Token`], + parties: [bob.partyId], + filterByParty: true, + }), + p3Sdk.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') + + 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, + }, + ], + 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..fa44c1a16 --- /dev/null +++ b/docs/wallet-integration-guide/examples/scripts/15-multi-sync/index.ts @@ -0,0 +1,103 @@ +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). +// See README.md for the full flow description. + +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 all synchronizers (global + app) and all participants (P1, P2, P3) +// 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/15-multi-sync/splice-test-token-v1-1.0.0.dar b/docs/wallet-integration-guide/examples/scripts/15-multi-sync/splice-test-token-v1-1.0.0.dar new file mode 100644 index 000000000..8aea10a1a Binary files /dev/null and b/docs/wallet-integration-guide/examples/scripts/15-multi-sync/splice-test-token-v1-1.0.0.dar differ 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..7a33ba5f7 --- /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 '@canton-network/wallet-sdk' + +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..fe1bba20d 100644 --- a/docs/wallet-integration-guide/examples/scripts/utils/index.ts +++ b/docs/wallet-integration-guide/examples/scripts/utils/index.ts @@ -9,6 +9,10 @@ import { AssetConfig, } from '@canton-network/wallet-sdk' +export type { SynchronizerMap } 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 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/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/ledger/dar/client.ts b/sdk/wallet-sdk/src/wallet/namespace/ledger/dar/client.ts index c35d271ab..bdf020635 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, 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..49cc502fb 100644 --- a/sdk/wallet-sdk/src/wallet/namespace/ledger/internal/index.ts +++ b/sdk/wallet-sdk/src/wallet/namespace/ledger/internal/index.ts @@ -22,9 +22,101 @@ 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 ) { diff --git a/sdk/wallet-sdk/src/wallet/namespace/ledger/namespace.ts b/sdk/wallet-sdk/src/wallet/namespace/ledger/namespace.ts index d6b7bd904..8683bd5f4 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() { 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..8f6dcf688 100644 --- a/sdk/wallet-sdk/src/wallet/namespace/party/external/service.ts +++ b/sdk/wallet-sdk/src/wallet/namespace/party/external/service.ts @@ -32,7 +32,7 @@ export class ExternalPartyNamespace { this.resolveParticipantUids( options?.confirmingParticipantEndpoints ?? [] ), - options?.synchronizerId || this.resolveSynchronizerId(), + options?.synchronizerId || this.findGlobalSynchronizer(), ]).then( ([ observingParticipantUids, @@ -79,7 +79,7 @@ export class ExternalPartyNamespace { ) } - private async resolveSynchronizerId() { + private async findGlobalSynchronizer() { const connectedSynchronizers = await this.ctx.ledgerProvider.request( { @@ -92,19 +92,16 @@ export class ExternalPartyNamespace { } ) - 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}` + const global = connectedSynchronizers.connectedSynchronizers?.find( + (s) => s.synchronizerAlias === 'global' + ) + if (!global) { + throw new Error( + 'Global synchronizer not found among connected synchronizers' ) } - return synchronizerId + return global.synchronizerId } /** 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..89af34335 100644 --- a/sdk/wallet-sdk/src/wallet/namespace/party/external/signed.ts +++ b/sdk/wallet-sdk/src/wallet/namespace/party/external/signed.ts @@ -50,7 +50,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 +151,9 @@ export class SignedPartyCreationService { } = options const ledgerProvider = defaultLedgerProvider ?? this.ctx.ledgerProvider try { - const synchronizerId = this.ctx.defaultSynchronizerId + const synchronizerId = + this.createPartyOptions?.synchronizerId ?? + this.ctx.defaultSynchronizerId await this.allocate( ledgerProvider, @@ -185,8 +194,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/state/client.ts b/sdk/wallet-sdk/src/wallet/namespace/state/client.ts new file mode 100644 index 000000000..2cdcc20fc --- /dev/null +++ b/sdk/wallet-sdk/src/wallet/namespace/state/client.ts @@ -0,0 +1,87 @@ +// 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 { Ops } from '@canton-network/core-provider-ledger' +import { SDKLogger } from '../../logger/index.js' +import { v3_4 } from '@canton-network/core-ledger-client-types' + +/** Maps the two synchronizer roles used in multi-synchronizer setups. */ +export type SynchronizerMap = { + globalSynchronizerId: string + appSynchronizerId: string +} + +export type ConnectedSynchronizersOptions = { + party?: string + participantId?: string + identityProviderId?: string +} + +export type ConnectedSynchronizer = + v3_4.components['schemas']['ConnectedSynchronizer'] + +export class State { + private readonly logger: SDKLogger + + constructor(private readonly ctx: SDKContext) { + this.logger = ctx.logger.child({ namespace: 'State' }) + } + + /** + * Returns the ID of the global synchronizer for this participant. + * + * 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). + * + * @returns The `synchronizerId` of the global synchronizer. + * @throws {Error} When no synchronizers are connected. + */ + public async globalSynchronizerId(): Promise { + const result = await this.connectedSynchronizers() + 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 + } + + /** + * 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/sdk.ts b/sdk/wallet-sdk/src/wallet/sdk.ts index 9138b4409..3b9b6ea28 100644 --- a/sdk/wallet-sdk/src/wallet/sdk.ts +++ b/sdk/wallet-sdk/src/wallet/sdk.ts @@ -19,6 +19,7 @@ import { } from './init/types/sdk.js' import { AuthTokenProvider } from '@canton-network/core-wallet-auth' import { toURL } from './common.js' +import { SynchronizerMap } from './namespace/state/index.js' import { ExtendedInitializedSDK, OfflineInitializedSDK, @@ -59,6 +60,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< @@ -168,6 +171,8 @@ export class SDK { } } +export type { SynchronizerMap } + async function getDefaultSynchronizerId( provider: AbstractLedgerProvider, logger: SDKLogger