From b0ebe214255cf963dbd44777b56d5a1a56d6681e Mon Sep 17 00:00:00 2001 From: vkalashnykov Date: Thu, 11 Jun 2026 16:57:01 +0200 Subject: [PATCH 1/5] Improvement: unified retrieval of Global Synchronizer inside SDK Signed-off-by: vkalashnykov --- core/wallet-test-utils/src/otc-trade.ts | 20 ++--------- .../examples/scripts/15-multi-sync/_setup.ts | 4 +-- .../examples/scripts/utils/index.ts | 33 +++---------------- .../examples/snippets/setupTests.ts | 9 +---- .../src/wallet/namespace/ledger/namespace.ts | 27 +++++++++++++++ 5 files changed, 37 insertions(+), 56 deletions(-) diff --git a/core/wallet-test-utils/src/otc-trade.ts b/core/wallet-test-utils/src/otc-trade.ts index 876f985dc..df38608e5 100644 --- a/core/wallet-test-utils/src/otc-trade.ts +++ b/core/wallet-test-utils/src/otc-trade.ts @@ -79,10 +79,8 @@ export class OTCTrade { PATH_TO_DAR_IN_LOCALNET ) - // Resolve the global synchronizer explicitly: the SDK no longer - // auto-selects one, and DAR upload cannot be autodetected when the - // participant is connected to multiple synchronizers. - const synchronizerId = await this.resolveGlobalSynchronizerId() + // Retrieve ID of Global Synchronizer for vetting the Trade App DAR + const synchronizerId = await this.sdk.ledger.getGlobalSynchronizerId() //upload dar const darBytes = await fs.readFile(tradingDarPath) @@ -216,20 +214,6 @@ export class OTCTrade { } } - private async resolveGlobalSynchronizerId(): Promise { - if (!this.sdk) throw new Error('SDK not initialized') - - const { connectedSynchronizers } = - await this.sdk.ledger.connectedSynchronizers({}) - const globalSynchronizer = (connectedSynchronizers ?? []).find( - (s) => s.synchronizerAlias === 'global' - ) - if (!globalSynchronizer) { - throw new Error('Global synchronizer not found') - } - return globalSynchronizer.synchronizerId - } - private async acceptProposal( approver: PartyId, approverName: string diff --git a/docs/wallet-integration-guide/examples/scripts/15-multi-sync/_setup.ts b/docs/wallet-integration-guide/examples/scripts/15-multi-sync/_setup.ts index 1fcb9ba66..bc3c5d024 100644 --- a/docs/wallet-integration-guide/examples/scripts/15-multi-sync/_setup.ts +++ b/docs/wallet-integration-guide/examples/scripts/15-multi-sync/_setup.ts @@ -21,7 +21,6 @@ import { AMULET_NAMESPACE_CONFIG, TOKEN_NAMESPACE_CONFIG, TOKEN_PROVIDER_CONFIG_DEFAULT, - resolveGlobalSynchronizerId, } from '../utils/index.js' import type { SynchronizerMap } from '../utils/index.js' import { TEST_TOKEN_REGISTRY_URL } from './_constants.js' @@ -155,7 +154,8 @@ export async function setupMultiSyncTrade( `Expected at least 2 connected synchronizers (global + app), found ${allSynchronizers.length}` ) - const globalSynchronizerId = resolveGlobalSynchronizerId(allSynchronizers) + const globalSynchronizerId = + await appUserSdk.ledger.getGlobalSynchronizerId() const appSynchronizerId = allSynchronizers.find( (s) => s.synchronizerAlias === 'app-synchronizer' )?.synchronizerId diff --git a/docs/wallet-integration-guide/examples/scripts/utils/index.ts b/docs/wallet-integration-guide/examples/scripts/utils/index.ts index 62856642c..8df943a95 100644 --- a/docs/wallet-integration-guide/examples/scripts/utils/index.ts +++ b/docs/wallet-integration-guide/examples/scripts/utils/index.ts @@ -24,40 +24,17 @@ export type SynchronizerMap = { } /** - * Resolve the global synchronizer ID from the list returned by the ledger API. - * - * Looks for the entry whose alias is `'global'`. Falls back to the first entry - * when no alias matches (e.g. single-synchronizer setups). - * - * @throws {Error} When the array is empty. - */ -export function resolveGlobalSynchronizerId( - synchronizers: Array<{ synchronizerAlias: string; synchronizerId: string }> -): string { - const global = synchronizers.find((s) => s.synchronizerAlias === 'global') - if (!global) throw new Error('Global synchronizer not found') - return global.synchronizerId -} - -/** - * Fetches connected synchronizers from the ledger API and returns the ID of the - * synchronizer aliased `'global'`. + * Returns the ID of the synchronizer aliased `'global'`. * * The wallet SDK no longer auto-selects a synchronizer, so client code (these * examples) resolves it explicitly and passes it to SDK calls that require one. + * Resolution lives in the SDK (`sdk.ledger.getGlobalSynchronizerId`); this is a + * thin convenience wrapper over it. */ export async function getGlobalSynchronizerId(sdk: { - ledger: { - connectedSynchronizers(args: object): Promise<{ - connectedSynchronizers?: Array<{ - synchronizerAlias: string - synchronizerId: string - }> - }> - } + ledger: { getGlobalSynchronizerId(): Promise } }): Promise { - const response = await sdk.ledger.connectedSynchronizers({}) - return resolveGlobalSynchronizerId(response.connectedSynchronizers ?? []) + return sdk.ledger.getGlobalSynchronizerId() } export const TOKEN_PROVIDER_CONFIG_DEFAULT: TokenProviderConfig = { diff --git a/docs/wallet-integration-guide/examples/snippets/setupTests.ts b/docs/wallet-integration-guide/examples/snippets/setupTests.ts index 7222a6712..7dc757ff5 100644 --- a/docs/wallet-integration-guide/examples/snippets/setupTests.ts +++ b/docs/wallet-integration-guide/examples/snippets/setupTests.ts @@ -98,14 +98,7 @@ async function beforeEachSetup() { }) // ========= Resolve the synchronizer parties are hosted on ========= - const { connectedSynchronizers } = await sdk.ledger.connectedSynchronizers( - {} - ) - const globalSynchronizer = (connectedSynchronizers ?? []).find( - (s) => s.synchronizerAlias === 'global' - ) - if (!globalSynchronizer) throw new Error('Global synchronizer not found') - global.SYNCHRONIZER_ID = globalSynchronizer.synchronizerId + global.SYNCHRONIZER_ID = await sdk.ledger.getGlobalSynchronizerId() // ========= Setup Existing Party 1 ========= diff --git a/sdk/wallet-sdk/src/wallet/namespace/ledger/namespace.ts b/sdk/wallet-sdk/src/wallet/namespace/ledger/namespace.ts index 99521d714..78ae1ef15 100644 --- a/sdk/wallet-sdk/src/wallet/namespace/ledger/namespace.ts +++ b/sdk/wallet-sdk/src/wallet/namespace/ledger/namespace.ts @@ -67,6 +67,33 @@ export class LedgerNamespace { ) } + /** + * Resolves the ID of the synchronizer aliased `'global'` from the + * synchronizers connected to the caller. + * + * The SDK no longer auto-selects a synchronizer, so callers that need to + * target the global synchronizer (DAR uploads, party creation, transfers, + * ...) resolve it through this single helper. + * + * @throws {Error} When no synchronizer aliased `'global'` is connected. + */ + public async getGlobalSynchronizerId( + options?: ConnectedSynchronizersOptions + ): Promise { + const { connectedSynchronizers } = + await this.connectedSynchronizers(options) + const global = connectedSynchronizers?.find( + (s) => s.synchronizerAlias === 'global' + ) + if (!global) { + this.sdkContext.error.throw({ + message: 'Global synchronizer not found', + type: 'SDKOperationUnsupported', + }) + } + return global.synchronizerId + } + public async ledgerEnd() { return ( await this.sdkContext.ledgerProvider.request( From ebf92df745d16956d36100e747656e9f0067c153 Mon Sep 17 00:00:00 2001 From: vkalashnykov Date: Thu, 11 Jun 2026 18:36:44 +0200 Subject: [PATCH 2/5] Improvement: caching of synchronizers in SDK Signed-off-by: vkalashnykov --- .../src/wallet/namespace/ledger/index.ts | 1 + .../src/wallet/namespace/ledger/namespace.ts | 71 ++--- .../namespace/ledger/synchronizer-cache.ts | 283 ++++++++++++++++++ .../namespace/party/external/service.ts | 12 +- .../wallet/namespace/party/external/signed.ts | 64 ++-- .../wallet/namespace/party/internal/index.ts | 13 +- sdk/wallet-sdk/src/wallet/sdk.ts | 6 + 7 files changed, 373 insertions(+), 77 deletions(-) create mode 100644 sdk/wallet-sdk/src/wallet/namespace/ledger/synchronizer-cache.ts diff --git a/sdk/wallet-sdk/src/wallet/namespace/ledger/index.ts b/sdk/wallet-sdk/src/wallet/namespace/ledger/index.ts index 8dace158d..a9b667b59 100644 --- a/sdk/wallet-sdk/src/wallet/namespace/ledger/index.ts +++ b/sdk/wallet-sdk/src/wallet/namespace/ledger/index.ts @@ -3,3 +3,4 @@ export * from './namespace.js' export * from './types.js' +export * from './synchronizer-cache.js' diff --git a/sdk/wallet-sdk/src/wallet/namespace/ledger/namespace.ts b/sdk/wallet-sdk/src/wallet/namespace/ledger/namespace.ts index 78ae1ef15..2bd989ea6 100644 --- a/sdk/wallet-sdk/src/wallet/namespace/ledger/namespace.ts +++ b/sdk/wallet-sdk/src/wallet/namespace/ledger/namespace.ts @@ -17,6 +17,7 @@ import { DarNamespace } from './dar/client.js' import { InternalLedgerNamespace } from './internal/index.js' import { PreparedTransactionNamespace } from './hash/namespace.js' import { AcsOptions, ACSReader } from '@canton-network/core-acs-reader' +import { ConnectedSynchronizer } from './synchronizer-cache.js' export class LedgerNamespace { public readonly dar: DarNamespace @@ -33,65 +34,57 @@ export class LedgerNamespace { /** * Returns connected synchronizers visible to the caller, optionally filtered - * by party, participant, or identity provider. - * - * Uses the Ledger API endpoint GET /v2/state/connected-synchronizers. + * by party, participant, or identity provider. Reeas connected synchronizers from the cache by default, but can be forced to re-fetch from the Ledger API with `opts.refresh = true`. */ public async connectedSynchronizers( - options?: ConnectedSynchronizersOptions + options?: ConnectedSynchronizersOptions, + extraOptions?: { refresh?: boolean } ) { - this.sdkContext.logger.debug( - { options }, - 'Fetching connected synchronizers' - ) + return { + connectedSynchronizers: await this.sdkContext.synchronizers.list( + options, + extraOptions + ), + } + } - return this.sdkContext.ledgerProvider.request( - { - method: 'ledgerApi', - params: { - resource: '/v2/state/connected-synchronizers', - requestMethod: 'get', - query: { - ...(options?.party !== undefined && { - party: options.party, - }), - ...(options?.participantId !== undefined && { - participantId: options.participantId, - }), - ...(options?.identityProviderId !== undefined && { - identityProviderId: options.identityProviderId, - }), - }, - }, - } - ) + /** + * Re-fetches the connected synchronizers from the Ledger API and updates the + * cache. + */ + public async refreshSynchronizers(): Promise { + return this.sdkContext.synchronizers.refresh() + } + + /** + * Adds connected synchronizers to the cache + */ + public addConnectedSynchronizers( + ...synchronizers: ConnectedSynchronizer[] + ): void { + this.sdkContext.synchronizers.add(...synchronizers) } /** * Resolves the ID of the synchronizer aliased `'global'` from the * synchronizers connected to the caller. * - * The SDK no longer auto-selects a synchronizer, so callers that need to - * target the global synchronizer (DAR uploads, party creation, transfers, - * ...) resolve it through this single helper. - * * @throws {Error} When no synchronizer aliased `'global'` is connected. */ public async getGlobalSynchronizerId( options?: ConnectedSynchronizersOptions ): Promise { - const { connectedSynchronizers } = - await this.connectedSynchronizers(options) - const global = connectedSynchronizers?.find( - (s) => s.synchronizerAlias === 'global' - ) - if (!global) { + const synchronizerId = + await this.sdkContext.synchronizers.resolveGlobalSynchronizerId( + options + ) + if (!synchronizerId) { this.sdkContext.error.throw({ message: 'Global synchronizer not found', type: 'SDKOperationUnsupported', }) } - return global.synchronizerId + return synchronizerId } public async ledgerEnd() { diff --git a/sdk/wallet-sdk/src/wallet/namespace/ledger/synchronizer-cache.ts b/sdk/wallet-sdk/src/wallet/namespace/ledger/synchronizer-cache.ts new file mode 100644 index 000000000..284b0d286 --- /dev/null +++ b/sdk/wallet-sdk/src/wallet/namespace/ledger/synchronizer-cache.ts @@ -0,0 +1,283 @@ +// Copyright (c) 2025-2026 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { + AbstractLedgerProvider, + Ops, +} from '@canton-network/core-provider-ledger' +import { SDKLogger } from '../../logger/logger.js' +import { ConnectedSynchronizersOptions } from './types.js' +import type { SDKContext } from '../../sdk.js' + +/** + * Resolves the synchronizer an operation should target: the explicit + * `synchronizerId` when given, otherwise the global synchronizer as a fallback. + * + * Shared by the party / participant flows so they all fall back to (and fail + * with) the same behaviour when no synchronizer is supplied. + * + * @throws When no `synchronizerId` is provided and no global synchronizer is + * connected to fall back to. + */ +export const resolveSynchronizerIdOrGlobal = async ( + ctx: SDKContext, + synchronizerId?: string +): Promise => { + const resolved = + synchronizerId ?? + (await ctx.synchronizers.resolveGlobalSynchronizerId()) + if (!resolved) { + ctx.error.throw({ + message: + 'No synchronizerId provided and no global synchronizer is connected to fall back to', + type: 'SDKOperationUnsupported', + }) + } + return resolved +} + +export type ConnectedSynchronizer = NonNullable< + Ops.GetV2StateConnectedSynchronizers['ledgerApi']['result']['connectedSynchronizers'] +>[number] + +/** + * A connected synchronizer plus the parties / participants / identity providers + * it has been observed connected to. + */ +export type CachedSynchronizer = { + synchronizerId: string + synchronizerAlias: string + parties: string[] + participantIds: string[] + identityProviderIds: string[] +} + +/** Adds a value to a list if not already present. */ +const addUnique = (list: string[], value: string): void => { + if (!list.includes(value)) list.push(value) +} + +/** + * Whether a synchronizer satisfies the given query scope. A scope dimension that + * is `undefined` matches anything; a defined dimension matches only when the + * synchronizer was observed connected under that value. + */ +const matchesScope = ( + synchronizer: CachedSynchronizer, + options?: ConnectedSynchronizersOptions +): boolean => + (options?.party === undefined || + synchronizer.parties.includes(options.party)) && + (options?.participantId === undefined || + synchronizer.participantIds.includes(options.participantId)) && + (options?.identityProviderId === undefined || + synchronizer.identityProviderIds.includes(options.identityProviderId)) + +/** Whether two query scopes target the same party / participant / idp. */ +const sameScope = ( + a: ConnectedSynchronizersOptions, + b: ConnectedSynchronizersOptions +): boolean => + a.party === b.party && + a.participantId === b.participantId && + a.identityProviderId === b.identityProviderId + +/** + * Caches the synchronizers connected to the participant so the SDK reads them + * from the Ledger API only once (at initialization) instead of on every call. + * + * Entries are keyed by `synchronizerId` — a synchronizer shared by several parties + * is a single entry whose membership lists (`parties`, `participantIds`, + * `identityProviderIds`) record every scope it was seen under. A read is a + * `filter` over the entries by {@link matchesScope}. The set of scopes already + * fetched is tracked separately so an empty result is still treated as a cache + * hit (we don't re-query a scope that is genuinely empty). The cache is kept + * current via {@link refresh} (rebuild every scope seen so far) and {@link add} + * (merge a newly connected synchronizer) — callers update it when they connect + * to, or host a party on, a new synchronizer. + */ +export class SynchronizerCache { + private cache = new Map() + private fetchedScopes: ConnectedSynchronizersOptions[] = [] + + constructor( + private readonly ledgerProvider: AbstractLedgerProvider, + private readonly logger: SDKLogger + ) {} + + /** + * Merges a synchronizer returned by the Ledger API into its entry, recording + * the scope it was fetched under in the entry's membership lists. + */ + private addOrUpdate( + synchronizer: ConnectedSynchronizer, + options?: ConnectedSynchronizersOptions + ): void { + const entry = this.cache.get(synchronizer.synchronizerId) ?? { + synchronizerId: synchronizer.synchronizerId, + synchronizerAlias: synchronizer.synchronizerAlias, + parties: [], + participantIds: [], + identityProviderIds: [], + } + entry.synchronizerAlias = synchronizer.synchronizerAlias + if (options?.party !== undefined) + addUnique(entry.parties, options.party) + if (options?.participantId !== undefined) + addUnique(entry.participantIds, options.participantId) + if (options?.identityProviderId !== undefined) + addUnique(entry.identityProviderIds, options.identityProviderId) + this.cache.set(synchronizer.synchronizerId, entry) + } + + /** The cached synchronizers matching the given scope. */ + private matching( + options?: ConnectedSynchronizersOptions + ): CachedSynchronizer[] { + return [...this.cache.values()].filter((s) => matchesScope(s, options)) + } + + /** Whether the given scope has already been fetched from the Ledger API. */ + private isScopeFetched(options?: ConnectedSynchronizersOptions): boolean { + return this.fetchedScopes.some((s) => sameScope(s, options ?? {})) + } + + /** Records that the given scope has been fetched. */ + private markFetched(options?: ConnectedSynchronizersOptions): void { + if (!this.isScopeFetched(options)) { + this.fetchedScopes.push({ + ...(options?.party !== undefined && { party: options.party }), + ...(options?.participantId !== undefined && { + participantId: options.participantId, + }), + ...(options?.identityProviderId !== undefined && { + identityProviderId: options.identityProviderId, + }), + }) + } + } + + /** + * Fetches a scope's synchronizers from the Ledger API, merges each into its + * entry, and marks the scope fetched. + */ + private async fetch( + options?: ConnectedSynchronizersOptions + ): Promise { + this.logger.debug({ options }, 'Fetching connected synchronizers') + const response = + await this.ledgerProvider.request( + { + method: 'ledgerApi', + params: { + resource: '/v2/state/connected-synchronizers', + requestMethod: 'get', + query: { + ...(options?.party !== undefined && { + party: options.party, + }), + ...(options?.participantId !== undefined && { + participantId: options.participantId, + }), + ...(options?.identityProviderId !== undefined && { + identityProviderId: options.identityProviderId, + }), + }, + }, + } + ) + for (const synchronizer of response.connectedSynchronizers ?? []) { + this.addOrUpdate(synchronizer, options) + } + this.markFetched(options) + return this.matching(options) + } + + /** + * Returns the synchronizers for the given scope, fetching from the Ledger + * API on the first request for that scope (or when `refresh` is set) and + * serving subsequent requests from the cache. + */ + public async list( + options?: ConnectedSynchronizersOptions, + extraOptions?: { refresh?: boolean } + ): Promise { + if (extraOptions?.refresh || !this.isScopeFetched(options)) { + return this.fetch(options) + } + return this.matching(options) + } + + /** + * Resolves the ID of the synchronizer aliased `'global'` from the cache, + * re-fetching once if it is not yet present (in case a synchronizer was + * connected after the SDK was initialized). Returns `undefined` when no + * global synchronizer is connected + * + */ + public async resolveGlobalSynchronizerId( + options?: ConnectedSynchronizersOptions + ): Promise { + const findGlobal = (synchronizers: CachedSynchronizer[]) => + synchronizers.find((s) => s.synchronizerAlias === 'global') + + let global = findGlobal(await this.list(options)) + if (!global) { + global = findGlobal(await this.list(options, { refresh: true })) + } + return global?.synchronizerId + } + + /** + * Rebuilds the cache by re-fetching every scope queried so far from the + * Ledger API. + */ + public async refresh(): Promise { + const scopes = this.fetchedScopes + this.cache = new Map() + this.fetchedScopes = [] + await Promise.all(scopes.map((scope) => this.fetch(scope))) + } + + /** + * Adds already-known connected synchronizers to the cache + */ + public add(...synchronizers: ConnectedSynchronizer[]): void { + for (const synchronizer of synchronizers) { + this.addOrUpdate(synchronizer) + } + } + + /** + * Records that the given scope (a party and/or participant) is now connected + * to a synchronizer, updating its membership lists in place. + * + * The scope is also marked fetched so a subsequent `list(scope)` is served + * from the cache. If the synchronizer is not yet cached, an entry is created; + * pass `synchronizerAlias` when it is known (e.g. for global-synchronizer + * resolution). + */ + public connect( + synchronizerId: string, + scope: ConnectedSynchronizersOptions, + synchronizerAlias?: string + ): void { + const entry = this.cache.get(synchronizerId) ?? { + synchronizerId, + synchronizerAlias: synchronizerAlias ?? '', + parties: [], + participantIds: [], + identityProviderIds: [], + } + if (synchronizerAlias !== undefined) { + entry.synchronizerAlias = synchronizerAlias + } + if (scope.party !== undefined) addUnique(entry.parties, scope.party) + if (scope.participantId !== undefined) + addUnique(entry.participantIds, scope.participantId) + if (scope.identityProviderId !== undefined) + addUnique(entry.identityProviderIds, scope.identityProviderId) + this.cache.set(synchronizerId, entry) + this.markFetched(scope) + } +} diff --git a/sdk/wallet-sdk/src/wallet/namespace/party/external/service.ts b/sdk/wallet-sdk/src/wallet/namespace/party/external/service.ts index bb30483b4..9cd8c6757 100644 --- a/sdk/wallet-sdk/src/wallet/namespace/party/external/service.ts +++ b/sdk/wallet-sdk/src/wallet/namespace/party/external/service.ts @@ -10,6 +10,7 @@ import { CreatePartyOptions } from './types.js' import { SDKLogger } from '../../../logger/index.js' import { LedgerProvider, Ops } from '@canton-network/core-provider-ledger' import { AuthTokenProvider } from '@canton-network/core-wallet-auth' +import { resolveSynchronizerIdOrGlobal } from '../../ledger/synchronizer-cache.js' export class ExternalPartyNamespace { private readonly logger: SDKLogger @@ -34,14 +35,15 @@ export class ExternalPartyNamespace { ), options?.synchronizerId, ]).then( - ([ + async ([ observingParticipantUids, otherHostingParticipantUids, synchronizerId, ]) => { - if (!synchronizerId) - throw new Error( - 'synchronizerId is required for party creation — pass it via options.synchronizerId' + const resolvedSynchronizerId = + await resolveSynchronizerIdOrGlobal( + this.ctx, + synchronizerId ) return this.ctx.ledgerProvider.request( { @@ -49,7 +51,7 @@ export class ExternalPartyNamespace { params: { resource: '/v2/parties/external/generate-topology', body: { - synchronizer: synchronizerId, + synchronizer: resolvedSynchronizerId, partyHint: options?.partyHint ?? v4(), publicKey: { format: 'CRYPTO_KEY_FORMAT_RAW', 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 e195391d2..9d51e359c 100644 --- a/sdk/wallet-sdk/src/wallet/namespace/party/external/signed.ts +++ b/sdk/wallet-sdk/src/wallet/namespace/party/external/signed.ts @@ -16,6 +16,7 @@ import { LedgerProvider, Ops, } from '@canton-network/core-provider-ledger' +import { resolveSynchronizerIdOrGlobal } from '../../ledger/synchronizer-cache.js' import { AuthTokenProvider } from '@canton-network/core-wallet-auth' import { PrivateKey, @@ -57,14 +58,9 @@ export class SignedPartyCreationService { type: 'SDKOperationUnsupported', }) - // 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 - ) - ) { + const synchronizerId = await this.resolveSynchronizerId() + + if (await this.checkIfPartyExists(party.partyId, synchronizerId)) { this.ctx.logger.info('Party already created.') return party } @@ -76,10 +72,15 @@ export class SignedPartyCreationService { await this.executeAllocateParty({ ...executeOptions, + synchronizerId, withErrorHandling: true, expectHeavyLoad: Boolean(options?.expectHeavyLoad), }) + this.ctx.synchronizers.connect(synchronizerId, { + party: party.partyId, + }) + const endpointConfig = [ ...(this.createPartyOptions?.confirmingParticipantEndpoints ?? []), ...(this.createPartyOptions?.observingParticipantEndpoints ?? []), @@ -89,6 +90,7 @@ export class SignedPartyCreationService { await this.allocateExternalPartyForAdditionalParticipants({ ...executeOptions, endpointConfig, + synchronizerId, }) } @@ -185,6 +187,7 @@ export class SignedPartyCreationService { }, ] ) + this.ctx.synchronizers.connect(synchronizerId, { party: partyId }) this.ctx.logger.info( `Party registered on additional synchronizer ${synchronizerId}.` @@ -199,9 +202,10 @@ export class SignedPartyCreationService { private async allocateExternalPartyForAdditionalParticipants( options: { endpointConfig: ParticipantEndpointConfig[] + synchronizerId: string } & ExecuteOptions ) { - const { endpointConfig, party, signature } = options + const { endpointConfig, party, signature, synchronizerId } = options for (const endpoint of endpointConfig) { const defaultLedgerProvider = new LedgerProvider({ baseUrl: endpoint.url, @@ -215,10 +219,25 @@ export class SignedPartyCreationService { defaultLedgerProvider, party, signature, + synchronizerId, }) } } + /** + * Resolves the synchronizer the party should be allocated on: the one given + * in {@link CreatePartyOptions}, or the global synchronizer as a fallback + * when none was provided. + * @throws {Error} When no synchronizerId is provided and no global + * synchronizer is connected to fall back to. + */ + private resolveSynchronizerId(): Promise { + return resolveSynchronizerIdOrGlobal( + this.ctx, + this.createPartyOptions?.synchronizerId + ) + } + /** * Performs the actual party allocation transaction on a ledger client. * Includes error handling for timeout scenarios when heavy load is expected. @@ -226,6 +245,7 @@ export class SignedPartyCreationService { */ private async executeAllocateParty( options: { + synchronizerId: string withErrorHandling?: boolean expectHeavyLoad?: boolean defaultLedgerProvider?: AbstractLedgerProvider @@ -234,18 +254,13 @@ export class SignedPartyCreationService { const { party, signature, + synchronizerId, withErrorHandling, expectHeavyLoad, defaultLedgerProvider, } = options const ledgerProvider = defaultLedgerProvider ?? this.ctx.ledgerProvider try { - const synchronizerId = this.createPartyOptions?.synchronizerId - if (!synchronizerId) - throw new Error( - 'synchronizerId is required for external party allocation — pass it via createPartyOptions.synchronizerId' - ) - await this.allocate( ledgerProvider, synchronizerId, @@ -291,21 +306,10 @@ export class SignedPartyCreationService { ): 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 connectedSynchronizers = + await this.ctx.synchronizers.list({ party: partyId }) + return connectedSynchronizers.some( + (s) => s.synchronizerId === synchronizerId ) } diff --git a/sdk/wallet-sdk/src/wallet/namespace/party/internal/index.ts b/sdk/wallet-sdk/src/wallet/namespace/party/internal/index.ts index ac25011e4..5df82527c 100644 --- a/sdk/wallet-sdk/src/wallet/namespace/party/internal/index.ts +++ b/sdk/wallet-sdk/src/wallet/namespace/party/internal/index.ts @@ -6,6 +6,7 @@ import { SDKContext } from '../../../sdk.js' import { v4 } from 'uuid' import { PartyId } from '@canton-network/core-types' import { SDKLogger } from '../../../logger/logger.js' +import { resolveSynchronizerIdOrGlobal } from '../../ledger/synchronizer-cache.js' export class InternalPartyNamespace { private readonly logger: SDKLogger @@ -24,6 +25,11 @@ export class InternalPartyNamespace { userId?: string } = {} ): Promise { + const synchronizerId = await resolveSynchronizerIdOrGlobal( + this.ctx, + params.synchronizerId + ) + if (params.partyHint) { const pIdFingerprint = await this.getParticipantIdFingerprint() @@ -50,9 +56,7 @@ export class InternalPartyNamespace { body: { partyIdHint: params.partyHint ?? v4(), identityProviderId: '', - ...(params.synchronizerId !== undefined && { - synchronizerId: params.synchronizerId, - }), + synchronizerId, userId: params.userId ?? this.ctx.userId, }, }, @@ -64,6 +68,9 @@ export class InternalPartyNamespace { type: 'CantonError', }) } + this.ctx.synchronizers.connect(synchronizerId, { + party: allocatedParty.partyDetails.party, + }) return allocatedParty.partyDetails.party } diff --git a/sdk/wallet-sdk/src/wallet/sdk.ts b/sdk/wallet-sdk/src/wallet/sdk.ts index a4a4741f9..9f951702c 100644 --- a/sdk/wallet-sdk/src/wallet/sdk.ts +++ b/sdk/wallet-sdk/src/wallet/sdk.ts @@ -27,6 +27,7 @@ import { } from '@canton-network/core-ledger-client-types' import { AllowedLogAdapters } from './logger/types.js' import { DappLedgerRpc } from '@canton-network/core-provider-dapp' +import { SynchronizerCache } from './namespace/ledger/synchronizer-cache.js' export * from './namespace/asset/index.js' export type * from './namespace/token/index.js' export type * from './namespace/amulet/index.js' @@ -49,6 +50,7 @@ export type SDKContext = { userId: string logger: SDKLogger error: SDKErrorHandler + synchronizers: SynchronizerCache } export type OfflineSDKContext = { @@ -126,11 +128,15 @@ export class SDK { }) } + const synchronizers = new SynchronizerCache(ledgerProvider, logger) + await synchronizers.list() + const ctx: SDKContext = { ledgerProvider, userId: userId!, logger, error, + synchronizers, } const config = {} as Pick< From 6fae77cf7027497d4e5afe90744fd6fb73b6d417 Mon Sep 17 00:00:00 2001 From: vkalashnykov Date: Thu, 11 Jun 2026 19:09:37 +0200 Subject: [PATCH 3/5] Improvement: refactoring Signed-off-by: vkalashnykov --- core/wallet-test-utils/src/otc-trade.ts | 4 --- .../scripts/02-two-step-transfer/index.ts | 2 -- .../scripts/04-token-standard-allocation.ts | 3 --- .../examples/scripts/06-merge-utxos.ts | 2 -- .../examples/scripts/08-merge-delegation.ts | 3 --- .../examples/scripts/09-multi-user-setup.ts | 2 -- .../examples/scripts/11-hashing.ts | 2 -- .../scripts/12-subscribe-to-events.ts | 2 -- .../scripts/stress/02-merge-utxos-delegate.ts | 3 --- .../examples/scripts/utils/index.ts | 5 ---- .../examples/scripts/utils/upload-dars.ts | 3 --- .../src/wallet/namespace/ledger/namespace.ts | 2 +- .../namespace/ledger/synchronizer-cache.ts | 25 ++++++------------- 13 files changed, 9 insertions(+), 49 deletions(-) diff --git a/core/wallet-test-utils/src/otc-trade.ts b/core/wallet-test-utils/src/otc-trade.ts index df38608e5..0ad016c3a 100644 --- a/core/wallet-test-utils/src/otc-trade.ts +++ b/core/wallet-test-utils/src/otc-trade.ts @@ -13,9 +13,6 @@ import { localNetStaticConfig, } from '@canton-network/wallet-sdk' -// This example needs uploaded .dar for splice-token-test-trading-app -// It's in files of localnet, but it's not uploaded to participant, so we need to do this in the script -// Adjust if to your .localnet location const PATH_TO_LOCALNET = '../../../.localnet' const PATH_TO_DAR_IN_LOCALNET = '/dars/splice-token-test-trading-app-1.0.0.dar' const TRADING_APP_PACKAGE_ID = @@ -79,7 +76,6 @@ export class OTCTrade { PATH_TO_DAR_IN_LOCALNET ) - // Retrieve ID of Global Synchronizer for vetting the Trade App DAR const synchronizerId = await this.sdk.ledger.getGlobalSynchronizerId() //upload dar diff --git a/docs/wallet-integration-guide/examples/scripts/02-two-step-transfer/index.ts b/docs/wallet-integration-guide/examples/scripts/02-two-step-transfer/index.ts index ccc41eea7..a1ac91f99 100644 --- a/docs/wallet-integration-guide/examples/scripts/02-two-step-transfer/index.ts +++ b/docs/wallet-integration-guide/examples/scripts/02-two-step-transfer/index.ts @@ -21,8 +21,6 @@ const sdk = await SDK.create({ amulet: AMULET_NAMESPACE_CONFIG, }) -// The wallet SDK no longer auto-selects a synchronizer, so resolve the global -// synchronizer explicitly and pass it to external party creation. const globalSynchronizerId = await getGlobalSynchronizerId(sdk) const senderKeys = sdk.keys.generate() diff --git a/docs/wallet-integration-guide/examples/scripts/04-token-standard-allocation.ts b/docs/wallet-integration-guide/examples/scripts/04-token-standard-allocation.ts index bf2fff340..a777ab0d7 100644 --- a/docs/wallet-integration-guide/examples/scripts/04-token-standard-allocation.ts +++ b/docs/wallet-integration-guide/examples/scripts/04-token-standard-allocation.ts @@ -44,9 +44,6 @@ const tradingDarPath = path.join( PATH_TO_DAR_IN_LOCALNET ) -// The wallet SDK no longer auto-selects a synchronizer, and DAR upload and party -// creation cannot be autodetected when the participant is connected to multiple -// synchronizers, so resolve the global synchronizer explicitly and pass it on. const globalSynchronizerId = await getGlobalSynchronizerId(sdk) //upload dar diff --git a/docs/wallet-integration-guide/examples/scripts/06-merge-utxos.ts b/docs/wallet-integration-guide/examples/scripts/06-merge-utxos.ts index c6e24e806..91cccec25 100644 --- a/docs/wallet-integration-guide/examples/scripts/06-merge-utxos.ts +++ b/docs/wallet-integration-guide/examples/scripts/06-merge-utxos.ts @@ -16,8 +16,6 @@ const sdk = await SDK.create({ amulet: AMULET_NAMESPACE_CONFIG, }) -// The wallet SDK no longer auto-selects a synchronizer, so resolve the global -// synchronizer explicitly and pass it to external party creation. const globalSynchronizerId = await getGlobalSynchronizerId(sdk) const aliceKeys = sdk.keys.generate() diff --git a/docs/wallet-integration-guide/examples/scripts/08-merge-delegation.ts b/docs/wallet-integration-guide/examples/scripts/08-merge-delegation.ts index b3fcda486..fee75fb47 100644 --- a/docs/wallet-integration-guide/examples/scripts/08-merge-delegation.ts +++ b/docs/wallet-integration-guide/examples/scripts/08-merge-delegation.ts @@ -37,9 +37,6 @@ const sdk = await SDK.create({ amulet: AMULET_NAMESPACE_CONFIG, }) -// The wallet SDK no longer auto-selects a synchronizer, and DAR upload cannot be -// autodetected when the participant is connected to multiple synchronizers, so -// resolve the global synchronizer explicitly and pass it to the upload. const synchronizerId = await getGlobalSynchronizerId(sdk) const darBytes = await readFile(spliceUtilTokenStandardWalletDarPath) diff --git a/docs/wallet-integration-guide/examples/scripts/09-multi-user-setup.ts b/docs/wallet-integration-guide/examples/scripts/09-multi-user-setup.ts index 58edfa0c6..b6cce2a94 100644 --- a/docs/wallet-integration-guide/examples/scripts/09-multi-user-setup.ts +++ b/docs/wallet-integration-guide/examples/scripts/09-multi-user-setup.ts @@ -13,8 +13,6 @@ const operatorSdk = await SDK.create({ ledgerClientUrl: localNetStaticConfig.LOCALNET_APP_USER_LEDGER_URL, }) -// The wallet SDK no longer auto-selects a synchronizer, so resolve the global -// synchronizer explicitly and pass it to external party creation. const globalSynchronizerId = await getGlobalSynchronizerId(operatorSdk) const aliceInternal = await operatorSdk.party.internal.allocate({ diff --git a/docs/wallet-integration-guide/examples/scripts/11-hashing.ts b/docs/wallet-integration-guide/examples/scripts/11-hashing.ts index 57b55da7f..b794bb01b 100644 --- a/docs/wallet-integration-guide/examples/scripts/11-hashing.ts +++ b/docs/wallet-integration-guide/examples/scripts/11-hashing.ts @@ -14,8 +14,6 @@ const sdk = await SDK.create({ amulet: AMULET_NAMESPACE_CONFIG, }) -// The wallet SDK no longer auto-selects a synchronizer, so resolve the global -// synchronizer explicitly and pass it to external party creation. const globalSynchronizerId = await getGlobalSynchronizerId(sdk) const senderKeys = sdk.keys.generate() diff --git a/docs/wallet-integration-guide/examples/scripts/12-subscribe-to-events.ts b/docs/wallet-integration-guide/examples/scripts/12-subscribe-to-events.ts index 45941171c..c7fac78a2 100644 --- a/docs/wallet-integration-guide/examples/scripts/12-subscribe-to-events.ts +++ b/docs/wallet-integration-guide/examples/scripts/12-subscribe-to-events.ts @@ -25,8 +25,6 @@ const sdk = await SDK.create({ }, }) -// The wallet SDK no longer auto-selects a synchronizer, so resolve the global -// synchronizer explicitly and pass it to external party creation. const globalSynchronizerId = await getGlobalSynchronizerId(sdk) const allocatedParties = await Promise.all( diff --git a/docs/wallet-integration-guide/examples/scripts/stress/02-merge-utxos-delegate.ts b/docs/wallet-integration-guide/examples/scripts/stress/02-merge-utxos-delegate.ts index 26c2c48f6..7141bb0cf 100644 --- a/docs/wallet-integration-guide/examples/scripts/stress/02-merge-utxos-delegate.ts +++ b/docs/wallet-integration-guide/examples/scripts/stress/02-merge-utxos-delegate.ts @@ -38,9 +38,6 @@ const sdk = await SDK.create({ amulet: AMULET_NAMESPACE_CONFIG, }) -// The wallet SDK no longer auto-selects a synchronizer, and DAR upload cannot be -// autodetected when the participant is connected to multiple synchronizers, so -// resolve the global synchronizer explicitly and pass it to the upload. const synchronizerId = await getGlobalSynchronizerId(sdk) const darBytes = await readFile(spliceUtilTokenStandardWalletDarPath) diff --git a/docs/wallet-integration-guide/examples/scripts/utils/index.ts b/docs/wallet-integration-guide/examples/scripts/utils/index.ts index 8df943a95..bfe25e278 100644 --- a/docs/wallet-integration-guide/examples/scripts/utils/index.ts +++ b/docs/wallet-integration-guide/examples/scripts/utils/index.ts @@ -25,11 +25,6 @@ export type SynchronizerMap = { /** * Returns the ID of the synchronizer aliased `'global'`. - * - * The wallet SDK no longer auto-selects a synchronizer, so client code (these - * examples) resolves it explicitly and passes it to SDK calls that require one. - * Resolution lives in the SDK (`sdk.ledger.getGlobalSynchronizerId`); this is a - * thin convenience wrapper over it. */ export async function getGlobalSynchronizerId(sdk: { ledger: { getGlobalSynchronizerId(): Promise } diff --git a/docs/wallet-integration-guide/examples/scripts/utils/upload-dars.ts b/docs/wallet-integration-guide/examples/scripts/utils/upload-dars.ts index 6e0d39cae..d3620f002 100644 --- a/docs/wallet-integration-guide/examples/scripts/utils/upload-dars.ts +++ b/docs/wallet-integration-guide/examples/scripts/utils/upload-dars.ts @@ -31,9 +31,6 @@ const TRADING_APP_PACKAGE_ID = const here = path.dirname(fileURLToPath(import.meta.url)) -// The wallet SDK no longer auto-selects a synchronizer, and DAR upload cannot be -// autodetected when the participant is connected to multiple synchronizers, so -// resolve the global synchronizer explicitly and pass it to every upload. const synchronizerId = await getGlobalSynchronizerId(sdk) const tradingDarPath = path.join( diff --git a/sdk/wallet-sdk/src/wallet/namespace/ledger/namespace.ts b/sdk/wallet-sdk/src/wallet/namespace/ledger/namespace.ts index 2bd989ea6..8debca11b 100644 --- a/sdk/wallet-sdk/src/wallet/namespace/ledger/namespace.ts +++ b/sdk/wallet-sdk/src/wallet/namespace/ledger/namespace.ts @@ -34,7 +34,7 @@ export class LedgerNamespace { /** * Returns connected synchronizers visible to the caller, optionally filtered - * by party, participant, or identity provider. Reeas connected synchronizers from the cache by default, but can be forced to re-fetch from the Ledger API with `opts.refresh = true`. + * by party, participant, or identity provider. Reads connected synchronizers from the cache by default, but can be forced to re-fetch from the Ledger API with `opts.refresh = true`. */ public async connectedSynchronizers( options?: ConnectedSynchronizersOptions, diff --git a/sdk/wallet-sdk/src/wallet/namespace/ledger/synchronizer-cache.ts b/sdk/wallet-sdk/src/wallet/namespace/ledger/synchronizer-cache.ts index 284b0d286..8652074da 100644 --- a/sdk/wallet-sdk/src/wallet/namespace/ledger/synchronizer-cache.ts +++ b/sdk/wallet-sdk/src/wallet/namespace/ledger/synchronizer-cache.ts @@ -13,9 +13,6 @@ import type { SDKContext } from '../../sdk.js' * Resolves the synchronizer an operation should target: the explicit * `synchronizerId` when given, otherwise the global synchronizer as a fallback. * - * Shared by the party / participant flows so they all fall back to (and fail - * with) the same behaviour when no synchronizer is supplied. - * * @throws When no `synchronizerId` is provided and no global synchronizer is * connected to fall back to. */ @@ -84,17 +81,15 @@ const sameScope = ( /** * Caches the synchronizers connected to the participant so the SDK reads them - * from the Ledger API only once (at initialization) instead of on every call. + * from the Ledger API once per scope * - * Entries are keyed by `synchronizerId` — a synchronizer shared by several parties - * is a single entry whose membership lists (`parties`, `participantIds`, - * `identityProviderIds`) record every scope it was seen under. A read is a - * `filter` over the entries by {@link matchesScope}. The set of scopes already - * fetched is tracked separately so an empty result is still treated as a cache - * hit (we don't re-query a scope that is genuinely empty). The cache is kept - * current via {@link refresh} (rebuild every scope seen so far) and {@link add} - * (merge a newly connected synchronizer) — callers update it when they connect - * to, or host a party on, a new synchronizer. + * - Entries are keyed by `synchronizerId` (one shared by several parties is a + * single entry); its membership lists record every scope it was seen under. + * - Reads filter entries by {@link matchesScope}. + * - Fetched scopes are tracked separately so a genuinely empty scope counts as a + * cache hit instead of being re-queried every call. + * - {@link add} / {@link connect} merge in a newly known synchronizer; + * {@link refresh} rebuilds by re-fetching every scope seen so far. */ export class SynchronizerCache { private cache = new Map() @@ -252,10 +247,6 @@ export class SynchronizerCache { * Records that the given scope (a party and/or participant) is now connected * to a synchronizer, updating its membership lists in place. * - * The scope is also marked fetched so a subsequent `list(scope)` is served - * from the cache. If the synchronizer is not yet cached, an entry is created; - * pass `synchronizerAlias` when it is known (e.g. for global-synchronizer - * resolution). */ public connect( synchronizerId: string, From 0256183a4c3039661f0cad3bbc639563ca4b2a32 Mon Sep 17 00:00:00 2001 From: jarekr-da Date: Mon, 15 Jun 2026 14:48:03 +0200 Subject: [PATCH 4/5] refactor(wallet-sdk): generalize cached synchronizer resolution by alias Add SynchronizerCache.resolveSynchronizerIdByAlias and the public LedgerNamespace.getSynchronizerIdByAlias, and refactor resolveGlobalSynchronizerId to delegate to the generalized lookup. Keeps all synchronizer-resolution/caching logic within the synchronizer refactor. Signed-off-by: jarekr-da --- .../src/wallet/namespace/ledger/namespace.ts | 16 ++++++++++ .../namespace/ledger/synchronizer-cache.ts | 29 ++++++++++++++----- 2 files changed, 37 insertions(+), 8 deletions(-) diff --git a/sdk/wallet-sdk/src/wallet/namespace/ledger/namespace.ts b/sdk/wallet-sdk/src/wallet/namespace/ledger/namespace.ts index 8debca11b..91e443019 100644 --- a/sdk/wallet-sdk/src/wallet/namespace/ledger/namespace.ts +++ b/sdk/wallet-sdk/src/wallet/namespace/ledger/namespace.ts @@ -65,6 +65,22 @@ export class LedgerNamespace { this.sdkContext.synchronizers.add(...synchronizers) } + /** + * Resolves the ID of the synchronizer with the given alias from the + * synchronizers connected to the caller. Returns `undefined` when no + * synchronizer with that alias is connected, leaving it to the caller to + * decide how a missing synchronizer should be handled. + */ + public async getSynchronizerIdByAlias( + alias: string, + options?: ConnectedSynchronizersOptions + ): Promise { + return this.sdkContext.synchronizers.resolveSynchronizerIdByAlias( + alias, + options + ) + } + /** * Resolves the ID of the synchronizer aliased `'global'` from the * synchronizers connected to the caller. diff --git a/sdk/wallet-sdk/src/wallet/namespace/ledger/synchronizer-cache.ts b/sdk/wallet-sdk/src/wallet/namespace/ledger/synchronizer-cache.ts index 8652074da..1f51feb3b 100644 --- a/sdk/wallet-sdk/src/wallet/namespace/ledger/synchronizer-cache.ts +++ b/sdk/wallet-sdk/src/wallet/namespace/ledger/synchronizer-cache.ts @@ -203,6 +203,26 @@ export class SynchronizerCache { return this.matching(options) } + /** + * Resolves the ID of the synchronizer with the given alias from the cache, + * re-fetching once if it is not yet present (in case a synchronizer was + * connected after the SDK was initialized). Returns `undefined` when no + * synchronizer with that alias is connected. + */ + public async resolveSynchronizerIdByAlias( + alias: string, + options?: ConnectedSynchronizersOptions + ): Promise { + const findByAlias = (synchronizers: CachedSynchronizer[]) => + synchronizers.find((s) => s.synchronizerAlias === alias) + + let match = findByAlias(await this.list(options)) + if (!match) { + match = findByAlias(await this.list(options, { refresh: true })) + } + return match?.synchronizerId + } + /** * Resolves the ID of the synchronizer aliased `'global'` from the cache, * re-fetching once if it is not yet present (in case a synchronizer was @@ -213,14 +233,7 @@ export class SynchronizerCache { public async resolveGlobalSynchronizerId( options?: ConnectedSynchronizersOptions ): Promise { - const findGlobal = (synchronizers: CachedSynchronizer[]) => - synchronizers.find((s) => s.synchronizerAlias === 'global') - - let global = findGlobal(await this.list(options)) - if (!global) { - global = findGlobal(await this.list(options, { refresh: true })) - } - return global?.synchronizerId + return this.resolveSynchronizerIdByAlias('global', options) } /** From e1a29a5223748134e0f44a89ea9290c698737009 Mon Sep 17 00:00:00 2001 From: jarekr-da Date: Mon, 15 Jun 2026 15:11:16 +0200 Subject: [PATCH 5/5] refactor: extract multi-sync example logic into core modules + test improvements Squashes the non-synchronizer changes from the original jarekr/sdk_synchronizers branch on top of the synchronizer refactor (Part 1): move shared multi-sync example logic into core packages (test-token, amulet-ops, trading-app); add the localnet helper and default auth config; add vitest configs and test-coverage setup; export vetDarIdempotent; SDK reference renames. Signed-off-by: jarekr-da --- core/amulet-ops/package.json | 55 ++++ core/amulet-ops/src/allocation.ts | 95 +++++++ core/amulet-ops/src/index.ts | 8 + core/amulet-ops/src/tap.ts | 46 +++ core/amulet-ops/tsconfig.json | 8 + core/amulet-ops/tsup.config.ts | 11 + core/amulet-ops/vitest.config.ts | 29 ++ core/test-token/package.json | 8 + core/test-token/src/allocation.ts | 136 +++++++++ core/test-token/src/commands.ts | 98 +++++++ core/test-token/src/index.ts | 109 ++----- core/test-token/src/setup.ts | 160 +++++++++++ core/test-token/src/transfer.ts | 257 +++++++++++++++++ core/trading-app/package.json | 55 ++++ core/trading-app/src/commands.ts | 81 ++++++ core/trading-app/src/index.ts | 29 ++ core/trading-app/src/propose.ts | 158 ++++++++++ core/trading-app/src/settle.ts | 140 +++++++++ core/trading-app/src/withdrawal.ts | 54 ++++ core/trading-app/tsconfig.json | 8 + core/trading-app/tsup.config.ts | 11 + core/trading-app/vitest.config.ts | 29 ++ .../examples/package.json | 2 + .../scripts/15-multi-sync/_amulet_ops.ts | 90 ++---- .../examples/scripts/15-multi-sync/_setup.ts | 70 +---- .../15-multi-sync/_token_allocation.ts | 78 +---- .../scripts/15-multi-sync/_token_setup.ts | 104 ++----- .../scripts/15-multi-sync/_token_transfer.ts | 154 ++-------- .../scripts/15-multi-sync/_trade_propose.ts | 183 ++---------- .../scripts/15-multi-sync/_trade_settle.ts | 269 +++++------------- sdk/wallet-sdk/src/config.ts | 13 + sdk/wallet-sdk/src/wallet/index.ts | 1 + sdk/wallet-sdk/src/wallet/localnet.ts | 90 ++++++ .../wallet/namespace/ledger/dar/vetting.ts | 40 +++ sdk/wallet-sdk/src/wallet/sdk.ts | 5 +- yarn.lock | 47 +++ 36 files changed, 1871 insertions(+), 860 deletions(-) create mode 100644 core/amulet-ops/package.json create mode 100644 core/amulet-ops/src/allocation.ts create mode 100644 core/amulet-ops/src/index.ts create mode 100644 core/amulet-ops/src/tap.ts create mode 100644 core/amulet-ops/tsconfig.json create mode 100644 core/amulet-ops/tsup.config.ts create mode 100644 core/amulet-ops/vitest.config.ts create mode 100644 core/test-token/src/allocation.ts create mode 100644 core/test-token/src/commands.ts create mode 100644 core/test-token/src/setup.ts create mode 100644 core/test-token/src/transfer.ts create mode 100644 core/trading-app/package.json create mode 100644 core/trading-app/src/commands.ts create mode 100644 core/trading-app/src/index.ts create mode 100644 core/trading-app/src/propose.ts create mode 100644 core/trading-app/src/settle.ts create mode 100644 core/trading-app/src/withdrawal.ts create mode 100644 core/trading-app/tsconfig.json create mode 100644 core/trading-app/tsup.config.ts create mode 100644 core/trading-app/vitest.config.ts create mode 100644 sdk/wallet-sdk/src/wallet/localnet.ts diff --git a/core/amulet-ops/package.json b/core/amulet-ops/package.json new file mode 100644 index 000000000..5db8aacaa --- /dev/null +++ b/core/amulet-ops/package.json @@ -0,0 +1,55 @@ +{ + "name": "@canton-network/core-amulet-ops", + "version": "0.0.1", + "type": "module", + "description": "SDK-level Amulet operations (tap/mint, allocate) built on the wallet SDK", + "license": "Apache-2.0", + "packageManager": "yarn@4.9.4", + "main": "dist/index.cjs", + "module": "dist/index.js", + "types": "dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "require": "./dist/index.cjs", + "default": "./dist/index.js" + } + }, + "scripts": { + "build": "tsup --onSuccess \"tsc\"", + "dev": "tsup --watch --onSuccess \"tsc\"", + "clean": "tsc -b --clean; rm -rf dist", + "flatpack": "yarn pack --out \"$FLATPACK_OUTDIR\"", + "test": "vitest run --project node --passWithNoTests", + "test:coverage": "vitest run --project node --coverage --passWithNoTests" + }, + "dependencies": { + "@canton-network/core-amulet-service": "workspace:^" + }, + "peerDependencies": { + "@canton-network/core-signing-lib": "workspace:^", + "@canton-network/wallet-sdk": "workspace:^", + "pino": "^10.3.1" + }, + "devDependencies": { + "@canton-network/core-signing-lib": "workspace:^", + "@canton-network/wallet-sdk": "workspace:^", + "@vitest/coverage-v8": "^4.1.2", + "pino": "^10.3.1", + "tsup": "^8.5.1", + "typescript": "^5.9.3", + "vitest": "^4.1.2" + }, + "files": [ + "dist/**" + ], + "publishConfig": { + "access": "public" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/canton-network/wallet.git", + "directory": "core/amulet-ops" + } +} diff --git a/core/amulet-ops/src/allocation.ts b/core/amulet-ops/src/allocation.ts new file mode 100644 index 000000000..43cfee1a2 --- /dev/null +++ b/core/amulet-ops/src/allocation.ts @@ -0,0 +1,95 @@ +// Copyright (c) 2025-2026 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import type { Logger } from 'pino' +import type { SDKInterface } from '@canton-network/wallet-sdk' +import { AMULET_TEMPLATE_ID } from '@canton-network/core-amulet-service' +import type { SigningParty } from './tap.js' + +const AMULET_INSTRUMENT = { + id: 'Amulet', + displayName: 'Amulet', + symbol: 'CC', +} as const + +export interface AllocateAmuletParams { + sdk: SDKInterface<'token'> + sender: SigningParty + adminPartyId: string + registryUrl: URL + globalSynchronizerId: string + logger?: Logger +} + +/** + * Allocates the sender's Amulet holding against its leg of a pending token + * allocation request. + * + * Looks up the sender's transfer leg in the pending allocation request, reads its + * Amulet holding, builds the allocation instruction for the Amulet instrument, and + * submits it signed by the sender. + * + * @returns The transfer-leg id that was allocated. + */ +export async function allocateAmulet( + params: AllocateAmuletParams +): Promise { + const { + sdk, + sender, + adminPartyId, + registryUrl, + globalSynchronizerId, + logger, + } = params + const token = sdk.token + + const pendingRequests = await token.allocation.request.pending( + sender.partyId + ) + const requestView = pendingRequests[0].interfaceViewValue! + const legId = Object.keys(requestView.transferLegs).find( + (key) => requestView.transferLegs[key].sender === sender.partyId + )! + if (!legId) throw new Error('No transfer leg found for sender') + + const amuletHoldings = await sdk.ledger.acsReader.readJsContracts({ + templateIds: [AMULET_TEMPLATE_ID], + parties: [sender.partyId], + filterByParty: true, + }) + const amuletHoldingCid = amuletHoldings[0]?.contractId + if (!amuletHoldingCid) + throw new Error('Amulet holding not found for sender') + + const [command, disclosedContracts] = + await token.allocation.instruction.create({ + allocationSpecification: { + settlement: requestView.settlement, + transferLegId: legId, + transferLeg: requestView.transferLegs[legId], + }, + asset: { + id: AMULET_INSTRUMENT.id, + displayName: AMULET_INSTRUMENT.displayName, + symbol: AMULET_INSTRUMENT.symbol, + registryUrl, + admin: adminPartyId, + }, + inputUtxos: [amuletHoldingCid], + requestedAt: new Date().toISOString(), + }) + + await sdk.ledger + .prepare({ + partyId: sender.partyId, + commands: [command], + disclosedContracts, + synchronizerId: globalSynchronizerId, + }) + .sign(sender.privateKey) + .execute({ partyId: sender.partyId }) + + logger?.info('Amulet allocated for sender leg (global synchronizer)') + return legId +} diff --git a/core/amulet-ops/src/index.ts b/core/amulet-ops/src/index.ts new file mode 100644 index 000000000..3e0eb4cba --- /dev/null +++ b/core/amulet-ops/src/index.ts @@ -0,0 +1,8 @@ +// Copyright (c) 2025-2026 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +export { mintAmulet } from './tap.js' +export type { MintAmuletParams, SigningParty } from './tap.js' + +export { allocateAmulet } from './allocation.js' +export type { AllocateAmuletParams } from './allocation.js' diff --git a/core/amulet-ops/src/tap.ts b/core/amulet-ops/src/tap.ts new file mode 100644 index 000000000..b71b4ab0e --- /dev/null +++ b/core/amulet-ops/src/tap.ts @@ -0,0 +1,46 @@ +// Copyright (c) 2025-2026 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import type { Logger } from 'pino' +import type { PrivateKey } from '@canton-network/core-signing-lib' +import type { SDKInterface } from '@canton-network/wallet-sdk' + +export interface SigningParty { + partyId: string + privateKey: PrivateKey +} + +export interface MintAmuletParams { + sdk: SDKInterface<'amulet'> + receiver: SigningParty + amount: string + synchronizerId: string + logger?: Logger +} + +/** + * Taps (mints) `amount` Amulet into `receiver`'s wallet on `synchronizerId`. + * + * Builds the tap command via the SDK's `amulet` namespace, then prepares, signs, + * and executes it as a single-party submission by the receiver. + */ +export async function mintAmulet(params: MintAmuletParams): Promise { + const { sdk, receiver, amount, synchronizerId, logger } = params + + const [tapCommand, disclosedContracts] = await sdk.amulet.tap( + receiver.partyId, + amount + ) + + await sdk.ledger + .prepare({ + partyId: receiver.partyId, + commands: tapCommand, + disclosedContracts, + synchronizerId, + }) + .sign(receiver.privateKey) + .execute({ partyId: receiver.partyId }) + + logger?.info(`Amulet minted (${amount}) for receiver on synchronizer`) +} diff --git a/core/amulet-ops/tsconfig.json b/core/amulet-ops/tsconfig.json new file mode 100644 index 000000000..572eba58e --- /dev/null +++ b/core/amulet-ops/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.web.json", + "compilerOptions": { + "rootDir": "./src", + "outDir": "./dist" + }, + "include": ["src"] +} diff --git a/core/amulet-ops/tsup.config.ts b/core/amulet-ops/tsup.config.ts new file mode 100644 index 000000000..eede71ea8 --- /dev/null +++ b/core/amulet-ops/tsup.config.ts @@ -0,0 +1,11 @@ +// Copyright (c) 2025-2026 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { defineConfig } from 'tsup' +import { base } from '../../tsup.base' + +export default defineConfig({ + ...base, + entry: ['src/index.ts'], + platform: 'node', +}) diff --git a/core/amulet-ops/vitest.config.ts b/core/amulet-ops/vitest.config.ts new file mode 100644 index 000000000..67370d096 --- /dev/null +++ b/core/amulet-ops/vitest.config.ts @@ -0,0 +1,29 @@ +// Copyright (c) 2025-2026 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { defineConfig, defineProject } from 'vitest/config' + +export default defineConfig({ + test: { + coverage: { + include: ['src/**/*.ts'], + provider: 'v8', + reporter: ['text', 'html', 'lcov'], + thresholds: { + lines: 0, + functions: 0, + branches: 0, + statements: 0, + }, + }, + projects: [ + defineProject({ + test: { + name: 'node', + environment: 'node', + include: ['src/**/*.test.ts'], + }, + }), + ], + }, +}) diff --git a/core/test-token/package.json b/core/test-token/package.json index 70561d3fb..9bbe7e021 100644 --- a/core/test-token/package.json +++ b/core/test-token/package.json @@ -26,12 +26,20 @@ "@daml/types": "^3.5.0", "@mojotech/json-type-validation": "^3.1.0" }, + "peerDependencies": { + "@canton-network/core-signing-lib": "workspace:^", + "@canton-network/wallet-sdk": "workspace:^", + "pino": "^10.3.1" + }, "devDependencies": { + "@canton-network/core-signing-lib": "workspace:^", + "@canton-network/wallet-sdk": "workspace:^", "@rollup/plugin-alias": "^5.0.0", "@rollup/plugin-commonjs": "^29.0.0", "@rollup/plugin-json": "^6.1.0", "@rollup/plugin-node-resolve": "^16.0.3", "@rollup/plugin-typescript": "^12.3.0", + "pino": "^10.3.1", "rollup": "^4.59.0", "rollup-plugin-dts": "^6.3.0", "tslib": "^2.8.1", diff --git a/core/test-token/src/allocation.ts b/core/test-token/src/allocation.ts new file mode 100644 index 000000000..60d32b500 --- /dev/null +++ b/core/test-token/src/allocation.ts @@ -0,0 +1,136 @@ +// Copyright (c) 2025-2026 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import type { Logger } from 'pino' +import type { PrivateKey } from '@canton-network/core-signing-lib' +import type { SDKInterface } from '@canton-network/wallet-sdk' +import { Splice } from '@daml.js/splice-test-token-v1-1.0.0' + +const TestTokenV1 = Splice.Testing.Tokens.TestTokenV1 + +/** The instrument the TestToken DAR mints holdings for. */ +const TEST_TOKEN_INSTRUMENT = { + id: 'TestToken', + displayName: 'TestToken', + symbol: 'TT', +} as const + +export interface AllocateTestTokenParams { + /** SDK for the participant hosting the sender (must have the `token` namespace). */ + sdk: SDKInterface<'token'> + /** The party allocating its TestToken holding, plus the key used to sign. */ + sender: { partyId: string; privateKey: PrivateKey } + /** The party that administers the TestToken (the `TokenRules` admin). */ + adminPartyId: string + /** Synchronizer the TokenRules live on and the allocation is submitted to. */ + globalSynchronizerId: string + logger?: Logger +} + +/** + * Allocates the sender's TestToken holding against its leg of a pending token + * allocation request. + * + * Looks up the sender's transfer leg in the pending allocation request, reassigns + * its TestToken holding onto the target synchronizer (no-op if already there), + * builds the allocation instruction against the `TokenRules` on that synchronizer, + * and submits it signed by the sender. + * + * The TestToken-specific knowledge (the `Token` / `TokenRules` template IDs and the + * `TestToken`/`TT` instrument descriptor) lives here; everything else is supplied + * by the caller so the same flow works for any environment. + * + * @returns The transfer-leg id that was allocated. + */ +export async function allocateTestToken( + params: AllocateTestTokenParams +): Promise<{ legId: string }> { + const { sdk, sender, adminPartyId, globalSynchronizerId, logger } = params + const token = sdk.token + + const pendingRequests = await token.allocation.request.pending( + sender.partyId + ) + const requestView = pendingRequests[0].interfaceViewValue! + const legId = Object.keys(requestView.transferLegs).find( + (key) => requestView.transferLegs[key].sender === sender.partyId + )! + if (!legId) throw new Error('No transfer leg found for sender') + + const [tokenHoldings, tokenRulesContracts] = await Promise.all([ + sdk.ledger.acs.read({ + templateIds: [TestTokenV1.Token.templateId], + parties: [sender.partyId], + filterByParty: true, + }), + sdk.ledger.acs.read({ + templateIds: [TestTokenV1.TokenRules.templateId], + parties: [adminPartyId], + filterByParty: true, + }), + ]) + + const tokenHolding = tokenHoldings[0] + if (!tokenHolding) throw new Error('Token holding not found for sender') + const tokenRulesOnGlobal = tokenRulesContracts.find( + (c) => c.synchronizerId === globalSynchronizerId + ) + if (!tokenRulesOnGlobal) + throw new Error('TokenRules not found on global synchronizer') + + await sdk.ledger.internal.reassign({ + submitter: sender.partyId, + contractId: tokenHolding.contractId, + source: tokenHolding.synchronizerId, + target: globalSynchronizerId, + skipIfAlreadyOn: true, + }) + + const [command, disclosedFromHelper] = + await token.allocation.instruction.create({ + allocationSpecification: { + settlement: requestView.settlement, + transferLegId: legId, + transferLeg: requestView.transferLegs[legId], + }, + asset: { + id: TEST_TOKEN_INSTRUMENT.id, + displayName: TEST_TOKEN_INSTRUMENT.displayName, + symbol: TEST_TOKEN_INSTRUMENT.symbol, + registryUrl: new URL('http://unused.invalid'), + admin: adminPartyId, + }, + inputUtxos: [tokenHolding.contractId], + requestedAt: new Date(Date.now()).toISOString(), + prefetchedRegistryChoiceContext: { + factoryId: tokenRulesOnGlobal.contractId, + choiceContext: { + choiceContextData: {} as Record, + disclosedContracts: [], + }, + }, + }) + + await sdk.ledger + .prepare({ + partyId: sender.partyId, + commands: [command], + disclosedContracts: [ + ...disclosedFromHelper, + { + templateId: tokenRulesOnGlobal.templateId, + contractId: tokenRulesOnGlobal.contractId, + createdEventBlob: tokenRulesOnGlobal.createdEventBlob!, + synchronizerId: tokenRulesOnGlobal.synchronizerId, + }, + ], + synchronizerId: globalSynchronizerId, + }) + .sign(sender.privateKey) + .execute({ partyId: sender.partyId }) + + logger?.info( + 'TestToken allocated for sender leg (global synchronizer, single-party)' + ) + return { legId } +} diff --git a/core/test-token/src/commands.ts b/core/test-token/src/commands.ts new file mode 100644 index 000000000..91bacc7e4 --- /dev/null +++ b/core/test-token/src/commands.ts @@ -0,0 +1,98 @@ +// Copyright (c) 2025-2026 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { Splice } from '@daml.js/splice-test-token-v1-1.0.0' + +const T = Splice.Testing.Tokens.TestTokenV1 + +const TRANSFER_FACTORY_INTERFACE_ID = + '#splice-api-token-transfer-instruction-v1:Splice.Api.Token.TransferInstructionV1:TransferFactory' +const TRANSFER_INSTRUCTION_INTERFACE_ID = + '#splice-api-token-transfer-instruction-v1:Splice.Api.Token.TransferInstructionV1:TransferInstruction' + +/** Build a CreateCommand that creates a TokenRules contract for the given admin party. */ +export function buildCreateTokenRulesCommand(adminParty: string) { + return { + CreateCommand: { + templateId: T.TokenRules.templateId, + createArguments: { admin: adminParty }, + }, + } +} + +/** Build a CreateCommand that mints a Token held by `owner`. */ +export function buildMintTokenCommand(params: { + owner: string + admin: string + amount: string +}) { + return { + CreateCommand: { + templateId: T.Token.templateId, + createArguments: { + holding: { + owner: params.owner, + instrumentId: { admin: params.admin, id: 'TestToken' }, + amount: params.amount, + lock: null, + meta: { values: {} }, + }, + }, + }, + } +} + +/** Build an ExerciseCommand for TransferFactory_Transfer on a TokenRules contract. */ +export function buildTransferTokenCommand(params: { + tokenRulesCid: string + expectedAdmin: string + sender: string + receiver: string + amount: string + admin: string + inputHoldingCids: string[] + requestedAt: string + executeBefore: string +}) { + return { + ExerciseCommand: { + templateId: TRANSFER_FACTORY_INTERFACE_ID, + contractId: params.tokenRulesCid, + choice: 'TransferFactory_Transfer', + choiceArgument: { + expectedAdmin: params.expectedAdmin, + transfer: { + sender: params.sender, + receiver: params.receiver, + amount: params.amount, + instrumentId: { admin: params.admin, id: 'TestToken' }, + requestedAt: params.requestedAt, + executeBefore: params.executeBefore, + inputHoldingCids: params.inputHoldingCids, + meta: { values: {} }, + }, + extraArgs: { + context: { values: {} }, + meta: { values: {} }, + }, + }, + }, + } +} + +/** Build an ExerciseCommand that accepts a pending TransferInstruction (TokenTransferOffer). */ +export function buildAcceptTransferInstructionCommand(offerCid: string) { + return { + ExerciseCommand: { + templateId: TRANSFER_INSTRUCTION_INTERFACE_ID, + contractId: offerCid, + choice: 'TransferInstruction_Accept', + choiceArgument: { + extraArgs: { + context: { values: {} }, + meta: { values: {} }, + }, + }, + }, + } +} diff --git a/core/test-token/src/index.ts b/core/test-token/src/index.ts index 9c1760def..a3652b6a1 100644 --- a/core/test-token/src/index.ts +++ b/core/test-token/src/index.ts @@ -4,96 +4,25 @@ import { Splice, packageId } from '@daml.js/splice-test-token-v1-1.0.0' export { Splice, packageId } -const T = Splice.Testing.Tokens.TestTokenV1 +export { + buildCreateTokenRulesCommand, + buildMintTokenCommand, + buildTransferTokenCommand, + buildAcceptTransferInstructionCommand, +} from './commands.js' -const TRANSFER_FACTORY_INTERFACE_ID = - '#splice-api-token-transfer-instruction-v1:Splice.Api.Token.TransferInstructionV1:TransferFactory' -const TRANSFER_INSTRUCTION_INTERFACE_ID = - '#splice-api-token-transfer-instruction-v1:Splice.Api.Token.TransferInstructionV1:TransferInstruction' +export { allocateTestToken } from './allocation.js' +export type { AllocateTestTokenParams } from './allocation.js' -/** Build a CreateCommand that creates a TokenRules contract for the given admin party. */ -export function buildCreateTokenRulesCommand(adminParty: string) { - return { - CreateCommand: { - templateId: T.TokenRules.templateId, - createArguments: { admin: adminParty }, - }, - } -} +export { createTokenRules, mintTestToken } from './setup.js' +export type { + SigningParty, + CreateTokenRulesParams, + MintTestTokenParams, +} from './setup.js' -/** Build a CreateCommand that mints a Token held by `owner`. */ -export function buildMintTokenCommand(params: { - owner: string - admin: string - amount: string -}) { - return { - CreateCommand: { - templateId: T.Token.templateId, - createArguments: { - holding: { - owner: params.owner, - instrumentId: { admin: params.admin, id: 'TestToken' }, - amount: params.amount, - lock: null, - meta: { values: {} }, - }, - }, - }, - } -} - -/** Build an ExerciseCommand for TransferFactory_Transfer on a TokenRules contract. */ -export function buildTransferTokenCommand(params: { - tokenRulesCid: string - expectedAdmin: string - sender: string - receiver: string - amount: string - admin: string - inputHoldingCids: string[] - requestedAt: string - executeBefore: string -}) { - return { - ExerciseCommand: { - templateId: TRANSFER_FACTORY_INTERFACE_ID, - contractId: params.tokenRulesCid, - choice: 'TransferFactory_Transfer', - choiceArgument: { - expectedAdmin: params.expectedAdmin, - transfer: { - sender: params.sender, - receiver: params.receiver, - amount: params.amount, - instrumentId: { admin: params.admin, id: 'TestToken' }, - requestedAt: params.requestedAt, - executeBefore: params.executeBefore, - inputHoldingCids: params.inputHoldingCids, - meta: { values: {} }, - }, - extraArgs: { - context: { values: {} }, - meta: { values: {} }, - }, - }, - }, - } -} - -/** Build an ExerciseCommand that accepts a pending TransferInstruction (TokenTransferOffer). */ -export function buildAcceptTransferInstructionCommand(offerCid: string) { - return { - ExerciseCommand: { - templateId: TRANSFER_INSTRUCTION_INTERFACE_ID, - contractId: offerCid, - choice: 'TransferInstruction_Accept', - choiceArgument: { - extraArgs: { - context: { values: {} }, - meta: { values: {} }, - }, - }, - }, - } -} +export { selfTransferTestToken, selfTransferAllTestTokens } from './transfer.js' +export type { + SelfTransferTestTokenParams, + SelfTransferAllTestTokensParams, +} from './transfer.js' diff --git a/core/test-token/src/setup.ts b/core/test-token/src/setup.ts new file mode 100644 index 000000000..3b88f36bf --- /dev/null +++ b/core/test-token/src/setup.ts @@ -0,0 +1,160 @@ +// Copyright (c) 2025-2026 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import type { Logger } from 'pino' +import type { PrivateKey } from '@canton-network/core-signing-lib' +import type { SDKInterface } from '@canton-network/wallet-sdk' +import { Splice } from '@daml.js/splice-test-token-v1-1.0.0' +import { + buildCreateTokenRulesCommand, + buildMintTokenCommand, + buildTransferTokenCommand, + buildAcceptTransferInstructionCommand, +} from './commands.js' + +const TestTokenV1 = Splice.Testing.Tokens.TestTokenV1 + +/** Default validity window for a mint's transfer offer: 24 hours. */ +const DEFAULT_TRANSFER_VALIDITY_MS = 24 * 60 * 60 * 1000 + +export interface SigningParty { + partyId: string + privateKey: PrivateKey +} + +export interface CreateTokenRulesParams { + sdk: SDKInterface<'token'> + admin: SigningParty + synchronizerIds: string[] + logger?: Logger +} + +/** + * Creates a `TokenRules` contract administered by `admin` on each of the given + * synchronizers (prepared, signed, and executed in parallel per synchronizer). + */ +export async function createTokenRules( + params: CreateTokenRulesParams +): Promise { + const { sdk, admin, synchronizerIds, logger } = params + + await sdk.ledger.executeOnSynchronizers( + { + partyId: admin.partyId, + commands: buildCreateTokenRulesCommand(admin.partyId), + disclosedContracts: [], + }, + synchronizerIds, + admin.privateKey + ) + + logger?.info( + `TokenRules created on ${synchronizerIds.length} synchronizer(s)` + ) +} + +export interface MintTestTokenParams { + sdk: SDKInterface<'token'> + admin: SigningParty + receiver: SigningParty + amount: string + synchronizerId: string + transferValidityMs?: number + logger?: Logger +} + +/** + * Mints `amount` TestToken into `receiver`'s wallet on `synchronizerId`. + * + * The TestToken DAR has no direct mint-to-arbitrary-party choice, so this runs + * the full flow: `admin` mints a holding to itself, transfers it to `receiver` + * (creating a `TokenTransferOffer`), and `receiver` accepts the offer. + */ +export async function mintTestToken( + params: MintTestTokenParams +): Promise { + const { sdk, admin, receiver, amount, synchronizerId, logger } = params + const validityMs = params.transferValidityMs ?? DEFAULT_TRANSFER_VALIDITY_MS + + await sdk.ledger + .prepare({ + partyId: admin.partyId, + commands: [ + buildMintTokenCommand({ + owner: admin.partyId, + admin: admin.partyId, + amount, + }), + ], + disclosedContracts: [], + synchronizerId, + }) + .sign(admin.privateKey) + .execute({ partyId: admin.partyId }) + + const [tokenRulesContracts, adminTokenHoldings] = await Promise.all([ + sdk.ledger.acs.read({ + templateIds: [TestTokenV1.TokenRules.templateId], + parties: [admin.partyId], + filterByParty: true, + }), + sdk.ledger.acs.read({ + templateIds: [TestTokenV1.Token.templateId], + parties: [admin.partyId], + filterByParty: true, + }), + ]) + const tokenRules = tokenRulesContracts.find( + (c) => c.synchronizerId === synchronizerId + ) + if (!tokenRules) + throw new Error('TokenRules not found on synchronizer after creation') + const adminTokenCid = adminTokenHoldings[0]?.contractId + if (!adminTokenCid) + throw new Error('Admin Token holding not found after mint') + + await sdk.ledger + .prepare({ + partyId: admin.partyId, + commands: [ + buildTransferTokenCommand({ + tokenRulesCid: tokenRules.contractId, + expectedAdmin: admin.partyId, + sender: admin.partyId, + receiver: receiver.partyId, + amount, + admin: admin.partyId, + inputHoldingCids: [adminTokenCid], + requestedAt: new Date(Date.now()).toISOString(), + executeBefore: new Date( + Date.now() + validityMs + ).toISOString(), + }), + ], + disclosedContracts: [], + synchronizerId, + }) + .sign(admin.privateKey) + .execute({ partyId: admin.partyId }) + + const transferOffers = await sdk.ledger.acs.read({ + templateIds: [TestTokenV1.TokenTransferOffer.templateId], + parties: [receiver.partyId], + filterByParty: true, + }) + const transferOfferCid = transferOffers[0]?.contractId + if (!transferOfferCid) + throw new Error('TokenTransferOffer not found for receiver') + + await sdk.ledger + .prepare({ + partyId: receiver.partyId, + commands: [buildAcceptTransferInstructionCommand(transferOfferCid)], + disclosedContracts: [], + synchronizerId, + }) + .sign(receiver.privateKey) + .execute({ partyId: receiver.partyId }) + + logger?.info(`${amount} TestToken minted to receiver on synchronizer`) +} diff --git a/core/test-token/src/transfer.ts b/core/test-token/src/transfer.ts new file mode 100644 index 000000000..11836e324 --- /dev/null +++ b/core/test-token/src/transfer.ts @@ -0,0 +1,257 @@ +// Copyright (c) 2025-2026 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import type { Logger } from 'pino' +import type { SDKInterface } from '@canton-network/wallet-sdk' +import { Splice } from '@daml.js/splice-test-token-v1-1.0.0' +import { buildTransferTokenCommand } from './commands.js' +import type { SigningParty } from './setup.js' + +const TestTokenV1 = Splice.Testing.Tokens.TestTokenV1 + +const DEFAULT_TRANSFER_VALIDITY_MS = 24 * 60 * 60 * 1000 + +const DEFAULT_POLL_TIMEOUT_MS = 30_000 +const DEFAULT_POLL_INTERVAL_MS = 500 + +type TokenHolding = Awaited< + ReturnType['ledger']['acs']['read']> +>[number] + +/** Finds the `TokenRules` administered by `adminPartyId` on the given synchronizer. */ +async function findTokenRulesOnSynchronizer( + sdk: SDKInterface<'token'>, + adminPartyId: string, + synchronizerId: string +): Promise { + const contracts = await sdk.ledger.acs.read({ + templateIds: [TestTokenV1.TokenRules.templateId], + parties: [adminPartyId], + filterByParty: true, + }) + const tokenRules = contracts.find( + (c) => c.synchronizerId === synchronizerId + ) + if (!tokenRules) + throw new Error( + `TokenRules not found on synchronizer ${synchronizerId}` + ) + return tokenRules +} + +/** Reads the holding amount off a Token contract. */ +function readHoldingAmount(holding: TokenHolding): string { + const amount = ( + holding as unknown as { + createArgument: Splice.Testing.Tokens.TestTokenV1.Token + } + ).createArgument?.holding?.amount + if (!amount) throw new Error('Cannot read amount from Token holding') + return amount +} + +/** + * Reassigns a holding onto the target synchronizer (no-op if already there) and + * self-transfers `amount` of it via the admin's `TokenRules`. + */ +async function selfTransferHolding(args: { + sdk: SDKInterface<'token'> + owner: SigningParty + adminPartyId: string + synchronizerId: string + tokenRules: TokenHolding + holding: TokenHolding + amount: string + validityMs: number +}): Promise { + const { sdk, owner, adminPartyId, synchronizerId, tokenRules, holding } = + args + + if (holding.synchronizerId !== synchronizerId) { + await sdk.ledger.internal.reassign({ + submitter: owner.partyId, + contractId: holding.contractId, + source: holding.synchronizerId, + target: synchronizerId, + skipIfAlreadyOn: true, + }) + } + + await sdk.ledger + .prepare({ + partyId: owner.partyId, + commands: [ + buildTransferTokenCommand({ + tokenRulesCid: tokenRules.contractId, + expectedAdmin: adminPartyId, + sender: owner.partyId, + receiver: owner.partyId, + amount: args.amount, + admin: adminPartyId, + inputHoldingCids: [holding.contractId], + requestedAt: new Date(Date.now()).toISOString(), + executeBefore: new Date( + Date.now() + args.validityMs + ).toISOString(), + }), + ], + disclosedContracts: [ + { + templateId: tokenRules.templateId, + contractId: tokenRules.contractId, + createdEventBlob: tokenRules.createdEventBlob!, + synchronizerId: tokenRules.synchronizerId, + }, + ], + synchronizerId, + }) + .sign(owner.privateKey) + .execute({ partyId: owner.partyId }) +} + +/** Polls for the owner's first Token holding to appear, up to a timeout. */ +async function waitForFirstHolding( + sdk: SDKInterface<'token'>, + ownerPartyId: string, + timeoutMs: number, + intervalMs: number +): Promise { + const deadline = Date.now() + timeoutMs + for (;;) { + const holdings = await sdk.ledger.acs.read({ + templateIds: [TestTokenV1.Token.templateId], + parties: [ownerPartyId], + filterByParty: true, + }) + if (holdings[0]) return holdings[0] + if (Date.now() >= deadline) + throw new Error( + `Token holding not found for ${ownerPartyId} within ${timeoutMs}ms` + ) + await new Promise((resolve) => setTimeout(resolve, intervalMs)) + } +} + +export interface SelfTransferTestTokenParams { + sdk: SDKInterface<'token'> + owner: SigningParty + adminPartyId: string + adminSdk?: SDKInterface<'token'> + synchronizerId: string + amount: string + transferValidityMs?: number + waitForHolding?: { timeoutMs?: number; intervalMs?: number } | false + logger?: Logger +} + +/** + * Self-transfers `amount` of the owner's first TestToken holding onto + * `synchronizerId`, reassigning the holding there first if needed. + * + * By default it waits for the holding to appear (useful right after a + * settlement), then runs the transfer via the admin's `TokenRules`. + */ +export async function selfTransferTestToken( + params: SelfTransferTestTokenParams +): Promise { + const { sdk, owner, adminPartyId, synchronizerId, amount, logger } = params + const validityMs = params.transferValidityMs ?? DEFAULT_TRANSFER_VALIDITY_MS + + let holding: TokenHolding + if (params.waitForHolding === false) { + const holdings = await sdk.ledger.acs.read({ + templateIds: [TestTokenV1.Token.templateId], + parties: [owner.partyId], + filterByParty: true, + }) + if (!holdings[0]) + throw new Error(`Token holding not found for ${owner.partyId}`) + holding = holdings[0] + } else { + holding = await waitForFirstHolding( + sdk, + owner.partyId, + params.waitForHolding?.timeoutMs ?? DEFAULT_POLL_TIMEOUT_MS, + params.waitForHolding?.intervalMs ?? DEFAULT_POLL_INTERVAL_MS + ) + } + + const tokenRules = await findTokenRulesOnSynchronizer( + params.adminSdk ?? sdk, + adminPartyId, + synchronizerId + ) + + await selfTransferHolding({ + sdk, + owner, + adminPartyId, + synchronizerId, + tokenRules, + holding, + amount, + validityMs, + }) + + logger?.info( + `${amount} TestToken self-transferred on synchronizer ${synchronizerId}` + ) +} + +export interface SelfTransferAllTestTokensParams { + sdk: SDKInterface<'token'> + owner: SigningParty + adminPartyId: string + adminSdk?: SDKInterface<'token'> + synchronizerId: string + transferValidityMs?: number + logger?: Logger +} + +/** + * Self-transfers every TestToken holding the owner currently has onto + * `synchronizerId`, each at its full holding amount (reassigning to the target + * synchronizer first if needed). No-op when the owner has no holdings. + * + * @returns The number of holdings transferred. + */ +export async function selfTransferAllTestTokens( + params: SelfTransferAllTestTokensParams +): Promise { + const { sdk, owner, adminPartyId, synchronizerId, logger } = params + const validityMs = params.transferValidityMs ?? DEFAULT_TRANSFER_VALIDITY_MS + + const holdings = await sdk.ledger.acs.read({ + templateIds: [TestTokenV1.Token.templateId], + parties: [owner.partyId], + filterByParty: true, + }) + if (holdings.length === 0) { + logger?.info(`${owner.partyId}: no TestToken holdings to self-transfer`) + return 0 + } + + const tokenRules = await findTokenRulesOnSynchronizer( + params.adminSdk ?? sdk, + adminPartyId, + synchronizerId + ) + + for (const holding of holdings) { + await selfTransferHolding({ + sdk, + owner, + adminPartyId, + synchronizerId, + tokenRules, + holding, + amount: readHoldingAmount(holding), + validityMs, + }) + } + + logger?.info( + `${owner.partyId}: ${holdings.length} TestToken holding(s) self-transferred on synchronizer ${synchronizerId}` + ) + return holdings.length +} diff --git a/core/trading-app/package.json b/core/trading-app/package.json new file mode 100644 index 000000000..ae3a031cb --- /dev/null +++ b/core/trading-app/package.json @@ -0,0 +1,55 @@ +{ + "name": "@canton-network/core-trading-app", + "version": "0.0.1", + "type": "module", + "description": "OTC trading-app flows (propose, accept, initiate, settle) for the splice-token-test-trading-app", + "license": "Apache-2.0", + "packageManager": "yarn@4.9.4", + "main": "dist/index.cjs", + "module": "dist/index.js", + "types": "dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "require": "./dist/index.cjs", + "default": "./dist/index.js" + } + }, + "scripts": { + "build": "tsup --onSuccess \"tsc\"", + "dev": "tsup --watch --onSuccess \"tsc\"", + "clean": "tsc -b --clean; rm -rf dist", + "flatpack": "yarn pack --out \"$FLATPACK_OUTDIR\"", + "test": "vitest run --project node --passWithNoTests", + "test:coverage": "vitest run --project node --coverage --passWithNoTests" + }, + "dependencies": { + "@canton-network/core-ledger-client-types": "workspace:^" + }, + "peerDependencies": { + "@canton-network/core-signing-lib": "workspace:^", + "@canton-network/wallet-sdk": "workspace:^", + "pino": "^10.3.1" + }, + "devDependencies": { + "@canton-network/core-signing-lib": "workspace:^", + "@canton-network/wallet-sdk": "workspace:^", + "@vitest/coverage-v8": "^4.1.2", + "pino": "^10.3.1", + "tsup": "^8.5.1", + "typescript": "^5.9.3", + "vitest": "^4.1.2" + }, + "files": [ + "dist/**" + ], + "publishConfig": { + "access": "public" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/canton-network/wallet.git", + "directory": "core/trading-app" + } +} diff --git a/core/trading-app/src/commands.ts b/core/trading-app/src/commands.ts new file mode 100644 index 000000000..ab7dcaf91 --- /dev/null +++ b/core/trading-app/src/commands.ts @@ -0,0 +1,81 @@ +// Copyright (c) 2025-2026 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import type { PrivateKey } from '@canton-network/core-signing-lib' + +export interface SigningParty { + partyId: string + privateKey: PrivateKey +} + +export const OTC_TRADE_PROPOSAL_TEMPLATE_ID = + '#splice-token-test-trading-app:Splice.Testing.Apps.TradingApp:OTCTradeProposal' +export const OTC_TRADE_TEMPLATE_ID = + '#splice-token-test-trading-app:Splice.Testing.Apps.TradingApp:OTCTrade' + +export function buildOtcTradeProposalCommand(params: { + venue: string + transferLegs: Record + approvers: string[] + tradeCid?: string | null +}) { + return { + CreateCommand: { + templateId: OTC_TRADE_PROPOSAL_TEMPLATE_ID, + createArguments: { + venue: params.venue, + tradeCid: params.tradeCid ?? null, + transferLegs: params.transferLegs, + approvers: params.approvers, + }, + }, + } +} + +export function buildAcceptOtcTradeCommand(params: { + proposalCid: string + approver: string +}) { + return { + ExerciseCommand: { + templateId: OTC_TRADE_PROPOSAL_TEMPLATE_ID, + contractId: params.proposalCid, + choice: 'OTCTradeProposal_Accept', + choiceArgument: { approver: params.approver }, + }, + } +} + +export function buildInitiateSettlementCommand(params: { + proposalCid: string + prepareUntil: string + settleBefore: string +}) { + return { + ExerciseCommand: { + templateId: OTC_TRADE_PROPOSAL_TEMPLATE_ID, + contractId: params.proposalCid, + choice: 'OTCTradeProposal_InitiateSettlement', + choiceArgument: { + prepareUntil: params.prepareUntil, + settleBefore: params.settleBefore, + }, + }, + } +} + +export function buildSettleOtcTradeCommand(params: { + tradeCid: string + allocationsWithContext: Record +}) { + return { + ExerciseCommand: { + templateId: OTC_TRADE_TEMPLATE_ID, + contractId: params.tradeCid, + choice: 'OTCTrade_Settle', + choiceArgument: { + allocationsWithContext: params.allocationsWithContext, + }, + }, + } +} diff --git a/core/trading-app/src/index.ts b/core/trading-app/src/index.ts new file mode 100644 index 000000000..bf20cae86 --- /dev/null +++ b/core/trading-app/src/index.ts @@ -0,0 +1,29 @@ +// Copyright (c) 2025-2026 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +export { + OTC_TRADE_PROPOSAL_TEMPLATE_ID, + OTC_TRADE_TEMPLATE_ID, + buildOtcTradeProposalCommand, + buildAcceptOtcTradeCommand, + buildInitiateSettlementCommand, + buildSettleOtcTradeCommand, +} from './commands.js' +export type { SigningParty } from './commands.js' + +export { createAndInitiateOtcTrade } from './propose.js' +export type { CreateAndInitiateOtcTradeParams } from './propose.js' + +export { settleOtcTrade } from './settle.js' +export type { + SettleOtcTradeParams, + ContextLeg, + DisclosedLeg, +} from './settle.js' + +export { withdrawAllocations } from './withdrawal.js' +export type { + WithdrawAllocationsParams, + AllocationWithdrawal, + AllocationWithdrawParams, +} from './withdrawal.js' diff --git a/core/trading-app/src/propose.ts b/core/trading-app/src/propose.ts new file mode 100644 index 000000000..8eeab0877 --- /dev/null +++ b/core/trading-app/src/propose.ts @@ -0,0 +1,158 @@ +// Copyright (c) 2025-2026 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import type { Logger } from 'pino' +import type { SDKInterface } from '@canton-network/wallet-sdk' +import { + OTC_TRADE_PROPOSAL_TEMPLATE_ID, + OTC_TRADE_TEMPLATE_ID, + buildOtcTradeProposalCommand, + buildAcceptOtcTradeCommand, + buildInitiateSettlementCommand, + type SigningParty, +} from './commands.js' + +const MS_30_MIN = 30 * 60 * 1000 +const MS_1_HOUR = 60 * 60 * 1000 + +const PROPOSAL_POLL_TIMEOUT_MS = 30_000 +const PROPOSAL_POLL_INTERVAL_MS = 500 + +export interface CreateAndInitiateOtcTradeParams { + proposerSdk: SDKInterface<'token'> + proposer: SigningParty + acceptorSdk: SDKInterface<'token'> + acceptor: SigningParty + venueSdk: SDKInterface<'token'> + venue: SigningParty + transferLegs: Record + globalSynchronizerId: string + logger?: Logger +} + +/** + * Runs the full OTC trade initiation flow on the trading-app: + * 1. The proposer creates an `OTCTradeProposal` (itself as the sole initial approver). + * 2. The acceptor exercises `OTCTradeProposal_Accept`. + * 3. The venue exercises `OTCTradeProposal_InitiateSettlement`, producing an `OTCTrade`. + * + * @returns The contract id of the created `OTCTrade`. + */ +export async function createAndInitiateOtcTrade( + params: CreateAndInitiateOtcTradeParams +): Promise { + const { + proposerSdk, + proposer, + acceptorSdk, + acceptor, + venueSdk, + venue, + transferLegs, + globalSynchronizerId, + logger, + } = params + + const readProposalCid = async ( + sdk: SDKInterface<'token'>, + party: string, + predicate: (approvers: string[]) => boolean = () => true + ): Promise => { + const deadline = Date.now() + PROPOSAL_POLL_TIMEOUT_MS + for (;;) { + const proposals = await sdk.ledger.acsReader.readJsContracts({ + templateIds: [OTC_TRADE_PROPOSAL_TEMPLATE_ID], + parties: [party], + filterByParty: true, + }) + const match = proposals.find((proposal) => + predicate( + (( + proposal as unknown as { + createArgument?: { approvers?: string[] } + } + ).createArgument?.approvers ?? []) as string[] + ) + ) + if (match) return match.contractId + if (Date.now() >= deadline) { + throw new Error( + `OTCTradeProposal not visible to ${party} within ${PROPOSAL_POLL_TIMEOUT_MS}ms` + ) + } + await new Promise((resolve) => + setTimeout(resolve, PROPOSAL_POLL_INTERVAL_MS) + ) + } + } + + await proposerSdk.ledger + .prepare({ + partyId: proposer.partyId, + commands: buildOtcTradeProposalCommand({ + venue: venue.partyId, + transferLegs, + approvers: [proposer.partyId], + }), + disclosedContracts: [], + synchronizerId: globalSynchronizerId, + }) + .sign(proposer.privateKey) + .execute({ partyId: proposer.partyId }) + logger?.info('Proposer: OTCTradeProposal created') + + await acceptorSdk.ledger + .prepare({ + partyId: acceptor.partyId, + commands: [ + buildAcceptOtcTradeCommand({ + proposalCid: await readProposalCid( + acceptorSdk, + acceptor.partyId + ), + approver: acceptor.partyId, + }), + ], + disclosedContracts: [], + synchronizerId: globalSynchronizerId, + }) + .sign(acceptor.privateKey) + .execute({ partyId: acceptor.partyId }) + logger?.info('Acceptor: OTCTradeProposal_Accept executed') + + const prepareUntil = new Date(Date.now() + MS_30_MIN).toISOString() + const settleBefore = new Date(Date.now() + MS_1_HOUR).toISOString() + + await venueSdk.ledger + .prepare({ + partyId: venue.partyId, + commands: [ + buildInitiateSettlementCommand({ + proposalCid: await readProposalCid( + venueSdk, + venue.partyId, + (approvers) => approvers.includes(acceptor.partyId) + ), + prepareUntil, + settleBefore, + }), + ], + disclosedContracts: [], + synchronizerId: globalSynchronizerId, + }) + .sign(venue.privateKey) + .execute({ partyId: venue.partyId }) + logger?.info( + 'Venue: OTCTradeProposal_InitiateSettlement executed → OTCTrade created' + ) + + const otcTradeContracts = await venueSdk.ledger.acsReader.readJsContracts({ + templateIds: [OTC_TRADE_TEMPLATE_ID], + parties: [venue.partyId], + filterByParty: true, + }) + const otcTradeCid = otcTradeContracts[0]?.contractId + if (!otcTradeCid) + throw new Error('OTCTrade contract not found after initiation') + return otcTradeCid +} diff --git a/core/trading-app/src/settle.ts b/core/trading-app/src/settle.ts new file mode 100644 index 000000000..7b2b36269 --- /dev/null +++ b/core/trading-app/src/settle.ts @@ -0,0 +1,140 @@ +// Copyright (c) 2025-2026 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import type { Logger } from 'pino' +import type { SDKInterface, TokenNamespace } from '@canton-network/wallet-sdk' +import type { LedgerCommonSchemas } from '@canton-network/core-ledger-client-types' +import { buildSettleOtcTradeCommand, type SigningParty } from './commands.js' + +type DisclosedContract = LedgerCommonSchemas['DisclosedContract'] + +export interface ContextLeg { + tokenNamespace: TokenNamespace + ownerPartyId: string + legId: string + registryUrl: URL | string +} + +/** + * A settlement leg supplied pre-resolved: the allocation contract id plus the + * disclosed contract needed for the venue's participant to see it. + */ +export interface DisclosedLeg { + legId: string + allocationCid: string + disclosedContract: DisclosedContract +} + +export interface SettleOtcTradeParams { + venueSdk: SDKInterface<'token'> + venue: SigningParty + otcTradeCid: string + contextLeg: ContextLeg + disclosedLeg: DisclosedLeg + globalSynchronizerId: string + onSettlementFailure?: (contextLegAllocationCid: string) => Promise + logger?: Logger +} + +/** + * Settles a two-leg OTC trade by exercising `OTCTrade_Settle` on the venue's + * participant. One leg's allocation is located and its registry choice context + * resolved here; the other leg is supplied pre-disclosed by the caller. + * + * If settlement fails and `onSettlementFailure` is provided, it is invoked for + * compensation before the original error is re-thrown. + */ +export async function settleOtcTrade( + params: SettleOtcTradeParams +): Promise { + const { + venueSdk, + venue, + otcTradeCid, + contextLeg, + disclosedLeg, + globalSynchronizerId, + onSettlementFailure, + logger, + } = params + + const ownerAllocations = await contextLeg.tokenNamespace.allocation.pending( + contextLeg.ownerPartyId + ) + const contextAllocation = ownerAllocations.find( + (a) => + a.interfaceViewValue.allocation.transferLegId === contextLeg.legId + ) + if (!contextAllocation) + throw new Error('Allocation not found for context leg') + + const contextExecCtx = + await contextLeg.tokenNamespace.allocation.context.execute({ + allocationCid: contextAllocation.contractId, + registryUrl: contextLeg.registryUrl, + }) + + const allocationsWithContext = { + [contextLeg.legId]: { + _1: contextAllocation.contractId, + _2: { + context: { + ...(contextExecCtx.choiceContextData ?? {}), + values: + (contextExecCtx.choiceContextData?.values as Record< + string, + unknown + >) ?? {}, + }, + meta: { values: {} }, + }, + }, + [disclosedLeg.legId]: { + _1: disclosedLeg.allocationCid, + _2: { context: { values: {} }, meta: { values: {} } }, + }, + } + + const disclosedContracts = [ + ...(contextExecCtx.disclosedContracts ?? []).map((c) => ({ + ...c, + synchronizerId: '', + })), + disclosedLeg.disclosedContract, + ] + + try { + await venueSdk.ledger + .prepare({ + partyId: venue.partyId, + commands: [ + buildSettleOtcTradeCommand({ + tradeCid: otcTradeCid, + allocationsWithContext, + }), + ], + disclosedContracts, + synchronizerId: globalSynchronizerId, + }) + .sign(venue.privateKey) + .execute({ partyId: venue.partyId }) + } catch (settleError) { + logger?.error( + { err: settleError }, + 'Settlement failed — running compensation if provided' + ) + if (onSettlementFailure) { + try { + await onSettlementFailure(contextAllocation.contractId) + } catch (compensationError) { + logger?.error( + { err: compensationError }, + 'Compensation failed — manual intervention required to withdraw allocations' + ) + } + } + throw settleError + } + + logger?.info('Venue: OTCTrade settled — holdings transferred') +} diff --git a/core/trading-app/src/withdrawal.ts b/core/trading-app/src/withdrawal.ts new file mode 100644 index 000000000..958e2b999 --- /dev/null +++ b/core/trading-app/src/withdrawal.ts @@ -0,0 +1,54 @@ +// Copyright (c) 2025-2026 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import type { Logger } from 'pino' +import type { SDKInterface, TokenNamespace } from '@canton-network/wallet-sdk' +import type { SigningParty } from './commands.js' + +export type AllocationWithdrawParams = Parameters< + TokenNamespace['allocation']['withdraw'] +>[0] + +export interface AllocationWithdrawal { + sdk: SDKInterface<'token'> + owner: SigningParty + withdrawParams: AllocationWithdrawParams + logMessage?: string +} + +export interface WithdrawAllocationsParams { + withdrawals: AllocationWithdrawal[] + globalSynchronizerId: string + logger?: Logger +} + +/** + * Withdraws each allocation in parallel, returning the held funds to its owner. + * + * Useful as compensation when an OTC settlement fails: build one + * {@link AllocationWithdrawal} per locked allocation and the held holdings are + * released back to their respective parties. The asset descriptors are supplied + * by the caller, so this stays asset-agnostic. + */ +export async function withdrawAllocations( + params: WithdrawAllocationsParams +): Promise { + const { withdrawals, globalSynchronizerId, logger } = params + + await Promise.all( + withdrawals.map(async ({ sdk, owner, withdrawParams, logMessage }) => { + const [cmd, disclosed] = + await sdk.token.allocation.withdraw(withdrawParams) + await sdk.ledger + .prepare({ + partyId: owner.partyId, + commands: [cmd], + disclosedContracts: disclosed, + synchronizerId: globalSynchronizerId, + }) + .sign(owner.privateKey) + .execute({ partyId: owner.partyId }) + if (logMessage) logger?.info(logMessage) + }) + ) +} diff --git a/core/trading-app/tsconfig.json b/core/trading-app/tsconfig.json new file mode 100644 index 000000000..572eba58e --- /dev/null +++ b/core/trading-app/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.web.json", + "compilerOptions": { + "rootDir": "./src", + "outDir": "./dist" + }, + "include": ["src"] +} diff --git a/core/trading-app/tsup.config.ts b/core/trading-app/tsup.config.ts new file mode 100644 index 000000000..eede71ea8 --- /dev/null +++ b/core/trading-app/tsup.config.ts @@ -0,0 +1,11 @@ +// Copyright (c) 2025-2026 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { defineConfig } from 'tsup' +import { base } from '../../tsup.base' + +export default defineConfig({ + ...base, + entry: ['src/index.ts'], + platform: 'node', +}) diff --git a/core/trading-app/vitest.config.ts b/core/trading-app/vitest.config.ts new file mode 100644 index 000000000..67370d096 --- /dev/null +++ b/core/trading-app/vitest.config.ts @@ -0,0 +1,29 @@ +// Copyright (c) 2025-2026 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { defineConfig, defineProject } from 'vitest/config' + +export default defineConfig({ + test: { + coverage: { + include: ['src/**/*.ts'], + provider: 'v8', + reporter: ['text', 'html', 'lcov'], + thresholds: { + lines: 0, + functions: 0, + branches: 0, + statements: 0, + }, + }, + projects: [ + defineProject({ + test: { + name: 'node', + environment: 'node', + include: ['src/**/*.test.ts'], + }, + }), + ], + }, +}) diff --git a/docs/wallet-integration-guide/examples/package.json b/docs/wallet-integration-guide/examples/package.json index 35efcd2dc..b0c106e4b 100644 --- a/docs/wallet-integration-guide/examples/package.json +++ b/docs/wallet-integration-guide/examples/package.json @@ -41,6 +41,7 @@ "vitest": "^4.1.2" }, "dependencies": { + "@canton-network/core-amulet-ops": "workspace:^", "@canton-network/core-amulet-service": "workspace:^", "@canton-network/core-ledger-client": "workspace:^", "@canton-network/core-ledger-client-types": "workspace:^", @@ -48,6 +49,7 @@ "@canton-network/core-signing-lib": "workspace:^", "@canton-network/core-test-token": "workspace:^", "@canton-network/core-token-standard": "workspace:^", + "@canton-network/core-trading-app": "workspace:^", "@canton-network/core-tx-parser": "workspace:^", "@canton-network/core-types": "workspace:^", "@canton-network/core-wallet-auth": "workspace:^", diff --git a/docs/wallet-integration-guide/examples/scripts/15-multi-sync/_amulet_ops.ts b/docs/wallet-integration-guide/examples/scripts/15-multi-sync/_amulet_ops.ts index baa1bbd80..4f4172a49 100644 --- a/docs/wallet-integration-guide/examples/scripts/15-multi-sync/_amulet_ops.ts +++ b/docs/wallet-integration-guide/examples/scripts/15-multi-sync/_amulet_ops.ts @@ -2,92 +2,44 @@ // SPDX-License-Identifier: Apache-2.0 import type { Logger } from 'pino' -import { AMULET_TEMPLATE_ID } from '@canton-network/core-amulet-service' +import { mintAmulet, allocateAmulet } from '@canton-network/core-amulet-ops' import { localNetStaticConfig } from '@canton-network/wallet-sdk' import type { MultiSyncSetup } from './_setup.js' import { ALICE_AMULET_TAP_AMOUNT } from './_constants.js' -export { AMULET_TEMPLATE_ID } - export async function mintAmuletForAlice( setup: MultiSyncSetup, logger: Logger ): Promise { const { appUserSdk, alice, globalSynchronizerId } = setup - const [aliceTapCreateCommand, aliceTapCreateDisclosedContracts] = - await appUserSdk.amulet.tap(alice.partyId, ALICE_AMULET_TAP_AMOUNT) - await appUserSdk.ledger - .prepare({ + await mintAmulet({ + sdk: appUserSdk, + receiver: { partyId: alice.partyId, - commands: aliceTapCreateCommand, - disclosedContracts: aliceTapCreateDisclosedContracts, - synchronizerId: globalSynchronizerId, - }) - .sign(alice.keyPair.privateKey) - .execute({ partyId: alice.partyId }) - - logger.info( - `Alice: Amulet minted (${ALICE_AMULET_TAP_AMOUNT}) on global synchronizer` - ) + privateKey: alice.keyPair.privateKey, + }, + amount: ALICE_AMULET_TAP_AMOUNT, + synchronizerId: globalSynchronizerId, + logger, + }) } export async function allocateAmuletForAlice( setup: MultiSyncSetup, logger: Logger ): Promise { - const { - appUserSdk, - tokenNamespaceAppUser, - alice, - globalSynchronizerId, - amuletAdmin, - } = setup - - const pendingRequests = - await tokenNamespaceAppUser.allocation.request.pending(alice.partyId) - const requestView = pendingRequests[0].interfaceViewValue! - const legId = Object.keys(requestView.transferLegs).find( - (key) => requestView.transferLegs[key].sender === alice.partyId - )! - if (!legId) throw new Error('No transfer leg found for Alice') + const { appUserSdk, alice, globalSynchronizerId, amuletAdmin } = setup - const amuletHoldings = await appUserSdk.ledger.acs.read({ - templateIds: [AMULET_TEMPLATE_ID], - parties: [alice.partyId], - filterByParty: true, - }) - const amuletHoldingCid = amuletHoldings[0]?.contractId - if (!amuletHoldingCid) throw new Error('Amulet holding not found for Alice') - - const [command, disclosedContracts] = - await tokenNamespaceAppUser.allocation.instruction.create({ - allocationSpecification: { - settlement: requestView.settlement, - transferLegId: legId, - transferLeg: requestView.transferLegs[legId], - }, - asset: { - id: 'Amulet', - displayName: 'Amulet', - symbol: 'CC', - registryUrl: localNetStaticConfig.LOCALNET_REGISTRY_API_URL, - admin: amuletAdmin, - }, - inputUtxos: [amuletHoldingCid], - requestedAt: new Date().toISOString(), - }) - - await appUserSdk.ledger - .prepare({ + return allocateAmulet({ + sdk: appUserSdk, + sender: { partyId: alice.partyId, - commands: [command], - disclosedContracts, - synchronizerId: globalSynchronizerId, - }) - .sign(alice.keyPair.privateKey) - .execute({ partyId: alice.partyId }) - - logger.info('Alice: Amulet allocated for leg-0 (global synchronizer)') - return legId + privateKey: alice.keyPair.privateKey, + }, + adminPartyId: amuletAdmin, + registryUrl: localNetStaticConfig.LOCALNET_REGISTRY_API_URL, + globalSynchronizerId, + logger, + }) } diff --git a/docs/wallet-integration-guide/examples/scripts/15-multi-sync/_setup.ts b/docs/wallet-integration-guide/examples/scripts/15-multi-sync/_setup.ts index bc3c5d024..8d5b858a5 100644 --- a/docs/wallet-integration-guide/examples/scripts/15-multi-sync/_setup.ts +++ b/docs/wallet-integration-guide/examples/scripts/15-multi-sync/_setup.ts @@ -6,12 +6,12 @@ import { fileURLToPath } from 'url' import fs from 'fs/promises' import type { Logger } from 'pino' import { + createLocalNetSdks, localNetStaticConfig, - SDK, type SDKInterface, type SDKContext, type TokenNamespace, - vetPackage, + vetPackageIdempotent, } from '@canton-network/wallet-sdk' import type { KeyPair } from '@canton-network/core-signing-lib' import type { GenerateTransactionResponse } from '@canton-network/core-ledger-client' @@ -50,40 +50,6 @@ const TEST_TOKEN_V1_DAR = const LOCALNET_PATH = '../../../../../.localnet' const TRADING_APP_DAR_LOCALNET = '/dars/splice-token-test-trading-app-1.0.0.dar' -/** - * Vet a DAR, tolerating the case where a package with the same name+version is - * already vetted on the participant. - * - * On a persistent localnet, a previous build of a DAR (e.g. `splice-test-token-v1`) - * may already be vetted. Re-running the example after rebuilding the DAR produces a - * different package hash for the same name+version, which Canton rejects with - * `KNOWN_PACKAGE_VERSION`. Since the already-vetted package is resolved by - * package-name at command-submission time, it is safe to reuse it and continue. - */ -async function vetPackageIdempotent( - ledgerProvider: Parameters[0], - dar: Uint8Array, - synchronizerId: string, - logger: Logger -): Promise { - try { - await vetPackage(ledgerProvider, dar, synchronizerId) - } catch (e) { - const code = (e as { code?: string })?.code - const message = `${(e as { cause?: unknown })?.cause ?? (e as Error)?.message ?? e}` - if ( - code === 'KNOWN_PACKAGE_VERSION' || - message.includes('same name and version') - ) { - logger.warn( - 'A package with the same name+version is already vetted; reusing the existing package.' - ) - return - } - throw e - } -} - export interface MultiSyncSetup { appUserSdk: SDKInterface<'token' | 'amulet'> appProviderSdk: SDKInterface<'token'> @@ -117,25 +83,18 @@ export interface MultiSyncSetup { export async function setupMultiSyncTrade( logger: Logger ): Promise { - const [appUserSdk, appProviderSdk, svSdk] = await Promise.all([ - SDK.create({ - auth: TOKEN_PROVIDER_CONFIG_DEFAULT, - ledgerClientUrl: localNetStaticConfig.LOCALNET_APP_USER_LEDGER_URL, + const { + appUser: appUserSdk, + appProvider: appProviderSdk, + sv: svSdk, + } = await createLocalNetSdks({ + appUser: { amulet: AMULET_NAMESPACE_CONFIG, - token: TOKEN_NAMESPACE_CONFIG_WITH_REGISTRIES, - }), - SDK.create({ - auth: TOKEN_PROVIDER_CONFIG_DEFAULT, - ledgerClientUrl: - localNetStaticConfig.LOCALNET_APP_PROVIDER_LEDGER_URL, - token: TOKEN_NAMESPACE_CONFIG_WITH_REGISTRIES, - }), - SDK.create({ - auth: TOKEN_PROVIDER_CONFIG_DEFAULT, - ledgerClientUrl: localNetStaticConfig.LOCALNET_SV_LEDGER_URL, token: TOKEN_NAMESPACE_CONFIG, - }), - ]) + }, + appProvider: { token: TOKEN_NAMESPACE_CONFIG }, + sv: { token: TOKEN_NAMESPACE_CONFIG }, + }) const appUserCtx = ( appUserSdk.ledger as unknown as { sdkContext: SDKContext } @@ -156,9 +115,8 @@ export async function setupMultiSyncTrade( const globalSynchronizerId = await appUserSdk.ledger.getGlobalSynchronizerId() - const appSynchronizerId = allSynchronizers.find( - (s) => s.synchronizerAlias === 'app-synchronizer' - )?.synchronizerId + const appSynchronizerId = + await appUserSdk.ledger.getSynchronizerIdByAlias('app-synchronizer') if (!appSynchronizerId) throw new Error( diff --git a/docs/wallet-integration-guide/examples/scripts/15-multi-sync/_token_allocation.ts b/docs/wallet-integration-guide/examples/scripts/15-multi-sync/_token_allocation.ts index 5856ec4ab..b273808c3 100644 --- a/docs/wallet-integration-guide/examples/scripts/15-multi-sync/_token_allocation.ts +++ b/docs/wallet-integration-guide/examples/scripts/15-multi-sync/_token_allocation.ts @@ -2,82 +2,20 @@ // SPDX-License-Identifier: Apache-2.0 import type { Logger } from 'pino' -import * as SpliceTestTokenV1 from '@canton-network/core-test-token' +import { allocateTestToken } from '@canton-network/core-test-token' import type { MultiSyncSetup } from './_setup.js' -const TestTokenV1 = SpliceTestTokenV1.Splice.Testing.Tokens.TestTokenV1 - export async function allocateTokenForBob( setup: MultiSyncSetup, logger: Logger ): Promise<{ legId: string }> { - const { - appProviderSdk, - tokenNamespaceAppProvider, - bob, - tokenAdmin, - globalSynchronizerId, - testTokenRegistryUrl, - } = setup - - const pendingRequests = - await tokenNamespaceAppProvider.allocation.request.pending(bob.partyId) - const requestView = pendingRequests[0].interfaceViewValue! - const legId = Object.keys(requestView.transferLegs).find( - (key) => requestView.transferLegs[key].sender === bob.partyId - )! - if (!legId) throw new Error('No transfer leg found for Bob') - - const tokenHoldings = await appProviderSdk.ledger.acs.read({ - templateIds: [TestTokenV1.Token.templateId], - parties: [bob.partyId], - filterByParty: true, - }) + const { appProviderSdk, bob, tokenAdmin, globalSynchronizerId } = setup - const tokenHolding = tokenHoldings[0] - if (!tokenHolding) throw new Error('Token holding not found for Bob') - - await appProviderSdk.ledger.internal.reassign({ - submitter: bob.partyId, - contractId: tokenHolding.contractId, - source: tokenHolding.synchronizerId, - target: globalSynchronizerId, - skipIfAlreadyOn: true, + return allocateTestToken({ + sdk: appProviderSdk, + sender: { partyId: bob.partyId, privateKey: bob.keyPair.privateKey }, + adminPartyId: tokenAdmin.partyId, + globalSynchronizerId, + logger, }) - - // Fetch the AllocationFactory + choice context from the TestToken registry's - // allocation-instruction-v1 API. The registry returns the global-synchronizer - // TokenRules contract as the factory (disclosed in `disclosedFromHelper`). - const [command, disclosedFromHelper] = - await tokenNamespaceAppProvider.allocation.instruction.create({ - allocationSpecification: { - settlement: requestView.settlement, - transferLegId: legId, - transferLeg: requestView.transferLegs[legId], - }, - asset: { - id: 'TestToken', - displayName: 'TestToken', - symbol: 'TT', - registryUrl: testTokenRegistryUrl, - admin: tokenAdmin.partyId, - }, - inputUtxos: [tokenHolding.contractId], - requestedAt: new Date(Date.now()).toISOString(), - }) - - await appProviderSdk.ledger - .prepare({ - partyId: bob.partyId, - commands: [command], - disclosedContracts: disclosedFromHelper, - synchronizerId: globalSynchronizerId, - }) - .sign(bob.keyPair.privateKey) - .execute({ partyId: bob.partyId }) - - logger.info( - 'Bob: TestToken allocated for leg-1 (global synchronizer, single-party) via registry allocation-factory' - ) - return { legId } } diff --git a/docs/wallet-integration-guide/examples/scripts/15-multi-sync/_token_setup.ts b/docs/wallet-integration-guide/examples/scripts/15-multi-sync/_token_setup.ts index 2e99a472e..167fa5e0d 100644 --- a/docs/wallet-integration-guide/examples/scripts/15-multi-sync/_token_setup.ts +++ b/docs/wallet-integration-guide/examples/scripts/15-multi-sync/_token_setup.ts @@ -3,15 +3,12 @@ import type { Logger } from 'pino' import { - buildCreateTokenRulesCommand, - buildMintTokenCommand, + createTokenRules, + mintTestToken, } from '@canton-network/core-test-token' -import * as SpliceTestTokenV1 from '@canton-network/core-test-token' import type { MultiSyncSetup } from './_setup.js' import { BOB_TOKEN_MINT_AMOUNT } from './_constants.js' -const TestTokenV1 = SpliceTestTokenV1.Splice.Testing.Tokens.TestTokenV1 - export async function createTokenRulesAndMintForBob( setup: MultiSyncSetup, logger: Logger @@ -26,90 +23,27 @@ export async function createTokenRulesAndMintForBob( testTokenRegistryUrl, } = setup - await appProviderSdk.ledger.executeOnSynchronizers( - { - partyId: tokenAdmin.partyId, - commands: buildCreateTokenRulesCommand(tokenAdmin.partyId), - disclosedContracts: [], - }, - [globalSynchronizerId, appSynchronizerId], - tokenAdmin.keyPair.privateKey - ) - - await appProviderSdk.ledger - .prepare({ - partyId: tokenAdmin.partyId, - commands: [ - buildMintTokenCommand({ - owner: tokenAdmin.partyId, - admin: tokenAdmin.partyId, - amount: BOB_TOKEN_MINT_AMOUNT, - }), - ], - disclosedContracts: [], - synchronizerId: appSynchronizerId, - }) - .sign(tokenAdmin.keyPair.privateKey) - .execute({ partyId: tokenAdmin.partyId }) + const admin = { + partyId: tokenAdmin.partyId, + privateKey: tokenAdmin.keyPair.privateKey, + } - const adminTokenHoldings = await appProviderSdk.ledger.acs.read({ - templateIds: [TestTokenV1.Token.templateId], - parties: [tokenAdmin.partyId], - filterByParty: true, + await createTokenRules({ + sdk: appProviderSdk, + admin, + synchronizerIds: [globalSynchronizerId, appSynchronizerId], }) - const adminTokenCid = adminTokenHoldings[0]?.contractId - if (!adminTokenCid) - throw new Error('TokenAdmin Token holding not found after mint') - - // TokenAdmin offers the freshly-minted TestToken to Bob. The transfer factory - // and choice context come from the registry's transfer-instruction-v1 API - // (the TestToken registry is also resolved via the metadata-v1 API). - const [transferCommand, transferDisclosed] = - await tokenNamespaceAppProvider.transfer.create({ - sender: tokenAdmin.partyId, - recipient: bob.partyId, - amount: BOB_TOKEN_MINT_AMOUNT, - instrumentId: 'TestToken', - registryUrl: testTokenRegistryUrl, - inputUtxos: [adminTokenCid], - }) - - await appProviderSdk.ledger - .prepare({ - partyId: tokenAdmin.partyId, - commands: [transferCommand], - disclosedContracts: transferDisclosed, - synchronizerId: appSynchronizerId, - }) - .sign(tokenAdmin.keyPair.privateKey) - .execute({ partyId: tokenAdmin.partyId }) - const transferOffers = await appProviderSdk.ledger.acs.read({ - templateIds: [TestTokenV1.TokenTransferOffer.templateId], - parties: [bob.partyId], - filterByParty: true, - }) - const transferOfferCid = transferOffers[0]?.contractId - if (!transferOfferCid) - throw new Error('TokenTransferOffer not found for Bob') - - // Bob accepts the transfer offer using the registry's transfer-instruction-v1 - // accept choice context. - const [acceptCommand, acceptDisclosed] = - await tokenNamespaceAppProvider.transfer.accept({ - transferInstructionCid: transferOfferCid, - registryUrl: testTokenRegistryUrl, - }) - - await appProviderSdk.ledger - .prepare({ + await mintTestToken({ + sdk: appProviderSdk, + admin, + receiver: { partyId: bob.partyId, - commands: [acceptCommand], - disclosedContracts: acceptDisclosed, - synchronizerId: appSynchronizerId, - }) - .sign(bob.keyPair.privateKey) - .execute({ partyId: bob.partyId }) + privateKey: bob.keyPair.privateKey, + }, + amount: BOB_TOKEN_MINT_AMOUNT, + synchronizerId: appSynchronizerId, + }) logger.info( `TokenAdmin: TokenRules created on global + app synchronizers; Bob: ${BOB_TOKEN_MINT_AMOUNT} TestToken minted on app-synchronizer via registry transfer-factory` diff --git a/docs/wallet-integration-guide/examples/scripts/15-multi-sync/_token_transfer.ts b/docs/wallet-integration-guide/examples/scripts/15-multi-sync/_token_transfer.ts index f4f258311..f255b71a4 100644 --- a/docs/wallet-integration-guide/examples/scripts/15-multi-sync/_token_transfer.ts +++ b/docs/wallet-integration-guide/examples/scripts/15-multi-sync/_token_transfer.ts @@ -2,149 +2,45 @@ // SPDX-License-Identifier: Apache-2.0 import type { Logger } from 'pino' -import * as SpliceTestTokenV1 from '@canton-network/core-test-token' -import type { Splice as SpliceTestTokenTypes } from '@canton-network/core-test-token' +import { + selfTransferTestToken, + selfTransferAllTestTokens, +} from '@canton-network/core-test-token' import type { MultiSyncSetup } from './_setup.js' import { TRADE_TOKEN_AMOUNT } from './_constants.js' -const TestTokenV1 = SpliceTestTokenV1.Splice.Testing.Tokens.TestTokenV1 - -const TOKEN_POLL_TIMEOUT_MS = 30_000 -const TOKEN_POLL_INTERVAL_MS = 500 - export async function aliceSelfTransferToApp( setup: MultiSyncSetup, logger: Logger ): Promise { - const { - appUserSdk, - tokenNamespaceAppUser, - alice, - appSynchronizerId, - testTokenRegistryUrl, - } = setup - - // The settlement is submitted by TradingApp (sv), so Alice's resulting Token - // holding propagates to her participant (app-user) asynchronously. Poll app-user until it - // becomes visible instead of reading once (cross-participant read-after-write). - const deadline = Date.now() + TOKEN_POLL_TIMEOUT_MS - let aliceToken - for (;;) { - const aliceTokens = await appUserSdk.ledger.acs.read({ - templateIds: [TestTokenV1.Token.templateId], - parties: [alice.partyId], - filterByParty: true, - }) - aliceToken = aliceTokens[0] - if (aliceToken) break - if (Date.now() >= deadline) - throw new Error('Alice: Token holding not found after settlement') - await new Promise((resolve) => - setTimeout(resolve, TOKEN_POLL_INTERVAL_MS) - ) - } - - // The settled holding lands on the global synchronizer; move it to the - // app-synchronizer before self-transferring there (mirrors Bob's flow). - if (aliceToken.synchronizerId !== appSynchronizerId) { - await appUserSdk.ledger.internal.reassign({ - submitter: alice.partyId, - contractId: aliceToken.contractId, - source: aliceToken.synchronizerId, - target: appSynchronizerId, - skipIfAlreadyOn: true, - }) - } + const { appUserSdk, appProviderSdk, alice, tokenAdmin, appSynchronizerId } = + setup - const [transferCommand, transferDisclosed] = - await tokenNamespaceAppUser.transfer.create({ - sender: alice.partyId, - recipient: alice.partyId, - amount: TRADE_TOKEN_AMOUNT, - instrumentId: 'TestToken', - registryUrl: testTokenRegistryUrl, - inputUtxos: [aliceToken.contractId], - }) - - await appUserSdk.ledger - .prepare({ + await selfTransferTestToken({ + sdk: appUserSdk, + owner: { partyId: alice.partyId, - commands: [transferCommand], - disclosedContracts: transferDisclosed, - synchronizerId: appSynchronizerId, - }) - .sign(alice.keyPair.privateKey) - .execute({ partyId: alice.partyId }) - - logger.info( - `Alice: ${TRADE_TOKEN_AMOUNT} TestToken self-transferred on app-synchronizer via registry transfer-factory` - ) + privateKey: alice.keyPair.privateKey, + }, + adminPartyId: tokenAdmin.partyId, + adminSdk: appProviderSdk, + synchronizerId: appSynchronizerId, + amount: TRADE_TOKEN_AMOUNT, + logger, + }) } export async function bobSelfTransferToApp( setup: MultiSyncSetup, logger: Logger ): Promise { - const { - appProviderSdk, - tokenNamespaceAppProvider, - bob, - appSynchronizerId, - testTokenRegistryUrl, - } = setup - - const bobTokens = await appProviderSdk.ledger.acs.read({ - templateIds: [TestTokenV1.Token.templateId], - parties: [bob.partyId], - filterByParty: true, + const { appProviderSdk, bob, tokenAdmin, appSynchronizerId } = setup + + await selfTransferAllTestTokens({ + sdk: appProviderSdk, + owner: { partyId: bob.partyId, privateKey: bob.keyPair.privateKey }, + adminPartyId: tokenAdmin.partyId, + synchronizerId: appSynchronizerId, + logger, }) - - if (bobTokens.length === 0) { - logger.info('Bob: no TestToken holdings to self-transfer') - return - } - - for (const token of bobTokens) { - if (token.synchronizerId !== appSynchronizerId) { - await appProviderSdk.ledger.internal.reassign({ - submitter: bob.partyId, - contractId: token.contractId, - source: token.synchronizerId, - target: appSynchronizerId, - skipIfAlreadyOn: true, - }) - } - - const holdingAmount = ( - token as unknown as { - createArgument: SpliceTestTokenTypes.Testing.Tokens.TestTokenV1.Token - } - ).createArgument.holding.amount - if (!holdingAmount) - throw new Error('Cannot read amount from Bob Token holding') - - const [transferCommand, transferDisclosed] = - await tokenNamespaceAppProvider.transfer.create({ - sender: bob.partyId, - recipient: bob.partyId, - amount: holdingAmount, - instrumentId: 'TestToken', - registryUrl: testTokenRegistryUrl, - inputUtxos: [token.contractId], - }) - - await appProviderSdk.ledger - .prepare({ - partyId: bob.partyId, - commands: [transferCommand], - disclosedContracts: transferDisclosed, - synchronizerId: appSynchronizerId, - }) - .sign(bob.keyPair.privateKey) - .execute({ partyId: bob.partyId }) - } - - logger.info( - `Bob: TestToken self-transferred on app-synchronizer via registry transfer-factory` - ) } diff --git a/docs/wallet-integration-guide/examples/scripts/15-multi-sync/_trade_propose.ts b/docs/wallet-integration-guide/examples/scripts/15-multi-sync/_trade_propose.ts index 6ec90f6b7..bbb73f1f4 100644 --- a/docs/wallet-integration-guide/examples/scripts/15-multi-sync/_trade_propose.ts +++ b/docs/wallet-integration-guide/examples/scripts/15-multi-sync/_trade_propose.ts @@ -2,72 +2,10 @@ // SPDX-License-Identifier: Apache-2.0 import type { Logger } from 'pino' -import type { SDKInterface } from '@canton-network/wallet-sdk' +import { createAndInitiateOtcTrade as createAndInitiateOtcTradeCore } from '@canton-network/core-trading-app' import type { MultiSyncSetup } from './_setup.js' -import { TRADE_AMULET_AMOUNT, TRADE_TOKEN_AMOUNT } from './_constants.js' - -const OTC_TRADE_PROPOSAL_TEMPLATE_ID = - '#splice-token-test-trading-app:Splice.Testing.Apps.TradingApp:OTCTradeProposal' -const OTC_TRADE_TEMPLATE_ID = - '#splice-token-test-trading-app:Splice.Testing.Apps.TradingApp:OTCTrade' - -function buildOtcTradeProposalCommand(params: { - venue: string - transferLegs: Record - approvers: string[] - tradeCid?: string | null -}) { - return { - CreateCommand: { - templateId: OTC_TRADE_PROPOSAL_TEMPLATE_ID, - createArguments: { - venue: params.venue, - tradeCid: params.tradeCid ?? null, - transferLegs: params.transferLegs, - approvers: params.approvers, - }, - }, - } -} - -function buildAcceptOtcTradeCommand(params: { - proposalCid: string - approver: string -}) { - return { - ExerciseCommand: { - templateId: OTC_TRADE_PROPOSAL_TEMPLATE_ID, - contractId: params.proposalCid, - choice: 'OTCTradeProposal_Accept', - choiceArgument: { approver: params.approver }, - }, - } -} - -function buildInitiateSettlementCommand(params: { - proposalCid: string - prepareUntil: string - settleBefore: string -}) { - return { - ExerciseCommand: { - templateId: OTC_TRADE_PROPOSAL_TEMPLATE_ID, - contractId: params.proposalCid, - choice: 'OTCTradeProposal_InitiateSettlement', - choiceArgument: { - prepareUntil: params.prepareUntil, - settleBefore: params.settleBefore, - }, - }, - } -} - -const MS_30_MIN = 30 * 60 * 1000 -const MS_1_HOUR = 60 * 60 * 1000 - -const PROPOSAL_POLL_TIMEOUT_MS = 30_000 -const PROPOSAL_POLL_INTERVAL_MS = 500 +/** Adapts the example's {@link MultiSyncSetup} to the trading-app OTC trade flow. */ export async function createAndInitiateOtcTrade( setup: MultiSyncSetup, transferLegs: Record, @@ -83,110 +21,21 @@ export async function createAndInitiateOtcTrade( globalSynchronizerId, } = setup - // The proposal is created on Alice's participant but read from other - // participants (Bob, TradingApp) - const readProposalCid = async ( - sdk: SDKInterface<'token'>, - party: string, - predicate: (approvers: string[]) => boolean = () => true - ): Promise => { - const deadline = Date.now() + PROPOSAL_POLL_TIMEOUT_MS - for (;;) { - const proposals = await sdk.ledger.acs.read({ - templateIds: [OTC_TRADE_PROPOSAL_TEMPLATE_ID], - parties: [party], - filterByParty: true, - }) - const match = proposals.find((proposal) => - predicate( - (( - proposal as unknown as { - createArgument?: { approvers?: string[] } - } - ).createArgument?.approvers ?? []) as string[] - ) - ) - if (match) return match.contractId - if (Date.now() >= deadline) { - throw new Error( - `OTCTradeProposal not visible to ${party} within ${PROPOSAL_POLL_TIMEOUT_MS}ms` - ) - } - await new Promise((resolve) => - setTimeout(resolve, PROPOSAL_POLL_INTERVAL_MS) - ) - } - } - - await appUserSdk.ledger - .prepare({ + return createAndInitiateOtcTradeCore({ + proposerSdk: appUserSdk, + proposer: { partyId: alice.partyId, - commands: buildOtcTradeProposalCommand({ - venue: tradingApp.partyId, - transferLegs, - approvers: [alice.partyId], - }), - disclosedContracts: [], - synchronizerId: globalSynchronizerId, - }) - .sign(alice.keyPair.privateKey) - .execute({ partyId: alice.partyId }) - logger.info( - `Alice: OTCTradeProposal created (leg-0: ${TRADE_AMULET_AMOUNT} Amulet → Bob, leg-1: ${TRADE_TOKEN_AMOUNT} TestToken → Alice)` - ) - - await appProviderSdk.ledger - .prepare({ - partyId: bob.partyId, - commands: [ - buildAcceptOtcTradeCommand({ - proposalCid: await readProposalCid( - appProviderSdk, - bob.partyId - ), - approver: bob.partyId, - }), - ], - disclosedContracts: [], - synchronizerId: globalSynchronizerId, - }) - .sign(bob.keyPair.privateKey) - .execute({ partyId: bob.partyId }) - logger.info('Bob: OTCTradeProposal_Accept executed') - - const prepareUntil = new Date(Date.now() + MS_30_MIN).toISOString() - const settleBefore = new Date(Date.now() + MS_1_HOUR).toISOString() - - await svSdk.ledger - .prepare({ + privateKey: alice.keyPair.privateKey, + }, + acceptorSdk: appProviderSdk, + acceptor: { partyId: bob.partyId, privateKey: bob.keyPair.privateKey }, + venueSdk: svSdk, + venue: { partyId: tradingApp.partyId, - commands: [ - buildInitiateSettlementCommand({ - proposalCid: await readProposalCid( - svSdk, - tradingApp.partyId, - (approvers) => approvers.includes(bob.partyId) - ), - prepareUntil, - settleBefore, - }), - ], - disclosedContracts: [], - synchronizerId: globalSynchronizerId, - }) - .sign(tradingApp.keyPair.privateKey) - .execute({ partyId: tradingApp.partyId }) - logger.info( - 'TradingApp: OTCTradeProposal_InitiateSettlement executed → OTCTrade created' - ) - - const otcTradeContracts = await svSdk.ledger.acs.read({ - templateIds: [OTC_TRADE_TEMPLATE_ID], - parties: [tradingApp.partyId], - filterByParty: true, + privateKey: tradingApp.keyPair.privateKey, + }, + transferLegs, + globalSynchronizerId, + logger, }) - const otcTradeCid = otcTradeContracts[0]?.contractId - if (!otcTradeCid) - throw new Error('OTCTrade contract not found after initiation') - return otcTradeCid } diff --git a/docs/wallet-integration-guide/examples/scripts/15-multi-sync/_trade_settle.ts b/docs/wallet-integration-guide/examples/scripts/15-multi-sync/_trade_settle.ts index f254e140a..f3e82be59 100644 --- a/docs/wallet-integration-guide/examples/scripts/15-multi-sync/_trade_settle.ts +++ b/docs/wallet-integration-guide/examples/scripts/15-multi-sync/_trade_settle.ts @@ -3,28 +3,12 @@ import type { Logger } from 'pino' import { localNetStaticConfig } from '@canton-network/wallet-sdk' +import { + settleOtcTrade as settleOtcTradeCore, + withdrawAllocations, +} from '@canton-network/core-trading-app' import type { LedgerCommonSchemas } from '@canton-network/core-ledger-client-types' import type { MultiSyncSetup } from './_setup.js' -import { TRADE_AMULET_AMOUNT, TRADE_TOKEN_AMOUNT } from './_constants.js' - -const OTC_TRADE_TEMPLATE_ID = - '#splice-token-test-trading-app:Splice.Testing.Apps.TradingApp:OTCTrade' - -function buildSettleOtcTradeCommand(params: { - tradeCid: string - allocationsWithContext: Record -}) { - return { - ExerciseCommand: { - templateId: OTC_TRADE_TEMPLATE_ID, - contractId: params.tradeCid, - choice: 'OTCTrade_Settle', - choiceArgument: { - allocationsWithContext: params.allocationsWithContext, - }, - }, - } -} type DisclosedContract = LedgerCommonSchemas['DisclosedContract'] @@ -36,91 +20,25 @@ export interface SettleParams { testTokenAllocationDisclosed: DisclosedContract } -/** Withdraws both allocations in parallel after a settlement failure, returning funds to each party. */ -async function withdrawAllocationsOnFailure( +/** Adapts the example's {@link MultiSyncSetup} to the trading-app OTC settlement flow. */ +export async function settleOtcTrade( setup: MultiSyncSetup, - amuletAllocationCid: string, - testTokenAllocationCid: string, + params: SettleParams, logger: Logger ): Promise { const { appUserSdk, appProviderSdk, + svSdk, tokenNamespaceAppUser, - tokenNamespaceAppProvider, alice, bob, + tradingApp, tokenAdmin, globalSynchronizerId, amuletAdmin, testTokenRegistryUrl, } = setup - - await Promise.all([ - (async () => { - const [cmd, disclosed] = - await tokenNamespaceAppUser.allocation.withdraw({ - allocationCid: amuletAllocationCid, - asset: { - id: 'Amulet', - displayName: 'Amulet', - symbol: 'CC', - registryUrl: - localNetStaticConfig.LOCALNET_REGISTRY_API_URL, - admin: amuletAdmin, - }, - }) - await appUserSdk.ledger - .prepare({ - partyId: alice.partyId, - commands: [cmd], - disclosedContracts: disclosed, - synchronizerId: globalSynchronizerId, - }) - .sign(alice.keyPair.privateKey) - .execute({ partyId: alice.partyId }) - logger.info('Alice: Amulet allocation withdrawn — funds returned') - })(), - (async () => { - const [cmd, disclosed] = - await tokenNamespaceAppProvider.allocation.withdraw({ - allocationCid: testTokenAllocationCid, - asset: { - id: 'TestToken', - displayName: 'TestToken', - symbol: 'TT', - registryUrl: testTokenRegistryUrl, - admin: tokenAdmin.partyId, - }, - }) - await appProviderSdk.ledger - .prepare({ - partyId: bob.partyId, - commands: [cmd], - disclosedContracts: disclosed, - synchronizerId: globalSynchronizerId, - }) - .sign(bob.keyPair.privateKey) - .execute({ partyId: bob.partyId }) - logger.info('Bob: TestToken allocation withdrawn — funds returned') - })(), - ]) -} - -export async function settleOtcTrade( - setup: MultiSyncSetup, - params: SettleParams, - logger: Logger -): Promise { - const { - svSdk, - tokenNamespaceAppUser, - tokenNamespaceAppProvider, - alice, - tradingApp, - globalSynchronizerId, - testTokenRegistryUrl, - } = setup const { otcTradeCid, legIdAlice, @@ -129,110 +47,75 @@ export async function settleOtcTrade( testTokenAllocationDisclosed, } = params - const allocationsAlice = await tokenNamespaceAppUser.allocation.pending( - alice.partyId - ) - const amuletAllocation = allocationsAlice.find( - (a) => a.interfaceViewValue.allocation.transferLegId === legIdAlice - ) - if (!amuletAllocation) throw new Error('Amulet allocation not found') - - const amuletExecCtx = - await tokenNamespaceAppUser.allocation.context.execute({ - allocationCid: amuletAllocation.contractId, + await settleOtcTradeCore({ + venueSdk: svSdk, + venue: { + partyId: tradingApp.partyId, + privateKey: tradingApp.keyPair.privateKey, + }, + otcTradeCid, + contextLeg: { + tokenNamespace: tokenNamespaceAppUser, + ownerPartyId: alice.partyId, + legId: legIdAlice, registryUrl: localNetStaticConfig.LOCALNET_REGISTRY_API_URL, - }) - - // Fetch Bob's TestToken execute-transfer choice context from the registry's - // allocation-v1 API (instead of hard-coding an empty context). - const testTokenExecCtx = - await tokenNamespaceAppProvider.allocation.context.execute({ - allocationCid: testTokenAllocationCid, - registryUrl: testTokenRegistryUrl, - }) - - const allocationsWithContext = { - [legIdAlice]: { - _1: amuletAllocation.contractId, - _2: { - context: { - ...(amuletExecCtx.choiceContextData ?? {}), - values: - (amuletExecCtx.choiceContextData?.values as Record< - string, - unknown - >) ?? {}, - }, - meta: { values: {} }, - }, }, - [legIdBob]: { - _1: testTokenAllocationCid, - _2: { - context: { - ...(testTokenExecCtx.choiceContextData ?? {}), - values: - (testTokenExecCtx.choiceContextData?.values as Record< - string, - unknown - >) ?? {}, - }, - meta: { values: {} }, - }, + disclosedLeg: { + legId: legIdBob, + allocationCid: testTokenAllocationCid, + disclosedContract: testTokenAllocationDisclosed, }, - } - - const disclosedContracts = [ - ...(amuletExecCtx.disclosedContracts ?? []).map((c) => ({ - ...c, - synchronizerId: '', - })), - ...(testTokenExecCtx.disclosedContracts ?? []).map((c) => ({ - ...c, - synchronizerId: '', - })), - // Disclose Bob's TestToken allocation so the TradingApp's participant can - // resolve it without waiting for cross-participant ACS propagation. - testTokenAllocationDisclosed, - ] - - try { - await svSdk.ledger - .prepare({ - partyId: tradingApp.partyId, - commands: [ - buildSettleOtcTradeCommand({ - tradeCid: otcTradeCid, - allocationsWithContext, - }), + globalSynchronizerId, + onSettlementFailure: (amuletAllocationCid) => + withdrawAllocations({ + globalSynchronizerId, + logger, + withdrawals: [ + { + sdk: appUserSdk, + owner: { + partyId: alice.partyId, + privateKey: alice.keyPair.privateKey, + }, + withdrawParams: { + allocationCid: amuletAllocationCid, + asset: { + id: 'Amulet', + displayName: 'Amulet', + symbol: 'CC', + registryUrl: + localNetStaticConfig.LOCALNET_REGISTRY_API_URL, + admin: amuletAdmin, + }, + }, + logMessage: + 'Alice: Amulet allocation withdrawn — funds returned', + }, + { + sdk: appProviderSdk, + owner: { + partyId: bob.partyId, + privateKey: bob.keyPair.privateKey, + }, + withdrawParams: { + allocationCid: testTokenAllocationCid, + asset: { + id: 'TestToken', + displayName: 'TestToken', + symbol: 'TT', + registryUrl: new URL('http://unused.invalid'), + admin: tokenAdmin.partyId, + }, + prefetchedRegistryChoiceContext: { + choiceContextData: { values: {} as never }, + disclosedContracts: [], + }, + }, + logMessage: + 'Bob: TestToken allocation withdrawn — funds returned', + }, ], - disclosedContracts, - synchronizerId: globalSynchronizerId, - }) - .sign(tradingApp.keyPair.privateKey) - .execute({ partyId: tradingApp.partyId }) - } catch (settleError) { - logger.error( - { err: settleError }, - 'Settlement failed — withdrawing allocations to return funds' - ) - try { - await withdrawAllocationsOnFailure( - setup, - amuletAllocation.contractId, - testTokenAllocationCid, - logger - ) - } catch (compensationError) { - logger.error( - { err: compensationError }, - 'Compensation failed — manual intervention required to withdraw allocations' - ) - } - throw settleError - } - - logger.info( - `TradingApp: OTCTrade settled — ${TRADE_AMULET_AMOUNT} Amulet transferred to Bob, ${TRADE_TOKEN_AMOUNT} TestToken transferred to Alice` - ) + }), + logger, + }) } diff --git a/sdk/wallet-sdk/src/config.ts b/sdk/wallet-sdk/src/config.ts index 1b2292e7b..66989ffa5 100644 --- a/sdk/wallet-sdk/src/config.ts +++ b/sdk/wallet-sdk/src/config.ts @@ -1,6 +1,8 @@ // Copyright (c) 2025-2026 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. // SPDX-License-Identifier: Apache-2.0 +import { TokenProviderConfig } from '@canton-network/core-wallet-auth' + const LOCALNET_APP_VALIDATOR_URL = new URL( 'http://localhost:2000/api/validator' ) @@ -29,3 +31,14 @@ export const localNetStaticConfig = { LOCALNET_TOKEN_STANDARD_URL, LOCALNET_USER_ID, } + +export const localNetDefaultAuth: TokenProviderConfig = { + method: 'self_signed', + issuer: 'unsafe-auth', + credentials: { + clientId: LOCALNET_USER_ID, + clientSecret: 'unsafe', + audience: 'https://canton.network.global', + scope: '', + }, +} diff --git a/sdk/wallet-sdk/src/wallet/index.ts b/sdk/wallet-sdk/src/wallet/index.ts index cc6fc5301..a6471e5f4 100644 --- a/sdk/wallet-sdk/src/wallet/index.ts +++ b/sdk/wallet-sdk/src/wallet/index.ts @@ -2,3 +2,4 @@ // SPDX-License-Identifier: Apache-2.0 export * from './sdk.js' +export * from './localnet.js' diff --git a/sdk/wallet-sdk/src/wallet/localnet.ts b/sdk/wallet-sdk/src/wallet/localnet.ts new file mode 100644 index 000000000..0faa7d830 --- /dev/null +++ b/sdk/wallet-sdk/src/wallet/localnet.ts @@ -0,0 +1,90 @@ +// Copyright (c) 2025-2026 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { TokenProviderConfig } from '@canton-network/core-wallet-auth' +import { localNetDefaultAuth, localNetStaticConfig } from '../config.js' +import { SDK } from './sdk.js' +import { AllowedLogAdapters } from './logger/types.js' +import { + ExtendedSDKOptions, + GetExtendedKeys, + SDKInterface, +} from './init/types/sdk.js' + +export type LocalNetParticipant = 'app-user' | 'app-provider' | 'sv' + +const LOCALNET_LEDGER_URLS: Record = { + 'app-user': localNetStaticConfig.LOCALNET_APP_USER_LEDGER_URL, + 'app-provider': localNetStaticConfig.LOCALNET_APP_PROVIDER_LEDGER_URL, + sv: localNetStaticConfig.LOCALNET_SV_LEDGER_URL, +} + +export interface LocalNetSdkOptions { + auth?: TokenProviderConfig + logAdapter?: AllowedLogAdapters +} + +function createLocalNetSdk>( + participant: LocalNetParticipant, + extensions: Ext, + options?: LocalNetSdkOptions +): Promise>> { + return SDK.create({ + auth: options?.auth ?? localNetDefaultAuth, + ledgerClientUrl: LOCALNET_LEDGER_URLS[participant], + ...(options?.logAdapter !== undefined && { + logAdapter: options.logAdapter, + }), + ...extensions, + }) as unknown as Promise>> +} + +/** + * Creates SDKs for all three LocalNet participants (app-user, app-provider, sv) + * in parallel, filling in each participant's ledger URL and the LocalNet default + * auth so callers only supply the namespace extensions they need per participant. + * + * @example + * const { appUser, appProvider, sv } = await createLocalNetSdks({ + * appUser: { amulet: amuletConfig, token: tokenConfig }, + * appProvider: { token: tokenConfig }, + * sv: { token: tokenConfig }, + * }) + * + * @param participants - Namespace extensions (`amulet`, `token`, `asset`, + * `events`) for each participant; each returned SDK is typed with exactly the + * namespaces provided for it. + * @param options - Optional shared overrides for `auth` (defaults to the LocalNet + * self-signed auth) and the log adapter. + */ +export async function createLocalNetSdks< + AppUser extends Partial = Record, + AppProvider extends Partial = Record, + Sv extends Partial = Record, +>( + participants: { + appUser?: AppUser + appProvider?: AppProvider + sv?: Sv + } = {}, + options?: LocalNetSdkOptions +): Promise<{ + appUser: SDKInterface> + appProvider: SDKInterface> + sv: SDKInterface> +}> { + const [appUser, appProvider, sv] = await Promise.all([ + createLocalNetSdk( + 'app-user', + participants.appUser ?? ({} as AppUser), + options + ), + createLocalNetSdk( + 'app-provider', + participants.appProvider ?? ({} as AppProvider), + options + ), + createLocalNetSdk('sv', participants.sv ?? ({} as Sv), options), + ]) + return { appUser, appProvider, sv } +} diff --git a/sdk/wallet-sdk/src/wallet/namespace/ledger/dar/vetting.ts b/sdk/wallet-sdk/src/wallet/namespace/ledger/dar/vetting.ts index 61f511be0..84d2aa4ef 100644 --- a/sdk/wallet-sdk/src/wallet/namespace/ledger/dar/vetting.ts +++ b/sdk/wallet-sdk/src/wallet/namespace/ledger/dar/vetting.ts @@ -30,3 +30,43 @@ export async function vetDar( }, }) } + +/** + * Like {@link vetDar}, but tolerates the case where a package with the same + * name+version is already vetted on the participant. + * + * On a persistent network, a previous build of a DAR (e.g. `splice-test-token-v1`) + * may already be vetted. Re-vetting after rebuilding the DAR produces a different + * package hash for the same name+version, which Canton rejects with + * `KNOWN_PACKAGE_VERSION`. Since the already-vetted package is resolved by + * package-name at command-submission time, it is safe to reuse it and continue. + * + * @param ledgerProvider - The ledger provider for the target participant node. + * @param darBytes - Raw DAR file bytes. + * @param synchronizerId - The synchronizer on which the package should be vetted. + * @param logger - Optional logger; a warning is emitted when an existing package + * is reused. + */ +export async function vetDarIdempotent( + ledgerProvider: AbstractLedgerProvider, + darBytes: Uint8Array | Buffer, + synchronizerId: string, + logger?: { warn(message: string): void } +): Promise { + try { + await vetDar(ledgerProvider, darBytes, synchronizerId) + } catch (e) { + const code = (e as { code?: string })?.code + const message = `${(e as { cause?: unknown })?.cause ?? (e as Error)?.message ?? e}` + if ( + code === 'KNOWN_PACKAGE_VERSION' || + message.includes('same name and version') + ) { + logger?.warn( + 'A package with the same name+version is already vetted; reusing the existing package.' + ) + return + } + throw e + } +} diff --git a/sdk/wallet-sdk/src/wallet/sdk.ts b/sdk/wallet-sdk/src/wallet/sdk.ts index 9f951702c..b77404fb7 100644 --- a/sdk/wallet-sdk/src/wallet/sdk.ts +++ b/sdk/wallet-sdk/src/wallet/sdk.ts @@ -62,7 +62,10 @@ export * from './init/index.js' export { PrepareOptions, ExecuteOptions } from './namespace/ledger/index.js' export * from './namespace/transactions/prepared.js' export * from './namespace/transactions/signed.js' -export { vetDar as vetPackage } from './namespace/ledger/dar/vetting.js' +export { + vetDar as vetPackage, + vetDarIdempotent as vetPackageIdempotent, +} from './namespace/ledger/dar/vetting.js' export { ScanProxyClient } from '@canton-network/core-splice-client' export class SDK { diff --git a/yarn.lock b/yarn.lock index 33e868a7d..e34d699bc 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1488,6 +1488,25 @@ __metadata: languageName: unknown linkType: soft +"@canton-network/core-amulet-ops@workspace:^, @canton-network/core-amulet-ops@workspace:core/amulet-ops": + version: 0.0.0-use.local + resolution: "@canton-network/core-amulet-ops@workspace:core/amulet-ops" + dependencies: + "@canton-network/core-amulet-service": "workspace:^" + "@canton-network/core-signing-lib": "workspace:^" + "@canton-network/wallet-sdk": "workspace:^" + "@vitest/coverage-v8": "npm:^4.1.2" + pino: "npm:^10.3.1" + tsup: "npm:^8.5.1" + typescript: "npm:^5.9.3" + vitest: "npm:^4.1.2" + peerDependencies: + "@canton-network/core-signing-lib": "workspace:^" + "@canton-network/wallet-sdk": "workspace:^" + pino: ^10.3.1 + languageName: unknown + linkType: soft + "@canton-network/core-amulet-service@workspace:^, @canton-network/core-amulet-service@workspace:core/amulet-service": version: 0.0.0-use.local resolution: "@canton-network/core-amulet-service@workspace:core/amulet-service" @@ -1883,6 +1902,8 @@ __metadata: version: 0.0.0-use.local resolution: "@canton-network/core-test-token@workspace:core/test-token" dependencies: + "@canton-network/core-signing-lib": "workspace:^" + "@canton-network/wallet-sdk": "workspace:^" "@daml/types": "npm:^3.5.0" "@mojotech/json-type-validation": "npm:^3.1.0" "@rollup/plugin-alias": "npm:^5.0.0" @@ -1890,10 +1911,15 @@ __metadata: "@rollup/plugin-json": "npm:^6.1.0" "@rollup/plugin-node-resolve": "npm:^16.0.3" "@rollup/plugin-typescript": "npm:^12.3.0" + pino: "npm:^10.3.1" rollup: "npm:^4.59.0" rollup-plugin-dts: "npm:^6.3.0" tslib: "npm:^2.8.1" typescript: "npm:^5.9.3" + peerDependencies: + "@canton-network/core-signing-lib": "workspace:^" + "@canton-network/wallet-sdk": "workspace:^" + pino: ^10.3.1 languageName: unknown linkType: soft @@ -1950,6 +1976,25 @@ __metadata: languageName: unknown linkType: soft +"@canton-network/core-trading-app@workspace:^, @canton-network/core-trading-app@workspace:core/trading-app": + version: 0.0.0-use.local + resolution: "@canton-network/core-trading-app@workspace:core/trading-app" + dependencies: + "@canton-network/core-ledger-client-types": "workspace:^" + "@canton-network/core-signing-lib": "workspace:^" + "@canton-network/wallet-sdk": "workspace:^" + "@vitest/coverage-v8": "npm:^4.1.2" + pino: "npm:^10.3.1" + tsup: "npm:^8.5.1" + typescript: "npm:^5.9.3" + vitest: "npm:^4.1.2" + peerDependencies: + "@canton-network/core-signing-lib": "workspace:^" + "@canton-network/wallet-sdk": "workspace:^" + pino: ^10.3.1 + languageName: unknown + linkType: soft + "@canton-network/core-tx-parser@workspace:^, @canton-network/core-tx-parser@workspace:core/tx-parser": version: 0.0.0-use.local resolution: "@canton-network/core-tx-parser@workspace:core/tx-parser" @@ -12491,6 +12536,7 @@ __metadata: version: 0.0.0-use.local resolution: "docs-wallet-integration-guide-examples@workspace:docs/wallet-integration-guide/examples" dependencies: + "@canton-network/core-amulet-ops": "workspace:^" "@canton-network/core-amulet-service": "workspace:^" "@canton-network/core-ledger-client": "workspace:^" "@canton-network/core-ledger-client-types": "workspace:^" @@ -12498,6 +12544,7 @@ __metadata: "@canton-network/core-signing-lib": "workspace:^" "@canton-network/core-test-token": "workspace:^" "@canton-network/core-token-standard": "workspace:^" + "@canton-network/core-trading-app": "workspace:^" "@canton-network/core-tx-parser": "workspace:^" "@canton-network/core-types": "workspace:^" "@canton-network/core-wallet-auth": "workspace:^"