From 1267c601fd897246f19c69b141a665b3df59ac8b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Pi=C4=85tkowski?= Date: Fri, 12 Jun 2026 17:28:33 +0200 Subject: [PATCH 01/17] test(wallet-sdk): add party namespace tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Mateusz Piątkowski --- sdk/wallet-sdk/src/wallet/__test__/mocks.ts | 41 + .../wallet/namespace/party/internal/index.ts | 2 +- .../src/wallet/namespace/party/party.test.ts | 762 ++++++++++++++++++ 3 files changed, 804 insertions(+), 1 deletion(-) create mode 100644 sdk/wallet-sdk/src/wallet/__test__/mocks.ts create mode 100644 sdk/wallet-sdk/src/wallet/namespace/party/party.test.ts diff --git a/sdk/wallet-sdk/src/wallet/__test__/mocks.ts b/sdk/wallet-sdk/src/wallet/__test__/mocks.ts new file mode 100644 index 000000000..80f07d847 --- /dev/null +++ b/sdk/wallet-sdk/src/wallet/__test__/mocks.ts @@ -0,0 +1,41 @@ +// Copyright (c) 2025-2026 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { MockedObject, vi } from 'vitest' +import { SDKContext } from '../sdk.js' +import { SDKLogger } from '../logger/logger.js' +import { SDKErrorHandler } from '../error/handler.js' + +const ledgerProvider = { + request: vi.fn().mockResolvedValue(undefined), +} + +const mockLogger = { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + trace: vi.fn(), + child: vi.fn().mockReturnThis(), + additionalContext: {}, + adapter: { log: vi.fn() }, + allowedAdapter: 'pino' as const, +} as unknown as MockedObject + +const mockErrorHandler = new SDKErrorHandler(mockLogger) +const throwSpy = vi.spyOn(mockErrorHandler, 'throw') +throwSpy.mockImplementation(vi.fn() as never) + +const ctx: SDKContext = { + ledgerProvider, + userId: 'userId', + logger: mockLogger, + error: mockErrorHandler, + defaultSynchronizerId: '', +} + +export const mock = { + ledgerProvider, + mockLogger, + ctx, +} diff --git a/sdk/wallet-sdk/src/wallet/namespace/party/internal/index.ts b/sdk/wallet-sdk/src/wallet/namespace/party/internal/index.ts index 0c910f083..5e0f11b5e 100644 --- a/sdk/wallet-sdk/src/wallet/namespace/party/internal/index.ts +++ b/sdk/wallet-sdk/src/wallet/namespace/party/internal/index.ts @@ -17,7 +17,7 @@ export class InternalPartyNamespace { * Allocates a new internal party on the ledger. If no partyHint is provided, a random UUID will be used. * Internal parties use the Canton keys for signing and do not use the interactive submission flow. */ - async allocate( + public async allocate( params: { partyHint?: string synchronizerId?: string diff --git a/sdk/wallet-sdk/src/wallet/namespace/party/party.test.ts b/sdk/wallet-sdk/src/wallet/namespace/party/party.test.ts new file mode 100644 index 000000000..00e79db5b --- /dev/null +++ b/sdk/wallet-sdk/src/wallet/namespace/party/party.test.ts @@ -0,0 +1,762 @@ +// Copyright (c) 2025-2026 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { mock } from '../../__test__/mocks' +import { it, describe, beforeEach, vi, expect } from 'vitest' +import { + PartyNamespace, + PreparedPartyCreationService, + SignedPartyCreationService, +} from '.' +import { LedgerCommonSchemas } from '@canton-network/core-ledger-client-types' +import { signTransactionHash } from '@canton-network/core-signing-lib' + +const { ctx, ledgerProvider } = mock + +vi.mock('@canton-network/core-provider-ledger', async (importOriginal) => { + const actual = + await importOriginal< + typeof import('@canton-network/core-provider-ledger') + >() + return { + ...actual, + LedgerProvider: vi.fn( + class { + request = ledgerProvider.request + } + ), + } +}) + +vi.mock('@canton-network/core-signing-lib', async (importOriginal) => { + const actual = + await importOriginal< + typeof import('@canton-network/core-signing-lib') + >() + return { + ...actual, + signTransactionHash: vi.fn().mockReturnValue('hash'), + } +}) + +describe('Party namespace', () => { + let party: PartyNamespace + + beforeEach(() => { + vi.clearAllMocks() + + party = new PartyNamespace(ctx) + }) + + describe('list', () => { + it('should properly list all parties with specific access', async () => { + // Mock list user rights response + ledgerProvider.request.mockResolvedValueOnce({ + rights: [ + { + kind: { + CanActAs: { + value: { + party: 'some-party', + }, + }, + }, + }, + { + kind: { + CanExecuteAs: { + value: { + party: 'some-other-party', + }, + }, + }, + }, + { + kind: { + CanReadAs: { + value: { + party: 'some-other-party2', + }, + }, + }, + }, + ], + } satisfies LedgerCommonSchemas['ListUserRightsResponse']) + + const list = await party.list() + + expect(ledgerProvider.request).toHaveBeenCalledExactlyOnceWith({ + method: 'ledgerApi', + params: { + requestMethod: 'get', + resource: '/v2/users/{user-id}/rights', + path: { 'user-id': ctx.userId }, + }, + }) + + expect(list).toEqual([ + 'some-party', + 'some-other-party', + 'some-other-party2', + ]) + }) + + it('should return all local parties if user has admin rights', async () => { + // Mock list user rights response (admin rights) + ledgerProvider.request + .mockResolvedValueOnce({ + rights: [ + { + kind: { + CanReadAsAnyParty: { + value: {}, + }, + }, + }, + ], + } satisfies LedgerCommonSchemas['ListUserRightsResponse']) + // Mock list known parties response + .mockResolvedValueOnce({ + partyDetails: [ + { + party: 'party1', + }, + { + party: 'party2', + isLocal: true, + }, + ], + } satisfies LedgerCommonSchemas['ListKnownPartiesResponse']) + + const list = await party.list() + + expect(ledgerProvider.request).toHaveBeenCalledTimes(2) + expect(ledgerProvider.request).toHaveBeenNthCalledWith(1, { + method: 'ledgerApi', + params: { + requestMethod: 'get', + resource: '/v2/users/{user-id}/rights', + path: { 'user-id': ctx.userId }, + }, + }) + expect(ledgerProvider.request).toHaveBeenNthCalledWith(2, { + method: 'ledgerApi', + params: { + requestMethod: 'get', + resource: '/v2/parties', + query: {}, + }, + }) + + expect(list).toEqual(['party2']) + }) + }) + + describe('internal', () => { + it('should properly check for existing internal party', async () => { + const partyId = 'partyHint::partyFingerprint' + + // Mock get participant ID + ledgerProvider.request + .mockResolvedValueOnce({ + participantId: 'unusedPart::partyFingerprint', + } satisfies LedgerCommonSchemas['GetParticipantIdResponse']) + // Mock get party details (party exists) + .mockResolvedValueOnce({ + partyDetails: [ + { + party: partyId, + isLocal: true, + }, + ], + } satisfies LedgerCommonSchemas['GetPartiesResponse']) + + const result = await party.internal.allocate({ + partyHint: 'partyHint', + synchronizerId: 'syncId', + userId: 'userId', + }) + + expect(ledgerProvider.request).toHaveBeenNthCalledWith(1, { + method: 'ledgerApi', + params: { + resource: '/v2/parties/participant-id', + requestMethod: 'get', + }, + }) + expect(ledgerProvider.request).toHaveBeenNthCalledWith(2, { + method: 'ledgerApi', + params: { + resource: '/v2/parties/{party}', + requestMethod: 'get', + path: { + party: partyId, + }, + query: { + 'identity-provider-id': '', + parties: [partyId], + }, + }, + }) + + expect(result).toBe(partyId) + }) + + it('should create an allocated party when not existing yet', async () => { + // Mock get participant ID + ledgerProvider.request + .mockResolvedValueOnce({ + participantId: '', + } satisfies LedgerCommonSchemas['GetParticipantIdResponse']) + // Mock get party details (party doesn't exist) + .mockResolvedValueOnce({ + partyDetails: [], + } satisfies LedgerCommonSchemas['GetPartiesResponse']) + // Mock allocate internal party + .mockResolvedValueOnce({ + partyDetails: { + party: 'allocated_party', + }, + }) + + const result = await party.internal.allocate({ + partyHint: 'partyHint', + synchronizerId: 'syncId', + userId: 'userId', + }) + + expect(ledgerProvider.request).toHaveBeenNthCalledWith(1, { + method: 'ledgerApi', + params: { + resource: '/v2/parties/participant-id', + requestMethod: 'get', + }, + }) + expect(ledgerProvider.request).toHaveBeenNthCalledWith(2, { + method: 'ledgerApi', + params: { + resource: '/v2/parties/{party}', + requestMethod: 'get', + path: { + party: 'partyHint::', + }, + query: { + 'identity-provider-id': '', + parties: ['partyHint::'], + }, + }, + }) + expect(ledgerProvider.request).toHaveBeenNthCalledWith(3, { + method: 'ledgerApi', + params: { + resource: '/v2/parties', + requestMethod: 'post', + body: { + partyIdHint: 'partyHint', + identityProviderId: '', + synchronizerId: 'syncId', + userId: 'userId', + }, + }, + }) + + expect(result).toBe('allocated_party') + }) + }) + + describe('external', () => { + let preparedParty: PreparedPartyCreationService + let signedParty: SignedPartyCreationService + let signTransactionHashSpy: ReturnType< + typeof vi.mocked + > + + const partyTransaction = { + partyId: 'partyId', + publicKeyFingerprint: 'fingerprint', + topologyTransactions: ['tx1', 'tx2'], + multiHash: 'multiHash', + } + + beforeEach(() => { + vi.clearAllMocks() + vi.restoreAllMocks() + + signTransactionHashSpy = vi + .mocked(signTransactionHash) + .mockReturnValue('signature') + + preparedParty = new PreparedPartyCreationService( + ctx, + Promise.resolve(partyTransaction) + ) + signedParty = new SignedPartyCreationService( + ctx, + Promise.resolve({ + party: partyTransaction, + signature: 'defaultSignature', + }) + ) + }) + + describe('external.create', () => { + it('should generate a topology based on options and return a prepared party service instance', async () => { + // Mock generate external party topology + ledgerProvider.request.mockResolvedValueOnce({ + partyId: 'partyId', + publicKeyFingerprint: 'string', + topologyTransactions: [], + multiHash: 'hash', + } satisfies LedgerCommonSchemas['GenerateExternalPartyTopologyResponse']) + + const partyCreationService = party.external.create( + 'publicKey', + { + partyHint: 'partyHint', + synchronizerId: 'syncId', + } + ) + + expect(partyCreationService).toBeInstanceOf( + PreparedPartyCreationService + ) + + await partyCreationService.topology() + + expect(ledgerProvider.request).toHaveBeenCalledWith({ + method: 'ledgerApi', + params: { + resource: '/v2/parties/external/generate-topology', + body: { + synchronizer: 'syncId', + partyHint: 'partyHint', + publicKey: { + format: 'CRYPTO_KEY_FORMAT_RAW', + keyData: 'publicKey', + keySpec: 'SIGNING_KEY_SPEC_EC_CURVE25519', + }, + localParticipantObservationOnly: false, + confirmationThreshold: 1, + otherConfirmingParticipantUids: [], + observingParticipantUids: [], + }, + requestMethod: 'post', + }, + }) + }) + + it('should call participant-id endpoint depending on options', () => { + const observingParticipantEndpoints = Array(3).fill({ + url: new URL('http://example.com'), + tokenProviderConfig: { + method: 'static', + token: '', + }, + }) + + const confirmingParticipantEndpoints = Array(2).fill({ + url: new URL('http://example.com'), + tokenProviderConfig: { + method: 'static', + token: '', + }, + }) + + // Mock get participant ID for each endpoint (5 endpoints) + ;[ + ...observingParticipantEndpoints, + ...confirmingParticipantEndpoints, + ].forEach(() => { + ledgerProvider.request.mockResolvedValueOnce({ + participantId: 'participantId', + }) + }) + + // Mock get connected synchronizers + ledgerProvider.request.mockResolvedValueOnce({ + connectedSynchronizers: [ + { + synchronizerId: 'syncId', + }, + ], + }) + + party.external.create('publicKey', { + observingParticipantEndpoints, + confirmingParticipantEndpoints, + }) + ;[ + ...observingParticipantEndpoints, + ...confirmingParticipantEndpoints, + ].forEach((_, idx) => { + expect(ledgerProvider.request).toHaveBeenNthCalledWith( + idx + 1, + { + method: 'ledgerApi', + params: { + resource: '/v2/parties/participant-id', + requestMethod: 'get', + }, + } + ) + }) + expect(ledgerProvider.request).toHaveBeenCalledTimes(6) + }) + }) + + describe('external.create.sign', () => { + it('should sign the prepared party with a signature', async () => { + const result = await preparedParty.sign('privateKey') + + expect(result).toBeInstanceOf(SignedPartyCreationService) + + expect(signTransactionHashSpy).toHaveBeenCalledExactlyOnceWith( + partyTransaction.multiHash, + 'privateKey' + ) + }) + }) + + describe('external.create.execute', () => { + it('should be able to execute party creating with offline signature', async () => { + const mockPartyResponse = { + partyId: 'partyId', + publicKeyFingerprint: 'fingerprint', + topologyTransactions: ['tx1', 'tx2'], + multiHash: 'multiHash', + } + + const executeSpy = vi + .spyOn(SignedPartyCreationService.prototype, 'execute') + .mockResolvedValue(mockPartyResponse) + + const result = await preparedParty.execute('signature', { + expectHeavyLoad: true, + grantUserRights: true, + }) + + expect(executeSpy).toHaveBeenCalledExactlyOnceWith({ + expectHeavyLoad: true, + grantUserRights: true, + }) + expect(result).toEqual(mockPartyResponse) + }) + }) + + describe('external.create.topology', () => { + it('should return party transaction topology properly', async () => { + expect(await preparedParty.topology()).toEqual(partyTransaction) + }) + }) + + describe('external.create.sign.execute', () => { + it('should return the party if it is already existing', async () => { + // Mock checkIfPartyExists - party already exists + ledgerProvider.request.mockResolvedValueOnce({ + partyDetails: [ + { + party: partyTransaction.partyId, + }, + ], + } satisfies LedgerCommonSchemas['GetPartiesResponse']) + + const result = await signedParty.execute() + + expect(ledgerProvider.request).toHaveBeenNthCalledWith(1, { + method: 'ledgerApi', + params: { + resource: '/v2/parties/{party}', + requestMethod: 'get', + path: { party: partyTransaction.partyId }, + query: {}, + }, + }) + + expect(result).toEqual(partyTransaction) + }) + + it('should execute a new party into the ledger', async () => { + // Mock checkIfPartyExists - party doesn't exist yet + ledgerProvider.request + .mockResolvedValueOnce({ + partyDetails: [], + } satisfies LedgerCommonSchemas['GetPartiesResponse']) + // Mock allocate call + .mockResolvedValueOnce({ + partyId: 'partyId', + } satisfies LedgerCommonSchemas['AllocateExternalPartyResponse']) + // Mock checkIfPartyExists in polling loop - party now exists + .mockResolvedValueOnce({ + partyDetails: [ + { + party: partyTransaction.partyId, + }, + ], + } satisfies LedgerCommonSchemas['GetPartiesResponse']) + // Mock grantRights call + .mockResolvedValueOnce({ + newlyGrantedRights: [{}], + } satisfies LedgerCommonSchemas['GrantUserRightsResponse']) + + const result = await signedParty.execute() + + expect(ledgerProvider.request).toHaveBeenNthCalledWith(2, { + method: 'ledgerApi', + params: { + resource: '/v2/parties/external/allocate', + requestMethod: 'post', + body: { + synchronizer: ctx.defaultSynchronizerId, + identityProviderId: '', + onboardingTransactions: + partyTransaction.topologyTransactions.map( + (transaction) => ({ + transaction, + }) + ), + multiHashSignatures: [ + { + format: 'SIGNATURE_FORMAT_CONCAT', + signature: 'defaultSignature', + signedBy: + partyTransaction.publicKeyFingerprint, + signingAlgorithmSpec: + 'SIGNING_ALGORITHM_SPEC_ED25519', + }, + ], + }, + }, + }) + + expect(result).toEqual(partyTransaction) + }) + + it('should execute without granting user rights when grantUserRights is false', async () => { + // Mock checkIfPartyExists - party doesn't exist yet + ledgerProvider.request + .mockResolvedValueOnce({ + partyDetails: [], + } satisfies LedgerCommonSchemas['GetPartiesResponse']) + // Mock allocate call + .mockResolvedValueOnce({ + partyId: 'partyId', + } satisfies LedgerCommonSchemas['AllocateExternalPartyResponse']) + + const result = await signedParty.execute({ + grantUserRights: false, + }) + + expect(ledgerProvider.request).toHaveBeenCalledTimes(2) + expect(ledgerProvider.request).toHaveBeenNthCalledWith(2, { + method: 'ledgerApi', + params: { + resource: '/v2/parties/external/allocate', + requestMethod: 'post', + body: { + synchronizer: ctx.defaultSynchronizerId, + identityProviderId: '', + onboardingTransactions: + partyTransaction.topologyTransactions.map( + (transaction) => ({ + transaction, + }) + ), + multiHashSignatures: [ + { + format: 'SIGNATURE_FORMAT_CONCAT', + signature: 'defaultSignature', + signedBy: + partyTransaction.publicKeyFingerprint, + signingAlgorithmSpec: + 'SIGNING_ALGORITHM_SPEC_ED25519', + }, + ], + }, + }, + }) + expect(result).toEqual(partyTransaction) + }) + + it('should poll multiple times before party exists and then grant rights', async () => { + vi.useFakeTimers() + + // Mock checkIfPartyExists - party doesn't exist yet + ledgerProvider.request + .mockResolvedValueOnce({ + partyDetails: [], + } satisfies LedgerCommonSchemas['GetPartiesResponse']) + // Mock allocate call + .mockResolvedValueOnce({ + partyId: 'partyId', + } satisfies LedgerCommonSchemas['AllocateExternalPartyResponse']) + // Mock checkIfPartyExists polling - party doesn't exist (1st try) + .mockResolvedValueOnce({ + partyDetails: [], + } satisfies LedgerCommonSchemas['GetPartiesResponse']) + // Mock checkIfPartyExists polling - party doesn't exist (2nd try) + .mockResolvedValueOnce({ + partyDetails: [], + } satisfies LedgerCommonSchemas['GetPartiesResponse']) + // Mock checkIfPartyExists polling - party now exists (3rd try) + .mockResolvedValueOnce({ + partyDetails: [ + { + party: partyTransaction.partyId, + }, + ], + } satisfies LedgerCommonSchemas['GetPartiesResponse']) + // Mock grantRights call + .mockResolvedValueOnce({ + newlyGrantedRights: [{}], + } satisfies LedgerCommonSchemas['GrantUserRightsResponse']) + + const resultPromise = signedParty.execute() + await vi.runAllTimersAsync() + const result = await resultPromise + + // Should have been called 6 times total (1 initial check + 1 allocate + 3 polling + 1 grant) + expect(ledgerProvider.request).toHaveBeenCalledTimes(6) + expect(result).toEqual(partyTransaction) + + vi.useRealTimers() + }) + + it('should throw error when party does not appear after max retries', async () => { + vi.useFakeTimers() + + // Mock checkIfPartyExists - party doesn't exist yet + ledgerProvider.request + .mockResolvedValueOnce({ + partyDetails: [], + } satisfies LedgerCommonSchemas['GetPartiesResponse']) + // Mock allocate call + .mockResolvedValueOnce({ + partyId: 'partyId', + } satisfies LedgerCommonSchemas['AllocateExternalPartyResponse']) + // Mock checkIfPartyExists polling - party never exists (30 times for default maxTries) + .mockResolvedValue({ + partyDetails: [], + } satisfies LedgerCommonSchemas['GetPartiesResponse']) + + const executePromise = signedParty.execute() + + // Run timers and wait for rejection + const runTimersPromise = vi.runAllTimersAsync() + await Promise.all([ + expect(executePromise).rejects.toThrow(), + runTimersPromise, + ]) + + vi.useRealTimers() + }) + + it('should throw error when granting user rights fails', async () => { + // Mock checkIfPartyExists - party doesn't exist yet + ledgerProvider.request + .mockResolvedValueOnce({ + partyDetails: [], + } satisfies LedgerCommonSchemas['GetPartiesResponse']) + // Mock allocate call + .mockResolvedValueOnce({ + partyId: 'partyId', + } satisfies LedgerCommonSchemas['AllocateExternalPartyResponse']) + // Mock checkIfPartyExists polling - party exists + .mockResolvedValueOnce({ + partyDetails: [ + { + party: partyTransaction.partyId, + }, + ], + } satisfies LedgerCommonSchemas['GetPartiesResponse']) + // Mock grantRights call - fails to grant rights + .mockResolvedValueOnce({ + newlyGrantedRights: undefined, + } satisfies LedgerCommonSchemas['GrantUserRightsResponse']) + + await expect(signedParty.execute()).rejects.toThrow() + }) + + it('should handle expectHeavyLoad with timeout and continue polling', async () => { + vi.useFakeTimers() + + // Mock checkIfPartyExists - party doesn't exist yet + ledgerProvider.request + .mockResolvedValueOnce({ + partyDetails: [], + } satisfies LedgerCommonSchemas['GetPartiesResponse']) + // Mock allocate call - throws timeout error + .mockRejectedValueOnce( + new Error( + 'The server was not able to produce a timely response to your request' + ) + ) + // Mock checkIfPartyExists in error handler loop - party doesn't exist (1st check) + .mockResolvedValueOnce({ + partyDetails: [], + } satisfies LedgerCommonSchemas['GetPartiesResponse']) + // Mock checkIfPartyExists in error handler loop - party now exists (2nd check) + .mockResolvedValueOnce({ + partyDetails: [ + { + party: partyTransaction.partyId, + }, + ], + } satisfies LedgerCommonSchemas['GetPartiesResponse']) + // Mock checkIfPartyExists in waitForPartyAndGrantUserRights - party exists + .mockResolvedValueOnce({ + partyDetails: [ + { + party: partyTransaction.partyId, + }, + ], + } satisfies LedgerCommonSchemas['GetPartiesResponse']) + // Mock grantRights call + .mockResolvedValueOnce({ + newlyGrantedRights: [{}], + } satisfies LedgerCommonSchemas['GrantUserRightsResponse']) + + const resultPromise = signedParty.execute({ + expectHeavyLoad: true, + }) + await vi.runAllTimersAsync() + const result = await resultPromise + + expect(result).toEqual(partyTransaction) + + vi.useRealTimers() + }) + + it('should throw error when expectHeavyLoad is false and allocate times out', async () => { + // Mock checkIfPartyExists - party doesn't exist yet + ledgerProvider.request + .mockResolvedValueOnce({ + partyDetails: [], + } satisfies LedgerCommonSchemas['GetPartiesResponse']) + // Mock allocate call - throws timeout error + .mockRejectedValueOnce( + new Error( + 'The server was not able to produce a timely response to your request' + ) + ) + + await expect( + signedParty.execute({ expectHeavyLoad: false }) + ).rejects.toThrow() + }) + + it('should throw error for non-timeout errors regardless of expectHeavyLoad', async () => { + // Mock checkIfPartyExists - party doesn't exist yet + ledgerProvider.request + .mockResolvedValueOnce({ + partyDetails: [], + } satisfies LedgerCommonSchemas['GetPartiesResponse']) + // Mock allocate call - throws non-timeout error + .mockRejectedValueOnce(new Error('Invalid party data')) + + await expect( + signedParty.execute({ expectHeavyLoad: true }) + ).rejects.toThrow() + }) + }) + }) +}) From 59d773797db1b36cca48e0d8e4846639e7cf7718 Mon Sep 17 00:00:00 2001 From: Marc Juchli <120378272+mjuchli-da@users.noreply.github.com> Date: Thu, 11 Jun 2026 17:09:55 +0200 Subject: [PATCH 02/17] chore: signing security disclaimer (#1984) Signed-off-by: Marc Juchli --- .../wallet-gateway/configuration/index.md | 2 +- docs/dapp-building/wallet-gateway/deployment/index.md | 2 +- .../wallet-gateway/signing-providers/index.md | 11 ++++++++--- 3 files changed, 10 insertions(+), 5 deletions(-) diff --git a/docs/dapp-building/wallet-gateway/configuration/index.md b/docs/dapp-building/wallet-gateway/configuration/index.md index c907cfd97..fbe126fcd 100644 --- a/docs/dapp-building/wallet-gateway/configuration/index.md +++ b/docs/dapp-building/wallet-gateway/configuration/index.md @@ -574,7 +574,7 @@ For participant-only signing with no external custody, leave the Helm chart `sig The signing store is an optional secondary database used for storing private keys when the Wallet Gateway is configured to act as a signing provider (using the `wallet-kernel` signing provider). > [!IMPORTANT] -> If you use the Wallet Gateway as a signing provider, private keys will be stored in the signing store database. This is **not recommended** for production environments with valuable assets. Use external signing providers (Dfns, Fireblocks, Blockdaemon, or Participant-based) for production. +> If you use the Wallet Gateway as a signing provider, private keys will be stored in the signing store database. This is **not recommended** for production environments with valuable assets. Use external signing providers (Dfns, Fireblocks, or Blockdaemon) for production when the User API is accessible. Participant-based signing is only appropriate in production when wallet creation is restricted to trusted operators; see [Signing Providers](../signing-providers/index.md#participant-based-signing). **Configuration:** diff --git a/docs/dapp-building/wallet-gateway/deployment/index.md b/docs/dapp-building/wallet-gateway/deployment/index.md index f58e0af66..33c8401ca 100644 --- a/docs/dapp-building/wallet-gateway/deployment/index.md +++ b/docs/dapp-building/wallet-gateway/deployment/index.md @@ -56,7 +56,7 @@ signing: {} With `signing: {}` (or with external drivers omitted), the Gateway still offers: -- **Participant** — signs via your Canton participant node (typical for validator / operator deployments) +- **Participant** — signs via your Canton participant node (typical for validator / operator deployments). Not recommended in production when the User API is accessible; see [Signing Providers](../signing-providers/index.md#participant-based-signing). - **Wallet Gateway (internal)** — not recommended for production You do **not** need participant-specific fields under `signing` when the participant node handles keys. Add entries under `signing` only when enabling an external custody provider. diff --git a/docs/dapp-building/wallet-gateway/signing-providers/index.md b/docs/dapp-building/wallet-gateway/signing-providers/index.md index 6f7a5de1a..530b706a3 100644 --- a/docs/dapp-building/wallet-gateway/signing-providers/index.md +++ b/docs/dapp-building/wallet-gateway/signing-providers/index.md @@ -35,7 +35,12 @@ This provider is always available and requires no additional configuration. You - Enterprise deployments where the participant node manages keys - Scenarios where key management is handled by the infrastructure -- Production environments with dedicated participant nodes +- Operator-controlled deployments where wallet creation is not exposed via the User API + +**Security Considerations:** + +> [!IMPORTANT] +> Participant-based signing is **not recommended** in production setups where the User API is accessible. Any user who can reach the User API can create parties that sign via your participant node, which may grant broader signing authority than intended. Reserve participant-based signing for deployments where wallet creation is restricted to trusted operators, or use an external signing provider (Fireblocks, Dfns, Blockdaemon) when the User API is exposed in production. **How it Works:** @@ -115,8 +120,8 @@ When creating a new party through the User API or web UI, you can select which s **Recommendations:** - **Development/Testing**: Use Wallet Gateway (internal) or Participant-based signing -- **Production (Enterprise)**: Use Fireblocks, Dfns, or Participant-based signing -- **Production (Managed)**: Use Blockdaemon, Dfns, or Participant-based signing +- **Production (User API accessible)**: Use Fireblocks, Dfns, or Blockdaemon +- **Production (operator-controlled, User API restricted)**: Participant-based signing may be appropriate when wallet creation is limited to trusted operators The signing provider is selected per-party, so you can have different parties using different providers within the same Gateway instance. From d8adf6be210378d5c9490612388b503f1840eeb6 Mon Sep 17 00:00:00 2001 From: Fayi <112705750+fayi-da@users.noreply.github.com> Date: Fri, 12 Jun 2026 12:40:30 +0100 Subject: [PATCH 03/17] feat(example-portfolio): support configured token registries (#1972) Signed-off-by: Fayi Femi-Balogun --- core/types/src/index.ts | 15 +++- .../dapp-building/examples/portfolio/index.md | 44 ++++++--- examples/portfolio/README.md | 18 +++- examples/portfolio/public/config.json | 16 +++- .../src/components/registry-settings.tsx | 16 +--- .../portfolio/src/components/tap-settings.tsx | 4 +- .../portfolio/src/config/portfolio-config.ts | 32 +------ .../src/contexts/PortfolioConfigContext.tsx | 2 +- .../src/contexts/PortfolioConfigProvider.tsx | 2 +- examples/portfolio/src/hooks/query-options.ts | 4 +- .../portfolio/src/hooks/useRegistryUrls.ts | 90 ++++++++++++++++++- examples/portfolio/src/lib/schemas.ts | 63 +++++++++++++ 12 files changed, 243 insertions(+), 63 deletions(-) create mode 100644 examples/portfolio/src/lib/schemas.ts diff --git a/core/types/src/index.ts b/core/types/src/index.ts index 53d5ca5af..5b5d737bd 100644 --- a/core/types/src/index.ts +++ b/core/types/src/index.ts @@ -8,12 +8,25 @@ import { z } from 'zod' */ export type Logger = Pick +export const PARTY_ID_EXAMPLE = 'party-hint::fingerprint' +export const PARTY_ID_ERROR_MESSAGE = `Must be in the form ${PARTY_ID_EXAMPLE}` +export const PARTY_ID_PATTERN = /^[a-zA-Z0-9:_-]*::[a-z0-9]*/ + export const PartyId = z .string() - .regex(/^[a-zA-Z0-9:_-]*::[a-z0-9]*/, 'Invalid party ID format') + .regex(PARTY_ID_PATTERN, PARTY_ID_ERROR_MESSAGE) export type PartyId = z.infer +export const HttpUrl = z + .url({ + message: 'Must be a valid HTTP or HTTPS URL', + protocol: /^https?$/, + }) + .transform((value) => new URL(value).toString()) + +export type HttpUrl = z.infer + /** * Requests / responses */ diff --git a/docs/dapp-building/examples/portfolio/index.md b/docs/dapp-building/examples/portfolio/index.md index ad52b0daa..a54eff348 100644 --- a/docs/dapp-building/examples/portfolio/index.md +++ b/docs/dapp-building/examples/portfolio/index.md @@ -2,7 +2,7 @@ The Splice Portfolio is a dApp for managing token standard assets and allocations. The app is a static single-page application that can be hosted anywhere. It allows you to connect to your own Wallet Gateway, or any CIP-103 compatible wallet, to sign transactions. -**NOTE:** You currently need to supply a `validatorUrl` for Canton Coin (Amulet) operations. +**NOTE:** You currently need to supply `amulet.validatorUrl` for Canton Coin (Amulet) operations. There are two supported ways of running an instance the app UI: @@ -18,20 +18,40 @@ It is also possible to host the UI directly on any webserver by downloading the ## Configuration -The app uses a simple configuration file, `config.json`. The only required option currently is `validatorUrl` which must point to a Validator API accessible to your browser using the same authentication setup as the Wallet Gateway user. +The app uses a simple configuration file, `config.json`. `amulet.validatorUrl` must point to a Validator API accessible to your browser using the same authentication setup as the Wallet Gateway user. ```json { - "validatorUrl": "http://localhost:2000/api/validator", - "registries": [] // not currently used but still needed in the config.json file + "amulet": { + "validatorUrl": "http://localhost:2000/api/validator", + "registry": "http://scan.localhost:4000/registry/" + }, + "token": { + "validatorUrl": "http://localhost:2000/api/validator", + "registries": [ + { + "name": "DA Registry", + "partyId": "operator::1234567890", + "url": "https://apps.da.com/registrar/operator::1234567890/" + } + ] + } } ``` +- `amulet.registry` points to the Amulet/Scan registry endpoint. +- `token.validatorUrl` points to the Validator API used for token-standard operations. +- `token.registries` points to Token Standard registry APIs. + - Registry names are optional. + - Registry `partyId` values are optional. + - When a registry omits `partyId`, the app discovers the registry admin party from the registry metadata endpoint. +- Configured registries from `amulet.registry` and `token.registries` are combined with registries saved in browser storage, with saved entries overriding matching configured parties. + ## Deployment ### Docker -To start the UI with Docker, create a `config.json` in your chosen directory and set your validatorUrl. Then, run the container: +To start the UI with Docker, create a `config.json` in your chosen directory and set the Amulet and token configuration. Then, run the container: ```sh docker run --rm \ @@ -52,11 +72,15 @@ image: tag: '' config: - validatorUrl: 'http://localhost:2000/api/validator' # @schema required - registries: - - url: 'https://registry.example.com' # @schema required - name: '' # (optional) - partyId: '' # (optional) + amulet: + validatorUrl: 'http://localhost:2000/api/validator' # @schema required + registry: 'http://scan.localhost:4000/registry/' # @schema required + token: + validatorUrl: 'http://localhost:2000/api/validator' # @schema required + registries: + - url: 'https://registry.example.com' # @schema required + name: '' # (optional) + partyId: 'party-hint::fingerprint' # (optional) ``` Then diff --git a/examples/portfolio/README.md b/examples/portfolio/README.md index 051b6b61e..f261a48ee 100644 --- a/examples/portfolio/README.md +++ b/examples/portfolio/README.md @@ -47,11 +47,25 @@ The app loads `config.json` at startup and validates it before rendering. The lo ```json { - "validatorUrl": "http://localhost:2000/api/validator", - "registries": [] // not currently used + "amulet": { + "validatorUrl": "http://localhost:2000/api/validator", + "registry": "http://scan.localhost:4000/registry/" + }, + "token": { + "validatorUrl": "http://localhost:2000/api/validator", + "registries": [ + { + "name": "DA Registry", + "partyId": "operator::1234567890", + "url": "https://apps.da.com/registrar/operator::1234567890/" + } + ] + } } ``` +The `amulet` section configures Canton Coin (Amulet) operations. The `token` section configures token-standard operations and default registries. Registry `partyId` values are optional; when omitted, the app discovers the registry admin party from the registry metadata endpoint. + For static or Docker deployments, replace or mount `/config.json`. Alternatively, start all services (Wallet Gateway + example dApps) together from the repository root: diff --git a/examples/portfolio/public/config.json b/examples/portfolio/public/config.json index fde580a4e..c6393a83a 100644 --- a/examples/portfolio/public/config.json +++ b/examples/portfolio/public/config.json @@ -1,4 +1,16 @@ { - "validatorUrl": "http://localhost:2000/api/validator", - "registries": [] + "amulet": { + "validatorUrl": "http://localhost:2000/api/validator", + "registry": "http://scan.localhost:4000" + }, + "token": { + "validatorUrl": "http://localhost:2000/api/validator", + "registries": [ + { + "name": "Local Scan", + "partyId": "", + "url": "http://scan.localhost:4000" + } + ] + } } diff --git a/examples/portfolio/src/components/registry-settings.tsx b/examples/portfolio/src/components/registry-settings.tsx index f99e40998..a08e1376e 100644 --- a/examples/portfolio/src/components/registry-settings.tsx +++ b/examples/portfolio/src/components/registry-settings.tsx @@ -19,8 +19,9 @@ import DeleteIcon from '@mui/icons-material/Delete' import { CopyableIdentifier } from './copyable-identifier' import { useRegistryUrls, useRegistryMutations } from '@hooks/useRegistryUrls' import { useForm } from '@tanstack/react-form' -import { z } from 'zod' +import { HttpUrl } from '@canton-network/core-types' import { toast } from 'sonner' +import { registryFormSchema, type RegistryFormData } from '@lib/schemas' interface Registry { partyId: string @@ -29,23 +30,13 @@ interface Registry { const INSECURE_REGISTRY_URL_WARNING = 'Registry responses can be spoofed by network attackers. Use HTTPS.' -const registryUrlSchema = z.url({ - message: 'Must be a valid HTTP or HTTPS URL', - protocol: /^https?$/, -}) +const registryUrlSchema = HttpUrl const isInsecureRegistryUrl = (value: string) => { const result = registryUrlSchema.safeParse(value) return result.success && new URL(result.data).protocol === 'http:' } -const registryFormSchema = z.object({ - partyId: z.string().min(1, 'Party ID is required'), - registryUrl: registryUrlSchema, -}) - -type RegistryFormData = z.infer - export function RegistrySettings() { const { setRegistryUrl, deleteRegistryUrl } = useRegistryMutations() const registryUrls = useRegistryUrls() @@ -128,6 +119,7 @@ export function RegistrySettings() { {(field) => ( field.handleChange(e.target.value) diff --git a/examples/portfolio/src/components/tap-settings.tsx b/examples/portfolio/src/components/tap-settings.tsx index cdd4e3618..95b726ad6 100644 --- a/examples/portfolio/src/components/tap-settings.tsx +++ b/examples/portfolio/src/components/tap-settings.tsx @@ -37,7 +37,9 @@ export const TapSettings: React.FC = () => { const sessionToken = useConnection().status?.session?.accessToken const primaryParty = usePrimaryAccount()?.partyId const { tap } = usePortfolio() - const { validatorUrl } = usePortfolioConfig() + const { + amulet: { validatorUrl }, + } = usePortfolioConfig() const registryUrls = useRegistryUrls() const instruments = useInstruments() diff --git a/examples/portfolio/src/config/portfolio-config.ts b/examples/portfolio/src/config/portfolio-config.ts index 0c455212f..19e1ed3ec 100644 --- a/examples/portfolio/src/config/portfolio-config.ts +++ b/examples/portfolio/src/config/portfolio-config.ts @@ -1,38 +1,12 @@ // Copyright (c) 2025-2026 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import { z } from 'zod' +import { type ZodError } from 'zod' +import { portfolioConfigSchema, type PortfolioConfig } from '@lib/schemas' const DEFAULT_CONFIG_URL = '/config.json' -const httpUrlSchema = z - .url({ - message: 'Must be a valid HTTP or HTTPS URL', - protocol: /^https?$/, - }) - .transform((value) => new URL(value).toString()) - -const optionalStringSchema = () => z.string().trim().optional() - -export const registryConfigSchema = z - .object({ - name: optionalStringSchema(), - partyId: optionalStringSchema(), - url: httpUrlSchema, - }) - .strict() - -export const portfolioConfigSchema = z - .object({ - validatorUrl: httpUrlSchema, - registries: z.array(registryConfigSchema), - }) - .strict() - -export type PortfolioRegistryConfig = z.infer -export type PortfolioConfig = z.infer - -const formatConfigError = (error: z.ZodError): string => +const formatConfigError = (error: ZodError): string => error.issues .map((issue) => { const path = issue.path.length > 0 ? issue.path.join('.') : '' diff --git a/examples/portfolio/src/contexts/PortfolioConfigContext.tsx b/examples/portfolio/src/contexts/PortfolioConfigContext.tsx index 7d512df12..addaceb66 100644 --- a/examples/portfolio/src/contexts/PortfolioConfigContext.tsx +++ b/examples/portfolio/src/contexts/PortfolioConfigContext.tsx @@ -2,7 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 import { createContext, useContext } from 'react' -import { type PortfolioConfig } from '@config/portfolio-config' +import { type PortfolioConfig } from '@lib/schemas' export const PortfolioConfigContext = createContext< PortfolioConfig | undefined diff --git a/examples/portfolio/src/contexts/PortfolioConfigProvider.tsx b/examples/portfolio/src/contexts/PortfolioConfigProvider.tsx index 78289f2e0..4a98f38ef 100644 --- a/examples/portfolio/src/contexts/PortfolioConfigProvider.tsx +++ b/examples/portfolio/src/contexts/PortfolioConfigProvider.tsx @@ -2,7 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 import { type ReactNode } from 'react' -import { type PortfolioConfig } from '@config/portfolio-config' +import { type PortfolioConfig } from '@lib/schemas' import { PortfolioConfigContext } from '@contexts/PortfolioConfigContext' export const PortfolioConfigProvider = ({ diff --git a/examples/portfolio/src/hooks/query-options.ts b/examples/portfolio/src/hooks/query-options.ts index 38b840d13..a3777c63a 100644 --- a/examples/portfolio/src/hooks/query-options.ts +++ b/examples/portfolio/src/hooks/query-options.ts @@ -38,7 +38,9 @@ export const useAllocationsQueryOptions = (party: string | undefined) => { export const useIsDevNetQueryOptions = (sessionToken: string | undefined) => { const { isDevNet } = usePortfolio() - const { validatorUrl } = usePortfolioConfig() + const { + token: { validatorUrl }, + } = usePortfolioConfig() return queryOptions({ queryKey: queryKeys.isDevNet.all, queryFn: async () => diff --git a/examples/portfolio/src/hooks/useRegistryUrls.ts b/examples/portfolio/src/hooks/useRegistryUrls.ts index 378d79489..3aa0e067b 100644 --- a/examples/portfolio/src/hooks/useRegistryUrls.ts +++ b/examples/portfolio/src/hooks/useRegistryUrls.ts @@ -1,9 +1,11 @@ // Copyright (c) 2025-2026 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import { useCallback } from 'react' +import { useCallback, useMemo } from 'react' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { type PartyId } from '@canton-network/core-types' +import { type PortfolioRegistryConfig } from '@lib/schemas' +import { usePortfolioConfig } from '@contexts/PortfolioConfigContext' import { resolveTokenStandardClient } from '@services/resolve' import { queryKeys } from '@hooks/query-keys' @@ -23,16 +25,98 @@ const writeToStorage = (next: ReadonlyMap): void => { ) } +// Build the best synchronous view we can from config alone. Registries without +// a partyId are skipped here because resolving their party requires a network call. +const configuredRegistriesWithPartyIdsToMap = ( + registries: PortfolioRegistryConfig[] +): ReadonlyMap => { + return new Map( + registries.flatMap((registry) => + registry.partyId ? [[registry.partyId, registry.url]] : [] + ) + ) +} + +const configuredRegistriesToMap = async ( + registries: PortfolioRegistryConfig[] +): Promise> => { + const settled = await Promise.allSettled( + registries.map(async (registry): Promise<[PartyId, string]> => { + if (registry.partyId) { + return [registry.partyId, registry.url] + } + + // Some config entries only know the registry URL. Ask the registry for + // its metadata so we can key the map by its admin partyId. + const client = await resolveTokenStandardClient({ + registryUrl: registry.url, + }) + const info = await client.get('/registry/metadata/v1/info') + + return [info.adminId, registry.url] + }) + ) + const entries = settled.flatMap((result, index) => { + if (result.status === 'fulfilled') { + return [result.value] + } + + // Keep the usable registries even if one configured registry is down or + // misconfigured; the UI can still operate with the remaining entries. + console.warn( + `Failed to resolve registry ${registries[index].url}:`, + result.reason + ) + return [] + }) + return new Map(entries) +} + +const mergeRegistryUrls = ( + configured: ReadonlyMap, + stored: ReadonlyMap +): ReadonlyMap => { + // User-provided overrides win over config values. + return new Map([...configured, ...stored]) +} + const EMPTY: ReadonlyMap = new Map() export const useRegistryUrls = (): ReadonlyMap => { + const { amulet, token } = usePortfolioConfig() + const configuredRegistryConfigs = useMemo( + () => [{ url: amulet.registry }, ...token.registries], + [amulet.registry, token.registries] + ) + + const readMergedRegistries = useCallback(async () => { + const configuredRegistries = await configuredRegistriesToMap( + configuredRegistryConfigs + ) + return mergeRegistryUrls(configuredRegistries, readFromStorage()) + }, [configuredRegistryConfigs]) + + const readInitialRegistries = useCallback( + () => + mergeRegistryUrls( + configuredRegistriesWithPartyIdsToMap( + configuredRegistryConfigs + ), + readFromStorage() + ), + [configuredRegistryConfigs] + ) + const { data } = useQuery({ queryKey: queryKeys.registries.all, - queryFn: readFromStorage, - initialData: readFromStorage, + queryFn: readMergedRegistries, + // Show immediately known registries while the async query resolves party + // party ids for config entries that only specify a URL. + placeholderData: readInitialRegistries, staleTime: Infinity, gcTime: Infinity, }) + return data ?? EMPTY } diff --git a/examples/portfolio/src/lib/schemas.ts b/examples/portfolio/src/lib/schemas.ts new file mode 100644 index 000000000..43de6c6a1 --- /dev/null +++ b/examples/portfolio/src/lib/schemas.ts @@ -0,0 +1,63 @@ +// Copyright (c) 2025-2026 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { + HttpUrl, + PartyId, + PARTY_ID_ERROR_MESSAGE, +} from '@canton-network/core-types' +import { z } from 'zod' + +const optionalStringSchema = () => z.string().trim().optional() + +export const optionalPartyIdSchema = z + .string() + .trim() + .refine( + (value) => value === '' || PartyId.safeParse(value).success, + PARTY_ID_ERROR_MESSAGE + ) + .transform((value) => (value === '' ? undefined : value)) + .optional() + +export const optionalPartyIdInputSchema = z + .string() + .trim() + .refine( + (value) => value === '' || PartyId.safeParse(value).success, + PARTY_ID_ERROR_MESSAGE + ) + +export const registryConfigSchema = z + .object({ + name: optionalStringSchema(), + partyId: optionalPartyIdSchema, + url: HttpUrl, + }) + .strict() + +export const portfolioConfigSchema = z + .object({ + amulet: z + .object({ + validatorUrl: HttpUrl, + registry: HttpUrl, + }) + .strict(), + token: z + .object({ + validatorUrl: HttpUrl, + registries: z.array(registryConfigSchema), + }) + .strict(), + }) + .strict() + +export const registryFormSchema = z.object({ + partyId: optionalPartyIdInputSchema, + registryUrl: HttpUrl, +}) + +export type PortfolioRegistryConfig = z.infer +export type PortfolioConfig = z.infer +export type RegistryFormData = z.infer From 21b0b68b30274d306178a844e14a013bc8c4b1a8 Mon Sep 17 00:00:00 2001 From: rukmini-basu-da <126689545+rukmini-basu-da@users.noreply.github.com> Date: Fri, 12 Jun 2026 09:18:00 -0400 Subject: [PATCH 04/17] test: wallet sdk keys namespace test (#1973) * keys namespace tst Signed-off-by: rukmini-basu-da * fix test Signed-off-by: rukmini-basu-da * fix Signed-off-by: rukmini-basu-da --------- Signed-off-by: rukmini-basu-da --- .../src/wallet/namespace/keys/client.ts | 25 ++++++++--- .../src/wallet/namespace/keys/index.ts | 29 +------------ .../src/wallet/namespace/keys/keys.test.ts | 41 +++++++++++++++++++ .../src/wallet/namespace/utils/encoding.ts | 21 ++++++++++ 4 files changed, 82 insertions(+), 34 deletions(-) create mode 100644 sdk/wallet-sdk/src/wallet/namespace/keys/keys.test.ts create mode 100644 sdk/wallet-sdk/src/wallet/namespace/utils/encoding.ts diff --git a/sdk/wallet-sdk/src/wallet/namespace/keys/client.ts b/sdk/wallet-sdk/src/wallet/namespace/keys/client.ts index 176150e05..78e76fc71 100644 --- a/sdk/wallet-sdk/src/wallet/namespace/keys/client.ts +++ b/sdk/wallet-sdk/src/wallet/namespace/keys/client.ts @@ -6,6 +6,7 @@ import { KeyPair, PublicKey, } from '@canton-network/core-signing-lib' +import { base64ToBytes, bytesToHex } from '../utils/encoding' export class KeysNamespace { constructor() {} @@ -18,16 +19,28 @@ export class KeysNamespace { return createKeyPair() } + /** + * + * @param publicKey base64 encoded public key + * @returns hex encoded fingerprint + */ public async fingerprint(publicKey: PublicKey) { const hashPurpose = 12 // For `PublicKeyFingerprint` - const keyBytes = Buffer.from(publicKey, 'base64') - const hashInput = Buffer.alloc(4 + keyBytes.length) - hashInput.writeUInt32BE(hashPurpose, 0) - Buffer.from(keyBytes).copy(hashInput, 4) + const keyBytes = base64ToBytes(publicKey) + const hashInput = new Uint8Array(4 + keyBytes.length) + hashInput[0] = (hashPurpose >>> 24) & 0xff + hashInput[1] = (hashPurpose >>> 16) & 0xff + hashInput[2] = (hashPurpose >>> 8) & 0xff + hashInput[3] = hashPurpose & 0xff + hashInput.set(keyBytes, 4) + const hash = new Uint8Array( await crypto.subtle.digest('SHA-256', hashInput) ) - const multiprefix = Buffer.from([0x12, 0x20]) - return Buffer.concat([multiprefix, hash]).toString('hex') + const multiprefix = new Uint8Array([0x12, 0x20]) + const result = new Uint8Array(multiprefix.length + hash.length) + result.set(multiprefix, 0) + result.set(hash, multiprefix.length) + return bytesToHex(result) } } diff --git a/sdk/wallet-sdk/src/wallet/namespace/keys/index.ts b/sdk/wallet-sdk/src/wallet/namespace/keys/index.ts index 21859fc2c..ccb7adf4e 100644 --- a/sdk/wallet-sdk/src/wallet/namespace/keys/index.ts +++ b/sdk/wallet-sdk/src/wallet/namespace/keys/index.ts @@ -1,31 +1,4 @@ // Copyright (c) 2025-2026 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import { - createKeyPair, - KeyPair, - PublicKey, -} from '@canton-network/core-signing-lib' - -export class KeysNamespace { - /** - * - * @returns A base64 encoded public/private key pair - */ - public generate(): KeyPair { - return createKeyPair() - } - - public async fingerprint(publicKey: PublicKey) { - const hashPurpose = 12 // For `PublicKeyFingerprint` - const keyBytes = Buffer.from(publicKey, 'base64') - const hashInput = Buffer.alloc(4 + keyBytes.length) - hashInput.writeUInt32BE(hashPurpose, 0) - Buffer.from(keyBytes).copy(hashInput, 4) - const hash = new Uint8Array( - await crypto.subtle.digest('SHA-256', hashInput) - ) - const multiprefix = Buffer.from([0x12, 0x20]) - return Buffer.concat([multiprefix, hash]).toString('hex') - } -} +export * from './client.js' diff --git a/sdk/wallet-sdk/src/wallet/namespace/keys/keys.test.ts b/sdk/wallet-sdk/src/wallet/namespace/keys/keys.test.ts new file mode 100644 index 000000000..bc8cf3806 --- /dev/null +++ b/sdk/wallet-sdk/src/wallet/namespace/keys/keys.test.ts @@ -0,0 +1,41 @@ +// Copyright (c) 2025-2026 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, it, expect } from 'vitest' +import { KeysNamespace } from './index.js' +import { + signTransactionHash, + verifySignedTxHash, +} from '@canton-network/core-signing-lib' + +describe('Keys namespace', () => { + it('should generate a valid keypair to sign transactions with', () => { + const keys = new KeysNamespace() + const keyPair = keys.generate() + + const messageToSign = 'EiAbC9+Qc4sRfwZLpRB7+ZtCgLHYiIhiENMoM6DsFhcFHQ==' + + const signature = signTransactionHash(messageToSign, keyPair.privateKey) + const verify = verifySignedTxHash( + messageToSign, + keyPair.publicKey, + signature + ) + + expect(verify).toBe(true) + }) + + it('should calculate the fingerprint correctly from a known base64 encoded public key', async () => { + const keys = new KeysNamespace() + const publicKeyWithKnownFingerprint = + 'PJCUPZmCN134OST9ofcs2BGLJ/4ju8BT/xiZjzSO6t4=' + + const fingerprint = await keys.fingerprint( + publicKeyWithKnownFingerprint + ) + + expect(fingerprint).toEqual( + '1220def9be3ebfa2ff62e63e4ce8e05551f0487371447ac19178cfdb40da37b28059' + ) + }) +}) diff --git a/sdk/wallet-sdk/src/wallet/namespace/utils/encoding.ts b/sdk/wallet-sdk/src/wallet/namespace/utils/encoding.ts new file mode 100644 index 000000000..608f3903e --- /dev/null +++ b/sdk/wallet-sdk/src/wallet/namespace/utils/encoding.ts @@ -0,0 +1,21 @@ +// Copyright (c) 2025-2026 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +export function base64ToBytes(base64: string): Uint8Array { + if (typeof atob === 'function') { + const binary = atob(base64) + const bytes = new Uint8Array(binary.length) + for (let i = 0; i < binary.length; i++) { + bytes[i] = binary.charCodeAt(i) + } + return bytes + } + + return new Uint8Array(Buffer.from(base64, 'base64')) +} + +export function bytesToHex(bytes: Uint8Array): string { + return Array.from(bytes) + .map((b) => b.toString(16).padStart(2, '0')) + .join('') +} From 33f417195cc55ae8c01eae353076451550db2961 Mon Sep 17 00:00:00 2001 From: Fayi <112705750+fayi-da@users.noreply.github.com> Date: Fri, 12 Jun 2026 14:58:35 +0100 Subject: [PATCH 05/17] feat: implement offers page in portfolio (#1989) Signed-off-by: Fayi Femi-Balogun --- .../action-required-row-skeleton.tsx | 46 ++-- .../dashboard/action-required-row.tsx | 246 ------------------ .../dashboard/action-required-section.tsx | 35 ++- .../components/offers/offer-row-layout.tsx | 147 +++++++++++ .../src/components/offers/offer-row.tsx | 170 ++++++++++++ .../src/components/offers/offer-tabs.tsx | 51 ++++ .../src/components/offers/offers-content.tsx | 82 ++++++ .../src/hooks/useActionRequiredItems.ts | 185 ++----------- examples/portfolio/src/hooks/useOfferItems.ts | 180 +++++++++++++ examples/portfolio/src/hooks/useOffers.ts | 137 ++++++++++ examples/portfolio/src/lib/theme.ts | 46 +++- .../src/routes/next/dashboard/offers.tsx | 71 ++++- 12 files changed, 927 insertions(+), 469 deletions(-) delete mode 100644 examples/portfolio/src/components/dashboard/action-required-row.tsx create mode 100644 examples/portfolio/src/components/offers/offer-row-layout.tsx create mode 100644 examples/portfolio/src/components/offers/offer-row.tsx create mode 100644 examples/portfolio/src/components/offers/offer-tabs.tsx create mode 100644 examples/portfolio/src/components/offers/offers-content.tsx create mode 100644 examples/portfolio/src/hooks/useOfferItems.ts create mode 100644 examples/portfolio/src/hooks/useOffers.ts diff --git a/examples/portfolio/src/components/dashboard/action-required-row-skeleton.tsx b/examples/portfolio/src/components/dashboard/action-required-row-skeleton.tsx index 859b2453a..8c863e0a5 100644 --- a/examples/portfolio/src/components/dashboard/action-required-row-skeleton.tsx +++ b/examples/portfolio/src/components/dashboard/action-required-row-skeleton.tsx @@ -1,36 +1,26 @@ // Copyright (c) 2025-2026 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import { Box, Paper, Skeleton } from '@mui/material' +import { Box, Skeleton } from '@mui/material' +import { + OfferRowGrid, + OfferRowShell, +} from '@components/offers/offer-row-layout' export function ActionRequiredRowSkeleton() { return ( - `1px solid ${theme.palette.divider}`, - borderRadius: 1, - }} - > - {Array.from({ length: 5 }, (_, index) => ( - - - - {index === 2 ? ( - - ) : null} - - ))} - + + + {Array.from({ length: 5 }, (_, index) => ( + + + + {index === 2 ? ( + + ) : null} + + ))} + + ) } diff --git a/examples/portfolio/src/components/dashboard/action-required-row.tsx b/examples/portfolio/src/components/dashboard/action-required-row.tsx deleted file mode 100644 index 9f4d83570..000000000 --- a/examples/portfolio/src/components/dashboard/action-required-row.tsx +++ /dev/null @@ -1,246 +0,0 @@ -// Copyright (c) 2025-2026 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -import { type KeyboardEvent, type ReactNode } from 'react' -import { Box, Chip, Typography } from '@mui/material' -import { CopyableIdentifier } from '@components/copyable-identifier' -import type { - ActionItem, - AllocationActionItem, - TransferActionItem, - TransferLegWithAllocation, -} from '@components/types' -import { - formatDistanceToNow, - formatIsoDateTimeString, -} from '@utils/date-format' -import { formatAmount } from '@utils/decimal' - -interface ActionRequiredRowProps { - item: ActionItem - onClick: () => void -} - -export function ActionRequiredRow({ item, onClick }: ActionRequiredRowProps) { - const handleKeyDown = (event: KeyboardEvent) => { - if (event.key === 'Enter' || event.key === ' ') { - event.preventDefault() - onClick() - } - } - - return ( - - theme.transitions.create( - ['background-color', 'border-color', 'transform'], - { duration: theme.transitions.duration.short } - ), - '&:hover': { - bgcolor: 'action.hover', - borderColor: 'text.secondary', - }, - '&:focus-visible': { - outline: (theme) => - `2px solid ${theme.palette.secondary.main}`, - outlineOffset: 2, - }, - '&:active': { transform: 'scale(0.995)' }, - }} - > - {item.kind === 'transfer' ? ( - - ) : ( - - )} - - ) -} - -function TransferRequiredRow({ item }: { item: TransferActionItem }) { - return ( - - - - - - - - ) -} - -function AllocationRequiredRow({ item }: { item: AllocationActionItem }) { - const sendLeg = item.transferLegs.find((leg) => - isCurrentPartySender(item.currentPartyId, leg) - ) - const receiveLeg = item.transferLegs.find((leg) => - isCurrentPartyReceiver(item.currentPartyId, leg) - ) - - return ( - - - - - - - - ) -} - -const rowGridSx = { - width: '100%', - display: 'grid', - gridTemplateColumns: { - xs: '1fr', - md: '1fr 1fr 1fr 1fr 1fr', - }, - gap: { xs: 2, md: 3 }, - alignItems: 'start', -} - -function DetailBlock({ label, value }: { label: string; value: string }) { - return ( - - {label} - - {value} - - - ) -} - -interface ExpirationBlockProps { - label: string - expiration: string -} - -function ExpirationBlock({ label, expiration }: ExpirationBlockProps) { - return ( - - {label} - - {formatIsoDateTimeString(expiration)} - {' '} - - ({formatDistanceToNow(expiration)}) - - - ) -} - -interface PartyBlockProps { - label: string - value: string - isCurrentParty?: boolean -} - -function PartyBlock({ label, value, isCurrentParty = false }: PartyBlockProps) { - return ( - - - {label} - {isCurrentParty ? ( - - ) : null} - - - - ) -} - -function FieldLabel({ children }: { children: ReactNode }) { - return ( - - {children} - - ) -} - -function getItemRowLabel(item: ActionItem) { - return item.kind === 'transfer' ? 'Transfer Offer' : 'Allocation' -} - -function isCurrentPartySender( - currentPartyId: string, - leg: TransferLegWithAllocation -) { - return leg.transferLeg.sender === currentPartyId -} - -function isCurrentPartyReceiver( - currentPartyId: string, - leg: TransferLegWithAllocation -) { - return leg.transferLeg.receiver === currentPartyId -} - -function formatLegAmount(leg: TransferLegWithAllocation | undefined) { - if (!leg) return '—' - return `${formatAmount(leg.transferLeg.amount)} ${leg.transferLeg.instrumentId.id}` -} diff --git a/examples/portfolio/src/components/dashboard/action-required-section.tsx b/examples/portfolio/src/components/dashboard/action-required-section.tsx index 4016f3393..a60d707cf 100644 --- a/examples/portfolio/src/components/dashboard/action-required-section.tsx +++ b/examples/portfolio/src/components/dashboard/action-required-section.tsx @@ -3,13 +3,13 @@ import { useMemo, useState, type ReactNode } from 'react' import { Alert, Box, Chip, Typography } from '@mui/material' -import type { ActionItem } from '@components/types' import { ActionRequiredDialog } from '@components/dashboard/action-required-dialog' -import { ActionRequiredRow } from '@components/dashboard/action-required-row' import { ActionRequiredRowSkeleton } from '@components/dashboard/action-required-row-skeleton' +import { OfferRow } from '@components/offers/offer-row' +import type { OfferItem } from '@hooks/useOffers' interface ActionRequiredSectionProps { - items: ActionItem[] + items: OfferItem[] isLoading?: boolean isError?: boolean error?: Error | null @@ -21,15 +21,14 @@ export function ActionRequiredSection({ isError = false, error, }: ActionRequiredSectionProps) { - const [selectedItemKey, setSelectedItemKey] = useState(null) + const [selectedOfferId, setSelectedOfferId] = useState(null) const visibleItems = useMemo(() => items.slice(0, 3), [items]) - const selectedItem = useMemo( + const selectedOffer = useMemo( () => - selectedItemKey - ? (items.find((item) => getItemKey(item) === selectedItemKey) ?? - null) + selectedOfferId + ? (items.find((item) => item.id === selectedOfferId) ?? null) : null, - [items, selectedItemKey] + [items, selectedOfferId] ) if (isError) { @@ -68,26 +67,22 @@ export function ActionRequiredSection({ return ( - {visibleItems.map((item) => ( - setSelectedItemKey(getItemKey(item))} + {visibleItems.map((offer) => ( + setSelectedOfferId(offer.id)} /> ))} setSelectedItemKey(null)} + item={selectedOffer?.source ?? null} + onClose={() => setSelectedOfferId(null)} /> ) } -function getItemKey(item: ActionItem) { - return `${item.kind}-${item.contractId}` -} - interface SectionShellProps { totalCount: number children: ReactNode diff --git a/examples/portfolio/src/components/offers/offer-row-layout.tsx b/examples/portfolio/src/components/offers/offer-row-layout.tsx new file mode 100644 index 000000000..b0d10fa9d --- /dev/null +++ b/examples/portfolio/src/components/offers/offer-row-layout.tsx @@ -0,0 +1,147 @@ +// Copyright (c) 2025-2026 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { type ReactNode } from 'react' +import { + Box, + Typography, + type BoxProps, + type SxProps, + type Theme, +} from '@mui/material' +import { CopyableIdentifier } from '@components/copyable-identifier' +import { + formatDistanceToNow, + formatIsoDateTimeString, +} from '@utils/date-format' + +interface OfferRowShellProps extends Omit { + children: ReactNode + sx?: SxProps +} + +export function OfferRowShell({ + children, + sx, + ...boxProps +}: OfferRowShellProps) { + return ( + + {children} + + ) +} + +interface OfferRowGridProps { + children: ReactNode + columns?: number +} + +export function OfferRowGrid({ children, columns = 5 }: OfferRowGridProps) { + return ( + + {children} + + ) +} + +export function OfferDetailBlock({ + label, + value, +}: { + label: string + value: string +}) { + return ( + + {label} + + {value} + + + ) +} + +interface OfferExpirationBlockProps { + label: string + expiration: string +} + +export function OfferExpirationBlock({ + label, + expiration, +}: OfferExpirationBlockProps) { + return ( + + {label} + + {formatIsoDateTimeString(expiration)} + {' '} + + ({formatDistanceToNow(expiration)}) + + + ) +} + +interface OfferPartyBlockProps { + label: string + value: string +} + +export function OfferPartyBlock({ label, value }: OfferPartyBlockProps) { + return ( + + {label} + + + ) +} + +export function OfferFieldLabel({ children }: { children: ReactNode }) { + return ( + + {children} + + ) +} diff --git a/examples/portfolio/src/components/offers/offer-row.tsx b/examples/portfolio/src/components/offers/offer-row.tsx new file mode 100644 index 000000000..72fe8be0f --- /dev/null +++ b/examples/portfolio/src/components/offers/offer-row.tsx @@ -0,0 +1,170 @@ +// Copyright (c) 2025-2026 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { type KeyboardEvent } from 'react' +import { Box, Chip, type SxProps, type Theme } from '@mui/material' +import type { + AllocationActionItem, + TransferActionItem, +} from '@components/types' +import { + OfferDetailBlock, + OfferExpirationBlock, + OfferFieldLabel, + OfferPartyBlock, + OfferRowGrid, + OfferRowShell, +} from './offer-row-layout' +import type { OfferItem, OfferStatus } from '@hooks/useOffers' +import { formatAmount } from '@utils/decimal' + +interface OfferRowProps { + offer: OfferItem + onClick: () => void +} + +export function OfferRow({ offer, onClick }: OfferRowProps) { + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault() + onClick() + } + } + + return ( + + {offer.source.kind === 'transfer' ? ( + + ) : ( + + )} + + ) +} + +function getOfferRowLabel(offer: OfferItem) { + return offer.source.kind === 'transfer' + ? 'Transfer Offer' + : 'Allocation Request' +} + +function TransferOfferRow({ + offer, + item, +}: { + offer: OfferItem + item: TransferActionItem +}) { + const counterparty = + offer.direction === 'incoming' + ? { label: 'Sender', value: item.sender } + : { label: 'Recipient', value: item.receiver } + + return ( + + + + + + + + ) +} + +function AllocationOfferRow({ + item, + status, +}: { + item: AllocationActionItem + status: OfferStatus +}) { + return ( + + + + + + + ) +} + +function StatusBlock({ status }: { status: OfferStatus }) { + return ( + + Status + { + const token = + theme.portfolio.status[ + STATUS_THEME_KEY_BY_STATUS[status] + ] + return { + bgcolor: token.background, + color: token.text, + } + }, + ]} + /> + + ) +} + +const interactiveOfferRowSx: SxProps = { + cursor: 'pointer', + userSelect: 'none', + transition: (theme) => + theme.transitions.create( + ['background-color', 'border-color', 'transform'], + { + duration: theme.transitions.duration.short, + } + ), + '&:hover': { + bgcolor: 'action.hover', + borderColor: 'text.secondary', + }, + '&:focus-visible': { + outline: (theme) => `2px solid ${theme.palette.secondary.main}`, + outlineOffset: 2, + }, + '&:active': { transform: 'scale(0.995)' }, +} + +const STATUS_THEME_KEY_BY_STATUS = { + Pending: 'pending', + 'Action Required': 'action-required', + Allocated: 'allocated', + Expired: 'expired', +} satisfies Record< + OfferStatus, + 'pending' | 'action-required' | 'allocated' | 'expired' +> diff --git a/examples/portfolio/src/components/offers/offer-tabs.tsx b/examples/portfolio/src/components/offers/offer-tabs.tsx new file mode 100644 index 000000000..591f0a348 --- /dev/null +++ b/examples/portfolio/src/components/offers/offer-tabs.tsx @@ -0,0 +1,51 @@ +// Copyright (c) 2025-2026 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { Tab, Tabs } from '@mui/material' +import type { OfferDirection } from '@hooks/useOffers' + +interface OfferTabsProps { + value: OfferDirection + onChange: (value: OfferDirection) => void +} + +export function OfferTabs({ value, onChange }: OfferTabsProps) { + return ( + onChange(nextValue)} + aria-label="Offer direction" + textColor="inherit" + slotProps={{ + indicator: { + sx: { + backgroundColor: (theme) => theme.portfolio.nav.main, + }, + }, + }} + sx={{ + minHeight: 'unset', + '& .MuiTab-root': { + minHeight: 'unset', + px: 0, + py: 0, + pb: 2, + mr: 2, + color: 'text.secondary', + fontSize: (theme) => theme.typography.body1.fontSize, + lineHeight: (theme) => theme.typography.body1.lineHeight, + textTransform: 'none', + alignItems: 'flex-start', + textAlign: 'left', + }, + '& .Mui-selected': { + color: 'text.primary', + fontWeight: 600, + }, + }} + > + + + + ) +} diff --git a/examples/portfolio/src/components/offers/offers-content.tsx b/examples/portfolio/src/components/offers/offers-content.tsx new file mode 100644 index 000000000..efddb9960 --- /dev/null +++ b/examples/portfolio/src/components/offers/offers-content.tsx @@ -0,0 +1,82 @@ +// Copyright (c) 2025-2026 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { Alert, Box, Skeleton } from '@mui/material' +import { OfferRowGrid, OfferRowShell } from './offer-row-layout' +import type { OfferDirection, OfferItem } from '@hooks/useOffers' +import { OfferRow } from './offer-row' + +interface OffersContentProps { + offers: OfferItem[] + direction: OfferDirection + isLoading: boolean + isError: boolean + error: Error | null + hasSearchQuery: boolean + onOfferClick: (offer: OfferItem) => void +} + +export function OffersContent({ + offers, + direction, + isLoading, + isError, + error, + hasSearchQuery, + onOfferClick, +}: OffersContentProps) { + if (isError) { + return ( + + {error?.message ?? 'Unable to load offers.'} + + ) + } + + if (isLoading) { + return ( + + {Array.from({ length: 5 }, (_, index) => ( + + ))} + + ) + } + + if (offers.length === 0) { + return ( + + {hasSearchQuery + ? 'No offers match your search.' + : `There are currently no ${direction} offers.`} + + ) + } + + return ( + + {offers.map((offer) => ( + onOfferClick(offer)} + /> + ))} + + ) +} + +function OfferRowSkeleton() { + return ( + + + {Array.from({ length: 6 }, (_, index) => ( + + + + + ))} + + + ) +} diff --git a/examples/portfolio/src/hooks/useActionRequiredItems.ts b/examples/portfolio/src/hooks/useActionRequiredItems.ts index a968cd726..4f6bd2f42 100644 --- a/examples/portfolio/src/hooks/useActionRequiredItems.ts +++ b/examples/portfolio/src/hooks/useActionRequiredItems.ts @@ -1,187 +1,32 @@ // Copyright (c) 2025-2026 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import { useCallback, useMemo } from 'react' -import { useQuery } from '@tanstack/react-query' -import { type PrettyContract } from '@canton-network/core-tx-parser' -import { TokenStandardService } from '@canton-network/core-token-standard-service' -import type { - AllocationView, - SettlementInfo, - TransferLeg, -} from '@canton-network/core-token-standard' -import { usePrimaryAccount } from './useAccounts' -import { - useAllocationRequestsQueryOptions, - useAllocationsQueryOptions, - usePendingTransfersQueryOptions, -} from './query-options' -import type { - ActionItem, - AllocationActionItem, - TransferActionItem, -} from '@components/types' -import { getExpiryTime, isExpired } from '@utils/date-format' +import { useMemo } from 'react' +import { useOffers, type OfferItem } from './useOffers' export interface ActionRequiredItemsResult { - items: ActionItem[] + items: OfferItem[] isLoading: boolean isError: boolean error: Error | null } export function useActionRequiredItems(): ActionRequiredItemsResult { - const primaryParty = usePrimaryAccount()?.partyId - - // Action required combines two independent sources: pending transfer - // instructions and allocation requests for the current primary wallet. - const pendingTransfers = useQuery( - usePendingTransfersQueryOptions(primaryParty) - ) - const allocationRequests = useQuery( - useAllocationRequestsQueryOptions(primaryParty) + const offers = useOffers() + const items = useMemo( + () => + offers.all.filter( + (offer) => + offer.status === 'Pending' || + offer.status === 'Action Required' + ), + [offers.all] ) - const allocations = useQuery(useAllocationsQueryOptions(primaryParty)) - - // Allocation requests and allocations are returned as separate contracts. - // Use the settlement reference plus transfer leg ID as a stable join key so - // each request leg can be decorated with any allocations already created. - const allocationKey = useCallback( - (settlement: SettlementInfo, transferLegId: string) => - JSON.stringify([ - settlement.settlementRef.id, - settlement.settlementRef.cid, - transferLegId, - ]), - [] - ) - - // Group existing allocations by the allocation request leg they fulfill. - const groupedAllocations = useMemo(() => { - const grouped = new Map[]>() - - for (const allocationRequest of allocationRequests.data ?? []) { - const { settlement, transferLegs } = - allocationRequest.interfaceViewValue - for (const transferLegId in transferLegs) { - const key = allocationKey(settlement, transferLegId) - grouped.set(key, []) - } - } - - for (const allocation of allocations.data ?? []) { - const { settlement, transferLegId } = - allocation.interfaceViewValue.allocation - const key = allocationKey(settlement, transferLegId) - if (grouped.has(key)) { - grouped.get(key)!.push(allocation) - } - } - - return grouped - }, [allocationRequests.data, allocations.data, allocationKey]) - - const items = useMemo(() => { - if (!primaryParty) return [] - - const actionItems: ActionItem[] = [] - - // Normalize transfer instruction contracts into the shared ActionItem shape used by the UI. - for (const contract of pendingTransfers.data ?? []) { - const view = contract.interfaceViewValue - const transfer = view.transfer - const status = view.status - const tag = ( - 'tag' in status ? status.tag : status.current?.tag - ) as string - const memo = - transfer?.meta?.values?.[TokenStandardService.MEMO_KEY] ?? '' - - if (isExpired(transfer.executeBefore)) continue - - const transferItem: TransferActionItem = { - kind: 'transfer', - contractId: contract.contractId, - currentPartyId: primaryParty, - tag, - type: tag?.startsWith('Transfer') ? 'Transfer' : tag, - date: transfer.requestedAt, - expiry: transfer.executeBefore, - message: memo, - sender: transfer.sender, - receiver: transfer.receiver, - instrumentId: transfer.instrumentId, - amount: transfer.amount, - } - actionItems.push(transferItem) - } - - // Normalize allocation requests. Each request can contain multiple transfer legs, - // but the current party only needs legs where it is either the sender or receiver. - for (const request of allocationRequests.data ?? []) { - const { settlement, transferLegs } = request.interfaceViewValue - if (isExpired(settlement.allocateBefore)) continue - - const typedTransferLegs = transferLegs as Record< - string, - TransferLeg - > - const legsWithAllocations = Object.entries(typedTransferLegs).map( - ([transferLegId, transferLeg]) => ({ - transferLegId, - transferLeg, - allocations: - groupedAllocations.get( - allocationKey(settlement, transferLegId) - ) ?? [], - }) - ) - - const relevantLegs = legsWithAllocations.filter( - (leg) => - leg.transferLeg.sender === primaryParty || - leg.transferLeg.receiver === primaryParty - ) - - if (relevantLegs.length === 0) continue - - const allocationItem: AllocationActionItem = { - kind: 'allocation', - contractId: request.contractId, - currentPartyId: primaryParty, - expiry: settlement.allocateBefore, - settlement, - transferLegs: relevantLegs, - } - actionItems.push(allocationItem) - } - - // Show the items closest to expiry first. - return [...actionItems].sort( - (left, right) => - getExpiryTime(left.expiry) - getExpiryTime(right.expiry) - ) - }, [ - pendingTransfers.data, - allocationRequests.data, - groupedAllocations, - primaryParty, - allocationKey, - ]) - - const error = - pendingTransfers.error ?? - allocationRequests.error ?? - allocations.error ?? - null return { + isLoading: offers.isLoading, + isError: offers.isError, + error: offers.error, items, - isLoading: - pendingTransfers.isLoading || - allocationRequests.isLoading || - allocations.isLoading, - isError: error !== null, - error: error, } } diff --git a/examples/portfolio/src/hooks/useOfferItems.ts b/examples/portfolio/src/hooks/useOfferItems.ts new file mode 100644 index 000000000..a57e88d02 --- /dev/null +++ b/examples/portfolio/src/hooks/useOfferItems.ts @@ -0,0 +1,180 @@ +// Copyright (c) 2025-2026 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { useMemo } from 'react' +import { useQuery } from '@tanstack/react-query' +import { type PrettyContract } from '@canton-network/core-tx-parser' +import { TokenStandardService } from '@canton-network/core-token-standard-service' +import type { + AllocationView, + SettlementInfo, + TransferLeg, +} from '@canton-network/core-token-standard' +import type { + ActionItem, + AllocationActionItem, + TransferActionItem, +} from '@components/types' +import { getExpiryTime } from '@utils/date-format' +import { usePrimaryAccount } from './useAccounts' +import { + useAllocationRequestsQueryOptions, + useAllocationsQueryOptions, + usePendingTransfersQueryOptions, +} from './query-options' + +export interface OfferItemsResult { + items: ActionItem[] + isLoading: boolean + isError: boolean + error: Error | null +} + +// Allocation requests and allocations are returned as separate contracts. +// Use the settlement reference plus transfer leg ID as a stable join key so +// each request leg can be decorated with any allocations already created. +function allocationKey(settlement: SettlementInfo, transferLegId: string) { + return JSON.stringify([ + settlement.settlementRef.id, + settlement.settlementRef.cid, + transferLegId, + ]) +} + +export function useOfferItems(): OfferItemsResult { + const primaryParty = usePrimaryAccount()?.partyId + + // Offers combine two independent sources: pending transfer instructions and + // allocation requests for the current primary wallet. + const pendingTransfers = useQuery( + usePendingTransfersQueryOptions(primaryParty) + ) + const allocationRequests = useQuery( + useAllocationRequestsQueryOptions(primaryParty) + ) + const allocations = useQuery(useAllocationsQueryOptions(primaryParty)) + + // Group existing allocations by the allocation request leg they fulfill. + const groupedAllocations = useMemo(() => { + const grouped = new Map[]>() + + for (const allocationRequest of allocationRequests.data ?? []) { + const { settlement, transferLegs } = + allocationRequest.interfaceViewValue + for (const transferLegId in transferLegs) { + const key = allocationKey(settlement, transferLegId) + grouped.set(key, []) + } + } + + for (const allocation of allocations.data ?? []) { + const { settlement, transferLegId } = + allocation.interfaceViewValue.allocation + const key = allocationKey(settlement, transferLegId) + if (grouped.has(key)) { + grouped.get(key)!.push(allocation) + } + } + + return grouped + }, [allocationRequests.data, allocations.data]) + + const items = useMemo(() => { + if (!primaryParty) return [] + + const offerItems: ActionItem[] = [] + + // Normalize transfer instruction contracts into the shared ActionItem shape used by the UI. + for (const contract of pendingTransfers.data ?? []) { + const view = contract.interfaceViewValue + const transfer = view.transfer + const status = view.status + const tag = ( + 'tag' in status ? status.tag : status.current?.tag + ) as string + const memo = + transfer?.meta?.values?.[TokenStandardService.MEMO_KEY] ?? '' + + const transferItem: TransferActionItem = { + kind: 'transfer', + contractId: contract.contractId, + currentPartyId: primaryParty, + tag, + type: tag?.startsWith('Transfer') ? 'Transfer' : tag, + date: transfer.requestedAt, + expiry: transfer.executeBefore, + message: memo, + sender: transfer.sender, + receiver: transfer.receiver, + instrumentId: transfer.instrumentId, + amount: transfer.amount, + } + offerItems.push(transferItem) + } + + // Normalize allocation requests. Each request can contain multiple transfer legs, + // but the current party only needs legs where it is either the sender or receiver. + for (const request of allocationRequests.data ?? []) { + const { settlement, transferLegs } = request.interfaceViewValue + const typedTransferLegs = transferLegs as Record< + string, + TransferLeg + > + const legsWithAllocations = Object.entries(typedTransferLegs).map( + ([transferLegId, transferLeg]) => ({ + transferLegId, + transferLeg, + allocations: + groupedAllocations.get( + allocationKey(settlement, transferLegId) + ) ?? [], + }) + ) + + const relevantLegs = legsWithAllocations.filter( + (leg) => + leg.transferLeg.sender === primaryParty || + leg.transferLeg.receiver === primaryParty + ) + + if (relevantLegs.length === 0) continue + + const allocationItem: AllocationActionItem = { + kind: 'allocation', + contractId: request.contractId, + currentPartyId: primaryParty, + expiry: settlement.allocateBefore, + settlement, + transferLegs: relevantLegs, + } + offerItems.push(allocationItem) + } + + // Show the items closest to expiry first. + return [...offerItems].sort( + (left, right) => + getExpiryTime(left.expiry) - getExpiryTime(right.expiry) + ) + }, [ + pendingTransfers.data, + allocationRequests.data, + groupedAllocations, + primaryParty, + ]) + + const error = + pendingTransfers.error ?? + allocationRequests.error ?? + allocations.error ?? + null + + return { + items, + isLoading: + pendingTransfers.isLoading || + allocationRequests.isLoading || + allocations.isLoading, + isError: error !== null, + error, + } +} diff --git a/examples/portfolio/src/hooks/useOffers.ts b/examples/portfolio/src/hooks/useOffers.ts new file mode 100644 index 000000000..dce81a1ce --- /dev/null +++ b/examples/portfolio/src/hooks/useOffers.ts @@ -0,0 +1,137 @@ +// Copyright (c) 2025-2026 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { useMemo } from 'react' +import type { + ActionItem, + AllocationActionItem, + TransferActionItem, + TransferLegWithAllocation, +} from '@components/types' +import { isExpired } from '@utils/date-format' +import { useOfferItems, type OfferItemsResult } from './useOfferItems' + +export type OfferDirection = 'incoming' | 'outgoing' +export type OfferStatus = + | 'Pending' + | 'Action Required' + | 'Allocated' + | 'Expired' + +export type OfferItem = { + id: string + source: ActionItem + direction: OfferDirection + status: OfferStatus +} + +export interface OffersResult extends Omit { + all: OfferItem[] + incoming: OfferItem[] + outgoing: OfferItem[] +} + +export function useOffers(): OffersResult { + const offerItems = useOfferItems() + const groupedOffers = useMemo(() => { + const all = deriveOffers(offerItems.items) + return { + all, + incoming: all.filter((offer) => offer.direction === 'incoming'), + outgoing: all.filter((offer) => offer.direction === 'outgoing'), + } + }, [offerItems.items]) + + return { + ...groupedOffers, + isLoading: offerItems.isLoading, + isError: offerItems.isError, + error: offerItems.error, + } +} + +function deriveOffers(items: ActionItem[]): OfferItem[] { + return items.flatMap((item) => { + if (item.kind === 'transfer') { + const offer = deriveTransferOffer(item) + return offer ? [offer] : [] + } + return deriveAllocationOffers(item) + }) +} + +function deriveTransferOffer(item: TransferActionItem): OfferItem | null { + const status = isExpired(item.expiry) ? 'Expired' : 'Pending' + + if (item.receiver === item.currentPartyId) { + return { + id: `${item.contractId}-incoming`, + source: item, + direction: 'incoming', + status, + } + } + + if (item.sender === item.currentPartyId) { + return { + id: `${item.contractId}-outgoing`, + source: item, + direction: 'outgoing', + status, + } + } + + return null +} + +function deriveAllocationOffers(item: AllocationActionItem): OfferItem[] { + const directions = new Set() + + for (const leg of item.transferLegs) { + if (isCurrentPartyReceiver(item.currentPartyId, leg)) { + directions.add('incoming') + } + if (isCurrentPartySender(item.currentPartyId, leg)) { + directions.add('outgoing') + } + } + + const isAllocated = areCurrentPartySenderLegsAllocated(item) + const status = isAllocated + ? 'Allocated' + : isExpired(item.expiry) + ? 'Expired' + : 'Action Required' + + return Array.from(directions).map((direction) => ({ + id: `${item.contractId}-${direction}`, + source: item, + direction, + status, + })) +} + +function areCurrentPartySenderLegsAllocated(item: AllocationActionItem) { + const senderLegs = item.transferLegs.filter((leg) => + isCurrentPartySender(item.currentPartyId, leg) + ) + + return ( + senderLegs.length === 0 || + senderLegs.every((leg) => leg.allocations.length > 0) + ) +} + +function isCurrentPartySender( + currentPartyId: string, + leg: TransferLegWithAllocation +) { + return leg.transferLeg.sender === currentPartyId +} + +function isCurrentPartyReceiver( + currentPartyId: string, + leg: TransferLegWithAllocation +) { + return leg.transferLeg.receiver === currentPartyId +} diff --git a/examples/portfolio/src/lib/theme.ts b/examples/portfolio/src/lib/theme.ts index 9ef375a84..14096e981 100644 --- a/examples/portfolio/src/lib/theme.ts +++ b/examples/portfolio/src/lib/theme.ts @@ -30,6 +30,12 @@ declare module '@mui/material/styles' { sidebar: { background: string; active: string } surface: { subtle: string; border: string; required: string } nav: { main: string; hover: string; soft: string } + status: { + pending: { background: string; text: string } + 'action-required': { background: string; text: string } + allocated: { background: string; text: string } + expired: { background: string; text: string } + } } } @@ -104,7 +110,27 @@ export const darkPortfolioTokens: ThemeOptions = { }, }, typography, - ...portfolioAppTokens, + portfolio: { + ...portfolioAppTokens.portfolio, + status: { + pending: { + background: portfolioColors.lightBlue89, + text: portfolioColors.black, + }, + 'action-required': { + background: portfolioColors.yellow99, + text: portfolioColors.black, + }, + allocated: { + background: portfolioColors.purple30, + text: portfolioColors.black, + }, + expired: { + background: portfolioColors.grey54, + text: portfolioColors.grey207, + }, + }, + }, } export const lightPortfolioTokens: ThemeOptions = { @@ -161,6 +187,24 @@ export const lightPortfolioTokens: ThemeOptions = { required: portfolioColors.grey226, }, nav: portfolioAppTokens.portfolio.nav, + status: { + pending: { + background: portfolioColors.purple100, + text: portfolioColors.white, + }, + 'action-required': { + background: portfolioColors.grey69, + text: portfolioColors.white, + }, + allocated: { + background: portfolioColors.purple30, + text: portfolioColors.black, + }, + expired: { + background: portfolioColors.grey207, + text: portfolioColors.grey54, + }, + }, }, } diff --git a/examples/portfolio/src/routes/next/dashboard/offers.tsx b/examples/portfolio/src/routes/next/dashboard/offers.tsx index a444cfa52..12d60ea34 100644 --- a/examples/portfolio/src/routes/next/dashboard/offers.tsx +++ b/examples/portfolio/src/routes/next/dashboard/offers.tsx @@ -1,14 +1,77 @@ -import { Typography } from '@mui/material' +import { useMemo, useState } from 'react' +import { Box, Typography } from '@mui/material' import { createFileRoute } from '@tanstack/react-router' +import { ActionRequiredDialog } from '@components/dashboard/action-required-dialog' +import { OffersContent } from '@components/offers/offers-content' +import { OfferTabs } from '@components/offers/offer-tabs' +import { useOffers, type OfferDirection } from '@hooks/useOffers' export const Route = createFileRoute('/next/dashboard/offers')({ component: RouteComponent, }) function RouteComponent() { + const offers = useOffers() + const [selectedDirection, setSelectedDirection] = + useState('incoming') + const [selectedOfferId, setSelectedOfferId] = useState(null) + const selectedOffers = + selectedDirection === 'incoming' ? offers.incoming : offers.outgoing + const visibleOffers = useMemo(() => selectedOffers, [selectedOffers]) + const selectedOffer = useMemo( + () => + selectedOfferId + ? (offers.all.find((offer) => offer.id === selectedOfferId) ?? + null) + : null, + [offers.all, selectedOfferId] + ) + return ( - - /next/dashboard/offers - + + + + Offers + + + These are offers sent to and from your primary wallet + + + + + `1px solid ${theme.palette.divider}`, + flexDirection: { xs: 'column', md: 'row' }, + }} + > + + + + + setSelectedOfferId(offer.id)} + /> + + + setSelectedOfferId(null)} + /> + ) } From 2339c456a1ec4d333601920f8e8d91aea661786e Mon Sep 17 00:00:00 2001 From: Alex Matson Date: Fri, 12 Jun 2026 10:15:07 -0400 Subject: [PATCH 06/17] feat: display list of suggested wallet extensions (#1987) * feat: display list of suggested wallet extensions Signed-off-by: Alex Matson * address pr comments Signed-off-by: Alex Matson --------- Signed-off-by: Alex Matson --- core/types/src/index.ts | 9 + .../src/components/wallet-picker.ts | 186 +++++++++++++++++- sdk/dapp-sdk/src/sdk.ts | 21 ++ sdk/dapp-sdk/src/wallets.json | 15 ++ 4 files changed, 230 insertions(+), 1 deletion(-) create mode 100644 sdk/dapp-sdk/src/wallets.json diff --git a/core/types/src/index.ts b/core/types/src/index.ts index 5b5d737bd..fd489cdf2 100644 --- a/core/types/src/index.ts +++ b/core/types/src/index.ts @@ -177,6 +177,8 @@ export type ProviderAdapterConfig = z.infer /** * Wallet picker entry and result */ +export type BrowserPlatform = 'chrome' | 'firefox' + export interface WalletPickerEntry { providerId: string name: string @@ -188,6 +190,13 @@ export interface WalletPickerEntry { reuseGlobalWalletPopup?: boolean | undefined } +export interface WalletPickerSuggestedEntry extends Omit< + WalletPickerEntry, + 'url' | 'reuseGlobalWalletPopup' +> { + installUrls: { platform: BrowserPlatform; url: string }[] +} + export interface WalletPickerResult { providerId: string name: string diff --git a/core/wallet-ui-components/src/components/wallet-picker.ts b/core/wallet-ui-components/src/components/wallet-picker.ts index fbd7e8332..df84c27c8 100644 --- a/core/wallet-ui-components/src/components/wallet-picker.ts +++ b/core/wallet-ui-components/src/components/wallet-picker.ts @@ -4,7 +4,11 @@ import { css } from 'lit' import { BaseElement } from '../internal/base-element' import { cssToString } from '../utils' -import { WalletPickerEntry } from '@canton-network/core-types' +import { + BrowserPlatform, + WalletPickerEntry, + WalletPickerSuggestedEntry, +} from '@canton-network/core-types' export type { WalletPickerEntry, @@ -108,6 +112,43 @@ const SUBSTITUTABLE_CSS = cssToString([ padding: 4px 12px 0; } + .wallet-suggested-card { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 12px; + padding: 14px 16px; + border-radius: 8px; + border: 1px solid var(--wg-theme-border-color); + background: var(--wg-theme-surface-color); + transition: all 0.15s ease; + width: 100%; + text-align: left; + margin-bottom: 8px; + } + + .wallet-suggested-card.wallet-suggested-card-disabled { + opacity: 0.8; + background: var(--wg-theme-border-color); + } + + .btn-secondary.wallet-install-btn { + text-decoration: none; + padding: 2px 4px; + font-size: 12px; + background: var(--wg-theme-surface-color); + } + + .wallet-install-buttons + > .btn-secondary.wallet-install-btn:not(:first-child) { + margin-left: 4px; + } + + .wallet-install-btn:hover { + background: var(--wg-theme-surface-hover); + border-color: var(--wg-theme-accent-color); + } + .wallet-card { display: flex; align-items: center; @@ -220,6 +261,10 @@ const SUBSTITUTABLE_CSS = cssToString([ padding: 0 4px 8px; } + .suggested-title { + margin-top: 24px; + } + .custom-url-label .info-wrap { display: inline-flex; align-items: center; @@ -430,6 +475,8 @@ export class WalletPicker extends HTMLElement { private root: HTMLElement private entries: WalletPickerEntry[] = [] + private platform: BrowserPlatform | 'unsupported' = 'unsupported' + private suggestedEntries: WalletPickerSuggestedEntry[] = [] private recentGateways: { name: string; rpcUrl: string }[] = [] private state: 'list' | 'connecting' | 'connected' | 'error' = 'list' private selectedEntry: WalletPickerEntry | null = null @@ -475,7 +522,9 @@ export class WalletPicker extends HTMLElement { this.root.className = 'root' this.loadEntries() + this.loadSuggestedEntries() this.recentGateways = this.loadRecentGateways() + this.platform = this.detectBrowserPlatform() } // ── localStorage helpers (inlined so they survive .toString() serialisation) ── @@ -529,6 +578,18 @@ export class WalletPicker extends HTMLElement { } } + private loadSuggestedEntries(): void { + const stored = localStorage.getItem( + 'splice_wallet_picker_suggested_entries' + ) + if (!stored) return + try { + this.suggestedEntries = JSON.parse(stored) + } catch { + this.suggestedEntries = [] + } + } + private getAllEntries(): WalletPickerEntry[] { // Merge all entries into a single flat list: // 1. Registered entries (extensions + gateways from discovery) @@ -552,6 +613,37 @@ export class WalletPicker extends HTMLElement { return [...this.entries, ...recentEntries] } + private getSuggestedEntries(): WalletPickerSuggestedEntry[] { + // We only want to show the following suggested entries: + // 1. Extensions that are not already detected (i.e. not in entries list) + const detectedEntries = this.getAllEntries() + + const entries = this.suggestedEntries.filter((entry) => { + const alreadyInstalled = detectedEntries.some( + (e) => e.providerId === entry.providerId + ) + return !alreadyInstalled + }) + + // We then want to sort the list priority by: + // 1. Extensions that are supported in the user's current browser + // 2. Alphabetically by name + + return entries.sort((a, b) => { + const aSupported = a.installUrls.some( + (url) => url.platform === this.platform + ) + const bSupported = b.installUrls.some( + (url) => url.platform === this.platform + ) + + if (aSupported && !bSupported) return -1 + if (!aSupported && bSupported) return 1 + + return a.name.localeCompare(b.name) + }) + } + private isRemovableEntry(entry: WalletPickerEntry): boolean { if (entry.type !== 'remote' || !entry.url) { return false @@ -700,6 +792,73 @@ export class WalletPicker extends HTMLElement { return card } + private renderSuggestedWalletCard( + entry: WalletPickerSuggestedEntry + ): HTMLElement { + const existsForCurrentPlatform = entry.installUrls.some( + (install) => install.platform === this.platform + ) + + const className = existsForCurrentPlatform + ? 'wallet-suggested-card' + : 'wallet-suggested-card wallet-suggested-card-disabled' + + const card = this.el('div', '', { + class: className, + tabindex: '0', + 'aria-label': `Install ${entry.name}`, + }) + + const icon = this.el('div', '', { class: 'wallet-icon' }) + if (entry.icon) { + const img = this.el('img', '', { src: entry.icon, alt: entry.name }) + icon.appendChild(img) + } else { + icon.innerHTML = + entry.type === 'browser' + ? '' + : '' + } + card.appendChild(icon) + + card.appendChild(this.el('span', entry.name, { class: 'wallet-name' })) + + const installButtons = this.el('div', '', { + class: 'wallet-install-buttons', + }) + + // Sort the available install URLs by: + // 1. The user's current detected browser + // 2. Alphabetically + const sortedInstallUrls = [...entry.installUrls].sort((a, b) => { + const aIsCurrentBrowser = this.platform === a.platform + const bIsCurrentBrowser = this.platform === b.platform + + if (aIsCurrentBrowser && !bIsCurrentBrowser) return -1 + if (!aIsCurrentBrowser && bIsCurrentBrowser) return 1 + + return a.platform.localeCompare(b.platform) + }) + + for (const { url, platform } of sortedInstallUrls) { + const badge = this.el('a', `Get for ${platform}`, { + class: 'btn-secondary wallet-install-btn', + href: url, + rel: 'noopener', + target: '_blank', + }) + badge.addEventListener('click', (e: Event) => { + e.stopPropagation() + window.close() + }) + installButtons.appendChild(badge) + } + + card.appendChild(installButtons) + + return card + } + private renderList(): HTMLElement { const container = this.el('div', '', { class: 'view-container', @@ -734,6 +893,19 @@ export class WalletPicker extends HTMLElement { } } + const suggestedEntries = this.getSuggestedEntries() + + if (suggestedEntries.length > 0) { + const suggestedTitle = this.el('div', 'Suggested Wallets', { + class: 'custom-url-label suggested-title', + }) + list.appendChild(suggestedTitle) + + for (const entry of suggestedEntries) { + list.appendChild(this.renderSuggestedWalletCard(entry)) + } + } + container.appendChild(list) // Custom URL section @@ -990,6 +1162,18 @@ export class WalletPicker extends HTMLElement { } return element } + + private detectBrowserPlatform(): BrowserPlatform | 'unsupported' { + const userAgent = window.navigator.userAgent + + const isFirefox = /firefox/i.test(userAgent) + const isChrome = /chrome|chromium|crios/i.test(userAgent) + + if (isFirefox) return 'firefox' + if (isChrome) return 'chrome' + + return 'unsupported' + } } customElements.define('swk-wallet-picker', WalletPicker) diff --git a/sdk/dapp-sdk/src/sdk.ts b/sdk/dapp-sdk/src/sdk.ts index b62ca26b0..01856be96 100644 --- a/sdk/dapp-sdk/src/sdk.ts +++ b/sdk/dapp-sdk/src/sdk.ts @@ -50,6 +50,7 @@ import { import * as storage from './storage' import { clearAllLocalState } from './util' import defaultGatewayList from './gateways.json' +import defaultExtensionsList from './wallets.json' import { CANTON_LOGO_PNG } from './assets' import { requestAnnouncedProviders } from './announce-discovery' @@ -58,6 +59,11 @@ export interface DappSDKConnectOptions< > { defaultAdapters?: TDefaultAdapter[] additionalAdapters?: ProviderAdapter[] | undefined + enableSuggestedWallets?: boolean +} + +function defaultTrue(b: boolean | undefined): boolean { + return b === undefined ? true : b } function normalizeConnectOptions( @@ -69,6 +75,7 @@ function normalizeConnectOptions( ? createDefaultAdapters(defaultGatewayList) : options.defaultAdapters, additionalAdapters: options.additionalAdapters, + enableSuggestedWallets: defaultTrue(options.enableSuggestedWallets), } } @@ -309,6 +316,20 @@ export class DappSDK { this.configuredAdapters = normalizeConnectOptions(options) } + const enableSuggestedWallets = defaultTrue( + this.configuredAdapters?.enableSuggestedWallets + ) + + if (enableSuggestedWallets) { + // Enable suggested wallets logic here + localStorage.setItem( + 'splice_wallet_picker_suggested_entries', + JSON.stringify(defaultExtensionsList) + ) + } else { + localStorage.removeItem('splice_wallet_picker_suggested_entries') + } + // Create discovery and attempt restore. // If init() is called again *with options*, make sure those adapters // are registered even if discovery was already created by an earlier call diff --git a/sdk/dapp-sdk/src/wallets.json b/sdk/dapp-sdk/src/wallets.json new file mode 100644 index 000000000..f405ca0c6 --- /dev/null +++ b/sdk/dapp-sdk/src/wallets.json @@ -0,0 +1,15 @@ +[ + { + "name": "Send Connect", + "type": "browser", + "providerId": "browser:ext:ldmohiccoioolenadmogclhoklmanpgi", + "description": "Connect via a browser extension wallet", + "icon": "https://info.send.it/img/favicon.svg", + "installUrls": [ + { + "platform": "chrome", + "url": "https://chromewebstore.google.com/detail/send-connect/ldmohiccoioolenadmogclhoklmanpgi" + } + ] + } +] From 764c54961164871358f4733a98fd2c36c5e7c117 Mon Sep 17 00:00:00 2001 From: rukmini-basu-da <126689545+rukmini-basu-da@users.noreply.github.com> Date: Fri, 12 Jun 2026 12:07:38 -0400 Subject: [PATCH 07/17] test: wallet-sdk asset namespace test (#1969) * asset namespace test Signed-off-by: rukmini-basu-da * test Signed-off-by: rukmini-basu-da --------- Signed-off-by: rukmini-basu-da --- .../src/wallet/namespace/asset/asset.test.ts | 116 ++++++++++++++++++ .../src/wallet/namespace/asset/index.ts | 7 +- 2 files changed, 121 insertions(+), 2 deletions(-) create mode 100644 sdk/wallet-sdk/src/wallet/namespace/asset/asset.test.ts diff --git a/sdk/wallet-sdk/src/wallet/namespace/asset/asset.test.ts b/sdk/wallet-sdk/src/wallet/namespace/asset/asset.test.ts new file mode 100644 index 000000000..7719a7393 --- /dev/null +++ b/sdk/wallet-sdk/src/wallet/namespace/asset/asset.test.ts @@ -0,0 +1,116 @@ +// Copyright (c) 2025-2026 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, it, expect, vi, MockedObject } from 'vitest' +import { AssetContext, AssetNamespace } from './index.js' +import { Logger } from '@canton-network/core-types' +import { TokenStandardService } from '@canton-network/core-token-standard-service' +import { SDKErrorHandler } from '../../error/handler.js' +import { SDKLogger } from '../../logger/logger' + +const makeProvider = (overrides: Record = {}) => ({ + request: vi.fn().mockResolvedValue(undefined), + on: vi.fn(), + off: vi.fn(), + ...overrides, +}) + +const mockLogger: MockedObject = { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), +} as MockedObject + +const accessTokenProvider = { + getAccessToken: vi.fn().mockResolvedValue('test-token'), + getAuthContext: vi.fn().mockResolvedValue(''), +} + +const amuletAsset = { + id: 'Amulet', + displayName: 'Amulet', + symbol: 'CC', + registryUrl: new URL('http://registry.com'), + admin: 'adminParty:123', +} + +const testAsset = { + id: 'test', + displayName: 'test', + symbol: 'test', + registryUrl: new URL('http://registry.com'), + admin: 'adminParty:123', +} + +const testAsset2 = { + id: 'test', + displayName: 'test', + symbol: 'test', + registryUrl: new URL('http://registry2.com'), + admin: 'adminParty:123', +} + +function makeAssetNamespace() { + const provider = makeProvider() + + const service = new TokenStandardService( + provider, + mockLogger, + accessTokenProvider, + false + ) + + const assetContext: AssetContext = { + tokenStandardService: service, + registries: [new URL('http://registry.com')], + error: new SDKErrorHandler(new SDKLogger('console')), + list: [amuletAsset, testAsset, testAsset2], + } + + const asset = new AssetNamespace(assetContext) + + return { asset, assetContext } +} + +describe('Asset namespace', () => { + it('should list assets', () => { + const { asset } = makeAssetNamespace() + const listedAsset = asset.list + + expect(listedAsset).toHaveLength(3) + expect(listedAsset[0]).toEqual(amuletAsset) + }) + + it('should find asset by ID', async () => { + const { asset } = makeAssetNamespace() + + const foundAsset = await asset.find('Amulet') + expect(foundAsset).toEqual(amuletAsset) + }) + + it('should throw an error if asset is not found within asset list', async () => { + const { asset } = makeAssetNamespace() + await expect(() => asset.find('bad-id')).rejects.toThrow( + 'Asset with id bad-id not found' + ) + }) + + it('should throw an error if multiple assets are found and suggest to provide a registryURL', async () => { + const { asset } = makeAssetNamespace() + await expect(() => asset.find('test')).rejects.toThrow( + 'Multiple assets found, please provide a registryUrl' + ) + }) + + it('should find an asset by registryURL and id', async () => { + const { asset } = makeAssetNamespace() + const foundAsset = await asset.find( + 'test', + new URL('http://registry2.com') + ) + + expect(foundAsset).toEqual(testAsset2) + expect(foundAsset).not.toEqual(testAsset) + }) +}) diff --git a/sdk/wallet-sdk/src/wallet/namespace/asset/index.ts b/sdk/wallet-sdk/src/wallet/namespace/asset/index.ts index 96b43ed9e..d63f386f5 100644 --- a/sdk/wallet-sdk/src/wallet/namespace/asset/index.ts +++ b/sdk/wallet-sdk/src/wallet/namespace/asset/index.ts @@ -28,7 +28,7 @@ export class AssetNamespace { } public async find(id: string, registryUrl?: URL): Promise { - return await findAsset(this.list, id, this.ctx.error, registryUrl) + return findAsset(this.list, id, this.ctx.error, registryUrl) } } @@ -39,7 +39,10 @@ export function findAsset( registryUrl?: URL ): AssetBody { const asset = registryUrl - ? assets.filter((asset) => asset.id === id && asset.registryUrl) + ? assets.filter( + (asset) => + asset.id === id && asset.registryUrl.href === registryUrl.href + ) : assets.filter((asset) => asset.id === id) if (asset.length === 0) { From 9c7251501ceed1e78d3e996a7e9a21083facbbe3 Mon Sep 17 00:00:00 2001 From: Alex Matson Date: Fri, 12 Jun 2026 12:10:04 -0400 Subject: [PATCH 08/17] chore: update yarn (#1993) Signed-off-by: Alex Matson --- .yarnrc.yml | 4 ++++ core/acs-reader/package.json | 1 - core/amulet-service/package.json | 1 - core/asyncapi-client/package.json | 1 - core/daml-codegen-helpers/package.json | 1 - core/ledger-client-types/package.json | 1 - core/ledger-client/package.json | 1 - core/ledger-proto/package.json | 1 - core/provider-dapp/package.json | 1 - core/provider-ledger/package.json | 1 - core/rpc-errors/package.json | 1 - core/rpc-generator/package.json | 1 - core/rpc-transport/package.json | 1 - core/signing-blockdaemon/package.json | 1 - core/signing-dfns/package.json | 1 - core/signing-fireblocks/package.json | 1 - core/signing-internal/package.json | 1 - core/signing-lib/package.json | 1 - core/signing-participant/package.json | 1 - core/signing-store-sql/package.json | 1 - core/splice-client/package.json | 1 - core/splice-provider/package.json | 1 - core/token-standard-service/package.json | 1 - core/token-standard/package.json | 1 - core/tx-parser/package.json | 1 - core/tx-visualizer/package.json | 1 - core/types/package.json | 1 - core/wallet-auth/package.json | 1 - core/wallet-store-inmemory/package.json | 1 - core/wallet-store-sql/package.json | 1 - core/wallet-store/package.json | 1 - core/wallet-ui-components/package.json | 1 - docs/wallet-integration-guide/examples/package.json | 1 - mock-oauth2/package.json | 1 - package.json | 2 +- scripts/package.json | 1 - scripts/src/lib/flat-pack.ts | 2 +- wallet-gateway/extension/package.json | 1 - yarn.lock | 2 +- 39 files changed, 7 insertions(+), 38 deletions(-) diff --git a/.yarnrc.yml b/.yarnrc.yml index c760f20b3..56a501971 100644 --- a/.yarnrc.yml +++ b/.yarnrc.yml @@ -1,3 +1,7 @@ +enableScripts: true + +npmMinimalAgeGate: 2880 # minimum age in minutes (48 hours) + npmRegistryServer: 'https://registry.npmjs.org' packageExtensions: diff --git a/core/acs-reader/package.json b/core/acs-reader/package.json index 4c0a2f42c..cd5e8533d 100644 --- a/core/acs-reader/package.json +++ b/core/acs-reader/package.json @@ -5,7 +5,6 @@ "description": "Reader for active contract set (ACS) data from the Canton ledger, providing functions to retrieve and process active contract information for specified filters.", "license": "Apache-2.0", "author": "Phillip Olesen ", - "packageManager": "yarn@4.9.4", "main": "dist/index.cjs", "module": "dist/index.js", "types": "dist/index.d.ts", diff --git a/core/amulet-service/package.json b/core/amulet-service/package.json index d25da3297..2c57edb93 100644 --- a/core/amulet-service/package.json +++ b/core/amulet-service/package.json @@ -5,7 +5,6 @@ "description": "A service layer for interacting with non-token-standard Amulet operations on Canton.", "license": "Apache-2.0", "author": "Phillip Olesen ", - "packageManager": "yarn@4.9.4", "main": "dist/index.cjs", "module": "dist/index.js", "types": "dist/index.d.ts", diff --git a/core/asyncapi-client/package.json b/core/asyncapi-client/package.json index 33d987ac4..0e3607c28 100644 --- a/core/asyncapi-client/package.json +++ b/core/asyncapi-client/package.json @@ -5,7 +5,6 @@ "description": "Provides a TypeScript asyncapi ledger client, generated by the OpenAPI spec", "license": "Apache-2.0", "author": "Phillip Olesen ", - "packageManager": "yarn@4.9.4", "main": "dist/index.cjs", "module": "dist/index.js", "types": "dist/index.d.ts", diff --git a/core/daml-codegen-helpers/package.json b/core/daml-codegen-helpers/package.json index 14055483b..f962c27df 100644 --- a/core/daml-codegen-helpers/package.json +++ b/core/daml-codegen-helpers/package.json @@ -5,7 +5,6 @@ "description": "Helpers for combining Daml codegen TypeScript output with the Splice Wallet SDK.", "license": "Apache-2.0", "author": "Phillip Olesen ", - "packageManager": "yarn@4.9.4", "main": "dist/index.cjs", "module": "dist/index.js", "types": "dist/index.d.ts", diff --git a/core/ledger-client-types/package.json b/core/ledger-client-types/package.json index 4e1fba7b6..ad5ecf3cb 100644 --- a/core/ledger-client-types/package.json +++ b/core/ledger-client-types/package.json @@ -5,7 +5,6 @@ "description": "Contains TypeScript types for the Canton Network ledger client, generated by the OpenAPI spec, and utility functions", "license": "Apache-2.0", "author": "Phillip Olesen ", - "packageManager": "yarn@4.9.4", "main": "dist/index.cjs", "module": "dist/index.js", "types": "dist/index.d.ts", diff --git a/core/ledger-client/package.json b/core/ledger-client/package.json index d7ecf8a1d..0d30767e2 100644 --- a/core/ledger-client/package.json +++ b/core/ledger-client/package.json @@ -5,7 +5,6 @@ "description": "Provides a TypeScript Canton Network ledger client, generated by the OpenAPI spec", "license": "Apache-2.0", "author": "Phillip Olesen ", - "packageManager": "yarn@4.9.4", "main": "dist/index.cjs", "module": "dist/index.js", "types": "dist/index.d.ts", diff --git a/core/ledger-proto/package.json b/core/ledger-proto/package.json index a0cd81504..03c08f3a7 100644 --- a/core/ledger-proto/package.json +++ b/core/ledger-proto/package.json @@ -5,7 +5,6 @@ "description": "Provides TypeScript protobuf bindings for Canton", "license": "Apache-2.0", "author": "Phillip Olesen ", - "packageManager": "yarn@4.9.4", "main": "dist/cjs/index.cjs", "module": "dist/esm/index.js", "types": "dist/types/index.d.ts", diff --git a/core/provider-dapp/package.json b/core/provider-dapp/package.json index 6c38d06f5..5402c7bfd 100644 --- a/core/provider-dapp/package.json +++ b/core/provider-dapp/package.json @@ -5,7 +5,6 @@ "description": "CIP-0103 provider for web dApps, covering both Sync and Async APIs.", "license": "Apache-2.0", "author": "Alex Matson ", - "packageManager": "yarn@4.9.4", "main": "./dist/index.cjs", "module": "./dist/index.js", "types": "./dist/index.d.ts", diff --git a/core/provider-ledger/package.json b/core/provider-ledger/package.json index 5ad8b324d..1ea8df5b5 100644 --- a/core/provider-ledger/package.json +++ b/core/provider-ledger/package.json @@ -5,7 +5,6 @@ "description": "A Splice Provider implementation for direct ledger access.", "license": "Apache-2.0", "author": "Alex Matson ", - "packageManager": "yarn@4.9.4", "main": "./dist/index.cjs", "module": "./dist/index.js", "types": "./dist/index.d.ts", diff --git a/core/rpc-errors/package.json b/core/rpc-errors/package.json index 70eeeb5a2..2d6d19ae6 100644 --- a/core/rpc-errors/package.json +++ b/core/rpc-errors/package.json @@ -4,7 +4,6 @@ "type": "module", "description": "Wrapper for JSON-RPC error objects", "author": "Alex Matson ", - "packageManager": "yarn@4.9.4", "license": "Apache-2.0", "main": "dist/index.cjs", "module": "dist/index.js", diff --git a/core/rpc-generator/package.json b/core/rpc-generator/package.json index cd9e9a12a..ef6ba412b 100644 --- a/core/rpc-generator/package.json +++ b/core/rpc-generator/package.json @@ -1,7 +1,6 @@ { "name": "@canton-network/core-rpc-generator", "private": true, - "packageManager": "yarn@4.9.4", "scripts": { "build": "mkdir -p ./dist && tsc -b", "clean": "tsc -b --clean && rm -rf ./dist", diff --git a/core/rpc-transport/package.json b/core/rpc-transport/package.json index 09986beda..2600c6247 100644 --- a/core/rpc-transport/package.json +++ b/core/rpc-transport/package.json @@ -5,7 +5,6 @@ "description": "RPC transport implementations", "license": "Apache-2.0", "author": "Digital Asset (Switzerland) GmbH and/or its affiliates", - "packageManager": "yarn@4.9.4", "main": "./dist/index.cjs", "module": "./dist/index.js", "types": "./dist/index.d.ts", diff --git a/core/signing-blockdaemon/package.json b/core/signing-blockdaemon/package.json index 7b0a5cb79..9b7e98279 100644 --- a/core/signing-blockdaemon/package.json +++ b/core/signing-blockdaemon/package.json @@ -4,7 +4,6 @@ "type": "module", "description": "Wallet Gateway signing driver for Blockdaemon", "license": "Apache-2.0", - "packageManager": "yarn@4.9.4", "main": "dist/index.cjs", "module": "dist/index.js", "types": "dist/index.d.ts", diff --git a/core/signing-dfns/package.json b/core/signing-dfns/package.json index 430088be0..2f3f23971 100644 --- a/core/signing-dfns/package.json +++ b/core/signing-dfns/package.json @@ -4,7 +4,6 @@ "type": "module", "description": "Dfns signing driver for Canton Network Wallet Gateway", "license": "Apache-2.0", - "packageManager": "yarn@4.9.4", "main": "dist/index.cjs", "module": "dist/index.js", "types": "dist/index.d.ts", diff --git a/core/signing-fireblocks/package.json b/core/signing-fireblocks/package.json index ff91320bc..b7eb1ed73 100644 --- a/core/signing-fireblocks/package.json +++ b/core/signing-fireblocks/package.json @@ -4,7 +4,6 @@ "type": "module", "description": "Wallet Gateway signing driver for Fireblocks", "license": "Apache-2.0", - "packageManager": "yarn@4.9.4", "main": "dist/index.cjs", "module": "dist/index.js", "types": "dist/index.d.ts", diff --git a/core/signing-internal/package.json b/core/signing-internal/package.json index 7c1094e6d..4b55a0b33 100644 --- a/core/signing-internal/package.json +++ b/core/signing-internal/package.json @@ -4,7 +4,6 @@ "type": "module", "description": "Wallet Gateway driver for offline Ed25519 keypairs", "license": "Apache-2.0", - "packageManager": "yarn@4.9.4", "main": "dist/index.cjs", "module": "dist/index.js", "types": "dist/index.d.ts", diff --git a/core/signing-lib/package.json b/core/signing-lib/package.json index 7444f1685..1e5f67c8e 100644 --- a/core/signing-lib/package.json +++ b/core/signing-lib/package.json @@ -4,7 +4,6 @@ "type": "module", "description": "Core library for signing driver implementations", "license": "Apache-2.0", - "packageManager": "yarn@4.9.4", "main": "dist/index.cjs", "module": "dist/index.js", "types": "dist/index.d.ts", diff --git a/core/signing-participant/package.json b/core/signing-participant/package.json index 06e2d60fa..54d4266f5 100644 --- a/core/signing-participant/package.json +++ b/core/signing-participant/package.json @@ -1,7 +1,6 @@ { "name": "@canton-network/core-signing-participant", "version": "1.5.0", - "packageManager": "yarn@4.9.4", "type": "module", "description": "Wallet Gateway driver for Canton participant internal parties", "license": "Apache-2.0", diff --git a/core/signing-store-sql/package.json b/core/signing-store-sql/package.json index 06541d13c..5870dee78 100644 --- a/core/signing-store-sql/package.json +++ b/core/signing-store-sql/package.json @@ -5,7 +5,6 @@ "description": "SQL implementation of the Store API", "license": "Apache-2.0", "author": "Marc Juchli ", - "packageManager": "yarn@4.9.4", "main": "dist/index.cjs", "module": "dist/index.js", "types": "dist/index.d.ts", diff --git a/core/splice-client/package.json b/core/splice-client/package.json index ef70e8419..e8c688ccd 100644 --- a/core/splice-client/package.json +++ b/core/splice-client/package.json @@ -15,7 +15,6 @@ "default": "./dist/index.js" } }, - "packageManager": "yarn@4.9.4", "scripts": { "build": "tsup --onSuccess \"tsc\"", "dev": "tsup --watch --onSuccess \"tsc\"", diff --git a/core/splice-provider/package.json b/core/splice-provider/package.json index 5283c57bc..92f28b8d6 100644 --- a/core/splice-provider/package.json +++ b/core/splice-provider/package.json @@ -5,7 +5,6 @@ "description": "A JavaScript Splice Provider API (EIP-1193 compliant).", "license": "Apache-2.0", "author": "Marc Juchli ", - "packageManager": "yarn@4.9.4", "main": "./dist/index.cjs", "module": "./dist/index.js", "types": "./dist/index.d.ts", diff --git a/core/token-standard-service/package.json b/core/token-standard-service/package.json index 680194c5b..551203f34 100644 --- a/core/token-standard-service/package.json +++ b/core/token-standard-service/package.json @@ -5,7 +5,6 @@ "description": "Provides a service layer that wraps the token-standard-client and contains higher level functions for interacting with token standard contracts on Canton.", "license": "Apache-2.0", "author": "Phillip Olesen ", - "packageManager": "yarn@4.9.4", "main": "dist/index.cjs", "module": "dist/index.js", "types": "dist/index.d.ts", diff --git a/core/token-standard/package.json b/core/token-standard/package.json index fb9fc9744..395232c68 100644 --- a/core/token-standard/package.json +++ b/core/token-standard/package.json @@ -4,7 +4,6 @@ "type": "module", "description": "daml codegen js for core token standard", "license": "Apache-2.0", - "packageManager": "yarn@4.9.4", "main": "dist/index.cjs", "module": "dist/index.js", "types": "dist/index.d.ts", diff --git a/core/tx-parser/package.json b/core/tx-parser/package.json index 7b1397585..c25237bf6 100644 --- a/core/tx-parser/package.json +++ b/core/tx-parser/package.json @@ -5,7 +5,6 @@ "description": "Transaction parsing utilities for Canton Network transactions, including parsing of transaction trees and extraction of relevant information from transaction events.", "license": "Apache-2.0", "author": "Phillip Olesen ", - "packageManager": "yarn@4.9.4", "main": "dist/index.cjs", "module": "dist/index.js", "types": "dist/index.d.ts", diff --git a/core/tx-visualizer/package.json b/core/tx-visualizer/package.json index a4d919ab1..f98e2305a 100644 --- a/core/tx-visualizer/package.json +++ b/core/tx-visualizer/package.json @@ -4,7 +4,6 @@ "type": "module", "description": "Decode and visualize prepared transactions from Canton", "license": "Apache-2.0", - "packageManager": "yarn@4.9.4", "main": "dist/index.cjs", "module": "dist/index.js", "types": "dist/index.d.ts", diff --git a/core/types/package.json b/core/types/package.json index 011e3f573..f8e65a5fa 100644 --- a/core/types/package.json +++ b/core/types/package.json @@ -5,7 +5,6 @@ "description": "Types and transport-agnostic parsers for data sent across Wallet components.", "license": "Apache-2.0", "author": "Alex Matson ", - "packageManager": "yarn@4.9.4", "main": "./dist/index.cjs", "module": "./dist/index.js", "types": "./dist/index.d.ts", diff --git a/core/wallet-auth/package.json b/core/wallet-auth/package.json index c7e0e76a7..1d81cfdbf 100644 --- a/core/wallet-auth/package.json +++ b/core/wallet-auth/package.json @@ -5,7 +5,6 @@ "description": "Provides authentication middleware and user management for the Wallet Gateway", "license": "Apache-2.0", "author": "Marc Juchli ", - "packageManager": "yarn@4.9.4", "main": "./dist/index.cjs", "module": "./dist/index.js", "types": "./dist/index.d.ts", diff --git a/core/wallet-store-inmemory/package.json b/core/wallet-store-inmemory/package.json index a11bbaa06..a69e9e0e0 100644 --- a/core/wallet-store-inmemory/package.json +++ b/core/wallet-store-inmemory/package.json @@ -5,7 +5,6 @@ "description": "In-memory implementation of the Store API", "author": "Marc Juchli ", "license": "Apache-2.0", - "packageManager": "yarn@4.9.4", "main": "./dist/index.js", "types": "./dist/index.d.ts", "scripts": { diff --git a/core/wallet-store-sql/package.json b/core/wallet-store-sql/package.json index a271efa47..183805dae 100644 --- a/core/wallet-store-sql/package.json +++ b/core/wallet-store-sql/package.json @@ -5,7 +5,6 @@ "description": "SQL implementation of the Store API", "license": "Apache-2.0", "author": "Marc Juchli ", - "packageManager": "yarn@4.9.4", "main": "dist/index.cjs", "module": "dist/index.js", "types": "dist/index.d.ts", diff --git a/core/wallet-store/package.json b/core/wallet-store/package.json index 5a8131a7f..b0ec2b936 100644 --- a/core/wallet-store/package.json +++ b/core/wallet-store/package.json @@ -5,7 +5,6 @@ "description": "The Store API provides persistency for the Wallet Gateway", "license": "Apache-2.0", "author": "Marc Juchli ", - "packageManager": "yarn@4.9.4", "main": "./dist/index.js", "types": "./dist/index.d.ts", "scripts": { diff --git a/core/wallet-ui-components/package.json b/core/wallet-ui-components/package.json index 5d885a7ab..9c0c482bf 100644 --- a/core/wallet-ui-components/package.json +++ b/core/wallet-ui-components/package.json @@ -43,7 +43,6 @@ "vite-plugin-dts": "^4.5.4", "vitest": "^4.1.2" }, - "packageManager": "yarn@4.9.4", "dependencies": { "@canton-network/core-tx-visualizer": "workspace:^", "@canton-network/core-types": "workspace:^", diff --git a/docs/wallet-integration-guide/examples/package.json b/docs/wallet-integration-guide/examples/package.json index faff497b3..40ecd497a 100644 --- a/docs/wallet-integration-guide/examples/package.json +++ b/docs/wallet-integration-guide/examples/package.json @@ -5,7 +5,6 @@ "description": "Example typescripts to be used in combination with the wallet integration guide.", "author": "Phillip Olesen ", "license": "Apache-2.0", - "packageManager": "yarn@4.9.4", "type": "module", "scripts": { "build": "tsc -b", diff --git a/mock-oauth2/package.json b/mock-oauth2/package.json index 0a31a69e5..a8075d379 100644 --- a/mock-oauth2/package.json +++ b/mock-oauth2/package.json @@ -4,7 +4,6 @@ "private": true, "description": "Creates mock oauth2 server", "license": "Apache-2.0", - "packageManager": "yarn@4.9.4", "main": "./dist/index.js", "types": "./dist/index.d.ts", "type": "module", diff --git a/package.json b/package.json index e01bc88e2..332e7e929 100644 --- a/package.json +++ b/package.json @@ -107,7 +107,7 @@ "*": "prettier --write --ignore-unknown", "*.{ts,tsx,js,jsx,mjs,cjs}": "eslint --fix" }, - "packageManager": "yarn@4.9.4", + "packageManager": "yarn@4.16.0", "dependencies": { "@nx/js": "22.5.4", "nx": "22.5.4" diff --git a/scripts/package.json b/scripts/package.json index 0db3a79f6..ef2a11482 100644 --- a/scripts/package.json +++ b/scripts/package.json @@ -5,7 +5,6 @@ "description": "Various scripts for the Wallet Gateway Project.", "author": "Phillip Olesen ", "license": "Apache-2.0", - "packageManager": "yarn@4.9.4", "type": "module", "scripts": { "test": "tsc -b", diff --git a/scripts/src/lib/flat-pack.ts b/scripts/src/lib/flat-pack.ts index 780aa12b4..2859ad7b6 100644 --- a/scripts/src/lib/flat-pack.ts +++ b/scripts/src/lib/flat-pack.ts @@ -102,7 +102,7 @@ export class FlatPack { version: '0.0.0', description: 'Temporary package for flat packing', ...(this.projectType === 'yarn' - ? { packageManager: 'yarn@4.9.4' } + ? { packageManager: 'yarn@4.16.0' } : {}), dependencies: {}, }, diff --git a/wallet-gateway/extension/package.json b/wallet-gateway/extension/package.json index 5f548285c..9ca70adf6 100644 --- a/wallet-gateway/extension/package.json +++ b/wallet-gateway/extension/package.json @@ -6,7 +6,6 @@ "author": "Alex Matson ", "license": "Apache-2.0", "type": "module", - "packageManager": "yarn@4.9.4", "scripts": { "build": "tsx esbuild.mts", "dev": "WATCH=1 && tsx esbuild.mts", diff --git a/yarn.lock b/yarn.lock index f66744421..58648d5b0 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2,7 +2,7 @@ # Manual changes might be lost - proceed with caution! __metadata: - version: 8 + version: 10 cacheKey: 10c0 "@adobe/css-tools@npm:^4.4.0": From d8417f803ce995184dceafd786de148a84920d9f Mon Sep 17 00:00:00 2001 From: rukmini-basu-da <126689545+rukmini-basu-da@users.noreply.github.com> Date: Fri, 12 Jun 2026 13:09:47 -0400 Subject: [PATCH 09/17] test: wallet sdk logging package and utils (#1986) * test Signed-off-by: rukmini-basu-da * test Signed-off-by: rukmini-basu-da * logging unit tests Signed-off-by: rukmini-basu-da * test Signed-off-by: rukmini-basu-da * url test Signed-off-by: rukmini-basu-da * ping test Signed-off-by: rukmini-basu-da * utils test Signed-off-by: rukmini-basu-da * test Signed-off-by: rukmini-basu-da * test Signed-off-by: rukmini-basu-da --------- Signed-off-by: rukmini-basu-da --- .../src/wallet/logger/logger.test.ts | 125 ++++++++++++++ .../wallet/namespace/utils/hash/service.ts | 7 + .../src/wallet/namespace/utils/utils.test.ts | 154 ++++++++++++++++++ 3 files changed, 286 insertions(+) create mode 100644 sdk/wallet-sdk/src/wallet/logger/logger.test.ts create mode 100644 sdk/wallet-sdk/src/wallet/namespace/utils/utils.test.ts diff --git a/sdk/wallet-sdk/src/wallet/logger/logger.test.ts b/sdk/wallet-sdk/src/wallet/logger/logger.test.ts new file mode 100644 index 000000000..d6a124b65 --- /dev/null +++ b/sdk/wallet-sdk/src/wallet/logger/logger.test.ts @@ -0,0 +1,125 @@ +// Copyright (c) 2025-2026 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest' +import ConsoleLogAdapter from './adapter/console' +import CustomLogAdapter from './adapter/custom' +import { SDKLogger } from './logger' +import { LogAdapter } from './types' + +function makeCustomAdapter() { + const log = vi.fn() + return { adapter: new CustomLogAdapter(log), log } +} + +function setNodeEnv(value: string | undefined) { + if (typeof process !== 'undefined') { + if (value === undefined) { + delete process.env.NODE_ENV + } else { + process.env.NODE_ENV = value + } + } +} + +describe('sdk logging package', () => { + describe('console log adapter', () => { + const adapter = new ConsoleLogAdapter() + + beforeEach(() => { + vi.spyOn(console, 'info').mockImplementation(() => {}) + vi.spyOn(console, 'error').mockImplementation(() => {}) + vi.spyOn(console, 'warn').mockImplementation(() => {}) + vi.spyOn(console, 'debug').mockImplementation(() => {}) + vi.spyOn(console, 'trace').mockImplementation(() => {}) + }) + + afterEach(() => vi.restoreAllMocks()) + + it('calls console[type] for each log level', () => { + adapter.log('info', {}, 'test') + expect(console.info).toHaveBeenCalledOnce() + }) + + it('includes namespace info', () => { + adapter.log('error', { namespace: 'amulet' }, 'message') + const label = (console.error as ReturnType).mock + .calls[0][0] + expect(label).toContain('ERROR:(amulet)/message') + }) + }) + + describe('custom log adapter', () => { + it('delegates to provided log function', () => { + const { adapter, log } = makeCustomAdapter() + adapter.log('info', { namespace: 'token' }, 'message') + expect(log).toHaveBeenCalledOnce() + expect(log).toHaveBeenCalledWith( + 'info', + { namespace: 'token' }, + 'message' + ) + }) + }) + + describe('adapter selection', () => { + afterEach(() => vi.restoreAllMocks()) + + it('accepts console adapter string without throwing an exception', () => { + vi.spyOn(console, 'info').mockImplementation(() => {}) + expect(() => new SDKLogger('console')).not.toThrow() + }) + + it('accepts console adapter string without throwing an exception', () => { + expect(() => new SDKLogger('pino')).not.toThrow() + }) + + it('accepts CustomLogAdapter instance directly', () => { + const { adapter } = makeCustomAdapter() + expect(() => new SDKLogger(adapter)).not.toThrow() + }) + }) + + describe('sdk logger logs correct levels based on node env', () => { + const isBrowserEnv = typeof process === 'undefined' + let log: ReturnType> + let logger: SDKLogger + let ogNodeEnv: string | undefined + beforeEach(() => { + ogNodeEnv = process.env.NODE_ENV + log = vi.fn() + logger = new SDKLogger(new CustomLogAdapter(log)) + }) + + afterEach(() => { + setNodeEnv(ogNodeEnv) + }) + + it.skipIf(isBrowserEnv)( + 'node env is development should not be supressed', + () => { + setNodeEnv('development') + logger.debug('should not be supressed') + expect(log).toHaveBeenCalled() + } + ) + + it.skipIf(isBrowserEnv)( + 'node env is production debug should be supressed', + () => { + setNodeEnv('production') + logger.debug({ requestId: '123' }, 'should be supressed') + expect(log).not.toHaveBeenCalled() + } + ) + + it.skipIf(isBrowserEnv)( + 'node env is undefined debug should be supressed', + () => { + setNodeEnv(undefined) + logger.debug({ requestId: '123' }, 'should be supressed') + expect(log).not.toHaveBeenCalled() + } + ) + }) +}) diff --git a/sdk/wallet-sdk/src/wallet/namespace/utils/hash/service.ts b/sdk/wallet-sdk/src/wallet/namespace/utils/hash/service.ts index 5ee59b905..a9ec80578 100644 --- a/sdk/wallet-sdk/src/wallet/namespace/utils/hash/service.ts +++ b/sdk/wallet-sdk/src/wallet/namespace/utils/hash/service.ts @@ -15,10 +15,17 @@ export class HashNamespace { this.encodePreparedTransaction = new PreparedTransactionEncoder(ctx) } + /** + * @deprecated use preparedTransaction + */ public async preparedTransacation(value: PreparedTransaction | string) { return await this.encodePreparedTransaction.hash(value) } + public async preparedTransaction(value: PreparedTransaction | string) { + return await this.encodePreparedTransaction.hash(value) + } + /** * * @param preparedTransactions list of prepared topology transactions diff --git a/sdk/wallet-sdk/src/wallet/namespace/utils/utils.test.ts b/sdk/wallet-sdk/src/wallet/namespace/utils/utils.test.ts new file mode 100644 index 000000000..c7ca0d303 --- /dev/null +++ b/sdk/wallet-sdk/src/wallet/namespace/utils/utils.test.ts @@ -0,0 +1,154 @@ +// Copyright (c) 2025-2026 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, it, vi, expect } from 'vitest' +import { parseAssets, ParsedURL } from './url.js' +import { SDKContext } from '../../sdk.js' +import { SDKLogger } from '../../logger/index.js' +import { SDKError, SDKErrorHandler } from '../../error/index.js' +import { SDKUtilsNamespace } from './index.js' + +const makeProvider = (overrides: Record = {}) => ({ + request: vi.fn().mockResolvedValue(undefined), + on: vi.fn(), + off: vi.fn(), + ...overrides, +}) + +const ctx: SDKContext = { + ledgerProvider: makeProvider(), + userId: 'ledger-api-user', + logger: new SDKLogger('console'), + error: new SDKErrorHandler(new SDKLogger('console')), + defaultSynchronizerId: 'synchronizerId', +} + +const amuletAsset = { + id: 'Amulet', + displayName: 'Amulet', + symbol: 'CC', + registryUrl: 'http://registry.com', + admin: 'adminParty:123', +} + +const testAsset = { + id: 'test', + displayName: 'test', + symbol: 'test', + registryUrl: 'http://registry.com', + admin: 'adminParty:123', +} + +const pingTx = + 'CoQGCgMyLjESATAa1QUKATDCPs4FCssFCgMyLjESQjAwODM2ZmE0NzAxNmQ4OWJmZWRlNjM5NzlhZDA0OTJkZWI3OGQ5Yzc4MDYzNWNiNDhlMTdhYWE5YjRlNGE2OTczOBoiY2FudG9uLWJ1aWx0aW4tYWRtaW4td29ya2Zsb3ctcGluZyJeCkBkZTJjYzJmOTBlYjUyMzQxNGZmNTRlODk5OTUxZGFkZDg3ODlhNGMwN2UwZjcxZjZkNmM5ZWFmNTdkNDEyYTU0EhRDYW50b24uSW50ZXJuYWwuUGluZxoEUGluZyrVAnLSAgpeCkBkZTJjYzJmOTBlYjUyMzQxNGZmNTRlODk5OTUxZGFkZDg3ODlhNGMwN2UwZjcxZjZkNmM5ZWFmNTdkNDEyYTU0EhRDYW50b24uSW50ZXJuYWwuUGluZxoEUGluZxIsCgJpZBImQiQwNGQ4ODdkZC0xNWI4LTQ5NTQtOGI3OS1hODUzODExY2U3Y2YSYAoJaW5pdGlhdG9yElM6UXYxLTAxLWFsaWNlOjoxMjIwZWUyNjI0MTkwODM0ZGRkZjI4ZjE5NDY4NWFjZWU4ZjAwY2JkZTllZTBkMjExZGFlMjc3ZjRkNzk5ODg0ZjI4ORJgCglyZXNwb25kZXISUzpRdjEtMDEtYWxpY2U6OjEyMjBlZTI2MjQxOTA4MzRkZGRmMjhmMTk0Njg1YWNlZThmMDBjYmRlOWVlMGQyMTFkYWUyNzdmNGQ3OTk4ODRmMjg5MlF2MS0wMS1hbGljZTo6MTIyMGVlMjYyNDE5MDgzNGRkZGYyOGYxOTQ2ODVhY2VlOGYwMGNiZGU5ZWUwZDIxMWRhZTI3N2Y0ZDc5OTg4NGYyODk6UXYxLTAxLWFsaWNlOjoxMjIwZWUyNjI0MTkwODM0ZGRkZjI4ZjE5NDY4NWFjZWU4ZjAwY2JkZTllZTBkMjExZGFlMjc3ZjRkNzk5ODg0ZjI4OSIiEiC8zzmuPyGPcD7usKyRMqdiVWlRskpjZ3ZiBoyD9maILhL/ARJ5ClF2MS0wMS1hbGljZTo6MTIyMGVlMjYyNDE5MDgzNGRkZGYyOGYxOTQ2ODVhY2VlOGYwMGNiZGU5ZWUwZDIxMWRhZTI3N2Y0ZDc5OTg4NGYyODkSJDUxMjFkMTIwLTNjNTMtNDEwMy04NmFhLTdhM2VhNjZlYzQ2NRpTZ2xvYmFsLWRvbWFpbjo6MTIyMGI5NTM5MWNkMDNlMTE4NzFkNTRlOTBjMjAyNTc1ZDI5ZTc2YzI2NWRkMzg4YjhhZmIwNWFhZDU0ZDY3MGRmYzYqJDhiOTBhNDQ4LTFmZmEtNDA0OS04NTNlLWY4YjRmZTg1M2RjMjCwqJjB5v+UAw==' +const topologyTx = [ + 'CvEBCAEQARrqAUrnAQpPdjEtMDEtYm9iOjoxMjIwMjdlYjczZGRiODBhYTY0MWE2ZDQwZjhjY2I2MWU0MzIyMjFjZjdlZTI3NTJlMzAxZGIzMWMxYzZmMGRhZGYzNRABGlUKUXBhcnRpY2lwYW50OjoxMjIwOWIxZDBkZDhiMjVlMjAwMmE0NTJiOTlkNGJjMGRlZmVhZDY0ZmQ3YTkyNWEzY2I1MGM3MDJhMDYxNTQyNzVhZBACMjsKNxAEGiwwKjAFBgMrZXADIQCQesJGtB8HQ/zNPGhX6gG/fnapj7qC1nSAoXEMIWR3mCoDAQUEMAEQARAe', +] + +const isBrowserEnv = typeof process === 'undefined' +describe('utils package', () => { + it('tests ParsedURL with string input', () => { + const urlAsString = 'http://registry.com/path' + const parsedUrlAsString = new ParsedURL(ctx, urlAsString) + expect(parsedUrlAsString.href).toBe('http://registry.com/path') + expect(parsedUrlAsString).toBeInstanceOf(URL) + }) + + it('tests ParsedURL with URL input', () => { + const urlAsString = new URL('http://registry.com/path') + const parsedUrlAsString = new ParsedURL(ctx, urlAsString) + expect(parsedUrlAsString.href).toBe('http://registry.com/path') + expect(parsedUrlAsString).toBeInstanceOf(URL) + }) + + it('tests ParsedURL with bad input', () => { + const urlAsString = 'registry.com' + + let thrownError + try { + new ParsedURL(ctx, urlAsString) + } catch (e) { + thrownError = e as SDKError + } + + expect(thrownError).toBeDefined() + expect(thrownError).toBeInstanceOf(SDKError) + if (thrownError) { + const err = thrownError as SDKError + expect(err.context.type).toBe('BadRequest') + expect(err.context.message).toBe( + 'Invalid URL provided registry.com.' + ) + } + }) + + it('parses assets with valid registry urls', () => { + const assets = [amuletAsset, testAsset] + const result = parseAssets(ctx, assets) + expect(result).toHaveLength(2) + result.forEach((r, i) => { + expect(r.registryUrl).toBeInstanceOf(ParsedURL) + expect(r.registryUrl.href).toBe(assets[i].registryUrl + '/') + expect(r.id).toBe(assets[i].id) + }) + }) + + it('throws error for bad asset registry url', () => { + const asset = { + id: 'test', + displayName: 'test', + symbol: 'test', + registryUrl: 'reg.com', + admin: 'adminParty:123', + } + + expect(() => parseAssets(ctx, [asset])).toThrow() + }) + + it('tests ping service', () => { + const utils = new SDKUtilsNamespace({ + logger: new SDKLogger('console'), + error: new SDKErrorHandler(new SDKLogger('console')), + }) + + const ping = utils.ping.create([ + { + initiator: 'alice::abc', + responder: 'bob::def', + id: 'c5977c20-5078-46b0-ad3d-eef9b27ec981', + }, + ]) + + expect(ping).toStrictEqual([ + { + CreateCommand: { + createArguments: { + id: 'c5977c20-5078-46b0-ad3d-eef9b27ec981', + initiator: 'alice::abc', + responder: 'bob::def', + }, + templateId: + '#canton-builtin-admin-workflow-ping:Canton.Internal.Ping:Ping', + }, + }, + ]) + }) + + it.skipIf(isBrowserEnv)('tests hash service', async () => { + const utils = new SDKUtilsNamespace({ + logger: new SDKLogger('console'), + error: new SDKErrorHandler(new SDKLogger('console')), + }) + + const preparedTxHash = ( + await utils.hash.preparedTransaction(pingTx) + ).toBase64() + const topologyHash = await utils.hash.topologyTransaction(topologyTx) + expect(topologyHash).toBe( + 'EiAuQ/LV6dYD1fIWldav2upEt/c9Wc0k3KbACxMMEBA5lw==' + ) + expect(preparedTxHash).toBe( + 'Bp2sK8iqD+0g0Qh9cgmPf0Kl7XtJs710fySuuzs3LcI=' + ) + }) +}) From c3b29d133b034b29930c05bf89869d7f872d1761 Mon Sep 17 00:00:00 2001 From: Allen Eubank Date: Fri, 12 Jun 2026 16:23:22 -0500 Subject: [PATCH 10/17] feat: deliver CIP-103 push events through DappSyncProvider (#1814) CIP-103 requires wallets to deliver txChanged / accountsChanged / statusChanged / connected through provider.on(event, listener). On the sync (postMessage) path no wire shape a wallet posts results in delivery: WindowTransport only installs per-request response listeners, so wallet-pushed events are silently dropped (canton-network/wallet#1815). dApps on the extension flow sign transactions that land on the ledger but never see the lifecycle events. The event envelope is the JSON-RPC 2.0 notification (spec section 4.1): the existing SPLICE_WALLET_REQUEST frame with no id, method = event name. Reusing the existing wire vocabulary means no new message type and no SpliceMessage union change. - core/rpc-transport: WindowTransport.onNotification installs a single always-on window listener that demuxes id-less SPLICE_WALLET_REQUEST frames. Requests submitted by the transport always carry a uuid id, so the dApp's own outbound frames stay out of the event path. Delivery honors the transport's target routing key (the announced provider's key from requestAnnouncedProviders), so multi-wallet pages do not cross-deliver events. The DOM listener is dropped with the last unsubscribe. - core/provider-dapp: DappSyncProvider bridges transport notifications to AbstractProvider.emit in its constructor. - Tests: envelope semantics (canonical notification delivered, non-canonical frames ignored) and announced-provider demux (targeted delivery, cross-wallet isolation, no echo of id-carrying requests). Signed-off-by: Allen --- ...appSyncProvider.notification-demux.test.ts | 74 +++++++++++++++++++ .../src/DappSyncProvider.push-events.test.ts | 73 ++++++++++++++++++ core/provider-dapp/src/DappSyncProvider.ts | 14 +++- core/rpc-transport/src/index.ts | 65 ++++++++++++++++ 4 files changed, 223 insertions(+), 3 deletions(-) create mode 100644 core/provider-dapp/src/DappSyncProvider.notification-demux.test.ts create mode 100644 core/provider-dapp/src/DappSyncProvider.push-events.test.ts diff --git a/core/provider-dapp/src/DappSyncProvider.notification-demux.test.ts b/core/provider-dapp/src/DappSyncProvider.notification-demux.test.ts new file mode 100644 index 000000000..52d1a622b --- /dev/null +++ b/core/provider-dapp/src/DappSyncProvider.notification-demux.test.ts @@ -0,0 +1,74 @@ +// Copyright (c) 2025-2026 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it } from 'vitest' +import { WalletEvent } from '@canton-network/core-types' +import { WindowTransport } from '@canton-network/core-rpc-transport' +import { DappSyncProvider } from './DappSyncProvider' + +// Companion to DappSyncProvider.push-events.test.ts: exercises the always-on +// notification demux against the *announced-provider* construction path. +// +// Discovery hands the dApp a routing key, not a provider object +// (requestAnnouncedProviders → AnnouncedProvider.target), and the SDK then +// builds DappSyncProvider(new WindowTransport(window, { target })). In that +// future every transport is targeted, so event delivery must honor the same +// target gating as extension detection — otherwise a page with two wallets +// cross-delivers one wallet's events to the other wallet's provider. +describe('DappSyncProvider notification demux (announced-provider path)', () => { + const flushMessageQueue = () => new Promise((r) => setTimeout(r, 100)) + + const notification = (target?: string) => ({ + type: WalletEvent.SPLICE_WALLET_REQUEST, + request: { + jsonrpc: '2.0', + method: 'txChanged', + params: { status: 'executed', commandId: 'cmd-demux-1' }, + }, + ...(target !== undefined ? { target } : {}), + }) + + it('delivers notifications stamped with the provider announced routing key', async () => { + const provider = new DappSyncProvider( + new WindowTransport(window, { target: 'wallet-a' }) + ) + const received: unknown[] = [] + provider.on('txChanged', (payload: unknown) => received.push(payload)) + + window.postMessage(notification('wallet-a'), '*') + await flushMessageQueue() + + expect(received).toHaveLength(1) + }) + + it('does not cross-deliver notifications addressed to another wallet', async () => { + const provider = new DappSyncProvider( + new WindowTransport(window, { target: 'wallet-a' }) + ) + const received: unknown[] = [] + provider.on('txChanged', (payload: unknown) => received.push(payload)) + + window.postMessage(notification('wallet-b'), '*') + // An unstamped frame is also undeliverable to a targeted provider: + // with multiple announced wallets there is no way to attribute it. + window.postMessage(notification(), '*') + await flushMessageQueue() + + expect(received).toHaveLength(0) + }) + + it('does not echo the dApp own id-carrying requests into the event path', async () => { + const provider = new DappSyncProvider() + const received: unknown[] = [] + provider.on('ping', (payload: unknown) => received.push(payload)) + + // request() posts an id-carrying SPLICE_WALLET_REQUEST into the same + // window the demux listens on; it must be treated as an outbound call, + // not a wallet notification. No wallet responds in this harness, so + // the returned promise intentionally never settles. + void provider.request({ method: 'ping' } as never) + await flushMessageQueue() + + expect(received).toHaveLength(0) + }) +}) diff --git a/core/provider-dapp/src/DappSyncProvider.push-events.test.ts b/core/provider-dapp/src/DappSyncProvider.push-events.test.ts new file mode 100644 index 000000000..a403e4808 --- /dev/null +++ b/core/provider-dapp/src/DappSyncProvider.push-events.test.ts @@ -0,0 +1,73 @@ +// Copyright (c) 2025-2026 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it } from 'vitest' +import { WalletEvent } from '@canton-network/core-types' +import { DappSyncProvider } from './DappSyncProvider' + +// Regression coverage for https://github.com/canton-network/wallet/issues/1815. +// +// The CIP-103 Provider API requires wallets to deliver txChanged / +// accountsChanged / statusChanged / connected through +// provider.on(event, listener). On the sync (postMessage) path the wire +// shape for a wallet-pushed event is a JSON-RPC 2.0 notification — the +// existing SPLICE_WALLET_REQUEST envelope with no `id`, method = event +// name. WindowTransport demuxes those frames on an always-on listener and +// DappSyncProvider bridges them to AbstractProvider.emit. +// +// Targeted (announced-provider) delivery is covered in +// DappSyncProvider.notification-demux.test.ts; this file pins the envelope +// semantics themselves. +describe('DappSyncProvider push events (CIP-103 Provider API)', () => { + const flushMessageQueue = () => new Promise((r) => setTimeout(r, 100)) + + // The canonical envelope: id-less request frame, method = event name. + // Reuses upstream's own wire vocabulary instead of introducing a new + // message type, per the JSON-RPC 2.0 notification definition (§4.1). + it('delivers events shaped as JSON-RPC notifications (id-less request envelope)', async () => { + const provider = new DappSyncProvider() + const received: unknown[] = [] + provider.on('txChanged', (payload: unknown) => received.push(payload)) + + window.postMessage( + { + type: WalletEvent.SPLICE_WALLET_REQUEST, + request: { + jsonrpc: '2.0', + method: 'txChanged', + params: { + status: 'executed', + commandId: 'cmd-push-1', + updateId: 'update-push-1', + }, + }, + }, + '*' + ) + await flushMessageQueue() + + expect(received).toHaveLength(1) + }) + + // The demux must not become a sink for arbitrary window traffic: only + // the canonical notification shape is delivered. A frame in any other + // envelope (e.g. a wallet-invented event message type) is ignored, so + // the provider's event surface stays bound to the specified wire shape. + it('does not deliver frames in non-canonical envelopes', async () => { + const provider = new DappSyncProvider() + const received: unknown[] = [] + provider.on('txChanged', (payload: unknown) => received.push(payload)) + + window.postMessage( + { + type: 'WALLET_EVENT', + event: 'txChanged', + payload: { status: 'executed', commandId: 'cmd-push-2' }, + }, + '*' + ) + await flushMessageQueue() + + expect(received).toHaveLength(0) + }) +}) diff --git a/core/provider-dapp/src/DappSyncProvider.ts b/core/provider-dapp/src/DappSyncProvider.ts index 9ea5aad8d..81597d990 100644 --- a/core/provider-dapp/src/DappSyncProvider.ts +++ b/core/provider-dapp/src/DappSyncProvider.ts @@ -16,9 +16,17 @@ export class DappSyncProvider extends AbstractProvider { constructor(transport?: RpcTransport) { super() - this.client = new SpliceWalletJSONRPCDAppAPI( - transport ?? new WindowTransport(window) - ) + const rpcTransport = transport ?? new WindowTransport(window) + this.client = new SpliceWalletJSONRPCDAppAPI(rpcTransport) + // CIP-103 events (txChanged, accountsChanged, statusChanged, + // connected) arrive as wallet-pushed notifications on the window + // transport; bridge them into the AbstractProvider listener map so + // provider.on(event, listener) delivers them as the CIP requires. + if (rpcTransport instanceof WindowTransport) { + rpcTransport.onNotification((method, params) => { + this.emit(method, params) + }) + } } public async request( diff --git a/core/rpc-transport/src/index.ts b/core/rpc-transport/src/index.ts index 69e86a65b..8a809035b 100644 --- a/core/rpc-transport/src/index.ts +++ b/core/rpc-transport/src/index.ts @@ -40,6 +40,12 @@ export interface RpcTransport { submit: (payload: RequestPayload) => Promise } +/** + * Handler for wallet-pushed JSON-RPC notifications (CIP-103 events such as + * txChanged / accountsChanged / statusChanged / connected). + */ +export type NotificationHandler = (method: string, params?: unknown) => void + export type WindowTransportOptions = { /** * Optional routing key for browser-extension messaging. When set, extensions @@ -49,11 +55,70 @@ export type WindowTransportOptions = { } export class WindowTransport implements RpcTransport { + private notificationHandlers = new Set() + private notificationListener: ((event: MessageEvent) => void) | undefined + constructor( private win: Window, private options: WindowTransportOptions = {} ) {} + /** + * Subscribe to wallet-pushed JSON-RPC notifications: SPLICE_WALLET_REQUEST + * frames with no `id` (JSON-RPC 2.0 §4.1). The window listener must be + * always-on for the transport's lifetime — CIP-103 events arrive between + * requests, where a per-request listener can never observe them. + */ + onNotification = (handler: NotificationHandler): (() => void) => { + this.notificationHandlers.add(handler) + if (!this.notificationListener) { + this.notificationListener = (event: MessageEvent) => { + if ( + !isSpliceMessageEvent(event) || + event.data.type !== WalletEvent.SPLICE_WALLET_REQUEST + ) { + return + } + // Requests submitted by this transport always carry a uuid id, + // so an id-less request can only be a wallet-originated + // notification — this also keeps the dApp's own outgoing + // frames (posted to the same window) out of the event path. + if (event.data.request.id != null) { + return + } + // Same gating as extension detection: a transport constructed + // from an announced provider's routing key only accepts frames + // stamped with that key, so multi-wallet pages do not + // cross-deliver events between providers. + if ( + this.options.target && + event.data.target !== this.options.target + ) { + return + } + const { method, params } = event.data.request + this.notificationHandlers.forEach((h) => h(method, params)) + } + this.win.addEventListener('message', this.notificationListener) + } + return () => { + this.notificationHandlers.delete(handler) + // Drop the DOM listener with the last subscriber so an abandoned + // transport leaves nothing attached to the window; a later + // subscribe re-installs it. + if ( + this.notificationHandlers.size === 0 && + this.notificationListener + ) { + this.win.removeEventListener( + 'message', + this.notificationListener + ) + this.notificationListener = undefined + } + } + } + submit = async (payload: RequestPayload) => { const message: SpliceMessage = { request: jsonRpcRequest(uuidv4(), payload), From 0934f4ef8ba89a8c4cee8c61f9d649317e92982f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 12 Jun 2026 17:44:35 -0400 Subject: [PATCH 11/17] chore(deps-dev): bump esbuild from 0.27.3 to 0.28.1 (#1996) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps [esbuild](https://github.com/evanw/esbuild) from 0.27.3 to 0.28.1. - [Release notes](https://github.com/evanw/esbuild/releases) - [Changelog](https://github.com/evanw/esbuild/blob/main/CHANGELOG.md) - [Commits](https://github.com/evanw/esbuild/compare/v0.27.3...v0.28.1) Signed-off-by: Mateusz Piątkowski --- updated-dependencies: - dependency-name: esbuild dependency-version: 0.28.1 dependency-type: direct:development ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- wallet-gateway/extension/package.json | 2 +- yarn.lock | 275 +++++++++++++++++++++++++- 2 files changed, 274 insertions(+), 3 deletions(-) diff --git a/wallet-gateway/extension/package.json b/wallet-gateway/extension/package.json index 9ca70adf6..530c492c5 100644 --- a/wallet-gateway/extension/package.json +++ b/wallet-gateway/extension/package.json @@ -16,7 +16,7 @@ "devDependencies": { "@types/node": "^25.3.3", "@types/webextension-polyfill": "^0.12.5", - "esbuild": "^0.27.3", + "esbuild": "^0.28.1", "tsx": "^4.21.0", "typescript": "^5.9.3", "web-ext": "^9.3.0" diff --git a/yarn.lock b/yarn.lock index 58648d5b0..67cee8b31 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2401,7 +2401,7 @@ __metadata: "@canton-network/core-wallet-ui-components": "workspace:^" "@types/node": "npm:^25.3.3" "@types/webextension-polyfill": "npm:^0.12.5" - esbuild: "npm:^0.27.3" + esbuild: "npm:^0.28.1" lit: "npm:^3.3.2" tsx: "npm:^4.21.0" typescript: "npm:^5.9.3" @@ -3014,6 +3014,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/aix-ppc64@npm:0.28.1": + version: 0.28.1 + resolution: "@esbuild/aix-ppc64@npm:0.28.1" + conditions: os=aix & cpu=ppc64 + languageName: node + linkType: hard + "@esbuild/android-arm64@npm:0.27.3": version: 0.27.3 resolution: "@esbuild/android-arm64@npm:0.27.3" @@ -3021,6 +3028,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/android-arm64@npm:0.28.1": + version: 0.28.1 + resolution: "@esbuild/android-arm64@npm:0.28.1" + conditions: os=android & cpu=arm64 + languageName: node + linkType: hard + "@esbuild/android-arm@npm:0.27.3": version: 0.27.3 resolution: "@esbuild/android-arm@npm:0.27.3" @@ -3028,6 +3042,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/android-arm@npm:0.28.1": + version: 0.28.1 + resolution: "@esbuild/android-arm@npm:0.28.1" + conditions: os=android & cpu=arm + languageName: node + linkType: hard + "@esbuild/android-x64@npm:0.27.3": version: 0.27.3 resolution: "@esbuild/android-x64@npm:0.27.3" @@ -3035,6 +3056,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/android-x64@npm:0.28.1": + version: 0.28.1 + resolution: "@esbuild/android-x64@npm:0.28.1" + conditions: os=android & cpu=x64 + languageName: node + linkType: hard + "@esbuild/darwin-arm64@npm:0.27.3": version: 0.27.3 resolution: "@esbuild/darwin-arm64@npm:0.27.3" @@ -3042,6 +3070,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/darwin-arm64@npm:0.28.1": + version: 0.28.1 + resolution: "@esbuild/darwin-arm64@npm:0.28.1" + conditions: os=darwin & cpu=arm64 + languageName: node + linkType: hard + "@esbuild/darwin-x64@npm:0.27.3": version: 0.27.3 resolution: "@esbuild/darwin-x64@npm:0.27.3" @@ -3049,6 +3084,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/darwin-x64@npm:0.28.1": + version: 0.28.1 + resolution: "@esbuild/darwin-x64@npm:0.28.1" + conditions: os=darwin & cpu=x64 + languageName: node + linkType: hard + "@esbuild/freebsd-arm64@npm:0.27.3": version: 0.27.3 resolution: "@esbuild/freebsd-arm64@npm:0.27.3" @@ -3056,6 +3098,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/freebsd-arm64@npm:0.28.1": + version: 0.28.1 + resolution: "@esbuild/freebsd-arm64@npm:0.28.1" + conditions: os=freebsd & cpu=arm64 + languageName: node + linkType: hard + "@esbuild/freebsd-x64@npm:0.27.3": version: 0.27.3 resolution: "@esbuild/freebsd-x64@npm:0.27.3" @@ -3063,6 +3112,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/freebsd-x64@npm:0.28.1": + version: 0.28.1 + resolution: "@esbuild/freebsd-x64@npm:0.28.1" + conditions: os=freebsd & cpu=x64 + languageName: node + linkType: hard + "@esbuild/linux-arm64@npm:0.27.3": version: 0.27.3 resolution: "@esbuild/linux-arm64@npm:0.27.3" @@ -3070,6 +3126,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/linux-arm64@npm:0.28.1": + version: 0.28.1 + resolution: "@esbuild/linux-arm64@npm:0.28.1" + conditions: os=linux & cpu=arm64 + languageName: node + linkType: hard + "@esbuild/linux-arm@npm:0.27.3": version: 0.27.3 resolution: "@esbuild/linux-arm@npm:0.27.3" @@ -3077,6 +3140,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/linux-arm@npm:0.28.1": + version: 0.28.1 + resolution: "@esbuild/linux-arm@npm:0.28.1" + conditions: os=linux & cpu=arm + languageName: node + linkType: hard + "@esbuild/linux-ia32@npm:0.27.3": version: 0.27.3 resolution: "@esbuild/linux-ia32@npm:0.27.3" @@ -3084,6 +3154,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/linux-ia32@npm:0.28.1": + version: 0.28.1 + resolution: "@esbuild/linux-ia32@npm:0.28.1" + conditions: os=linux & cpu=ia32 + languageName: node + linkType: hard + "@esbuild/linux-loong64@npm:0.27.3": version: 0.27.3 resolution: "@esbuild/linux-loong64@npm:0.27.3" @@ -3091,6 +3168,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/linux-loong64@npm:0.28.1": + version: 0.28.1 + resolution: "@esbuild/linux-loong64@npm:0.28.1" + conditions: os=linux & cpu=loong64 + languageName: node + linkType: hard + "@esbuild/linux-mips64el@npm:0.27.3": version: 0.27.3 resolution: "@esbuild/linux-mips64el@npm:0.27.3" @@ -3098,6 +3182,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/linux-mips64el@npm:0.28.1": + version: 0.28.1 + resolution: "@esbuild/linux-mips64el@npm:0.28.1" + conditions: os=linux & cpu=mips64el + languageName: node + linkType: hard + "@esbuild/linux-ppc64@npm:0.27.3": version: 0.27.3 resolution: "@esbuild/linux-ppc64@npm:0.27.3" @@ -3105,6 +3196,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/linux-ppc64@npm:0.28.1": + version: 0.28.1 + resolution: "@esbuild/linux-ppc64@npm:0.28.1" + conditions: os=linux & cpu=ppc64 + languageName: node + linkType: hard + "@esbuild/linux-riscv64@npm:0.27.3": version: 0.27.3 resolution: "@esbuild/linux-riscv64@npm:0.27.3" @@ -3112,6 +3210,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/linux-riscv64@npm:0.28.1": + version: 0.28.1 + resolution: "@esbuild/linux-riscv64@npm:0.28.1" + conditions: os=linux & cpu=riscv64 + languageName: node + linkType: hard + "@esbuild/linux-s390x@npm:0.27.3": version: 0.27.3 resolution: "@esbuild/linux-s390x@npm:0.27.3" @@ -3119,6 +3224,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/linux-s390x@npm:0.28.1": + version: 0.28.1 + resolution: "@esbuild/linux-s390x@npm:0.28.1" + conditions: os=linux & cpu=s390x + languageName: node + linkType: hard + "@esbuild/linux-x64@npm:0.27.3": version: 0.27.3 resolution: "@esbuild/linux-x64@npm:0.27.3" @@ -3126,6 +3238,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/linux-x64@npm:0.28.1": + version: 0.28.1 + resolution: "@esbuild/linux-x64@npm:0.28.1" + conditions: os=linux & cpu=x64 + languageName: node + linkType: hard + "@esbuild/netbsd-arm64@npm:0.27.3": version: 0.27.3 resolution: "@esbuild/netbsd-arm64@npm:0.27.3" @@ -3133,6 +3252,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/netbsd-arm64@npm:0.28.1": + version: 0.28.1 + resolution: "@esbuild/netbsd-arm64@npm:0.28.1" + conditions: os=netbsd & cpu=arm64 + languageName: node + linkType: hard + "@esbuild/netbsd-x64@npm:0.27.3": version: 0.27.3 resolution: "@esbuild/netbsd-x64@npm:0.27.3" @@ -3140,6 +3266,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/netbsd-x64@npm:0.28.1": + version: 0.28.1 + resolution: "@esbuild/netbsd-x64@npm:0.28.1" + conditions: os=netbsd & cpu=x64 + languageName: node + linkType: hard + "@esbuild/openbsd-arm64@npm:0.27.3": version: 0.27.3 resolution: "@esbuild/openbsd-arm64@npm:0.27.3" @@ -3147,6 +3280,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/openbsd-arm64@npm:0.28.1": + version: 0.28.1 + resolution: "@esbuild/openbsd-arm64@npm:0.28.1" + conditions: os=openbsd & cpu=arm64 + languageName: node + linkType: hard + "@esbuild/openbsd-x64@npm:0.27.3": version: 0.27.3 resolution: "@esbuild/openbsd-x64@npm:0.27.3" @@ -3154,6 +3294,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/openbsd-x64@npm:0.28.1": + version: 0.28.1 + resolution: "@esbuild/openbsd-x64@npm:0.28.1" + conditions: os=openbsd & cpu=x64 + languageName: node + linkType: hard + "@esbuild/openharmony-arm64@npm:0.27.3": version: 0.27.3 resolution: "@esbuild/openharmony-arm64@npm:0.27.3" @@ -3161,6 +3308,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/openharmony-arm64@npm:0.28.1": + version: 0.28.1 + resolution: "@esbuild/openharmony-arm64@npm:0.28.1" + conditions: os=openharmony & cpu=arm64 + languageName: node + linkType: hard + "@esbuild/sunos-x64@npm:0.27.3": version: 0.27.3 resolution: "@esbuild/sunos-x64@npm:0.27.3" @@ -3168,6 +3322,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/sunos-x64@npm:0.28.1": + version: 0.28.1 + resolution: "@esbuild/sunos-x64@npm:0.28.1" + conditions: os=sunos & cpu=x64 + languageName: node + linkType: hard + "@esbuild/win32-arm64@npm:0.27.3": version: 0.27.3 resolution: "@esbuild/win32-arm64@npm:0.27.3" @@ -3175,6 +3336,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/win32-arm64@npm:0.28.1": + version: 0.28.1 + resolution: "@esbuild/win32-arm64@npm:0.28.1" + conditions: os=win32 & cpu=arm64 + languageName: node + linkType: hard + "@esbuild/win32-ia32@npm:0.27.3": version: 0.27.3 resolution: "@esbuild/win32-ia32@npm:0.27.3" @@ -3182,6 +3350,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/win32-ia32@npm:0.28.1": + version: 0.28.1 + resolution: "@esbuild/win32-ia32@npm:0.28.1" + conditions: os=win32 & cpu=ia32 + languageName: node + linkType: hard + "@esbuild/win32-x64@npm:0.27.3": version: 0.27.3 resolution: "@esbuild/win32-x64@npm:0.27.3" @@ -3189,6 +3364,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/win32-x64@npm:0.28.1": + version: 0.28.1 + resolution: "@esbuild/win32-x64@npm:0.28.1" + conditions: os=win32 & cpu=x64 + languageName: node + linkType: hard + "@eslint-community/eslint-utils@npm:^4.8.0, @eslint-community/eslint-utils@npm:^4.9.1": version: 4.9.1 resolution: "@eslint-community/eslint-utils@npm:4.9.1" @@ -12869,7 +13051,7 @@ __metadata: languageName: node linkType: hard -"esbuild@npm:^0.18.0 || ^0.19.0 || ^0.20.0 || ^0.21.0 || ^0.22.0 || ^0.23.0 || ^0.24.0 || ^0.25.0 || ^0.26.0 || ^0.27.0, esbuild@npm:^0.27.0, esbuild@npm:^0.27.3, esbuild@npm:~0.27.0": +"esbuild@npm:^0.18.0 || ^0.19.0 || ^0.20.0 || ^0.21.0 || ^0.22.0 || ^0.23.0 || ^0.24.0 || ^0.25.0 || ^0.26.0 || ^0.27.0, esbuild@npm:^0.27.0, esbuild@npm:~0.27.0": version: 0.27.3 resolution: "esbuild@npm:0.27.3" dependencies: @@ -12958,6 +13140,95 @@ __metadata: languageName: node linkType: hard +"esbuild@npm:^0.28.1": + version: 0.28.1 + resolution: "esbuild@npm:0.28.1" + dependencies: + "@esbuild/aix-ppc64": "npm:0.28.1" + "@esbuild/android-arm": "npm:0.28.1" + "@esbuild/android-arm64": "npm:0.28.1" + "@esbuild/android-x64": "npm:0.28.1" + "@esbuild/darwin-arm64": "npm:0.28.1" + "@esbuild/darwin-x64": "npm:0.28.1" + "@esbuild/freebsd-arm64": "npm:0.28.1" + "@esbuild/freebsd-x64": "npm:0.28.1" + "@esbuild/linux-arm": "npm:0.28.1" + "@esbuild/linux-arm64": "npm:0.28.1" + "@esbuild/linux-ia32": "npm:0.28.1" + "@esbuild/linux-loong64": "npm:0.28.1" + "@esbuild/linux-mips64el": "npm:0.28.1" + "@esbuild/linux-ppc64": "npm:0.28.1" + "@esbuild/linux-riscv64": "npm:0.28.1" + "@esbuild/linux-s390x": "npm:0.28.1" + "@esbuild/linux-x64": "npm:0.28.1" + "@esbuild/netbsd-arm64": "npm:0.28.1" + "@esbuild/netbsd-x64": "npm:0.28.1" + "@esbuild/openbsd-arm64": "npm:0.28.1" + "@esbuild/openbsd-x64": "npm:0.28.1" + "@esbuild/openharmony-arm64": "npm:0.28.1" + "@esbuild/sunos-x64": "npm:0.28.1" + "@esbuild/win32-arm64": "npm:0.28.1" + "@esbuild/win32-ia32": "npm:0.28.1" + "@esbuild/win32-x64": "npm:0.28.1" + dependenciesMeta: + "@esbuild/aix-ppc64": + optional: true + "@esbuild/android-arm": + optional: true + "@esbuild/android-arm64": + optional: true + "@esbuild/android-x64": + optional: true + "@esbuild/darwin-arm64": + optional: true + "@esbuild/darwin-x64": + optional: true + "@esbuild/freebsd-arm64": + optional: true + "@esbuild/freebsd-x64": + optional: true + "@esbuild/linux-arm": + optional: true + "@esbuild/linux-arm64": + optional: true + "@esbuild/linux-ia32": + optional: true + "@esbuild/linux-loong64": + optional: true + "@esbuild/linux-mips64el": + optional: true + "@esbuild/linux-ppc64": + optional: true + "@esbuild/linux-riscv64": + optional: true + "@esbuild/linux-s390x": + optional: true + "@esbuild/linux-x64": + optional: true + "@esbuild/netbsd-arm64": + optional: true + "@esbuild/netbsd-x64": + optional: true + "@esbuild/openbsd-arm64": + optional: true + "@esbuild/openbsd-x64": + optional: true + "@esbuild/openharmony-arm64": + optional: true + "@esbuild/sunos-x64": + optional: true + "@esbuild/win32-arm64": + optional: true + "@esbuild/win32-ia32": + optional: true + "@esbuild/win32-x64": + optional: true + bin: + esbuild: bin/esbuild + checksum: 10c0/29cd456a79ce35ac2c7e05fe871330416b2c395c045d849653f843e51378d6e0d6e774d6dcd01b35f4e83238a29bf8decd04fcd34b3780c589a250b21e5f92bb + languageName: node + linkType: hard + "escalade@npm:3.2.0, escalade@npm:^3.1.1, escalade@npm:^3.2.0": version: 3.2.0 resolution: "escalade@npm:3.2.0" From 137352c94ff2db34e966af7736cea763bfada7b8 Mon Sep 17 00:00:00 2001 From: yanziwei <31891512+yanziwei@users.noreply.github.com> Date: Sat, 13 Jun 2026 06:46:22 +0800 Subject: [PATCH 12/17] Add wallet SDK npm metadata (#1966) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: 燕资伟 <> Signed-off-by: Mateusz Piątkowski --- sdk/wallet-sdk/package.json | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/sdk/wallet-sdk/package.json b/sdk/wallet-sdk/package.json index d99ad4feb..7478b2faa 100644 --- a/sdk/wallet-sdk/package.json +++ b/sdk/wallet-sdk/package.json @@ -69,5 +69,9 @@ "type": "git", "url": "git+https://github.com/canton-network/wallet.git", "directory": "sdk/wallet-sdk" + }, + "homepage": "https://github.com/canton-network/wallet/tree/main/sdk/wallet-sdk#readme", + "bugs": { + "url": "https://github.com/canton-network/wallet/issues" } } From b19a598a7645830dbcff534288a0c79c4537ddfb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Pi=C4=85tkowski?= Date: Mon, 15 Jun 2026 09:13:59 +0200 Subject: [PATCH 13/17] test(wallet-sdk): start writing tests for init SDK MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Mateusz Piątkowski --- .../src/wallet/init/__test__/init.test.ts | 6 ++++++ .../src/wallet/init/__test__/plugin.test.ts | 17 +++++++++++++++++ sdk/wallet-sdk/src/wallet/init/plugin.ts | 18 ++++++++++++++++-- 3 files changed, 39 insertions(+), 2 deletions(-) create mode 100644 sdk/wallet-sdk/src/wallet/init/__test__/init.test.ts create mode 100644 sdk/wallet-sdk/src/wallet/init/__test__/plugin.test.ts diff --git a/sdk/wallet-sdk/src/wallet/init/__test__/init.test.ts b/sdk/wallet-sdk/src/wallet/init/__test__/init.test.ts new file mode 100644 index 000000000..b4e6f643d --- /dev/null +++ b/sdk/wallet-sdk/src/wallet/init/__test__/init.test.ts @@ -0,0 +1,6 @@ +// Copyright (c) 2025-2026 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe } from 'vitest' + +describe('init SDK', () => {}) diff --git a/sdk/wallet-sdk/src/wallet/init/__test__/plugin.test.ts b/sdk/wallet-sdk/src/wallet/init/__test__/plugin.test.ts new file mode 100644 index 000000000..1b8b16c0c --- /dev/null +++ b/sdk/wallet-sdk/src/wallet/init/__test__/plugin.test.ts @@ -0,0 +1,17 @@ +// Copyright (c) 2025-2026 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { beforeEach, describe, it } from 'vitest' +// import { SDKPlugin } from '../' + +describe('plugin', () => { + // const TestPlugin = class extends SDKPlugin { + + // } + + beforeEach(() => {}) + + it('should throw error if used name for plugin is reserved', () => { + // const pluginWrongName = new TestPlugin() + }) +}) diff --git a/sdk/wallet-sdk/src/wallet/init/plugin.ts b/sdk/wallet-sdk/src/wallet/init/plugin.ts index c3d508438..cc63709c9 100644 --- a/sdk/wallet-sdk/src/wallet/init/plugin.ts +++ b/sdk/wallet-sdk/src/wallet/init/plugin.ts @@ -9,19 +9,33 @@ import { } from '../sdk.js' export abstract class SDKPlugin { + /** + * @deprecated use this.ctx.logger instead + */ protected readonly logger: ReturnType + protected readonly ctx: SDKContext constructor( public readonly name: string, - protected readonly ctx: SDKContext + protected readonly _ctx: SDKContext ) { if (EXTENDED_SDK_OPTION_KEYS.includes(name as keyof ExtendedSDKOptions)) throw Error( `Name ${name} is reserved and cannot be used to register the plugin. Reserved names: ${EXTENDED_SDK_OPTION_KEYS.join(', ')}.` ) - this.logger = ctx.logger.child({ + const logger = _ctx.logger.child({ plugin: name, }) + + /** + * @deprecated + */ + this.logger = logger + + this.ctx = { + ..._ctx, + logger, + } } } From 3d829a98beb9ebe92be760a9af28fa3602ad898f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Pi=C4=85tkowski?= Date: Mon, 15 Jun 2026 11:33:45 +0200 Subject: [PATCH 14/17] refactor(wallet-sdk): change type imports MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Mateusz Piątkowski --- .../src/wallet/init/__test__/plugin.test.ts | 15 ++++---- sdk/wallet-sdk/src/wallet/init/index.ts | 1 + sdk/wallet-sdk/src/wallet/init/plugin.ts | 10 +++--- .../src/wallet/init/types/context.ts | 19 ++++++++++ sdk/wallet-sdk/src/wallet/init/types/sdk.ts | 3 +- .../src/wallet/namespace/amulet/namespace.ts | 3 +- .../wallet/namespace/amulet/preapproval.ts | 35 ++++++++++--------- .../src/wallet/namespace/events/types.ts | 2 +- .../src/wallet/namespace/ledger/namespace.ts | 9 +++-- .../src/wallet/namespace/ledger/types.ts | 10 +++--- .../src/wallet/namespace/party/namespace.ts | 2 +- .../src/wallet/namespace/token/namespace.ts | 2 +- .../wallet/namespace/transactions/prepared.ts | 2 +- .../wallet/namespace/transactions/signed.ts | 2 +- .../wallet/namespace/transactions/types.ts | 4 +-- .../src/wallet/namespace/user/namespace.ts | 2 +- .../src/wallet/namespace/utils/index.ts | 2 +- .../src/wallet/namespace/utils/url.ts | 2 +- .../src/wallet/namespace/utils/utils.test.ts | 2 +- sdk/wallet-sdk/src/wallet/sdk.ts | 15 +------- 20 files changed, 77 insertions(+), 65 deletions(-) create mode 100644 sdk/wallet-sdk/src/wallet/init/types/context.ts diff --git a/sdk/wallet-sdk/src/wallet/init/__test__/plugin.test.ts b/sdk/wallet-sdk/src/wallet/init/__test__/plugin.test.ts index 1b8b16c0c..3671dae0c 100644 --- a/sdk/wallet-sdk/src/wallet/init/__test__/plugin.test.ts +++ b/sdk/wallet-sdk/src/wallet/init/__test__/plugin.test.ts @@ -1,17 +1,18 @@ // Copyright (c) 2025-2026 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import { beforeEach, describe, it } from 'vitest' -// import { SDKPlugin } from '../' +import { beforeEach, describe, expect, it } from 'vitest' +import { EXTENDED_SDK_OPTION_KEYS, SDKPlugin } from '../' +import { mock } from '../../__test__/mocks' describe('plugin', () => { - // const TestPlugin = class extends SDKPlugin { - - // } + const TestPlugin = class extends SDKPlugin {} beforeEach(() => {}) - it('should throw error if used name for plugin is reserved', () => { - // const pluginWrongName = new TestPlugin() + EXTENDED_SDK_OPTION_KEYS.forEach((key) => { + it(`should throw error if ${key} is used as a name`, () => { + expect(() => new TestPlugin(key, mock.ctx)).toThrow() + }) }) }) diff --git a/sdk/wallet-sdk/src/wallet/init/index.ts b/sdk/wallet-sdk/src/wallet/init/index.ts index 1d8ca6c87..42b15a69f 100644 --- a/sdk/wallet-sdk/src/wallet/init/index.ts +++ b/sdk/wallet-sdk/src/wallet/init/index.ts @@ -1,6 +1,7 @@ // Copyright (c) 2025-2026 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. // SPDX-License-Identifier: Apache-2.0 +export * from './types/context.js' export * from './initializedSDK.js' export * from './types/index.js' export { SDKPlugin } from './plugin.js' diff --git a/sdk/wallet-sdk/src/wallet/init/plugin.ts b/sdk/wallet-sdk/src/wallet/init/plugin.ts index cc63709c9..6c9412dac 100644 --- a/sdk/wallet-sdk/src/wallet/init/plugin.ts +++ b/sdk/wallet-sdk/src/wallet/init/plugin.ts @@ -2,14 +2,12 @@ // SPDX-License-Identifier: Apache-2.0 import { SDKLogger } from '../logger/index.js' -import { - EXTENDED_SDK_OPTION_KEYS, - ExtendedSDKOptions, - SDKContext, -} from '../sdk.js' +import { EXTENDED_SDK_OPTION_KEYS, ExtendedSDKOptions } from './types/sdk.js' +import type { SDKContext } from './types/context.js' export abstract class SDKPlugin { /** + * * @deprecated use this.ctx.logger instead */ protected readonly logger: ReturnType @@ -21,7 +19,7 @@ export abstract class SDKPlugin { ) { if (EXTENDED_SDK_OPTION_KEYS.includes(name as keyof ExtendedSDKOptions)) throw Error( - `Name ${name} is reserved and cannot be used to register the plugin. Reserved names: ${EXTENDED_SDK_OPTION_KEYS.join(', ')}.` + `Name "${name}" is reserved and cannot be used to register the plugin. Reserved names: ${EXTENDED_SDK_OPTION_KEYS.join(', ')}.` ) const logger = _ctx.logger.child({ diff --git a/sdk/wallet-sdk/src/wallet/init/types/context.ts b/sdk/wallet-sdk/src/wallet/init/types/context.ts new file mode 100644 index 000000000..44f64fad8 --- /dev/null +++ b/sdk/wallet-sdk/src/wallet/init/types/context.ts @@ -0,0 +1,19 @@ +// Copyright (c) 2025-2026 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { AbstractLedgerProvider } from '@canton-network/core-provider-ledger' +import { SDKLogger } from '../../logger/logger.js' +import { SDKErrorHandler } from '../../error/handler.js' + +export type SDKContext = { + ledgerProvider: AbstractLedgerProvider + userId: string + logger: SDKLogger + error: SDKErrorHandler + defaultSynchronizerId: string +} + +export type OfflineSDKContext = { + logger: SDKLogger + error: SDKErrorHandler +} diff --git a/sdk/wallet-sdk/src/wallet/init/types/sdk.ts b/sdk/wallet-sdk/src/wallet/init/types/sdk.ts index a3e4f0195..48044a95f 100644 --- a/sdk/wallet-sdk/src/wallet/init/types/sdk.ts +++ b/sdk/wallet-sdk/src/wallet/init/types/sdk.ts @@ -9,8 +9,9 @@ import { PartyNamespace } from '../../namespace/party/index.js' import { UserNamespace } from '../../namespace/user/index.js' import { SDKUtilsNamespace } from '../../namespace/utils/index.js' import { AmuletNamespace } from '../../namespace/amulet/namespace.js' -import { AssetNamespace, SDKContext, TokenNamespace } from '../../sdk.js' +import type { AssetNamespace, TokenNamespace } from '../../sdk.js' import { EventsNamespace } from '../../namespace/events/namespace.js' +import type { SDKContext } from './context.js' import { AmuletConfig, AssetConfig, diff --git a/sdk/wallet-sdk/src/wallet/namespace/amulet/namespace.ts b/sdk/wallet-sdk/src/wallet/namespace/amulet/namespace.ts index 88a3a8d4b..a16f1ba6d 100644 --- a/sdk/wallet-sdk/src/wallet/namespace/amulet/namespace.ts +++ b/sdk/wallet-sdk/src/wallet/namespace/amulet/namespace.ts @@ -2,7 +2,8 @@ // SPDX-License-Identifier: Apache-2.0 import { PartyId } from '@canton-network/core-types' -import { AssetBody, SDKContext } from '../../sdk.js' +import type { AssetBody } from '../../sdk.js' +import type { SDKContext } from '../../init/types/context.js' import { PreparedCommand } from '../transactions/types.js' import { FeaturedAppRight, diff --git a/sdk/wallet-sdk/src/wallet/namespace/amulet/preapproval.ts b/sdk/wallet-sdk/src/wallet/namespace/amulet/preapproval.ts index fe31ed9a0..df386dbd6 100644 --- a/sdk/wallet-sdk/src/wallet/namespace/amulet/preapproval.ts +++ b/sdk/wallet-sdk/src/wallet/namespace/amulet/preapproval.ts @@ -2,7 +2,8 @@ // SPDX-License-Identifier: Apache-2.0 import { PartyId } from '@canton-network/core-types' -import { AmuletNamespaceConfig, LedgerTypes } from '../../sdk.js' +import type { AmuletNamespaceConfig } from '../../sdk.js' +import type { LedgerCommonSchemas } from '@canton-network/core-ledger-client-types' import { PreapprovalParties } from './types.js' import { LedgerNamespace } from '../ledger/namespace.js' import { fetchAmulet } from './namespace.js' @@ -17,14 +18,14 @@ export class PreapprovalNamespace { */ public readonly command: { create: (args: { parties: PreapprovalParties }) => Promise<{ - CreateCommand: LedgerTypes['CreateCommand'] + CreateCommand: LedgerCommonSchemas['CreateCommand'] }> cancel: (args: { parties: PreapprovalParties }) => Promise< | [ - { ExerciseCommand: LedgerTypes['ExerciseCommand'] }, - LedgerTypes['DisclosedContract'][], + { ExerciseCommand: LedgerCommonSchemas['ExerciseCommand'] }, + LedgerCommonSchemas['DisclosedContract'][], ] | typeof EMPTY_COMMAND_RESULT > @@ -43,20 +44,20 @@ export class PreapprovalNamespace { const amulet = await fetchAmulet(this.ctx) - const command: { CreateCommand: LedgerTypes['CreateCommand'] } = - { - CreateCommand: { - templateId: - '#splice-wallet:Splice.Wallet.TransferPreapproval:TransferPreapprovalProposal', - createArguments: { - provider: - parties?.provider ?? - this.ctx.validatorParty, - receiver: parties.receiver, - expectedDso: amulet.admin, - }, + const command: { + CreateCommand: LedgerCommonSchemas['CreateCommand'] + } = { + CreateCommand: { + templateId: + '#splice-wallet:Splice.Wallet.TransferPreapproval:TransferPreapprovalProposal', + createArguments: { + provider: + parties?.provider ?? this.ctx.validatorParty, + receiver: parties.receiver, + expectedDso: amulet.admin, }, - } + }, + } return command }, diff --git a/sdk/wallet-sdk/src/wallet/namespace/events/types.ts b/sdk/wallet-sdk/src/wallet/namespace/events/types.ts index a32ce56c8..4f889f6c9 100644 --- a/sdk/wallet-sdk/src/wallet/namespace/events/types.ts +++ b/sdk/wallet-sdk/src/wallet/namespace/events/types.ts @@ -4,7 +4,7 @@ import { AuthTokenProvider } from '@canton-network/core-wallet-auth' import { PartyId } from '@canton-network/core-types' import { type LedgerCommonSchemas } from '@canton-network/core-ledger-client-types' -import { SDKContext } from '../../sdk.js' +import type { SDKContext } from '../../init/types/context.js' import { ParsedURL } from '../utils/url.js' export type UpdatesOptions = { diff --git a/sdk/wallet-sdk/src/wallet/namespace/ledger/namespace.ts b/sdk/wallet-sdk/src/wallet/namespace/ledger/namespace.ts index 43ae6c662..f8cb4531e 100644 --- a/sdk/wallet-sdk/src/wallet/namespace/ledger/namespace.ts +++ b/sdk/wallet-sdk/src/wallet/namespace/ledger/namespace.ts @@ -1,7 +1,8 @@ // Copyright (c) 2025-2026 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import { LedgerTypes, SDKContext } from '../../sdk.js' +import type { LedgerCommonSchemas } from '@canton-network/core-ledger-client-types' +import type { SDKContext } from '../../init/types/context.js' import { v4 } from 'uuid' import { PrepareOptions, ExecuteOptions, AcsRequestOptions } from './types.js' import { PreparedTransaction } from '../transactions/prepared.js' @@ -181,7 +182,9 @@ export class LedgerNamespace { */ readRaw: async ( options: AcsRequestOptions - ): Promise> => { + ): Promise< + Array + > => { const resolvedOptions = await this.resolveAcsOptions(options) this.sdkContext.logger.debug( @@ -207,7 +210,7 @@ export class LedgerNamespace { .map((acs) => { const jsActiveContract = ( acs.contractEntry as { - JsActiveContract: LedgerTypes['JsActiveContract'] + JsActiveContract: LedgerCommonSchemas['JsActiveContract'] } ).JsActiveContract diff --git a/sdk/wallet-sdk/src/wallet/namespace/ledger/types.ts b/sdk/wallet-sdk/src/wallet/namespace/ledger/types.ts index 9f4c9c3ff..f1e5eb3ff 100644 --- a/sdk/wallet-sdk/src/wallet/namespace/ledger/types.ts +++ b/sdk/wallet-sdk/src/wallet/namespace/ledger/types.ts @@ -2,7 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 import { PartyId } from '@canton-network/core-types' -import { LedgerTypes } from '../../sdk.js' +import type { LedgerCommonSchemas } from '@canton-network/core-ledger-client-types' import { AcsOptions } from '@canton-network/core-acs-reader' export type PrepareOptions = { @@ -10,7 +10,7 @@ export type PrepareOptions = { commands: WrappedCommand | WrappedCommand[] | unknown commandId?: string synchronizerId?: string - disclosedContracts?: LedgerTypes['DisclosedContract'][] + disclosedContracts?: LedgerCommonSchemas['DisclosedContract'][] } export type ExecuteOptions = { @@ -19,9 +19,9 @@ export type ExecuteOptions = { } export type RawCommandMap = { - ExerciseCommand: LedgerTypes['ExerciseCommand'] - CreateCommand: LedgerTypes['CreateCommand'] - CreateAndExerciseCommand: LedgerTypes['CreateAndExerciseCommand'] + ExerciseCommand: LedgerCommonSchemas['ExerciseCommand'] + CreateCommand: LedgerCommonSchemas['CreateCommand'] + CreateAndExerciseCommand: LedgerCommonSchemas['CreateAndExerciseCommand'] } export type WrappedCommand< K extends keyof RawCommandMap = keyof RawCommandMap, diff --git a/sdk/wallet-sdk/src/wallet/namespace/party/namespace.ts b/sdk/wallet-sdk/src/wallet/namespace/party/namespace.ts index f1b5008e7..3aa4972e0 100644 --- a/sdk/wallet-sdk/src/wallet/namespace/party/namespace.ts +++ b/sdk/wallet-sdk/src/wallet/namespace/party/namespace.ts @@ -4,7 +4,7 @@ import { PartyId } from '@canton-network/core-types' import { ExternalPartyNamespace } from './external/index.js' import { Ops } from '@canton-network/core-provider-ledger' -import { SDKContext } from '../../sdk.js' +import type { SDKContext } from '../../init/types/context.js' import { InternalPartyNamespace } from './index.js' import { SDKUtilsNamespace } from '../utils/index.js' diff --git a/sdk/wallet-sdk/src/wallet/namespace/token/namespace.ts b/sdk/wallet-sdk/src/wallet/namespace/token/namespace.ts index 87ed0d8c4..a746af701 100644 --- a/sdk/wallet-sdk/src/wallet/namespace/token/namespace.ts +++ b/sdk/wallet-sdk/src/wallet/namespace/token/namespace.ts @@ -7,7 +7,7 @@ import { TransferNamespace } from './transfer/index.js' import { TokenStandardService } from '@canton-network/core-token-standard-service' import { PartyId } from '@canton-network/core-types' import { PrettyTransactions } from '@canton-network/core-tx-parser' -import { SDKContext } from '../../sdk.js' +import type { SDKContext } from '../../init/types/context.js' import { ParsedURL } from '../utils/url.js' export type TokenNamespaceConfig = { diff --git a/sdk/wallet-sdk/src/wallet/namespace/transactions/prepared.ts b/sdk/wallet-sdk/src/wallet/namespace/transactions/prepared.ts index 970cfcaf3..744c5a4d2 100644 --- a/sdk/wallet-sdk/src/wallet/namespace/transactions/prepared.ts +++ b/sdk/wallet-sdk/src/wallet/namespace/transactions/prepared.ts @@ -6,7 +6,7 @@ import { signTransactionHash, } from '@canton-network/core-signing-lib' import { SignedTransaction } from './signed.js' -import { SDKContext } from '../../sdk.js' +import type { SDKContext } from '../../init/types/context.js' import { Ops } from '@canton-network/core-provider-ledger' import { decodePreparedTransaction } from '@canton-network/core-tx-visualizer' import { LedgerNamespace } from '../ledger/index.js' diff --git a/sdk/wallet-sdk/src/wallet/namespace/transactions/signed.ts b/sdk/wallet-sdk/src/wallet/namespace/transactions/signed.ts index 5c09a3407..474034fcf 100644 --- a/sdk/wallet-sdk/src/wallet/namespace/transactions/signed.ts +++ b/sdk/wallet-sdk/src/wallet/namespace/transactions/signed.ts @@ -2,7 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 import { Ops } from '@canton-network/core-provider-ledger' -import { SDKContext } from '../../sdk.js' +import type { SDKContext } from '../../init/types/context.js' import { ExecuteOptions } from '../ledger/types.js' import { LedgerNamespace } from '../ledger/index.js' diff --git a/sdk/wallet-sdk/src/wallet/namespace/transactions/types.ts b/sdk/wallet-sdk/src/wallet/namespace/transactions/types.ts index 2830db524..520baa256 100644 --- a/sdk/wallet-sdk/src/wallet/namespace/transactions/types.ts +++ b/sdk/wallet-sdk/src/wallet/namespace/transactions/types.ts @@ -1,7 +1,7 @@ // Copyright (c) 2025-2026 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import { LedgerTypes } from '../../sdk.js' +import type { LedgerCommonSchemas } from '@canton-network/core-ledger-client-types' import { RawCommandMap, WrappedCommand } from '../ledger/index.js' export type PreparedCommand< @@ -15,5 +15,5 @@ export type PreparedCommand< : K extends keyof RawCommandMap ? WrappedCommand : never, - LedgerTypes['DisclosedContract'][], + LedgerCommonSchemas['DisclosedContract'][], ] diff --git a/sdk/wallet-sdk/src/wallet/namespace/user/namespace.ts b/sdk/wallet-sdk/src/wallet/namespace/user/namespace.ts index 9ac6c4d40..b0e2dd439 100644 --- a/sdk/wallet-sdk/src/wallet/namespace/user/namespace.ts +++ b/sdk/wallet-sdk/src/wallet/namespace/user/namespace.ts @@ -1,7 +1,7 @@ // Copyright (c) 2025-2026 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import { SDKContext } from '../../sdk.js' +import type { SDKContext } from '../../init/types/context.js' import { SDKLogger } from '../../logger/logger.js' import { CreateUserParams, GrantOrRevokeRightsParams } from './types.js' diff --git a/sdk/wallet-sdk/src/wallet/namespace/utils/index.ts b/sdk/wallet-sdk/src/wallet/namespace/utils/index.ts index 24670eb48..59ff8a011 100644 --- a/sdk/wallet-sdk/src/wallet/namespace/utils/index.ts +++ b/sdk/wallet-sdk/src/wallet/namespace/utils/index.ts @@ -4,7 +4,7 @@ import { HashNamespace } from './hash/service.js' import { PingService } from './ping/index.js' -import { OfflineSDKContext } from '../../sdk.js' +import type { OfflineSDKContext } from '../../init/types/context.js' export class SDKUtilsNamespace { public readonly ping: PingService diff --git a/sdk/wallet-sdk/src/wallet/namespace/utils/url.ts b/sdk/wallet-sdk/src/wallet/namespace/utils/url.ts index 9ee1c50be..e1ee8a8b9 100644 --- a/sdk/wallet-sdk/src/wallet/namespace/utils/url.ts +++ b/sdk/wallet-sdk/src/wallet/namespace/utils/url.ts @@ -1,7 +1,7 @@ // Copyright (c) 2025-2026 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import { SDKContext } from '../../sdk.js' +import type { SDKContext } from '../../init/types/context.js' import { TokenStandardService } from '@canton-network/core-token-standard-service' export type URLInput = URL | string diff --git a/sdk/wallet-sdk/src/wallet/namespace/utils/utils.test.ts b/sdk/wallet-sdk/src/wallet/namespace/utils/utils.test.ts index c7ca0d303..a9efa3613 100644 --- a/sdk/wallet-sdk/src/wallet/namespace/utils/utils.test.ts +++ b/sdk/wallet-sdk/src/wallet/namespace/utils/utils.test.ts @@ -3,7 +3,7 @@ import { describe, it, vi, expect } from 'vitest' import { parseAssets, ParsedURL } from './url.js' -import { SDKContext } from '../../sdk.js' +import type { SDKContext } from '../../init/types/context.js' import { SDKLogger } from '../../logger/index.js' import { SDKError, SDKErrorHandler } from '../../error/index.js' import { SDKUtilsNamespace } from './index.js' diff --git a/sdk/wallet-sdk/src/wallet/sdk.ts b/sdk/wallet-sdk/src/wallet/sdk.ts index c7a63e07c..21ad79c3c 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 { SDKContext } from './index.js' export * from './namespace/asset/index.js' export type * from './namespace/token/index.js' export type * from './namespace/amulet/index.js' @@ -44,21 +45,7 @@ export { } from '@canton-network/core-signing-lib' export type LedgerTypes = LedgerCommonSchemas -export type SDKContext = { - ledgerProvider: AbstractLedgerProvider - userId: string - logger: SDKLogger - error: SDKErrorHandler - defaultSynchronizerId: string -} - -export type OfflineSDKContext = { - logger: SDKLogger - error: SDKErrorHandler -} - 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' From 1db652af7f02479a6719a5e70e6ed7f20ff7564b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Pi=C4=85tkowski?= Date: Mon, 15 Jun 2026 12:09:52 +0200 Subject: [PATCH 15/17] test(wallet-sdk): add tests for plugin system MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Mateusz Piątkowski --- sdk/wallet-sdk/src/wallet/__test__/mocks.ts | 11 ++- .../src/wallet/init/__test__/plugin.test.ts | 82 +++++++++++++++++-- 2 files changed, 87 insertions(+), 6 deletions(-) diff --git a/sdk/wallet-sdk/src/wallet/__test__/mocks.ts b/sdk/wallet-sdk/src/wallet/__test__/mocks.ts index 80f07d847..4d8b7f227 100644 --- a/sdk/wallet-sdk/src/wallet/__test__/mocks.ts +++ b/sdk/wallet-sdk/src/wallet/__test__/mocks.ts @@ -2,7 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 import { MockedObject, vi } from 'vitest' -import { SDKContext } from '../sdk.js' +import { BasicSDKOptions, SDKContext } from '../sdk.js' import { SDKLogger } from '../logger/logger.js' import { SDKErrorHandler } from '../error/handler.js' @@ -34,8 +34,17 @@ const ctx: SDKContext = { defaultSynchronizerId: '', } +const basicSDKOptions: BasicSDKOptions = { + auth: { + method: 'static', + token: 'token', + }, + ledgerClientUrl: 'http://example.com', +} + export const mock = { ledgerProvider, mockLogger, ctx, + basicSDKOptions, } diff --git a/sdk/wallet-sdk/src/wallet/init/__test__/plugin.test.ts b/sdk/wallet-sdk/src/wallet/init/__test__/plugin.test.ts index 3671dae0c..a159ae9e6 100644 --- a/sdk/wallet-sdk/src/wallet/init/__test__/plugin.test.ts +++ b/sdk/wallet-sdk/src/wallet/init/__test__/plugin.test.ts @@ -1,18 +1,90 @@ // Copyright (c) 2025-2026 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import { beforeEach, describe, expect, it } from 'vitest' +import { beforeEach, describe, expect, it, vi } from 'vitest' import { EXTENDED_SDK_OPTION_KEYS, SDKPlugin } from '../' import { mock } from '../../__test__/mocks' +import { SDK, SDKContext } from '../..' -describe('plugin', () => { - const TestPlugin = class extends SDKPlugin {} +const testPluginFactory = (key: string) => { + return vi.fn( + class extends SDKPlugin { + constructor(ctx: SDKContext) { + super(key, ctx) + } + } + ) +} + +const pluginName = 'pluginName' + +class TestPlugin extends SDKPlugin { + constructor(ctx: SDKContext) { + super(pluginName, ctx) + } - beforeEach(() => {}) + public testMethod() { + return true + } +} + +describe('plugin', () => { + beforeEach(() => { + vi.clearAllMocks() + }) EXTENDED_SDK_OPTION_KEYS.forEach((key) => { it(`should throw error if ${key} is used as a name`, () => { - expect(() => new TestPlugin(key, mock.ctx)).toThrow() + expect(() => new (testPluginFactory(key))(mock.ctx)).toThrow() }) }) + + it('should call a plugin constructor when registering', async () => { + // Mock the authenticated user response + mock.ledgerProvider.request + .mockResolvedValueOnce({ + user: { id: 'test-user-id' }, + }) + // Mock the connected synchronizers response + .mockResolvedValueOnce({ + connectedSynchronizers: [{ id: 'sync-1' }], + }) + + const sdk = await SDK.create({ + ledgerProvider: mock.ledgerProvider as never, + }) + + const PluginClass = testPluginFactory('plugin') + + const SDKWithPlugin = sdk.registerPlugins({ + plugin: PluginClass, + }) + + expect(SDKWithPlugin.plugin).toBeInstanceOf(PluginClass) + expect(PluginClass).toHaveBeenCalledOnce() + }) + + it('should successfully register a plugin under provided name', async () => { + // Mock the authenticated user response + mock.ledgerProvider.request + .mockResolvedValueOnce({ + user: { id: 'test-user-id' }, + }) + // Mock the connected synchronizers response + .mockResolvedValueOnce({ + connectedSynchronizers: [{ id: 'sync-1' }], + }) + + const sdk = await SDK.create({ + ledgerProvider: mock.ledgerProvider as never, + }) + const SDKWithPlugin = sdk.registerPlugins({ + [pluginName]: TestPlugin, + }) + + const registeredPlugin = SDKWithPlugin[pluginName] + + expect(registeredPlugin).toBeDefined() + expect(registeredPlugin.testMethod()).toBe(true) + }) }) From 6de11ad5143d981f46e9d952516f36c817c598b8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Pi=C4=85tkowski?= Date: Tue, 16 Jun 2026 10:29:08 +0200 Subject: [PATCH 16/17] test(wallet-sdk): finalize init sdk unit tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Mateusz Piątkowski --- sdk/wallet-sdk/src/wallet/__test__/mocks.ts | 58 ++++-- .../src/wallet/init/__test__/init.test.ts | 170 +++++++++++++++++- .../src/wallet/init/__test__/plugin.test.ts | 2 +- .../src/wallet/namespace/party/party.test.ts | 2 +- sdk/wallet-sdk/src/wallet/sdk.ts | 3 +- 5 files changed, 215 insertions(+), 20 deletions(-) diff --git a/sdk/wallet-sdk/src/wallet/__test__/mocks.ts b/sdk/wallet-sdk/src/wallet/__test__/mocks.ts index 4d8b7f227..d1d62f46e 100644 --- a/sdk/wallet-sdk/src/wallet/__test__/mocks.ts +++ b/sdk/wallet-sdk/src/wallet/__test__/mocks.ts @@ -2,15 +2,25 @@ // SPDX-License-Identifier: Apache-2.0 import { MockedObject, vi } from 'vitest' -import { BasicSDKOptions, SDKContext } from '../sdk.js' +import { + AmuletConfig, + AssetConfig, + BasicSDKOptions, + EventsConfig, + SDKContext, + TokenConfig, + TokenProviderConfig, +} from '../sdk.js' import { SDKLogger } from '../logger/logger.js' import { SDKErrorHandler } from '../error/handler.js' -const ledgerProvider = { +const exampleLink = 'http://example.com' + +export const ledgerProvider = { request: vi.fn().mockResolvedValue(undefined), } -const mockLogger = { +export const mockLogger = { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), @@ -26,7 +36,7 @@ const mockErrorHandler = new SDKErrorHandler(mockLogger) const throwSpy = vi.spyOn(mockErrorHandler, 'throw') throwSpy.mockImplementation(vi.fn() as never) -const ctx: SDKContext = { +export const ctx: SDKContext = { ledgerProvider, userId: 'userId', logger: mockLogger, @@ -34,17 +44,35 @@ const ctx: SDKContext = { defaultSynchronizerId: '', } -const basicSDKOptions: BasicSDKOptions = { - auth: { - method: 'static', - token: 'token', - }, - ledgerClientUrl: 'http://example.com', +export const tokenProviderConfig: TokenProviderConfig = { + method: 'static', + token: 'token', } -export const mock = { - ledgerProvider, - mockLogger, - ctx, - basicSDKOptions, +export const basicSDKOptions: BasicSDKOptions = { + auth: tokenProviderConfig, + ledgerClientUrl: exampleLink, +} + +export const amuletConfig: AmuletConfig = { + validatorUrl: exampleLink, + scanApiUrl: exampleLink, + auth: tokenProviderConfig, + registryUrl: exampleLink, +} + +export const tokenConfig: TokenConfig = { + validatorUrl: exampleLink, + auth: tokenProviderConfig, + registries: [exampleLink, exampleLink], +} + +export const assetConfig: AssetConfig = { + auth: tokenProviderConfig, + registries: [exampleLink, exampleLink], +} + +export const eventsConfig: EventsConfig = { + websocketURL: exampleLink, + auth: tokenProviderConfig, } diff --git a/sdk/wallet-sdk/src/wallet/init/__test__/init.test.ts b/sdk/wallet-sdk/src/wallet/init/__test__/init.test.ts index b4e6f643d..dedce1e69 100644 --- a/sdk/wallet-sdk/src/wallet/init/__test__/init.test.ts +++ b/sdk/wallet-sdk/src/wallet/init/__test__/init.test.ts @@ -1,6 +1,172 @@ // Copyright (c) 2025-2026 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import { describe } from 'vitest' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import * as mock from '../../__test__/mocks' +import { + InitializedSDK, + OfflineInitializedSDK, + ExtendedInitializedSDK, +} from '../..' +import { KeysNamespace } from '../../namespace/keys' +import { SDKUtilsNamespace } from '../../namespace/utils' +import { LedgerNamespace } from '../../namespace/ledger' +import { PartyNamespace } from '../../namespace/party' +import { UserNamespace } from '../../namespace/user' +import { AmuletNamespace } from '../../namespace/amulet' +import { AssetNamespace } from '../../namespace/asset' +import { EventsNamespace } from '../../namespace/events' +import { TokenNamespace } from '../../namespace/token' -describe('init SDK', () => {}) +const { + ValidatorInternalClient, + get, + TokenStandardService, + AmuletService, + ScanProxyClient, + ScanClient, +} = vi.hoisted(() => { + const get = vi.fn().mockImplementation(() => + Promise.resolve({ + party_id: 'partyId', + }) + ) + + const registriesToAssets = vi.fn().mockResolvedValue([]) + + return { + ValidatorInternalClient: vi.fn( + class { + get = get + } + ), + get, + TokenStandardService: vi.fn( + class { + registriesToAssets = registriesToAssets + } + ), + AmuletService: vi.fn(class {}), + ScanProxyClient: vi.fn(class {}), + ScanClient: vi.fn(class {}), + } +}) + +vi.mock('@canton-network/core-splice-client', async (importOriginal) => { + const actual = + await importOriginal< + typeof import('@canton-network/core-splice-client') + >() + return { + ...actual, + ValidatorInternalClient, + ScanProxyClient, + ScanClient, + } +}) + +vi.mock('@canton-network/core-token-standard-service', () => ({ + TokenStandardService, +})) + +vi.mock('@canton-network/core-amulet-service', () => ({ + AmuletService, +})) + +describe('init SDK', () => { + describe('offline', () => { + let sdk: OfflineInitializedSDK + beforeEach(() => { + vi.clearAllMocks() + + sdk = new OfflineInitializedSDK(mock.ctx) + }) + + it('should expose offline interface', () => { + expect(sdk.keys).toBeInstanceOf(KeysNamespace) + expect(sdk.utils).toBeInstanceOf(SDKUtilsNamespace) + }) + }) + + describe('basic', () => { + let sdk: InitializedSDK + beforeEach(() => { + vi.clearAllMocks() + + sdk = new InitializedSDK(mock.ctx) + }) + + it('should expose basic interface', () => { + sdk = new InitializedSDK(mock.ctx) + + // OfflineSDKInterface + expect(sdk.keys).toBeInstanceOf(KeysNamespace) + expect(sdk.utils).toBeInstanceOf(SDKUtilsNamespace) + + // BasicSDKInterface + expect(sdk.ledger).toBeInstanceOf(LedgerNamespace) + expect(sdk.party).toBeInstanceOf(PartyNamespace) + expect(sdk.user).toBeInstanceOf(UserNamespace) + expect(sdk.registerPlugins).toBeDefined() + }) + }) + + describe('extended', () => { + beforeEach(async () => { + vi.clearAllMocks() + }) + + it('should expose extended interface', async () => { + const sdk = await ExtendedInitializedSDK.create(mock.ctx, { + amulet: mock.amuletConfig, + asset: mock.assetConfig, + events: mock.eventsConfig, + token: mock.tokenConfig, + }) + + // OfflineSDKInterface + expect(sdk.keys).toBeInstanceOf(KeysNamespace) + expect(sdk.utils).toBeInstanceOf(SDKUtilsNamespace) + + // BasicSDKInterface + expect(sdk.ledger).toBeInstanceOf(LedgerNamespace) + expect(sdk.party).toBeInstanceOf(PartyNamespace) + expect(sdk.user).toBeInstanceOf(UserNamespace) + expect(sdk.registerPlugins).toBeDefined() + + // ExtendedInitializedSDK + expect(sdk.amulet).toBeInstanceOf(AmuletNamespace) + expect(sdk.asset).toBeInstanceOf(AssetNamespace) + expect(sdk.events).toBeInstanceOf(EventsNamespace) + expect(sdk.token).toBeInstanceOf(TokenNamespace) + }) + + it('should create amulet namespace based on services', async () => { + await ExtendedInitializedSDK.create(mock.ctx, { + amulet: mock.amuletConfig, + }) + + expect(ScanClient).toHaveBeenCalledOnce() + expect(ScanProxyClient).toHaveBeenCalledOnce() + expect(TokenStandardService).toHaveBeenCalledOnce() + expect(AmuletService).toHaveBeenCalledOnce() + }) + + it('should create token namespace based on services', async () => { + await ExtendedInitializedSDK.create(mock.ctx, { + token: mock.tokenConfig, + }) + + expect(get).toHaveBeenCalledOnce() + expect(TokenStandardService).toHaveBeenCalledOnce() + }) + + it('should create asset namespace based on services', async () => { + await ExtendedInitializedSDK.create(mock.ctx, { + asset: mock.assetConfig, + }) + + expect(TokenStandardService).toHaveBeenCalledOnce() + }) + }) +}) diff --git a/sdk/wallet-sdk/src/wallet/init/__test__/plugin.test.ts b/sdk/wallet-sdk/src/wallet/init/__test__/plugin.test.ts index a159ae9e6..7a83c0056 100644 --- a/sdk/wallet-sdk/src/wallet/init/__test__/plugin.test.ts +++ b/sdk/wallet-sdk/src/wallet/init/__test__/plugin.test.ts @@ -3,7 +3,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { EXTENDED_SDK_OPTION_KEYS, SDKPlugin } from '../' -import { mock } from '../../__test__/mocks' +import * as mock from '../../__test__/mocks' import { SDK, SDKContext } from '../..' const testPluginFactory = (key: string) => { diff --git a/sdk/wallet-sdk/src/wallet/namespace/party/party.test.ts b/sdk/wallet-sdk/src/wallet/namespace/party/party.test.ts index 00e79db5b..245e32df8 100644 --- a/sdk/wallet-sdk/src/wallet/namespace/party/party.test.ts +++ b/sdk/wallet-sdk/src/wallet/namespace/party/party.test.ts @@ -1,7 +1,7 @@ // Copyright (c) 2025-2026 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import { mock } from '../../__test__/mocks' +import * as mock from '../../__test__/mocks' import { it, describe, beforeEach, vi, expect } from 'vitest' import { PartyNamespace, diff --git a/sdk/wallet-sdk/src/wallet/sdk.ts b/sdk/wallet-sdk/src/wallet/sdk.ts index 21ad79c3c..52d7d2460 100644 --- a/sdk/wallet-sdk/src/wallet/sdk.ts +++ b/sdk/wallet-sdk/src/wallet/sdk.ts @@ -28,7 +28,8 @@ import { import { AllowedLogAdapters } from './logger/types.js' import { DappLedgerRpc } from '@canton-network/core-provider-dapp' import { SDKContext } from './index.js' -export * from './namespace/asset/index.js' +export { findAsset } from './namespace/asset/index.js' +export type * from './namespace/asset/index.js' export type * from './namespace/token/index.js' export type * from './namespace/amulet/index.js' export { type TokenProviderConfig } from '@canton-network/core-wallet-auth' From 63244d2022e732a9fabb207fdf4da069600a73e1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Pi=C4=85tkowski?= Date: Wed, 17 Jun 2026 14:16:59 +0200 Subject: [PATCH 17/17] test(wallet-sdk): remove variable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Mateusz Piątkowski --- sdk/wallet-sdk/src/wallet/init/__test__/plugin.test.ts | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/sdk/wallet-sdk/src/wallet/init/__test__/plugin.test.ts b/sdk/wallet-sdk/src/wallet/init/__test__/plugin.test.ts index 7a83c0056..28bf686c1 100644 --- a/sdk/wallet-sdk/src/wallet/init/__test__/plugin.test.ts +++ b/sdk/wallet-sdk/src/wallet/init/__test__/plugin.test.ts @@ -82,9 +82,7 @@ describe('plugin', () => { [pluginName]: TestPlugin, }) - const registeredPlugin = SDKWithPlugin[pluginName] - - expect(registeredPlugin).toBeDefined() - expect(registeredPlugin.testMethod()).toBe(true) + expect(SDKWithPlugin[pluginName]).toBeDefined() + expect(SDKWithPlugin[pluginName].testMethod()).toBe(true) }) })