@ledgerhq/coin-canton 0.11.0-nightly.20251204023901 → 0.11.0-nightly.20251204135727

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/CHANGELOG.md +9 -7
  3. package/lib/bridge/acceptOffer.d.ts.map +1 -1
  4. package/lib/bridge/acceptOffer.js +8 -0
  5. package/lib/bridge/acceptOffer.js.map +1 -1
  6. package/lib/bridge/getTransactionStatus.d.ts +1 -0
  7. package/lib/bridge/getTransactionStatus.d.ts.map +1 -1
  8. package/lib/bridge/getTransactionStatus.js +48 -17
  9. package/lib/bridge/getTransactionStatus.js.map +1 -1
  10. package/lib/bridge/onboard.d.ts.map +1 -1
  11. package/lib/bridge/onboard.js +4 -1
  12. package/lib/bridge/onboard.js.map +1 -1
  13. package/lib/bridge/serialization.d.ts.map +1 -1
  14. package/lib/bridge/serialization.js +3 -1
  15. package/lib/bridge/serialization.js.map +1 -1
  16. package/lib/bridge/sync.d.ts.map +1 -1
  17. package/lib/bridge/sync.js +6 -2
  18. package/lib/bridge/sync.js.map +1 -1
  19. package/lib/network/gateway.d.ts +20 -3
  20. package/lib/network/gateway.d.ts.map +1 -1
  21. package/lib/network/gateway.js +75 -16
  22. package/lib/network/gateway.js.map +1 -1
  23. package/lib/test/cantonTestUtils.d.ts +3 -1
  24. package/lib/test/cantonTestUtils.d.ts.map +1 -1
  25. package/lib/test/cantonTestUtils.js +1 -1
  26. package/lib/test/cantonTestUtils.js.map +1 -1
  27. package/lib/types/bridge.d.ts +2 -0
  28. package/lib/types/bridge.d.ts.map +1 -1
  29. package/lib/types/errors.d.ts +3 -0
  30. package/lib/types/errors.d.ts.map +1 -1
  31. package/lib/types/errors.js +2 -1
  32. package/lib/types/errors.js.map +1 -1
  33. package/lib-es/bridge/acceptOffer.d.ts.map +1 -1
  34. package/lib-es/bridge/acceptOffer.js +8 -0
  35. package/lib-es/bridge/acceptOffer.js.map +1 -1
  36. package/lib-es/bridge/getTransactionStatus.d.ts +1 -0
  37. package/lib-es/bridge/getTransactionStatus.d.ts.map +1 -1
  38. package/lib-es/bridge/getTransactionStatus.js +47 -17
  39. package/lib-es/bridge/getTransactionStatus.js.map +1 -1
  40. package/lib-es/bridge/onboard.d.ts.map +1 -1
  41. package/lib-es/bridge/onboard.js +5 -2
  42. package/lib-es/bridge/onboard.js.map +1 -1
  43. package/lib-es/bridge/serialization.d.ts.map +1 -1
  44. package/lib-es/bridge/serialization.js +3 -1
  45. package/lib-es/bridge/serialization.js.map +1 -1
  46. package/lib-es/bridge/sync.d.ts.map +1 -1
  47. package/lib-es/bridge/sync.js +6 -2
  48. package/lib-es/bridge/sync.js.map +1 -1
  49. package/lib-es/network/gateway.d.ts +20 -3
  50. package/lib-es/network/gateway.d.ts.map +1 -1
  51. package/lib-es/network/gateway.js +70 -15
  52. package/lib-es/network/gateway.js.map +1 -1
  53. package/lib-es/test/cantonTestUtils.d.ts +3 -1
  54. package/lib-es/test/cantonTestUtils.d.ts.map +1 -1
  55. package/lib-es/test/cantonTestUtils.js +1 -1
  56. package/lib-es/test/cantonTestUtils.js.map +1 -1
  57. package/lib-es/types/bridge.d.ts +2 -0
  58. package/lib-es/types/bridge.d.ts.map +1 -1
  59. package/lib-es/types/errors.d.ts +3 -0
  60. package/lib-es/types/errors.d.ts.map +1 -1
  61. package/lib-es/types/errors.js +1 -0
  62. package/lib-es/types/errors.js.map +1 -1
  63. package/package.json +6 -6
  64. package/src/bridge/acceptOffer.test.ts +43 -4
  65. package/src/bridge/acceptOffer.ts +9 -1
  66. package/src/bridge/getTransactionStatus.test.ts +95 -3
  67. package/src/bridge/getTransactionStatus.ts +61 -24
  68. package/src/bridge/onboard.integ.test.ts +84 -3
  69. package/src/bridge/onboard.test.ts +107 -1
  70. package/src/bridge/onboard.ts +6 -1
  71. package/src/bridge/serialization.ts +3 -1
  72. package/src/bridge/sync.ts +6 -2
  73. package/src/network/gateway.integ.test.ts +23 -0
  74. package/src/network/gateway.test.ts +63 -1
  75. package/src/network/gateway.ts +98 -26
  76. package/src/test/cantonTestUtils.ts +1 -1
  77. package/src/types/bridge.ts +2 -0
  78. package/src/types/errors.ts +2 -0
@@ -1,18 +1,18 @@
1
1
  import {
2
2
  AmountRequired,
3
3
  FeeNotLoaded,
4
- FeeRequired,
5
4
  FeeTooHigh,
6
5
  InvalidAddress,
7
- InvalidAddressBecauseDestinationIsAlsoSource,
8
6
  NotEnoughBalanceBecauseDestinationNotCreated,
9
7
  NotEnoughSpendableBalance,
10
8
  RecipientRequired,
11
9
  } from "@ledgerhq/errors";
12
10
  import BigNumber from "bignumber.js";
13
11
  import coinConfig from "../config";
12
+ import * as gateway from "../network/gateway";
14
13
  import { createMockAccount } from "../test/fixtures";
15
14
  import { CantonAccount, TooManyUtxosCritical, TooManyUtxosWarning, Transaction } from "../types";
15
+ import { TopologyChangeError } from "../types/errors";
16
16
  import {
17
17
  getTransactionStatus,
18
18
  TO_MANY_UTXOS_CRITICAL_COUNT,
@@ -20,7 +20,9 @@ import {
20
20
  } from "./getTransactionStatus";
21
21
 
22
22
  jest.mock("../config", () => ({ getCoinConfig: jest.fn() }));
23
+ jest.mock("../network/gateway");
23
24
  const mockCoinConfig = jest.mocked(coinConfig);
25
+ const mockedGateway = gateway as jest.Mocked<typeof gateway>;
24
26
 
25
27
  describe("getTransactionStatus", () => {
26
28
  const mockAccount: CantonAccount = {
@@ -28,8 +30,11 @@ describe("getTransactionStatus", () => {
28
30
  balance: new BigNumber(1000),
29
31
  spendableBalance: new BigNumber(1000),
30
32
  freshAddress: "test::33333333333333333333333333333333333333333333333333333333333333333333",
33
+ xpub: "test-party-id",
31
34
  }),
32
35
  cantonResources: {
36
+ pendingTransferProposals: [],
37
+ publicKey: "test-public-key",
33
38
  instrumentUtxoCounts: {
34
39
  Amulet: 5,
35
40
  },
@@ -44,6 +49,7 @@ describe("getTransactionStatus", () => {
44
49
  status: { type: "active" },
45
50
  nativeInstrumentId: "Amulet",
46
51
  });
52
+ mockedGateway.isTopologyChangeRequiredCached.mockResolvedValue(false);
47
53
  });
48
54
 
49
55
  describe("fee validation", () => {
@@ -287,6 +293,7 @@ describe("getTransactionStatus", () => {
287
293
  const accountWithTooManyUtxos = {
288
294
  ...mockAccount,
289
295
  cantonResources: {
296
+ ...mockAccount.cantonResources,
290
297
  instrumentUtxoCounts: {
291
298
  Amulet: TO_MANY_UTXOS_CRITICAL_COUNT + 1,
292
299
  },
@@ -311,7 +318,7 @@ describe("getTransactionStatus", () => {
311
318
  const accountWithManyUtxos = {
312
319
  ...mockAccount,
313
320
  cantonResources: {
314
- pendingTransferProposals: [],
321
+ ...mockAccount.cantonResources,
315
322
  instrumentUtxoCounts: {
316
323
  Amulet: TO_MANY_UTXOS_WARNING_COUNT + 1,
317
324
  },
@@ -339,6 +346,7 @@ describe("getTransactionStatus", () => {
339
346
  const accountWithFewUtxos = {
340
347
  ...mockAccount,
341
348
  cantonResources: {
349
+ ...mockAccount.cantonResources,
342
350
  instrumentUtxoCounts: {
343
351
  Amulet: TO_MANY_UTXOS_WARNING_COUNT - 1,
344
352
  },
@@ -362,6 +370,7 @@ describe("getTransactionStatus", () => {
362
370
  const accountWithManyUtxos = {
363
371
  ...mockAccount,
364
372
  cantonResources: {
373
+ ...mockAccount.cantonResources,
365
374
  instrumentUtxoCounts: {
366
375
  Amulet: 25,
367
376
  },
@@ -382,4 +391,87 @@ describe("getTransactionStatus", () => {
382
391
  expect(result.errors.utxoCount).toBeUndefined();
383
392
  });
384
393
  });
394
+
395
+ describe("topology validation", () => {
396
+ it("should return TopologyChangeError when isTopologyChangeRequiredCached returns true", async () => {
397
+ // GIVEN
398
+ const accountWithPartyId: CantonAccount = {
399
+ ...mockAccount,
400
+ xpub: "test-party-id",
401
+ };
402
+ mockedGateway.isTopologyChangeRequiredCached.mockResolvedValue(true);
403
+
404
+ const transaction: Transaction = {
405
+ family: "canton",
406
+ amount: new BigNumber(100),
407
+ recipient: "valid::11111111111111111111111111111111111111111111111111111111111111111111",
408
+ fee: new BigNumber(10),
409
+ tokenId: "",
410
+ };
411
+
412
+ // WHEN
413
+ const result = await getTransactionStatus(accountWithPartyId, transaction);
414
+
415
+ // THEN
416
+ expect(result.errors.topologyChange).toBeInstanceOf(TopologyChangeError);
417
+ expect(mockedGateway.isTopologyChangeRequiredCached).toHaveBeenCalledWith(
418
+ accountWithPartyId.currency,
419
+ "test-public-key",
420
+ );
421
+ });
422
+
423
+ it("should not return topology error when isTopologyChangeRequiredCached returns false", async () => {
424
+ // GIVEN
425
+ const accountWithPartyId: CantonAccount = {
426
+ ...mockAccount,
427
+ xpub: "test-party-id",
428
+ };
429
+ mockedGateway.isTopologyChangeRequiredCached.mockResolvedValue(false);
430
+
431
+ const transaction: Transaction = {
432
+ family: "canton",
433
+ amount: new BigNumber(100),
434
+ recipient: "valid::11111111111111111111111111111111111111111111111111111111111111111111",
435
+ fee: new BigNumber(10),
436
+ tokenId: "",
437
+ };
438
+
439
+ // WHEN
440
+ const result = await getTransactionStatus(accountWithPartyId, transaction);
441
+
442
+ // THEN
443
+ expect(result.errors.topologyChange).toBeUndefined();
444
+ expect(mockedGateway.isTopologyChangeRequiredCached).toHaveBeenCalledWith(
445
+ accountWithPartyId.currency,
446
+ "test-public-key",
447
+ );
448
+ });
449
+
450
+ it("should not return topology error when isTopologyChangeRequiredCached throws an error", async () => {
451
+ // GIVEN
452
+ const accountWithPartyId: CantonAccount = {
453
+ ...mockAccount,
454
+ xpub: "test-party-id",
455
+ };
456
+ mockedGateway.isTopologyChangeRequiredCached.mockRejectedValue(new Error("Network error"));
457
+
458
+ const transaction: Transaction = {
459
+ family: "canton",
460
+ amount: new BigNumber(100),
461
+ recipient: "valid::11111111111111111111111111111111111111111111111111111111111111111111",
462
+ fee: new BigNumber(10),
463
+ tokenId: "",
464
+ };
465
+
466
+ // WHEN
467
+ const result = await getTransactionStatus(accountWithPartyId, transaction);
468
+
469
+ // THEN
470
+ expect(result.errors.topologyChange).toBeUndefined();
471
+ expect(mockedGateway.isTopologyChangeRequiredCached).toHaveBeenCalledWith(
472
+ accountWithPartyId.currency,
473
+ "test-public-key",
474
+ );
475
+ });
476
+ });
385
477
  });
@@ -1,7 +1,6 @@
1
1
  import {
2
2
  AmountRequired,
3
3
  FeeNotLoaded,
4
- FeeRequired,
5
4
  FeeTooHigh,
6
5
  InvalidAddress,
7
6
  NotEnoughBalanceBecauseDestinationNotCreated,
@@ -21,6 +20,8 @@ import {
21
20
  import { isRecipientValid } from "../common-logic/utils";
22
21
  import coinConfig from "../config";
23
22
  import { getAbandonSeedAddress } from "@ledgerhq/cryptoassets/abandonseed";
23
+ import { isTopologyChangeRequiredCached } from "../network/gateway";
24
+ import { TopologyChangeError } from "../types/errors";
24
25
 
25
26
  export const TO_MANY_UTXOS_CRITICAL_COUNT = 24;
26
27
  export const TO_MANY_UTXOS_WARNING_COUNT = 10;
@@ -81,7 +82,15 @@ export const getTransactionStatus: AccountBridge<
81
82
  errors.amount = new AmountRequired();
82
83
  }
83
84
 
84
- validateUtxoCount(account, transaction, warnings);
85
+ const utxoWarning = validateUtxoCount(account, transaction);
86
+ if (utxoWarning) {
87
+ warnings.tooManyUtxos = utxoWarning;
88
+ }
89
+
90
+ const topologyError = await validateTopology(account);
91
+ if (topologyError) {
92
+ errors.topologyChange = topologyError;
93
+ }
85
94
 
86
95
  return {
87
96
  errors,
@@ -92,29 +101,57 @@ export const getTransactionStatus: AccountBridge<
92
101
  };
93
102
  };
94
103
 
95
- function validateUtxoCount(
96
- account: CantonAccount,
97
- transaction: Transaction,
98
- warnings: Record<string, Error>,
99
- ): void {
104
+ function validateUtxoCount(account: CantonAccount, transaction: Transaction): Error | null {
105
+ if (!account.cantonResources?.instrumentUtxoCounts) {
106
+ return null;
107
+ }
108
+
109
+ if (!transaction.recipient) {
110
+ return null;
111
+ }
112
+
113
+ if (!isRecipientValid(transaction.recipient) || account.xpub === transaction.recipient) {
114
+ return null;
115
+ }
116
+
100
117
  const abandonSeedAddress = getAbandonSeedAddress(account.currency.id);
101
- const isAbandonSeedAddress = transaction.recipient?.includes(abandonSeedAddress);
102
-
103
- // UTXO count validation - only validate if recipient is valid and not equal to sender
104
- // Skip validation for abandon seed addresses
105
- if (
106
- account?.cantonResources?.instrumentUtxoCounts &&
107
- transaction.recipient &&
108
- isRecipientValid(transaction.recipient) &&
109
- account.xpub !== transaction.recipient &&
110
- !isAbandonSeedAddress
111
- ) {
112
- const { instrumentUtxoCounts } = account.cantonResources;
113
- const instrumentUtxoCount = instrumentUtxoCounts[transaction.tokenId] || 0;
114
- if (instrumentUtxoCount > TO_MANY_UTXOS_CRITICAL_COUNT) {
115
- warnings.tooManyUtxos = new TooManyUtxosCritical();
116
- } else if (instrumentUtxoCount > TO_MANY_UTXOS_WARNING_COUNT) {
117
- warnings.tooManyUtxos = new TooManyUtxosWarning("families.canton.tooManyUtxos.warning");
118
+ if (transaction.recipient.includes(abandonSeedAddress)) {
119
+ return null;
120
+ }
121
+
122
+ const { instrumentUtxoCounts } = account.cantonResources;
123
+ const instrumentUtxoCount = instrumentUtxoCounts[transaction.tokenId] || 0;
124
+
125
+ if (instrumentUtxoCount > TO_MANY_UTXOS_CRITICAL_COUNT) {
126
+ return new TooManyUtxosCritical();
127
+ }
128
+
129
+ if (instrumentUtxoCount > TO_MANY_UTXOS_WARNING_COUNT) {
130
+ return new TooManyUtxosWarning("families.canton.tooManyUtxos.warning");
131
+ }
132
+
133
+ return null;
134
+ }
135
+
136
+ export async function validateTopology(account: CantonAccount): Promise<Error | null> {
137
+ const publicKey = account.cantonResources?.publicKey;
138
+ if (!publicKey) {
139
+ return null;
140
+ }
141
+
142
+ try {
143
+ const isTopologyChangeRequired = await isTopologyChangeRequiredCached(
144
+ account.currency,
145
+ publicKey,
146
+ );
147
+
148
+ if (!isTopologyChangeRequired) {
149
+ return null;
118
150
  }
151
+
152
+ return new TopologyChangeError("Topology change detected. Re-onboarding required.");
153
+ } catch {
154
+ // If topology check fails, don't block the transaction
155
+ return null;
119
156
  }
120
157
  }
@@ -1,5 +1,5 @@
1
- import { CryptoCurrency } from "@ledgerhq/types-cryptoassets";
2
1
  import { firstValueFrom, toArray } from "rxjs";
2
+ import { getEnv, setEnv } from "@ledgerhq/live-env";
3
3
  import coinConfig from "../config";
4
4
  import { createMockSigner, generateMockKeyPair } from "../test/cantonTestUtils";
5
5
  import { createMockAccount, createMockCantonCurrency } from "../test/fixtures";
@@ -12,8 +12,12 @@ import {
12
12
  OnboardStatus,
13
13
  } from "../types/onboard";
14
14
  import { buildAuthorizePreapproval, buildOnboardAccount, isAccountOnboarded } from "./onboard";
15
+ import {
16
+ isTopologyChangeRequiredCached,
17
+ clearIsTopologyChangeRequiredCache,
18
+ } from "../network/gateway";
15
19
 
16
- describe.skip("onboard (devnet)", () => {
20
+ describe("onboard (devnet)", () => {
17
21
  const mockDeviceId = "test-device-id";
18
22
  const mockCurrency = createMockCantonCurrency();
19
23
  const mockAccount = createMockAccount();
@@ -166,7 +170,7 @@ describe.skip("onboard (devnet)", () => {
166
170
  }, 30000);
167
171
  });
168
172
 
169
- describe("buildAuthorizePreapproval", () => {
173
+ describe.skip("buildAuthorizePreapproval", () => {
170
174
  it("should complete preapproval flow for onboarded account", async () => {
171
175
  // GIVEN
172
176
  const { mockSignerContext, onboardResult } = getOnboardedAccount();
@@ -199,4 +203,81 @@ describe.skip("onboard (devnet)", () => {
199
203
  expect(typeof finalResult.isApproved).toBe("boolean");
200
204
  }, 30000);
201
205
  });
206
+
207
+ describe("TopologyChangeError", () => {
208
+ it("should require topology change and complete re-onboarding when accessing account from different node", async () => {
209
+ // GIVEN
210
+ const originalNodeId = getEnv("CANTON_NODE_ID_OVERRIDE");
211
+ setEnv("CANTON_NODE_ID_OVERRIDE", "devnet");
212
+ const keyPair = generateMockKeyPair();
213
+ const mockAccount = createMockAccount({ xpub: keyPair.publicKeyHex });
214
+ const mockSigner = createMockSigner(keyPair);
215
+ const mockSignerContext = jest.fn().mockImplementation((_, callback) => {
216
+ return callback(mockSigner);
217
+ });
218
+
219
+ const onboardObservable = buildOnboardAccount(mockSignerContext);
220
+ const onboardValues = await firstValueFrom(
221
+ onboardObservable(mockCurrency, mockDeviceId, mockAccount).pipe(toArray()),
222
+ );
223
+ const onboardResult = onboardValues.find(
224
+ (value): value is CantonOnboardResult => "partyId" in value,
225
+ );
226
+
227
+ if (!onboardResult) {
228
+ throw new Error("Failed to onboard account");
229
+ }
230
+
231
+ const partyId = onboardResult.partyId;
232
+ expect(partyId).toBeDefined();
233
+
234
+ // Verify account is accessible on devnet node
235
+ const isTopologyChangeRequiredOnDevnet = await isTopologyChangeRequiredCached(
236
+ mockCurrency,
237
+ keyPair.publicKeyHex,
238
+ );
239
+ expect(isTopologyChangeRequiredOnDevnet).toBe(false);
240
+
241
+ // WHEN: Switch to different node
242
+ setEnv("CANTON_NODE_ID_OVERRIDE", "devnet-replicated");
243
+ clearIsTopologyChangeRequiredCache(mockCurrency, keyPair.publicKeyHex);
244
+
245
+ // THEN: Verify topology change is required
246
+ const isTopologyChangeRequiredOnReplicated = await isTopologyChangeRequiredCached(
247
+ mockCurrency,
248
+ keyPair.publicKeyHex,
249
+ );
250
+ expect(isTopologyChangeRequiredOnReplicated).toBe(true);
251
+
252
+ // AND: Verify re-onboarding proceeds through full onboarding flow
253
+ const reonboardObservable = buildOnboardAccount(mockSignerContext);
254
+ const reonboardValues = await firstValueFrom(
255
+ reonboardObservable(mockCurrency, mockDeviceId, mockAccount).pipe(toArray()),
256
+ );
257
+ const progressValues = reonboardValues.filter(
258
+ (value): value is CantonOnboardProgress => "status" in value && !("partyId" in value),
259
+ );
260
+ const resultValues = reonboardValues.filter(
261
+ (value): value is CantonOnboardResult => "partyId" in value,
262
+ );
263
+
264
+ // Check expected status progression
265
+ expect(progressValues.some(p => p.status === OnboardStatus.INIT)).toBe(true);
266
+ expect(progressValues.some(p => p.status === OnboardStatus.PREPARE)).toBe(true);
267
+ expect(progressValues.some(p => p.status === OnboardStatus.SIGN)).toBe(true);
268
+ expect(progressValues.some(p => p.status === OnboardStatus.SUBMIT)).toBe(true);
269
+
270
+ // Check final result
271
+ expect(resultValues.length).toBeGreaterThan(0);
272
+ const finalResult = resultValues[resultValues.length - 1];
273
+ expect(finalResult.partyId).toBeDefined();
274
+ expect(typeof finalResult.partyId).toBe("string");
275
+
276
+ if (originalNodeId) {
277
+ setEnv("CANTON_NODE_ID_OVERRIDE", originalNodeId);
278
+ } else {
279
+ setEnv("CANTON_NODE_ID_OVERRIDE", "");
280
+ }
281
+ }, 60000);
282
+ });
202
283
  });
@@ -1,9 +1,17 @@
1
1
  import { CryptoCurrency } from "@ledgerhq/types-cryptoassets";
2
- import { isCantonCoinPreapproved } from "./onboard";
2
+ import { firstValueFrom, toArray } from "rxjs";
3
+ import { SignerContext } from "@ledgerhq/coin-framework/signer";
4
+ import { buildOnboardAccount, isCantonCoinPreapproved } from "./onboard";
3
5
  import * as gateway from "../network/gateway";
6
+ import * as signTransactionModule from "../common-logic/transaction/sign";
7
+ import { createMockAccount } from "../test/fixtures";
8
+ import type { CantonSigner, CantonSignature } from "../types";
9
+ import { OnboardStatus, CantonOnboardProgress, CantonOnboardResult } from "../types/onboard";
4
10
 
5
11
  jest.mock("../network/gateway");
12
+ jest.mock("../common-logic/transaction/sign");
6
13
  const mockedGateway = gateway as jest.Mocked<typeof gateway>;
14
+ const mockedSignTransaction = signTransactionModule as jest.Mocked<typeof signTransactionModule>;
7
15
 
8
16
  describe("onboard", () => {
9
17
  const mockPartyId = "test-party-id";
@@ -56,4 +64,102 @@ describe("onboard", () => {
56
64
  expect(mockedGateway.getTransferPreApproval).toHaveBeenCalledWith(mockCurrency, mockPartyId);
57
65
  });
58
66
  });
67
+
68
+ describe("buildOnboardAccount", () => {
69
+ const mockDeviceId = "test-device-id";
70
+ const mockPublicKey = "test-public-key";
71
+ const mockPartyId = "test-party-id";
72
+ const mockSignature: CantonSignature = {
73
+ signature: "test-signature",
74
+ };
75
+
76
+ const mockSigner: CantonSigner = {
77
+ getAddress: jest.fn().mockResolvedValue({
78
+ address: "test-address",
79
+ publicKey: mockPublicKey,
80
+ path: "44'/6767'/0'/0/0",
81
+ }),
82
+ signTransaction: jest.fn().mockResolvedValue(mockSignature),
83
+ } as unknown as CantonSigner;
84
+
85
+ const mockSignerContext: SignerContext<CantonSigner> = jest.fn(
86
+ async (deviceId: string, callback: (signer: CantonSigner) => Promise<CantonSignature>) => {
87
+ return callback(mockSigner);
88
+ },
89
+ ) as unknown as SignerContext<CantonSigner>;
90
+
91
+ beforeEach(() => {
92
+ jest.clearAllMocks();
93
+ });
94
+
95
+ it("should skip submission when account is onboarded on network but has no local xpub", async () => {
96
+ // GIVEN
97
+ const account = createMockAccount({ xpub: undefined });
98
+ mockedGateway.getPartyByPubKey.mockResolvedValue({ party_id: mockPartyId });
99
+
100
+ const onboardObservable = buildOnboardAccount(mockSignerContext);
101
+ const values = await firstValueFrom(
102
+ onboardObservable(mockCurrency, mockDeviceId, account).pipe(toArray()),
103
+ );
104
+
105
+ // THEN
106
+ const result = values.find((v): v is CantonOnboardResult => "partyId" in v);
107
+ expect(result).toBeDefined();
108
+ expect(result?.partyId).toBe(mockPartyId);
109
+ expect(result?.account.xpub).toBe(mockPartyId);
110
+
111
+ // Should NOT call prepareOnboarding or submitOnboarding
112
+ expect(mockedGateway.prepareOnboarding).not.toHaveBeenCalled();
113
+ expect(mockedGateway.submitOnboarding).not.toHaveBeenCalled();
114
+ expect(mockedSignTransaction.signTransaction).not.toHaveBeenCalled();
115
+ });
116
+
117
+ it("should proceed with submission when account has xpub (re-onboarding scenario)", async () => {
118
+ // GIVEN - account already has xpub (re-onboarding)
119
+ const existingPartyId = "existing-party-id";
120
+ const account = createMockAccount({ xpub: existingPartyId });
121
+ const newPartyId = "new-party-id";
122
+
123
+ mockedGateway.getPartyByPubKey.mockResolvedValue({ party_id: existingPartyId });
124
+ mockedGateway.prepareOnboarding.mockResolvedValue({
125
+ party_id: newPartyId,
126
+ transactions: {},
127
+ });
128
+ mockedGateway.submitOnboarding.mockResolvedValue({
129
+ party: {
130
+ party_id: newPartyId,
131
+ public_key: mockPublicKey,
132
+ },
133
+ });
134
+ mockedSignTransaction.signTransaction.mockResolvedValue(mockSignature);
135
+
136
+ const onboardObservable = buildOnboardAccount(mockSignerContext);
137
+ const values = await firstValueFrom(
138
+ onboardObservable(mockCurrency, mockDeviceId, account).pipe(toArray()),
139
+ );
140
+
141
+ // THEN - should proceed through full onboarding flow
142
+ const statuses = values
143
+ .filter((v): v is CantonOnboardProgress => "status" in v)
144
+ .map(v => v.status);
145
+ expect(statuses).toContain(OnboardStatus.PREPARE);
146
+ expect(statuses).toContain(OnboardStatus.SIGN);
147
+ expect(statuses).toContain(OnboardStatus.SUBMIT);
148
+
149
+ // Should call prepareOnboarding and submitOnboarding
150
+ expect(mockedGateway.prepareOnboarding).toHaveBeenCalledWith(mockCurrency, mockPublicKey);
151
+ expect(mockedGateway.submitOnboarding).toHaveBeenCalled();
152
+ expect(mockedSignTransaction.signTransaction).toHaveBeenCalled();
153
+
154
+ // Should clear topology change cache
155
+ expect(mockedGateway.clearIsTopologyChangeRequiredCache).toHaveBeenCalledWith(
156
+ mockCurrency,
157
+ mockPublicKey,
158
+ );
159
+
160
+ const result = values.find((v): v is CantonOnboardResult => "partyId" in v);
161
+ expect(result).toBeDefined();
162
+ expect(result?.partyId).toBe(newPartyId);
163
+ });
164
+ });
59
165
  });
@@ -16,6 +16,7 @@ import {
16
16
  preparePreApprovalTransaction,
17
17
  submitPreApprovalTransaction,
18
18
  getTransferPreApproval,
19
+ clearIsTopologyChangeRequiredCache,
19
20
  } from "../network/gateway";
20
21
  import { signTransaction } from "../common-logic/transaction/sign";
21
22
  import {
@@ -90,7 +91,9 @@ export const buildOnboardAccount =
90
91
 
91
92
  let { partyId } = await isAccountOnboarded(currency, publicKey);
92
93
 
93
- if (partyId) {
94
+ // Skip submission only if account is onboarded on network but has no local xpub.
95
+ // For re-onboarding (account has xpub), always proceed to submit a new onboarding transaction.
96
+ if (partyId && !account.xpub) {
94
97
  const onboardedAccount = createOnboardedAccount(account, partyId, currency);
95
98
  o.next({ partyId, account: onboardedAccount }); // success
96
99
  return;
@@ -109,6 +112,8 @@ export const buildOnboardAccount =
109
112
 
110
113
  await submitOnboarding(currency, publicKey, preparedTransaction, signature);
111
114
 
115
+ clearIsTopologyChangeRequiredCache(currency, publicKey);
116
+
112
117
  const onboardedAccount = createOnboardedAccount(account, partyId, currency);
113
118
  o.next({ partyId, account: onboardedAccount }); // success
114
119
  }
@@ -15,10 +15,11 @@ function isCantonAccountRaw(accountRaw: AccountRaw): accountRaw is CantonAccount
15
15
  }
16
16
 
17
17
  function toResourcesRaw(r: CantonResources): CantonResourcesRaw {
18
- const { instrumentUtxoCounts, pendingTransferProposals } = r;
18
+ const { instrumentUtxoCounts, pendingTransferProposals, publicKey } = r;
19
19
  return {
20
20
  instrumentUtxoCounts,
21
21
  pendingTransferProposals,
22
+ publicKey,
22
23
  };
23
24
  }
24
25
 
@@ -26,6 +27,7 @@ function fromResourcesRaw(r: CantonResourcesRaw): CantonResources {
26
27
  return {
27
28
  instrumentUtxoCounts: r.instrumentUtxoCounts,
28
29
  pendingTransferProposals: r.pendingTransferProposals,
30
+ publicKey: r.publicKey,
29
31
  };
30
32
  }
31
33
 
@@ -94,15 +94,17 @@ export function makeGetAccountShape(
94
94
  const { address, currency, derivationMode, derivationPath, initialAccount } = info;
95
95
 
96
96
  let xpubOrAddress = initialAccount?.xpub || "";
97
+ let publicKey: string | undefined = initialAccount?.cantonResources?.publicKey;
97
98
 
98
- if (!xpubOrAddress) {
99
+ if (!xpubOrAddress || !publicKey) {
99
100
  const getAddress = resolver(signerContext);
100
- const { publicKey } = await getAddress(info.deviceId || "", {
101
+ const addressResult = await getAddress(info.deviceId || "", {
101
102
  path: derivationPath,
102
103
  currency: currency,
103
104
  derivationMode: derivationMode,
104
105
  verify: false,
105
106
  });
107
+ publicKey = addressResult.publicKey;
106
108
 
107
109
  const { isOnboarded, partyId } = await isAccountOnboarded(currency, publicKey);
108
110
  if (isOnboarded && partyId) {
@@ -168,6 +170,7 @@ export function makeGetAccountShape(
168
170
  cantonResources: {
169
171
  instrumentUtxoCounts,
170
172
  pendingTransferProposals,
173
+ publicKey,
171
174
  },
172
175
  });
173
176
 
@@ -195,6 +198,7 @@ export function makeGetAccountShape(
195
198
  cantonResources: {
196
199
  instrumentUtxoCounts,
197
200
  pendingTransferProposals,
201
+ publicKey,
198
202
  },
199
203
  };
200
204
 
@@ -126,6 +126,29 @@ describe("gateway (devnet)", () => {
126
126
  },
127
127
  30000,
128
128
  );
129
+
130
+ it("should handle PARTY_ALREADY_EXISTS error and return party_id and public_key", async () => {
131
+ // GIVEN
132
+ const { keyPair, partyId } = getOnboardedAccount();
133
+ const signature = keyPair.sign(prepareResponse?.transactions?.combined_hash || "");
134
+
135
+ // WHEN
136
+ const response = await submitOnboarding(
137
+ mockCurrency,
138
+ keyPair.publicKeyHex,
139
+ prepareResponse!,
140
+ {
141
+ signature,
142
+ },
143
+ );
144
+
145
+ // THEN
146
+ expect(response).toHaveProperty("party");
147
+ expect(response.party).toHaveProperty("party_id");
148
+ expect(response.party).toHaveProperty("public_key");
149
+ expect(response.party.party_id).toBe(partyId);
150
+ expect(response.party.public_key).toBe(keyPair.publicKeyHex);
151
+ }, 30000);
129
152
  });
130
153
 
131
154
  describe("getLedgerEnd", () => {