@ledgerhq/coin-canton 0.11.0-nightly.20251204023901 → 0.11.0-nightly.20251205023918
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.
- package/CHANGELOG.md +11 -7
- package/README.md +1 -1
- package/lib/bridge/acceptOffer.d.ts.map +1 -1
- package/lib/bridge/acceptOffer.js +8 -0
- package/lib/bridge/acceptOffer.js.map +1 -1
- package/lib/bridge/getTransactionStatus.d.ts +1 -0
- package/lib/bridge/getTransactionStatus.d.ts.map +1 -1
- package/lib/bridge/getTransactionStatus.js +48 -17
- package/lib/bridge/getTransactionStatus.js.map +1 -1
- package/lib/bridge/onboard.d.ts.map +1 -1
- package/lib/bridge/onboard.js +4 -1
- package/lib/bridge/onboard.js.map +1 -1
- package/lib/bridge/serialization.d.ts.map +1 -1
- package/lib/bridge/serialization.js +3 -1
- package/lib/bridge/serialization.js.map +1 -1
- package/lib/bridge/sync.d.ts.map +1 -1
- package/lib/bridge/sync.js +6 -2
- package/lib/bridge/sync.js.map +1 -1
- package/lib/network/gateway.d.ts +20 -3
- package/lib/network/gateway.d.ts.map +1 -1
- package/lib/network/gateway.js +75 -16
- package/lib/network/gateway.js.map +1 -1
- package/lib/test/cantonTestUtils.d.ts +3 -1
- package/lib/test/cantonTestUtils.d.ts.map +1 -1
- package/lib/test/cantonTestUtils.js +1 -1
- package/lib/test/cantonTestUtils.js.map +1 -1
- package/lib/types/bridge.d.ts +2 -0
- package/lib/types/bridge.d.ts.map +1 -1
- package/lib/types/errors.d.ts +3 -0
- package/lib/types/errors.d.ts.map +1 -1
- package/lib/types/errors.js +2 -1
- package/lib/types/errors.js.map +1 -1
- package/lib-es/bridge/acceptOffer.d.ts.map +1 -1
- package/lib-es/bridge/acceptOffer.js +8 -0
- package/lib-es/bridge/acceptOffer.js.map +1 -1
- package/lib-es/bridge/getTransactionStatus.d.ts +1 -0
- package/lib-es/bridge/getTransactionStatus.d.ts.map +1 -1
- package/lib-es/bridge/getTransactionStatus.js +47 -17
- package/lib-es/bridge/getTransactionStatus.js.map +1 -1
- package/lib-es/bridge/onboard.d.ts.map +1 -1
- package/lib-es/bridge/onboard.js +5 -2
- package/lib-es/bridge/onboard.js.map +1 -1
- package/lib-es/bridge/serialization.d.ts.map +1 -1
- package/lib-es/bridge/serialization.js +3 -1
- package/lib-es/bridge/serialization.js.map +1 -1
- package/lib-es/bridge/sync.d.ts.map +1 -1
- package/lib-es/bridge/sync.js +6 -2
- package/lib-es/bridge/sync.js.map +1 -1
- package/lib-es/network/gateway.d.ts +20 -3
- package/lib-es/network/gateway.d.ts.map +1 -1
- package/lib-es/network/gateway.js +70 -15
- package/lib-es/network/gateway.js.map +1 -1
- package/lib-es/test/cantonTestUtils.d.ts +3 -1
- package/lib-es/test/cantonTestUtils.d.ts.map +1 -1
- package/lib-es/test/cantonTestUtils.js +1 -1
- package/lib-es/test/cantonTestUtils.js.map +1 -1
- package/lib-es/types/bridge.d.ts +2 -0
- package/lib-es/types/bridge.d.ts.map +1 -1
- package/lib-es/types/errors.d.ts +3 -0
- package/lib-es/types/errors.d.ts.map +1 -1
- package/lib-es/types/errors.js +1 -0
- package/lib-es/types/errors.js.map +1 -1
- package/package.json +6 -6
- package/src/api/getBalance.integ.test.ts +1 -1
- package/src/api/lastBlock.integ.test.ts +1 -1
- package/src/api/listOperations.integ.test.ts +1 -1
- package/src/bridge/acceptOffer.test.ts +43 -4
- package/src/bridge/acceptOffer.ts +9 -1
- package/src/bridge/getTransactionStatus.test.ts +95 -3
- package/src/bridge/getTransactionStatus.ts +61 -24
- package/src/bridge/onboard.integ.test.ts +85 -4
- package/src/bridge/onboard.test.ts +107 -1
- package/src/bridge/onboard.ts +6 -1
- package/src/bridge/prepareTransaction.test.ts +1 -1
- package/src/bridge/serialization.ts +3 -1
- package/src/bridge/sync.integ.test.ts +1 -1
- package/src/bridge/sync.ts +6 -2
- package/src/network/gateway.integ.test.ts +24 -1
- package/src/network/gateway.test.ts +64 -2
- package/src/network/gateway.ts +98 -26
- package/src/test/cantonTestUtils.ts +1 -1
- package/src/types/bridge.ts +2 -0
- 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
|
-
|
|
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
|
|
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
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
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
|
|
20
|
+
describe("onboard (devnet)", () => {
|
|
17
21
|
const mockDeviceId = "test-device-id";
|
|
18
22
|
const mockCurrency = createMockCantonCurrency();
|
|
19
23
|
const mockAccount = createMockAccount();
|
|
@@ -27,7 +31,7 @@ describe.skip("onboard (devnet)", () => {
|
|
|
27
31
|
|
|
28
32
|
beforeAll(() => {
|
|
29
33
|
coinConfig.setCoinConfig(() => ({
|
|
30
|
-
gatewayUrl: "https://canton-gateway.api.live.ledger-test.com",
|
|
34
|
+
gatewayUrl: "https://canton-gateway-devnet.api.live.ledger-test.com",
|
|
31
35
|
useGateway: true,
|
|
32
36
|
networkType: "devnet",
|
|
33
37
|
nativeInstrumentId: "Amulet",
|
|
@@ -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 {
|
|
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
|
});
|
package/src/bridge/onboard.ts
CHANGED
|
@@ -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
|
|
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
|
}
|
|
@@ -16,7 +16,7 @@ describe("prepareTransaction", () => {
|
|
|
16
16
|
|
|
17
17
|
beforeAll(async () => {
|
|
18
18
|
coinConfig.setCoinConfig(() => ({
|
|
19
|
-
gatewayUrl: "https://canton-gateway.api.live.ledger-test.com",
|
|
19
|
+
gatewayUrl: "https://canton-gateway-devnet.api.live.ledger-test.com",
|
|
20
20
|
useGateway: true,
|
|
21
21
|
networkType: "devnet",
|
|
22
22
|
nativeInstrumentId: "Amulet",
|
|
@@ -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
|
|
|
@@ -47,7 +47,7 @@ const mockSignerContext = jest.fn().mockImplementation((deviceId, callback) => {
|
|
|
47
47
|
describe.skip("sync (devnet)", () => {
|
|
48
48
|
beforeAll(async () => {
|
|
49
49
|
coinConfig.setCoinConfig(() => ({
|
|
50
|
-
gatewayUrl: "https://canton-gateway.api.live.ledger-test.com",
|
|
50
|
+
gatewayUrl: "https://canton-gateway-devnet.api.live.ledger-test.com",
|
|
51
51
|
useGateway: true,
|
|
52
52
|
networkType: "devnet",
|
|
53
53
|
nativeInstrumentId: "Amulet",
|
package/src/bridge/sync.ts
CHANGED
|
@@ -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
|
|
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
|
|
|
@@ -30,7 +30,7 @@ describe("gateway (devnet)", () => {
|
|
|
30
30
|
|
|
31
31
|
beforeAll(async () => {
|
|
32
32
|
coinConfig.setCoinConfig(() => ({
|
|
33
|
-
gatewayUrl: "https://canton-gateway.api.live.ledger-test.com",
|
|
33
|
+
gatewayUrl: "https://canton-gateway-devnet.api.live.ledger-test.com",
|
|
34
34
|
useGateway: true,
|
|
35
35
|
nativeInstrumentId: "Amulet",
|
|
36
36
|
networkType: "devnet",
|
|
@@ -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", () => {
|