diff --git a/.github/actions/setup_canton/action.yml b/.github/actions/setup_canton/action.yml index 2b684e3ce..48d6cf2d9 100644 --- a/.github/actions/setup_canton/action.yml +++ b/.github/actions/setup_canton/action.yml @@ -7,6 +7,10 @@ inputs: instance: description: 'Instance type: canton or localnet' required: true + multi-sync: + description: 'Start localnet with --profile multi-sync' + required: false + default: 'false' runs: using: 'composite' steps: @@ -105,7 +109,12 @@ runs: - name: Start Localnet if: inputs.instance == 'localnet' shell: bash - run: yarn start:localnet -- --network=${{ inputs.network }} + 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' }} diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 736952cb7..b6715e3a0 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -524,6 +524,61 @@ jobs: name: docker-logs-scripts-${{ matrix.network }} path: logs/ + 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 + + - 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 @@ -532,6 +587,7 @@ jobs: e2e-affected, wallet-sdk-snippets-e2e, wallet-sdk-scripts-e2e, + wallet-sdk-scripts-e2e-multi-sync, wallet-sdk-pkg, ] if: always() @@ -554,6 +610,10 @@ jobs: 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)" diff --git a/damljs/token-standard-models/daml.yaml b/damljs/token-standard-models/daml.yaml index 932ecf699..86faa2bd6 100644 --- a/damljs/token-standard-models/daml.yaml +++ b/damljs/token-standard-models/daml.yaml @@ -1,4 +1,4 @@ -sdk-version: 3.4.9 +sdk-version: 3.4.11 build-options: - --enable-interfaces=yes name: token-standard-models 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..1a7818237 --- /dev/null +++ b/docs/wallet-integration-guide/examples/scripts/15-multi-sync/README.md @@ -0,0 +1,239 @@ +# 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 private) +- Multi-synchronizer trade settlement with multi-party signing +- Cross-synchronizer contract reassignment + +## 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 two DARs required by this example are bundled in the same folder as the script: + +| DAR file | Purpose | +| -------------------------------------------- | ---------------------------------------------------------------------------------- | +| `splice-token-test-trading-app-v2-1.0.0.dar` | `OTCTrade` and `OTCTradeAllocationRequest` templates for orchestrating the trade | +| `splice-test-token-v1-1.0.0.dar` | `Token` and `TokenRules` templates — the custom instrument on the app-synchronizer | + +## 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 the DARs are missing from the script folder, example 15 will fail immediately with: +`Required DAR not found` + +### Expected output + +``` +[v1-15-multi-sync-trade] Connected synchronizers: global, app-synchronizer +[v1-15-multi-sync-trade] All required DARs uploaded successfully +[v1-15-multi-sync-trade] All DARs vetted on app-synchronizer +[v1-15-multi-sync-trade] Parties allocated — alice: ..., bob: ..., tradingApp: ... +[v1-15-multi-sync-trade] alice: registered on app-synchronizer +[v1-15-multi-sync-trade] bob: registered on app-synchronizer +[v1-15-multi-sync-trade] tradingApp: registered on app-synchronizer +[v1-15-multi-sync-trade] Alice: Amulet holding minted (2,000,000) +[v1-15-multi-sync-trade] TokenRules created by Bob (on app-synchronizer) +[v1-15-multi-sync-trade] Bob: Token holding minted (500 TestToken, on app-synchronizer) +[v1-15-multi-sync-trade] OTCTrade created by Trading App +[v1-15-multi-sync-trade] Trading App: Allocation requests created +[v1-15-multi-sync-trade] Alice: Amulet allocation created for leg-0 +[v1-15-multi-sync-trade] Bob: TestToken allocation created for leg-1 (on app-synchronizer) +[v1-15-multi-sync-trade] Trading App: OTCTrade settled +[v1-15-multi-sync-trade] Alice: TestToken self-transferred on app-synchronizer +[v1-15-multi-sync-trade] Final contract state after step 12 (Transfer): ... +``` + +## How it Works + +| Step | Who | What | Synchronizer | +| ---- | ----------- | ----------------------------------------------------------- | ------------ | +| 1 | — | Create SDKs (P1, P2, P3) and discover synchronizers | global + app | +| 2 | — | Vet DARs on all synchronizers and all participants | global + app | +| 3 | — | Allocate parties (Alice/P1, Bob/P2, TradingApp/P3) | global | +| 4 | — | Discover Token interface on app synchronizer | app | +| 5 | Alice | Mint 2,000,000 Amulet for Alice | global | +| 6a | Bob | Create `TokenRules` contract | app | +| 6b | Bob | Mint 500 `TestToken` holding | app | +| 6c | Bob | Reassign `TokenRules` + `Token` to global-domain | app → global | +| 7a | Alice | Create `OTCTradeProposal` (2 legs) | global | +| 7b | Bob | `OTCTradeProposal_Accept` | global | +| 7c | Trading App | `OTCTradeProposal_InitiateSettlement` → `OTCTrade` created | global | +| 8 | — | Read `OTCTrade` contract ID | global | +| 9 | Alice | `AllocationFactory_Allocate` (Amulet, leg-0) | global | +| 10 | Bob | `AllocationFactory_Allocate` (TestToken, leg-1) | global | +| 11a | — | Locate Bob's TestToken allocation | global | +| 11b | Trading App | `OTCTrade_Settle` (multi-party signing) | global | +| 11c | Bob + Alice | Reassign `TokenRules` + Alice's `Token` to app-synchronizer | global → app | +| 12 | Alice | `TransferFactory_Transfer` self-transfer | app | + +## Troubleshooting + +### `Required DAR not found` + +Verify the DAR files are present in the script folder: + +```bash +ls -la docs/wallet-integration-guide/examples/scripts/15-multi-sync/splice-token-test-trading-app-v2-1.0.0.dar \ + 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 `scripts/localnet/app-synchronizer.sc` bootstrap script must connect **both** +`app-provider` and `app-user` to the app-synchronizer. Check that you are using +the current version of that file (it should reference both 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 and app-user +``` + +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..1319ad226 --- /dev/null +++ b/docs/wallet-integration-guide/examples/scripts/15-multi-sync/_config.ts @@ -0,0 +1,18 @@ +// Copyright (c) 2025-2026 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +/** + * 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') 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..5e918869f --- /dev/null +++ b/docs/wallet-integration-guide/examples/scripts/15-multi-sync/_setup.ts @@ -0,0 +1,240 @@ +// 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 path from 'path' +import fs from 'fs/promises' +import { fileURLToPath } from 'url' +import { + TOKEN_NAMESPACE_CONFIG, + TOKEN_PROVIDER_CONFIG_DEFAULT, + resolvePreferredSynchronizerId, + createScanProxyClient, + vetDar, +} from '../utils/index.js' +import type { SynchronizerMap } from '../utils/index.js' +import { + LOCALNET_BOB_LEDGER_URL, + LOCALNET_TRADING_APP_LEDGER_URL, +} 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 + tokenP1: TokenNamespace + tokenP2: TokenNamespace + alice: PartyInfo + bob: PartyInfo + tradingApp: PartyInfo + globalSynchronizerId: string + appSynchronizerId: string + synchronizers: SynchronizerMap + scanProxy: Awaited> + 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 = + resolvePreferredSynchronizerId(allSynchronizers) + 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, + } + + // The standard splice-test-token-v1 and trading-app DARs are pre-installed + // and pre-vetted on the localnet (splice ≥ 0.6.1). We additionally upload + // and vet our custom `splice-test-token-self-transfer-v1` DAR which adds a + // `Token_SelfTransfer` choice on `Token` so post-settlement self-transfers + // do not require the `TokenRules` factory contract — letting `TokenRules` + // stay on the app-synchronizer permanently. + const here = path.dirname(fileURLToPath(import.meta.url)) + const SELF_TRANSFER_DAR = path.join( + here, + 'daml', + 'splice-test-token-self-transfer-v1', + 'splice-test-token-self-transfer-v1-1.0.1.dar' + ) + const selfTransferDar = await fs.readFile(SELF_TRANSFER_DAR) + await Promise.all([ + ...[p1SdkCtx, p2SdkCtx].flatMap((ctx) => + [globalSynchronizerId, appSynchronizerId].map((sid) => + vetDar(ctx.ledgerProvider, selfTransferDar, sid) + ) + ), + vetDar(p3SdkCtx.ledgerProvider, selfTransferDar, globalSynchronizerId), + ]) + logger.info( + 'Custom self-transfer DAR vetted: P1+P2 on both synchronizers, P3 on global only' + ) + + // Allocate parties: alice on P1, bob on P2, tradingApp on P3 (all on global synchronizer) + const aliceKey = p1Sdk.keys.generate() + const bobKey = p1Sdk.keys.generate() + const tradingAppKey = p1Sdk.keys.generate() + + const [allocatedAlice, allocatedBob, allocatedTradingApp] = + await Promise.all([ + p1Sdk.party.external + .create(aliceKey.publicKey, { + partyHint: 'v1-15-alice', + synchronizerId: globalSynchronizerId, + }) + .sign(aliceKey.privateKey) + .execute(), + p2Sdk.party.external + .create(bobKey.publicKey, { + partyHint: 'v1-15-bob', + synchronizerId: globalSynchronizerId, + }) + .sign(bobKey.privateKey) + .execute(), + p3Sdk.party.external + .create(tradingAppKey.publicKey, { + partyHint: 'v1-15-trading-app', + synchronizerId: globalSynchronizerId, + }) + .sign(tradingAppKey.privateKey) + .execute(), + ]) + + const alice: PartyInfo = { ...allocatedAlice, keyPair: aliceKey } + const bob: PartyInfo = { ...allocatedBob, keyPair: bobKey } + const tradingApp: PartyInfo = { + ...allocatedTradingApp, + keyPair: tradingAppKey, + } + + logger.info( + `Parties allocated — alice: ${alice.partyId} (P1), bob: ${bob.partyId} (P2), tradingApp: ${tradingApp.partyId} (P3)` + ) + + // Register Alice and Bob 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 }), + ]) + logger.info('Alice and Bob registered on app-synchronizer') + + // Connect scan proxy and discover Amulet admin + const scanProxy = await createScanProxyClient( + localNetStaticConfig.LOCALNET_REGISTRY_API_URL, + TOKEN_PROVIDER_CONFIG_DEFAULT, + logger + ) + const { amuletAdmin } = await scanProxy.fetchAmuletInfo() + logger.info(`Amulet asset discovered — admin: ${amuletAdmin}`) + + return { + p1Sdk, + p2Sdk, + p3Sdk, + p1SdkCtx, + p2SdkCtx, + p3SdkCtx, + tokenP1: p1Sdk.token, + tokenP2: p2Sdk.token, + alice, + bob, + tradingApp, + 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..1dcfa4be2 --- /dev/null +++ b/docs/wallet-integration-guide/examples/scripts/15-multi-sync/_trade_ops.ts @@ -0,0 +1,678 @@ +// Copyright (c) 2025-2026 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import type { Logger } from 'pino' +import type { ContractSpec } from '../utils/index.js' +import type { MultiSyncSetup } from './_setup.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' +// Custom token package (splice-test-token-self-transfer-v1) — a variant of +// splice-test-token-v1 that adds a `Token_SelfTransfer` choice on `Token`, +// so self-transfers do not need the `TokenRules` factory contract. +export const TEST_TOKEN_PREFIX = + '#splice-test-token-self-transfer-v1:Splice.Testing.Tokens.SelfTransferTokenV1' +export const TRADING_APP_PREFIX = + '#splice-token-test-trading-app:Splice.Testing.Apps.TradingApp' + +const ALLOCATION_FACTORY_IFACE = + '#splice-api-token-allocation-instruction-v1:Splice.Api.Token.AllocationInstructionV1:AllocationFactory' +const TRANSFER_FACTORY_IFACE = + '#splice-api-token-transfer-instruction-v1:Splice.Api.Token.TransferInstructionV1:TransferFactory' + +export function buildAllPartySpecs(setup: MultiSyncSetup): ContractSpec[] { + const { p1Sdk, p2Sdk, p3Sdk, alice, bob, tradingApp } = setup + return [ + { + label: 'Alice', + sdk: p1Sdk, + templateIds: [ + AMULET_TEMPLATE_ID, + `${TEST_TOKEN_PREFIX}:Token`, + `${TRADING_APP_PREFIX}:OTCTradeProposal`, + `${TRADING_APP_PREFIX}:OTCTrade`, + ], + parties: [alice.partyId], + }, + { + label: 'Bob', + sdk: p2Sdk, + templateIds: [ + AMULET_TEMPLATE_ID, + `${TEST_TOKEN_PREFIX}:TokenRules`, + `${TEST_TOKEN_PREFIX}:Token`, + ], + parties: [bob.partyId], + }, + { + label: 'TradingApp', + 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' + +export async function mintAmuletForAlice( + setup: MultiSyncSetup, + logger: Logger +): Promise { + const { p1Sdk, alice, globalSynchronizerId, scanProxy } = setup + const { + amuletRulesContract, + amuletRulesCid, + activeRoundContract, + openMiningRoundCid, + } = await scanProxy.fetchAmuletInfo() + + 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, appSynchronizerId } = setup + + await Promise.all([ + p2Sdk.ledger + .prepare({ + partyId: bob.partyId, + commands: { + CreateCommand: { + templateId: `${TEST_TOKEN_PREFIX}:TokenRules`, + createArguments: { admin: bob.partyId }, + }, + }, + disclosedContracts: [], + synchronizerId: appSynchronizerId, + }) + .sign(bob.keyPair.privateKey) + .execute({ partyId: bob.partyId }), + + p2Sdk.ledger + .prepare({ + partyId: bob.partyId, + commands: { + CreateCommand: { + templateId: `${TEST_TOKEN_PREFIX}:Token`, + createArguments: { + holding: { + owner: bob.partyId, + instrumentId: { + admin: bob.partyId, + id: 'TestToken', + }, + amount: BOB_TOKEN_MINT_AMOUNT, + lock: null, + meta: { values: {} }, + }, + }, + }, + }, + disclosedContracts: [], + synchronizerId: appSynchronizerId, + }) + .sign(bob.keyPair.privateKey) + .execute({ partyId: bob.partyId }), + ]) + + logger.info( + `Bob: TokenRules created + Token minted (${BOB_TOKEN_MINT_AMOUNT} TestToken) 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() + 1800 * 1000).toISOString() + const settleBefore = new Date(Date.now() + 3600 * 1000).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, + tokenP1, + alice, + globalSynchronizerId, + scanProxy, + amuletAdmin, + } = setup + + const pendingRequests = await tokenP1.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 allocationArgs = { + expectedAdmin: amuletAdmin, + allocation: { + settlement: requestView.settlement, + transferLegId: legId, + transferLeg: requestView.transferLegs[legId], + }, + requestedAt: new Date(Date.now() - 60_000).toISOString(), + inputHoldingCids: [amuletHoldingCid], + extraArgs: { + context: { values: {} as Record }, + meta: { values: {} }, + }, + } + + const { factoryId, choiceContext } = + await scanProxy.fetchAllocationFactory(allocationArgs) + allocationArgs.extraArgs.context = { + ...(choiceContext.choiceContextData ?? {}), + values: + (choiceContext.choiceContextData?.values as Record< + string, + unknown + >) ?? {}, + } + + await p1Sdk.ledger + .prepare({ + partyId: alice.partyId, + commands: [ + { + ExerciseCommand: { + templateId: ALLOCATION_FACTORY_IFACE, + contractId: factoryId, + choice: 'AllocationFactory_Allocate', + choiceArgument: allocationArgs, + }, + }, + ], + disclosedContracts: choiceContext.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 + tokenAllocationContract: AcsContractEntry +}> { + const { p2Sdk, tokenP2, bob, globalSynchronizerId, appSynchronizerId } = + setup + + const pendingRequests = await tokenP2.allocation.request.pending( + bob.partyId + ) + const requestView = pendingRequests[0].interfaceViewValue! + const legId = Object.keys(requestView.transferLegs).find( + (key) => requestView.transferLegs[key].sender === bob.partyId + )! + if (!legId) throw new Error('No transfer leg found for Bob') + + const tokenHoldings = await p2Sdk.ledger.acs.read({ + templateIds: [`${TEST_TOKEN_PREFIX}:Token`], + parties: [bob.partyId], + filterByParty: true, + }) + const tokenHoldingCid = tokenHoldings[0]?.contractId + if (!tokenHoldingCid) throw new Error('Token holding not found for Bob') + + // Use the custom `Token_Allocate` choice (controller=owner) on Bob's Token + // to create a TokenAllocation directly, bypassing TokenRules entirely. + // Runs on app-synchronizer: TokenRules and Bob's Token both stay on app, + // and the new TokenAllocation lives on app (no executor observer). + await p2Sdk.ledger + .prepare({ + partyId: bob.partyId, + commands: [ + { + ExerciseCommand: { + templateId: `${TEST_TOKEN_PREFIX}:Token`, + contractId: tokenHoldingCid, + choice: 'Token_Allocate', + choiceArgument: { + allocation: { + settlement: requestView.settlement, + transferLegId: legId, + transferLeg: requestView.transferLegs[legId], + }, + }, + }, + }, + ], + disclosedContracts: [], + synchronizerId: appSynchronizerId, + }) + .sign(bob.keyPair.privateKey) + .execute({ partyId: bob.partyId }) + + logger.info( + 'Bob: TestToken allocated for leg-1 via Token_Allocate (app-synchronizer)' + ) + + // Settlement runs on global, so move just the TokenAllocation app → global. + // Bob is sole signatory (sender + admin), so P2 can perform the + // reassignment alone. TokenRules is untouched. + const allocations = await p2Sdk.ledger.acs.read({ + templateIds: [`${TEST_TOKEN_PREFIX}:TokenAllocation`], + parties: [bob.partyId], + filterByParty: true, + }) + const tokenAllocationCid = allocations[0]?.contractId + if (!tokenAllocationCid) + throw new Error('TokenAllocation not found after Token_Allocate') + + await p2Sdk.ledger.internal.reassign({ + submitter: bob.partyId, + contractId: tokenAllocationCid, + source: appSynchronizerId, + target: globalSynchronizerId, + }) + logger.info('Bob: TokenAllocation reassigned app → global for settlement') + + // Re-read after reassignment so we have the up-to-date entry (incl. + // synchronizerId=global) for disclosure to TradingApp at settlement. + const allocationsOnGlobal = await p2Sdk.ledger.acs.read({ + templateIds: [`${TEST_TOKEN_PREFIX}:TokenAllocation`], + parties: [bob.partyId], + filterByParty: true, + }) + const tokenAllocationContract = allocationsOnGlobal.find( + (c) => c.contractId === tokenAllocationCid + ) + if (!tokenAllocationContract) + throw new Error('TokenAllocation not found after reassignment') + + return { legId, tokenAllocationContract } +} + +export interface SettleParams { + otcTradeCid: string + legIdAlice: string + legIdBob: string + testTokenAllocationContract: AcsContractEntry +} + +export async function settleOtcTrade( + setup: MultiSyncSetup, + params: SettleParams, + logger: Logger +): Promise { + const { + p3Sdk, + tokenP1, + alice, + tradingApp, + globalSynchronizerId, + scanProxy, + } = setup + const { otcTradeCid, legIdAlice, legIdBob, testTokenAllocationContract } = + params + + const allocationsAlice = await tokenP1.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 scanProxy.fetchExecuteTransferContext( + amuletAllocation.contractId + ) + + const allocationsWithContext = { + [legIdAlice]: { + _1: amuletAllocation.contractId, + _2: { + context: { + ...(amuletExecCtx.choiceContextData ?? {}), + values: + (amuletExecCtx.choiceContextData?.values as Record< + string, + unknown + >) ?? {}, + }, + meta: { values: {} }, + }, + }, + [legIdBob]: { + _1: testTokenAllocationContract.contractId, + _2: { context: { values: {} }, meta: { values: {} } }, + }, + } + + // Disclose Amulet system contracts from scan proxy AND Bob's TokenAllocation + // (TradingApp is no longer an observer of TokenAllocation, so it must be + // disclosed via createdEventBlob to allow Allocation_ExecuteTransfer). + const disclosedContracts = [ + ...(amuletExecCtx.disclosedContracts ?? []).map((c) => ({ + ...c, + synchronizerId: '', + })), + { + templateId: testTokenAllocationContract.templateId, + contractId: testTokenAllocationContract.contractId, + createdEventBlob: testTokenAllocationContract.createdEventBlob!, + 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 interface TransferParams { + tokenRulesCid: string +} + +/** + * Bob self-transfers a portion of his remaining TestToken holding from global + * back to app-synchronizer. After the OTC settlement, Bob's senderChange Token + * (the post-allocation remainder) and TokenRules both live on global. Bob is + * the signatory of both contracts (Token: owner+admin, TokenRules: admin), so + * when P2 submits this command targeting app-synchronizer, Canton automatically + * reassigns both contracts global → app-synchronizer. + * + * This demonstrates a second automatic cross-synchronizer reassignment + * (the inverse direction of step 10), with no manual reassign required. + */ +export async function selfTransferToken( + setup: MultiSyncSetup, + params: TransferParams, + logger: Logger +): Promise { + const { p2Sdk, bob, appSynchronizerId } = setup + const { tokenRulesCid } = params + + const bobTokens = await p2Sdk.ledger.acs.read({ + templateIds: [`${TEST_TOKEN_PREFIX}:Token`], + parties: [bob.partyId], + filterByParty: true, + }) + const bobTokenCid = bobTokens[0]?.contractId + if (!bobTokenCid) + throw new Error( + 'Bob: remainder Token holding not found after settlement' + ) + + const selfTransferAmount = '100' + + await p2Sdk.ledger + .prepare({ + partyId: bob.partyId, + commands: [ + { + ExerciseCommand: { + templateId: `${TEST_TOKEN_PREFIX}:Token`, + contractId: bobTokenCid, + choice: 'Token_SelfTransfer', + choiceArgument: { + splitAmount: selfTransferAmount, + }, + }, + }, + ], + // No TokenRules needed: `Token_SelfTransfer` operates only on the + // Token contract itself. P2 hosts Bob (signatory of Token), so + // Canton auto-reassigns the Token global → app for this command. + disclosedContracts: [], + synchronizerId: appSynchronizerId, + }) + .sign(bob.keyPair.privateKey) + .execute({ partyId: bob.partyId }) + + logger.info( + `Bob: ${selfTransferAmount} TestToken self-transferred on app-synchronizer ` + + `(Token_SelfTransfer choice — no TokenRules involved; ` + + `Canton auto-reassigned Token global → app)` + ) +} + +/** + * Alice self-transfers her TestToken (received from the OTC settlement) from + * global to app-synchronizer using the new `Token_SelfTransfer` choice on + * `Token`. Because that choice operates only on the Token contract (and the + * input Token already carries admin's signature, authorizing the new outputs), + * no `TokenRules` contract has to be referenced or disclosed at all. + * P1 hosts Alice (signatory of her Token), so Canton auto-reassigns the + * Token global → app as part of this command. + */ +export async function aliceSelfTransferToApp( + setup: MultiSyncSetup, + logger: Logger +): Promise { + const { p1Sdk, alice, appSynchronizerId } = setup + + const aliceTokens = await p1Sdk.ledger.acs.read({ + templateIds: [`${TEST_TOKEN_PREFIX}:Token`], + parties: [alice.partyId], + filterByParty: true, + }) + const aliceTokenCid = aliceTokens[0]?.contractId + if (!aliceTokenCid) + throw new Error('Alice: Token holding not found after settlement') + + await p1Sdk.ledger + .prepare({ + partyId: alice.partyId, + commands: [ + { + ExerciseCommand: { + templateId: `${TEST_TOKEN_PREFIX}:Token`, + contractId: aliceTokenCid, + choice: 'Token_SelfTransfer', + choiceArgument: { + splitAmount: TRADE_TOKEN_AMOUNT, + }, + }, + }, + ], + disclosedContracts: [], + synchronizerId: appSynchronizerId, + }) + .sign(alice.keyPair.privateKey) + .execute({ partyId: alice.partyId }) + + logger.info( + `Alice: ${TRADE_TOKEN_AMOUNT} TestToken self-transferred on app-synchronizer ` + + `(Token_SelfTransfer choice — no TokenRules involved; ` + + `Canton auto-reassigned Alice's Token global → app)` + ) +} diff --git a/docs/wallet-integration-guide/examples/scripts/15-multi-sync/daml/splice-test-token-self-transfer-v1/daml.yaml b/docs/wallet-integration-guide/examples/scripts/15-multi-sync/daml/splice-test-token-self-transfer-v1/daml.yaml new file mode 100644 index 000000000..3721a41ee --- /dev/null +++ b/docs/wallet-integration-guide/examples/scripts/15-multi-sync/daml/splice-test-token-self-transfer-v1/daml.yaml @@ -0,0 +1,17 @@ +sdk-version: 3.4.11 +name: splice-test-token-self-transfer-v1 +source: daml +version: 1.0.1 +dependencies: + - daml-prim + - daml-stdlib +data-dependencies: + - ../../../../../../../.localnet/dars/splice-api-token-metadata-v1-1.0.0.dar + - ../../../../../../../.localnet/dars/splice-api-token-holding-v1-1.0.0.dar + - ../../../../../../../.localnet/dars/splice-api-token-transfer-instruction-v1-1.0.0.dar + - ../../../../../../../.localnet/dars/splice-api-token-allocation-v1-1.0.0.dar + - ../../../../../../../.localnet/dars/splice-api-token-allocation-instruction-v1-1.0.0.dar + - splice-token-standard-utils-2.0.0.dar + +build-options: + - --target=2.1 diff --git a/docs/wallet-integration-guide/examples/scripts/15-multi-sync/daml/splice-test-token-self-transfer-v1/daml/Splice/Testing/Tokens/SelfTransferTokenV1.daml b/docs/wallet-integration-guide/examples/scripts/15-multi-sync/daml/splice-test-token-self-transfer-v1/daml/Splice/Testing/Tokens/SelfTransferTokenV1.daml new file mode 100644 index 000000000..5fe70377d --- /dev/null +++ b/docs/wallet-integration-guide/examples/scripts/15-multi-sync/daml/splice-test-token-self-transfer-v1/daml/Splice/Testing/Tokens/SelfTransferTokenV1.daml @@ -0,0 +1,317 @@ +-- Copyright (c) 2025-2026 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +-- SPDX-License-Identifier: Apache-2.0 + +-- | A variant of TestTokenV1 (`splice-test-token-v1`) for the wallet-kernel +-- multi-synchronizer DvP example. +-- +-- It is functionally equivalent for the OTC trade flow (mint, OTC propose / +-- accept / initiate, allocation, settlement), but adds a `Token_SelfTransfer` +-- choice on `Token` itself, controlled by the holding owner. Because that +-- choice operates *only* on the `Token` contract, it does not require the +-- `TokenRules` factory contract at all — so `TokenRules` can stay on the +-- app-synchronizer permanently while a self-transfer happens on a different +-- synchronizer (or the same one) without ever being reassigned or disclosed. +module Splice.Testing.Tokens.SelfTransferTokenV1 where + +import DA.Assert +import DA.Optional + +import Splice.Api.Token.MetadataV1 +import Splice.Api.Token.HoldingV1 qualified as V1 +import Splice.Api.Token.AllocationV1 qualified as V1 +import Splice.Api.Token.AllocationInstructionV1 qualified as V1 +import Splice.Api.Token.TransferInstructionV1 qualified as V1 +import Splice.TokenStandard.Utils + + +-- | A token holding. +template Token with + holding : V1.HoldingView + where + signatory holding.owner, holding.instrumentId.admin + + ensure + holding.amount > 0.0 && + isNone holding.lock + + interface instance V1.Holding for Token where + view = holding + + -- | Owner-only choice that splits this Token into two new Tokens + -- (a `splitAmount` slice and a remainder). Because the input Token already + -- carries the admin's signature, both output Tokens are authorized via + -- the input's signatories — no `TokenRules` contract needs to be involved + -- in the transaction at all. This lets the owner self-transfer (i.e., + -- split) holdings on any synchronizer without ever needing TokenRules to + -- be co-located. + choice Token_SelfTransfer : (ContractId Token, Optional (ContractId Token)) + with + splitAmount : Decimal + controller holding.owner + do + require "splitAmount must be positive" (splitAmount > 0.0) + require "splitAmount must not exceed balance" (splitAmount <= holding.amount) + splitCid <- create this with + holding = holding with amount = splitAmount, lock = None + let remainder = holding.amount - splitAmount + remCid <- + if remainder == 0.0 + then pure None + else Some <$> create this with + holding = holding with amount = remainder, lock = None + pure (splitCid, remCid) + + -- | Owner-only choice that creates a `TokenAllocation` directly from this + -- Token, bypassing `TokenRules.AllocationFactory_Allocate`. The standard + -- `TokenAllocation` template (see below) makes the settlement executor an + -- observer of the allocation, which forces the allocation transaction to + -- run on a synchronizer where the executor's hosting participant is + -- connected. By dropping that observer (see this variant) and creating + -- the allocation via this Token-level choice, the allocation can be + -- created on any synchronizer that hosts the holding owner, without + -- involving the executor at creation time. The allocation is later + -- disclosed to the executor via `createdEventBlob` for settlement. + choice Token_Allocate : + (ContractId TokenAllocation, Optional (ContractId Token)) + with + allocation : V1.AllocationSpecification + controller holding.owner + do + require "allocation amount must be positive" + (allocation.transferLeg.amount > 0.0) + require "allocation amount must not exceed balance" + (allocation.transferLeg.amount <= holding.amount) + require "allocation sender must be the holding owner" + (allocation.transferLeg.sender == holding.owner) + require "allocation instrumentId must match the holding" + (allocation.transferLeg.instrumentId == holding.instrumentId) + allocCid <- create TokenAllocation with allocation + let remainder = holding.amount - allocation.transferLeg.amount + remCid <- + if remainder == 0.0 + then pure None + else Some <$> create this with + holding = holding with amount = remainder, lock = None + pure (allocCid, remCid) + +-- | Transfer instruction representing a Token offer. +template TokenTransferOffer with + transfer : V1.Transfer + where + ensure transfer.amount > 0.0 && transfer.requestedAt <= transfer.executeBefore + + signatory transfer.sender, transfer.instrumentId.admin + observer transfer.receiver + + interface instance V1.Holding for TokenTransferOffer where + view = transferHoldingView transfer + + interface instance V1.TransferInstruction for TokenTransferOffer where + view = V1.TransferInstructionView with + originalInstructionCid = None + transfer + status = V1.TransferPendingReceiverAcceptance + meta = emptyMetadata + + transferInstruction_withdrawImpl _self _arg = do + tokenCid <- create Token with + holding = (transferHoldingView transfer) with lock = None + pure V1.TransferInstructionResult with + output = V1.TransferInstructionResult_Failed + senderChangeCids = [toInterfaceContractId tokenCid] + meta = emptyMetadata + + transferInstruction_rejectImpl _self _arg = do + tokenCid <- create Token with + holding = (transferHoldingView transfer) with lock = None + pure V1.TransferInstructionResult with + output = V1.TransferInstructionResult_Failed + senderChangeCids = [toInterfaceContractId tokenCid] + meta = emptyMetadata + + transferInstruction_updateImpl _self _arg = + fail "V1.TransferInstruction_Update: not supported by TokenTransferOffer" + + transferInstruction_acceptImpl _self _arg = do + tokenCid <- create Token with + holding = (transferHoldingView transfer) with + owner = transfer.receiver + lock = None + pure V1.TransferInstructionResult with + senderChangeCids = [] + output = V1.TransferInstructionResult_Completed with + receiverHoldingCids = [toInterfaceContractId tokenCid] + meta = emptyMetadata + + +-- | Allocation of a Token. +-- +-- NOTE: unlike `splice-test-token-v1`'s `TokenAllocation`, this variant does +-- NOT list `allocation.settlement.executor` as observer. That allows the +-- allocation to be created on a synchronizer where the executor's hosting +-- participant is not connected. The trade-off is that the executor must +-- receive the allocation via `createdEventBlob` disclosure at settlement +-- time (instead of being an automatic informee through the contract). +template TokenAllocation with + allocation : V1.AllocationSpecification + where + signatory allocation.transferLeg.sender, allocation.transferLeg.instrumentId.admin + ensure isValidAllocationSpecificationV1 allocation + + interface instance V1.Holding for TokenAllocation where + view = allocationHoldingView allocation + + interface instance V1.Allocation for TokenAllocation where + view = V1.AllocationView with + allocation + holdingCids = [] + meta = emptyMetadata + + allocation_withdrawImpl _self _arg = do + tokenCid <- create Token with + holding = (allocationHoldingView allocation) with lock = None + pure V1.Allocation_WithdrawResult with + senderHoldingCids = [toInterfaceContractId tokenCid] + meta = emptyMetadata + + allocation_cancelImpl _self _arg = do + tokenCid <- create Token with + holding = (allocationHoldingView allocation) with lock = None + pure V1.Allocation_CancelResult with + senderHoldingCids = [toInterfaceContractId tokenCid] + meta = emptyMetadata + + allocation_executeTransferImpl _self _arg = do + tokenCid <- create Token with + holding = (allocationHoldingView allocation) with + owner = allocation.transferLeg.receiver + lock = None + pure V1.Allocation_ExecuteTransferResult with + senderHoldingCids = [] + receiverHoldingCids = [toInterfaceContractId tokenCid] + meta = emptyMetadata + + +-- | Template providing the V1 standard factory choices. +template TokenRules with + admin : Party + where + signatory admin + + interface instance V1.TransferFactory for TokenRules where + view = V1.TransferFactoryView with + admin + meta = emptyMetadata + + transferFactory_publicFetchImpl _self _arg = pure V1.TransferFactoryView with + admin + meta = emptyMetadata + + transferFactory_transferImpl _self arg = do + requireMatchExpected ("expectedAdmin", arg.expectedAdmin) admin + let transfer = arg.transfer + require "Instrument-admin must match the factory" (transfer.instrumentId.admin == admin) + require "Amount must be positive" (transfer.amount > 0.0) + assertDeadlineExceeded "Transfer.requestedAt" transfer.requestedAt + assertWithinDeadline "Transfer.executeBefore" transfer.executeBefore + optSenderChangeCid <- consumeHoldingAmount transfer.sender transfer.inputHoldingCids transfer.amount transfer.instrumentId + let senderChangeCids = optionalToList (toInterfaceContractId <$> optSenderChangeCid) + if transfer.receiver == transfer.sender + then do + splitCid <- create Token with + holding = (transferHoldingView transfer) with lock = None + pure $ V1.TransferInstructionResult with + senderChangeCids + output = V1.TransferInstructionResult_Completed with + receiverHoldingCids = [toInterfaceContractId splitCid] + meta = emptyMetadata + else do + instrCid <- create TokenTransferOffer with + transfer + pure $ V1.TransferInstructionResult with + senderChangeCids + output = V1.TransferInstructionResult_Pending with + transferInstructionCid = toInterfaceContractId instrCid + meta = emptyMetadata + + + interface instance V1.AllocationFactory for TokenRules where + view = V1.AllocationFactoryView with + admin + meta = emptyMetadata + + allocationFactory_publicFetchImpl _self _arg = pure V1.AllocationFactoryView with + admin + meta = emptyMetadata + + allocationFactory_allocateImpl _self arg = do + requireMatchExpected ("expectedAdmin", arg.expectedAdmin) admin + let V1.AllocationFactory_Allocate {allocation, requestedAt, inputHoldingCids} = arg + let settlement = allocation.settlement + let transferLeg = allocation.transferLeg + assertDeadlineExceeded "Allocation.settlement.requestedAt" settlement.requestedAt + assertWithinDeadline "Allocation.settlement.allocateBefore" settlement.allocateBefore + require "Allocation.settlement.allocateBefore <= Allocation.settlement.settleBefore" (settlement.allocateBefore <= settlement.settleBefore) + require "Transfer amount must be positive" (transferLeg.amount > 0.0) + require "Instrument-admin must match the factory" (transferLeg.instrumentId.admin == admin) + assertDeadlineExceeded "requestedAt" requestedAt + require "At least one input holding must be provided" (not $ null inputHoldingCids) + optSenderChangeCid <- consumeHoldingAmount transferLeg.sender inputHoldingCids transferLeg.amount transferLeg.instrumentId + allocationCid <- toInterfaceContractId <$> create TokenAllocation with + allocation = arg.allocation + pure V1.AllocationInstructionResult with + senderChangeCids = optionalToList (toInterfaceContractId <$> optSenderChangeCid) + output = V1.AllocationInstructionResult_Completed with allocationCid + meta = emptyMetadata + + +-- Utils +-------- + +consumeHoldingAmount : Party -> [ContractId V1.Holding] -> Decimal -> V1.InstrumentId -> Update (Optional (ContractId V1.Holding)) +consumeHoldingAmount sender inputCids amount instrumentId = do + inputAmounts <- forA inputCids $ \cid -> do + holding <- fetch cid + archive cid + let holdingView = view holding + require' ("holding owner", holdingView.owner) isEqualR ("transfer.sender", sender) + require' ("holding.instrumentId", holdingView.instrumentId) isEqualR ("transfer.instrumentId", instrumentId) + pure holdingView.amount + let totalAmount = sum inputAmounts + require' ("input amount", totalAmount) isGreaterOrEqualR ("transfer.amount", amount) + let remainder = totalAmount - amount + if remainder == 0.0 + then pure None + else (Some . toInterfaceContractId) <$> create Token with + holding = V1.HoldingView with + owner = sender + amount = remainder + instrumentId + lock = None + meta = emptyMetadata + +allocationHoldingView : V1.AllocationSpecification -> V1.HoldingView +allocationHoldingView (V1.AllocationSpecification with settlement, transferLeg) = + V1.HoldingView with + owner = transferLeg.sender + amount = transferLeg.amount + instrumentId = transferLeg.instrumentId + lock = Some V1.Lock with + holders = [transferLeg.sender, transferLeg.instrumentId.admin] + expiresAt = Some settlement.settleBefore + expiresAfter = None + context = Some $ "allocation for settlement of " <> settlement.settlementRef.id + meta = emptyMetadata + +transferHoldingView : V1.Transfer -> V1.HoldingView +transferHoldingView transfer = + V1.HoldingView with + owner = transfer.sender + amount = transfer.amount + instrumentId = transfer.instrumentId + lock = Some V1.Lock with + holders = [transfer.sender, transfer.instrumentId.admin] + expiresAt = Some transfer.executeBefore + expiresAfter = None + context = Some $ "transfer to " <> show transfer.receiver + meta = emptyMetadata diff --git a/docs/wallet-integration-guide/examples/scripts/15-multi-sync/daml/splice-test-token-self-transfer-v1/splice-test-token-self-transfer-v1-1.0.1.dar b/docs/wallet-integration-guide/examples/scripts/15-multi-sync/daml/splice-test-token-self-transfer-v1/splice-test-token-self-transfer-v1-1.0.1.dar new file mode 100644 index 000000000..a2b9d856d Binary files /dev/null and b/docs/wallet-integration-guide/examples/scripts/15-multi-sync/daml/splice-test-token-self-transfer-v1/splice-test-token-self-transfer-v1-1.0.1.dar differ diff --git a/docs/wallet-integration-guide/examples/scripts/15-multi-sync/daml/splice-test-token-self-transfer-v1/splice-token-standard-utils-2.0.0.dar b/docs/wallet-integration-guide/examples/scripts/15-multi-sync/daml/splice-test-token-self-transfer-v1/splice-token-standard-utils-2.0.0.dar new file mode 100644 index 000000000..4040cfb96 Binary files /dev/null and b/docs/wallet-integration-guide/examples/scripts/15-multi-sync/daml/splice-test-token-self-transfer-v1/splice-token-standard-utils-2.0.0.dar differ 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..a7a8b8dfb --- /dev/null +++ b/docs/wallet-integration-guide/examples/scripts/15-multi-sync/index.ts @@ -0,0 +1,116 @@ +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, + selfTransferToken, + aliceSelfTransferToApp, + buildAllPartySpecs, +} 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 index.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), and TradingApp (P3) +// Step 4: Discover Token interface on app synchronizer for Bob's token (used in Steps 6b and 10) +const setup = await setupMultiSyncTrade(logger) +const { tokenP2, alice, bob, synchronizers, amuletAdmin } = setup + +const allPartySpecs = buildAllPartySpecs(setup) + +// ── Steps 5–6: Init holdings ──────────────────────────────────────────────── +// Step 5: Mint Amulet for Alice (global synchronizer) +// Steps 6a+6b: TokenRules + Token for Bob (app-synchronizer) +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: bob.partyId, id: 'TestToken' }, + meta: { values: {} }, + }, +} + +// ── Steps 7a–7c + 8: 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 9–10: Allocate in parallel ──────────────────────────────────────── +// Step 9: Alice allocates Amulet for leg-0 (global synchronizer) +// Step 10: Bob allocates Token for leg-1 via the custom Token_Allocate choice +// on app-synchronizer (TokenRules + Bob's input Token stay on app). +// Then the resulting TokenAllocation is solo-reassigned app → global +// (Bob is sole signatory) so OTCTrade_Settle can consume it. +const [legIdAlice, { legId: legIdBob, tokenAllocationContract }] = + await Promise.all([ + allocateAmuletForAlice(setup, logger), + allocateTokenForBob(setup, logger), + ]) +logger.info('Contracts after allocations:') +await logAllContracts(logger, synchronizers, allPartySpecs) + +// ── Step 11a: Locate Bob's TestToken allocation ──────────────────────────────────── +// (TokenAllocation is on global after step 10's reassignment; ACS read on P2.) +const allocationsBob = await tokenP2.allocation.pending(bob.partyId) +const testTokenAllocation = allocationsBob.find( + (a) => a.interfaceViewValue.allocation.transferLegId === legIdBob +) +if (!testTokenAllocation) throw new Error('TestToken allocation not found') + +// ── Step 11b: TradingApp settles the OTCTrade ───────────────────────────────── +await settleOtcTrade( + setup, + { + otcTradeCid, + legIdAlice, + legIdBob, + testTokenAllocationContract: tokenAllocationContract, + }, + logger +) +logger.info('Contracts after settlement:') +await logAllContracts(logger, synchronizers, allPartySpecs) + +// ── Step 12: (no-op) Bob's TestToken never moved ───────────────────────────── +// Step 10 ran on app-synchronizer, so TokenRules and Bob's senderChange Token +// both stayed on app the entire time. Nothing to bring back. +// await selfTransferToken(setup, { tokenRulesCid }, logger) + +// ── Step 13: Alice self-transfers her TestToken to app-synchronizer ───────── +// Alice's Token (received from settlement) is on global. The Token_SelfTransfer +// choice operates only on Token, so no TokenRules contract is involved. +// P1 hosts Alice (signatory of her Token), so Canton auto-reassigns Alice's +// Token global → app as part of this command. +await aliceSelfTransferToApp(setup, logger) +logger.info('Final contract state:') +await logAllContracts(logger, synchronizers, allPartySpecs) 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..228c491c1 100644 --- a/docs/wallet-integration-guide/examples/scripts/utils/index.ts +++ b/docs/wallet-integration-guide/examples/scripts/utils/index.ts @@ -9,6 +9,22 @@ import { AssetConfig, } from '@canton-network/wallet-sdk' +export { + syncAlias, + logAllContracts, + resolvePreferredSynchronizerId, +} from './synchronizer.js' +export type { SynchronizerMap, ContractSpec } from './synchronizer.js' + +export { vetDar } from './dar.js' +export { ScanProxyClient, createScanProxyClient } from './scan-proxy.js' +export type { + ScanProxyContract, + AmuletInfo, + ScanProxyChoiceContext, + AllocationFactoryResult, +} from './scan-proxy.js' + export function getActiveContractCid(entry: JSContractEntry) { if ('JsActiveContract' in entry) { return entry.JsActiveContract.createdEvent.contractId diff --git a/docs/wallet-integration-guide/examples/scripts/utils/scan-proxy.ts b/docs/wallet-integration-guide/examples/scripts/utils/scan-proxy.ts new file mode 100644 index 000000000..6a5e82933 --- /dev/null +++ b/docs/wallet-integration-guide/examples/scripts/utils/scan-proxy.ts @@ -0,0 +1,242 @@ +// Copyright (c) 2025-2026 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import type { Logger } from 'pino' +import type { + LedgerTypes, + TokenProviderConfig, +} from '@canton-network/wallet-sdk' +import { AuthTokenProvider } from '@canton-network/core-wallet-auth' + +// ───────────────────────────────────────────────────────────────────────────── +// Shared types +// ───────────────────────────────────────────────────────────────────────────── + +/** A contract as returned by the scan proxy API (camelCase JS field names). */ +export interface ScanProxyContract { + contract_id: string + template_id: string + created_event_blob: string + payload: Record +} + +/** Amulet instrument info fetched from the scan proxy. */ +export interface AmuletInfo { + /** Full AmuletRules contract from scan proxy. */ + amuletRulesContract: ScanProxyContract + /** Contract ID of AmuletRules. */ + amuletRulesCid: string + /** DSO party ID — admin of the Amulet instrument. */ + amuletAdmin: string + /** The currently active OpenMiningRound contract. */ + activeRoundContract: ScanProxyContract + /** Contract ID of the active OpenMiningRound. */ + openMiningRoundCid: string +} + +/** Choice context returned by the scan proxy token-standard registry. */ +export interface ScanProxyChoiceContext { + disclosedContracts: LedgerTypes['DisclosedContract'][] + choiceContextData?: Record +} + +/** Result of resolving the AllocationFactory for an Amulet instrument. */ +export interface AllocationFactoryResult { + factoryId: string + choiceContext: ScanProxyChoiceContext +} + +// ───────────────────────────────────────────────────────────────────────────── +// ScanProxyClient +// ───────────────────────────────────────────────────────────────────────────── + +/** + * Thin client for the scan proxy API calls needed by multi-sync examples. + * + * Covers three operations: + * 1. {@link fetchAmuletInfo} — AmuletRules + active OpenMiningRound + * 2. {@link fetchAllocationFactory} — resolve AllocationFactory for Amulet + * 3. {@link fetchExecuteTransferContext} — execute-transfer context for settlement + * + * Use {@link createScanProxyClient} to construct an authenticated instance. + */ +export class ScanProxyClient { + /** Base URL with guaranteed trailing slash for URL resolution. */ + private readonly baseHref: string + private readonly headers: Record + + constructor(baseUrl: URL, headers: Record) { + this.baseHref = baseUrl.href.endsWith('/') + ? baseUrl.href + : baseUrl.href + '/' + this.headers = headers + } + + /** + * Fetches AmuletRules and the currently active OpenMiningRound from the + * scan proxy (`/v0/scan-proxy/amulet-rules` and + * `/v0/scan-proxy/open-and-issuing-mining-rounds`). + * + * Returns all fields needed to build Amulet tap / allocation commands. + */ + async fetchAmuletInfo(): Promise { + const [amuletRulesResp, roundsResp] = await Promise.all([ + fetch(new URL('amulet-rules', this.baseHref), { + headers: this.headers, + }), + fetch(new URL('open-and-issuing-mining-rounds', this.baseHref), { + headers: this.headers, + }), + ]) + + if (!amuletRulesResp.ok) + throw new Error( + `Failed to fetch AmuletRules: ${amuletRulesResp.status}` + ) + if (!roundsResp.ok) + throw new Error( + `Failed to fetch mining rounds: ${roundsResp.status}` + ) + + const { amulet_rules: amuletRulesWithState } = + (await amuletRulesResp.json()) as { + amulet_rules: { contract: ScanProxyContract } + } + const { open_mining_rounds: openMiningRoundsWithState } = + (await roundsResp.json()) as { + open_mining_rounds: Array<{ + contract: ScanProxyContract & { + payload: { opensAt?: string; targetClosesAt?: string } + } + }> + } + + const amuletRulesContract = amuletRulesWithState.contract + const amuletRulesCid = amuletRulesContract.contract_id + const amuletAdmin = amuletRulesContract.payload['dso'] as string + + if (!amuletAdmin) + throw new Error('AmuletRules payload missing dso field') + + const nowMs = Date.now() + const activeRoundContract = openMiningRoundsWithState + .map((r) => r.contract) + .find((c) => { + const openMs = c.payload.opensAt + ? Number(new Date(c.payload.opensAt)) + : NaN + const closeMs = c.payload.targetClosesAt + ? Number(new Date(c.payload.targetClosesAt)) + : NaN + return openMs <= nowMs && nowMs < closeMs + }) + + if (!activeRoundContract) + throw new Error('No active OpenMiningRound found at current time') + + return { + amuletRulesContract, + amuletRulesCid, + amuletAdmin, + activeRoundContract, + openMiningRoundCid: activeRoundContract.contract_id, + } + } + + /** + * Resolves the AllocationFactory contract for an Amulet allocation via the + * scan proxy token-standard registry + * (`/registry/allocation-instruction/v1/allocation-factory`). + * + * Returns the factory contract ID and the choice context (disclosed + * contracts + context data) needed for `AllocationFactory_Allocate`. + * + * @param choiceArgs - The `AllocationFactory_Allocate` choice arguments to + * POST to the registry so it can determine the correct factory. + */ + async fetchAllocationFactory( + choiceArgs: unknown + ): Promise { + const resp = await fetch( + new URL( + 'registry/allocation-instruction/v1/allocation-factory', + this.baseHref + ), + { + method: 'POST', + headers: this.headers, + body: JSON.stringify({ + choiceArguments: choiceArgs, + excludeDebugFields: true, + }), + } + ) + if (!resp.ok) + throw new Error( + `Failed to fetch Amulet AllocationFactory: ${resp.status}` + ) + return resp.json() as Promise + } + + /** + * Fetches the execute-transfer choice context for an Amulet allocation + * from the scan proxy registry + * (`/registry/allocations/v1/{allocationId}/choice-contexts/execute-transfer`). + * + * `Allocation_ExecuteTransfer` on Amulet requires a non-empty context + * (e.g. `external-party-config-state`) that only the registry can provide, + * together with the required disclosed contracts. + * + * @param allocationCid - Contract ID of the Amulet allocation. + */ + async fetchExecuteTransferContext( + allocationCid: string + ): Promise { + const resp = await fetch( + new URL( + `registry/allocations/v1/${allocationCid}/choice-contexts/execute-transfer`, + this.baseHref + ), + { + method: 'POST', + headers: this.headers, + body: JSON.stringify({ excludeDebugFields: true }), + } + ) + if (!resp.ok) + throw new Error( + `Failed to fetch Amulet execute-transfer context: ${resp.status}` + ) + return resp.json() as Promise + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// Factory +// ───────────────────────────────────────────────────────────────────────────── + +/** + * Creates an authenticated {@link ScanProxyClient}. + * + * Obtains an access token once using `authConfig` and reuses it for all + * subsequent calls on the returned client instance. + * + * @param baseUrl - Scan proxy base URL (e.g. `LOCALNET_REGISTRY_API_URL`). + * @param authConfig - Token provider config (e.g. `TOKEN_PROVIDER_CONFIG_DEFAULT`). + * @param logger - Pino logger instance (passed to {@link AuthTokenProvider}). + */ +export async function createScanProxyClient( + baseUrl: URL, + authConfig: TokenProviderConfig, + logger: Logger +): Promise { + const token = await new AuthTokenProvider( + authConfig, + logger + ).getAccessToken() + const headers: Record = { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + } + return new ScanProxyClient(baseUrl, headers) +} diff --git a/docs/wallet-integration-guide/examples/scripts/utils/synchronizer.ts b/docs/wallet-integration-guide/examples/scripts/utils/synchronizer.ts new file mode 100644 index 000000000..34a9ea130 --- /dev/null +++ b/docs/wallet-integration-guide/examples/scripts/utils/synchronizer.ts @@ -0,0 +1,191 @@ +import type { SDKInterface } from '@canton-network/wallet-sdk' +import type { Logger } from 'pino' + +/** + * Pick the preferred synchronizer ID from the list returned by the ledger API. + * + * When a participant is connected to multiple synchronizers the ledger API may + * return them in any order. This helper ensures the global synchronizer is + * always selected — regardless of position — by looking for the entry whose + * alias is `'global'`. If no such entry exists (e.g. single-synchronizer + * setups) the first entry is returned as the default. + * + * Pass the returned ID as the explicit `synchronizerId` on `ledger.prepare()` + * and `ledger.internal.prepare()` calls that must route to the global + * synchronizer. + * + * @param synchronizers - Raw array from `GET /v2/state/connected-synchronizers`. + * @returns The `synchronizerId` of the entry aliased `'global'`, or the first + * entry's `synchronizerId` when no global alias is present. + * @throws {Error} When the array is empty. + */ +export function resolvePreferredSynchronizerId( + synchronizers: Array<{ synchronizerAlias: string; synchronizerId: string }> +): string { + const preferred = + synchronizers.find((s) => s.synchronizerAlias === 'global') ?? + synchronizers[0] + if (!preferred) throw new Error('No connected synchronizers found') + return preferred.synchronizerId +} + +export type SynchronizerMap = { + globalSynchronizerId: string + appSynchronizerId: 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}`) +} + +export type ContractSpec = { + label: string + sdk: SDKInterface + templateIds: string[] + parties: string[] +} + +/** + * 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: ContractSpec[] +): 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/package.json b/package.json index 64f21870a..9b9068dcb 100644 --- a/package.json +++ b/package.json @@ -37,6 +37,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/localnet/app-synchronizer.sc b/scripts/localnet/app-synchronizer.sc new file mode 100644 index 000000000..5a9f45411 --- /dev/null +++ b/scripts/localnet/app-synchronizer.sc @@ -0,0 +1,69 @@ +// Copyright (c) 2025-2026 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +bootstrap.synchronizer( + synchronizerName = "app-synchronizer", + sequencers = Seq(`app-sequencer`), + mediators = Seq(`app-mediator`), + synchronizerOwners = Seq(`app-sequencer`), + synchronizerThreshold = 1, + staticSynchronizerParameters = StaticSynchronizerParameters.defaultsWithoutKMS(ProtocolVersion.latest), +) + +// Connect app-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 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") +} + +// 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 + +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) + + 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 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 for app-provider and app-user") diff --git a/scripts/src/start-localnet.ts b/scripts/src/start-localnet.ts index e2b19d97b..0b7ba967c 100644 --- a/scripts/src/start-localnet.ts +++ b/scripts/src/start-localnet.ts @@ -4,10 +4,16 @@ 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') @@ -17,23 +23,33 @@ const GENERATED_COMPOSE_OVERRIDE = path.join( ) const CANTON_MAX_COMMANDS_IN_FLIGHT = 256 +const CUSTOM_APP_SYNCHRONIZER_SC = path.join( + rootDir, + 'scripts/localnet/app-synchronizer.sc' +) +const LOCALNET_DARS_DIR = path.join(rootDir, '.localnet/dars') 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}`, - '', - ].join('\n'), - 'utf8' - ) + 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`, + ` - ${LOCALNET_DARS_DIR}:/app/dars:ro` + ) + } + lines.push('') + fs.writeFileSync(GENERATED_COMPOSE_OVERRIDE, lines.join('\n'), 'utf8') } const composeBase = [ @@ -55,8 +71,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..18dad8d52 100644 --- a/scripts/src/test-example-scripts.ts +++ b/scripts/src/test-example-scripts.ts @@ -18,7 +18,7 @@ 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', '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..9828db764 --- /dev/null +++ b/sdk/wallet-sdk/src/wallet/namespace/state/client.ts @@ -0,0 +1,61 @@ +// 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' + +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 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 102f45c28..7f39952ef 100644 --- a/sdk/wallet-sdk/src/wallet/sdk.ts +++ b/sdk/wallet-sdk/src/wallet/sdk.ts @@ -57,6 +57,7 @@ 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 class SDK { static async create(