@ledgerhq/coin-evm 0.2.0-next.0

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 (53) hide show
  1. package/.eslintrc.js +57 -0
  2. package/.turbo/turbo-build.log +4 -0
  3. package/CHANGELOG.md +18 -0
  4. package/jest.config.js +6 -0
  5. package/package.json +102 -0
  6. package/src/__tests__/adapters.unit.test.ts +527 -0
  7. package/src/__tests__/broadcast.unit.test.ts +181 -0
  8. package/src/__tests__/buildOptimisticOperation.unit.test.ts +182 -0
  9. package/src/__tests__/createTransaction.unit.test.ts +52 -0
  10. package/src/__tests__/deviceTransactionConfig.unit.test.ts +245 -0
  11. package/src/__tests__/estimateMaxSpendable.unit.test.ts +123 -0
  12. package/src/__tests__/getTransactionStatus.unit.test.ts +355 -0
  13. package/src/__tests__/hw-getAddress.unit.test.ts +24 -0
  14. package/src/__tests__/logic.unit.test.ts +406 -0
  15. package/src/__tests__/preload.unit.test.ts +139 -0
  16. package/src/__tests__/prepareTransaction.unit.test.ts +394 -0
  17. package/src/__tests__/rpc.unit.test.ts +532 -0
  18. package/src/__tests__/signOperation.unit.test.ts +157 -0
  19. package/src/__tests__/synchronization.unit.test.ts +832 -0
  20. package/src/__tests__/transaction.unit.test.ts +196 -0
  21. package/src/abis/erc20.abi.json +230 -0
  22. package/src/abis/optimismGasPriceOracle.abi.json +252 -0
  23. package/src/adapters.ts +148 -0
  24. package/src/api/etherscan.ts +124 -0
  25. package/src/api/rpc.common.ts +354 -0
  26. package/src/api/rpc.native.ts +5 -0
  27. package/src/api/rpc.ts +2 -0
  28. package/src/bridge/js.ts +77 -0
  29. package/src/bridge.integration.test.ts +93 -0
  30. package/src/broadcast.ts +40 -0
  31. package/src/buildOptimisticOperation.ts +113 -0
  32. package/src/cli-transaction.ts +11 -0
  33. package/src/createTransaction.ts +25 -0
  34. package/src/datasets/ethereum.scanAccounts.1.ts +48 -0
  35. package/src/datasets/ethereum1.ts +20 -0
  36. package/src/datasets/ethereum2.ts +20 -0
  37. package/src/datasets/ethereum_classic.ts +68 -0
  38. package/src/deviceTransactionConfig.ts +64 -0
  39. package/src/errors.ts +5 -0
  40. package/src/estimateMaxSpendable.ts +19 -0
  41. package/src/getTransactionStatus.ts +186 -0
  42. package/src/hw-getAddress.ts +24 -0
  43. package/src/logic.ts +149 -0
  44. package/src/preload.ts +54 -0
  45. package/src/prepareTransaction.ts +176 -0
  46. package/src/signOperation.ts +127 -0
  47. package/src/specs.ts +344 -0
  48. package/src/speculos-deviceActions.ts +83 -0
  49. package/src/synchronization.ts +317 -0
  50. package/src/testUtils.ts +153 -0
  51. package/src/transaction.ts +193 -0
  52. package/src/types.ts +132 -0
  53. package/tsconfig.json +12 -0
@@ -0,0 +1,832 @@
1
+ import { decodeAccountId } from "@ledgerhq/coin-framework/account/index";
2
+ import { AccountShapeInfo } from "@ledgerhq/coin-framework/bridge/jsHelpers";
3
+ import { getCryptoCurrencyById, getTokenById } from "@ledgerhq/cryptoassets";
4
+ import { CryptoCurrency } from "@ledgerhq/types-cryptoassets";
5
+ import { AssertionError, fail } from "assert";
6
+ import BigNumber from "bignumber.js";
7
+ import { getEnv } from "../../../env";
8
+ import * as etherscanAPI from "../api/etherscan";
9
+ import * as rpcAPI from "../api/rpc.common";
10
+ import * as logic from "../logic";
11
+ import * as synchronization from "../synchronization";
12
+ import { makeAccount, makeOperation, makeTokenAccount } from "../testUtils";
13
+
14
+ jest.useFakeTimers().setSystemTime(new Date("2014-04-21"));
15
+
16
+ const currency: CryptoCurrency = {
17
+ ...getCryptoCurrencyById("ethereum"),
18
+ ethereumLikeInfo: {
19
+ chainId: 1,
20
+ rpc: "https://my-rpc.com",
21
+ explorer: {
22
+ uri: "https://api.com",
23
+ type: "etherscan",
24
+ },
25
+ },
26
+ };
27
+ const getAccountShapeParameters: AccountShapeInfo = {
28
+ address: "0xkvn",
29
+ currency,
30
+ derivationMode: "",
31
+ derivationPath: "44'/60'/0'/0/0",
32
+ index: 0,
33
+ };
34
+ const tokenCurrency1 = getTokenById("ethereum/erc20/usd__coin");
35
+ const tokenCurrency2 = getTokenById("ethereum/erc20/usd_tether__erc20_");
36
+ const tokenAccount = makeTokenAccount("0xkvn", tokenCurrency1);
37
+ const account = {
38
+ ...makeAccount("0xkvn", currency, [tokenAccount]),
39
+ syncHash: logic.getSyncHash(currency),
40
+ };
41
+ const coinOperation1 = makeOperation({
42
+ hash: "0xH4sH",
43
+ accountId: "js:2:ethereum:0xkvn:",
44
+ blockHash:
45
+ "0x8df71a12a8c06b36c06c26bf6248857dd2a2b75b6edbb4e33e9477078897b282",
46
+ senders: ["0xd48f2332Eeed88243Cb6b1D0d65a10368A5370f0"], // johnnyhallyday.eth
47
+ transactionSequenceNumber: 1,
48
+ date: new Date(),
49
+ blockHeight: 1,
50
+ });
51
+ const coinOperation2 = makeOperation({
52
+ hash: "0xOtherHash",
53
+ accountId: "js:2:ethereum:0xkvn:",
54
+ transactionSequenceNumber: 2,
55
+ date: new Date(Date.now() + 1),
56
+ blockHeight: 100,
57
+ });
58
+ const coinOperation3 = makeOperation({
59
+ hash: "0xYeTAnOtherHash",
60
+ accountId: "js:2:ethereum:0xkvn:",
61
+ transactionSequenceNumber: 5,
62
+ date: new Date(Date.now() + 2),
63
+ blockHeight: 1000,
64
+ });
65
+ const tokenOperation1 = makeOperation({
66
+ hash: "0xH4sHT0k3n",
67
+ accountId: "js:2:ethereum:0xkvn:+ethereum%2Ferc20%2Fusd__coin",
68
+ blockHash:
69
+ "0x95dc138a02c1b0e3fd49305f785e8e27e88a885004af13a9b4c62ad94eed07dd",
70
+ recipients: ["0xB0B"],
71
+ senders: ["0x9b744C0451D73C0958d8aA566dAd33022E4Ee797"], // sbf.eth
72
+ contract: "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48",
73
+ value: new BigNumber(152021496),
74
+ fee: new BigNumber(1935663357068271),
75
+ type: "OUT",
76
+ date: new Date(),
77
+ blockHeight: 10,
78
+ });
79
+ const tokenOperation2 = makeOperation({
80
+ hash: "0xTokenHashAga1n",
81
+ accountId: "js:2:ethereum:0xkvn:+ethereum%2Ferc20%2Fusd__coin",
82
+ contract: "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48",
83
+ date: new Date(Date.now() + 1),
84
+ blockHeight: 1000,
85
+ });
86
+ const tokenOperation3 = makeOperation({
87
+ hash: "0xTokenHashAga1n",
88
+ accountId: "js:2:ethereum:0xkvn:+ethereum%2Ferc20%2Fusd__coin",
89
+ contract: "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48",
90
+ date: new Date(Date.now() + 2),
91
+ blockHeight: 10000,
92
+ });
93
+ const tokenOperation4 = makeOperation({
94
+ hash: "0xTokenHashOtherToken",
95
+ accountId: "js:2:ethereum:0xkvn:+ethereum%2Ferc20%2Fusd_tether__erc20_",
96
+ contract: "0xdac17f958d2ee523a2206206994597c13d831ec7",
97
+ date: new Date(Date.now() + 3),
98
+ blockHeight: 11000,
99
+ });
100
+ const ignoredTokenOperation = makeOperation({
101
+ hash: "0xigN0r3Me",
102
+ accountId: "js:2:ethereum:0xkvn:+ethereum%2Ferc20%2Fusd_tether__erc20_",
103
+ contract: "0xUnknownContract",
104
+ date: new Date(Date.now() + 4),
105
+ blockHeight: 12000,
106
+ });
107
+ const pendingOperation = makeOperation({
108
+ hash: "123",
109
+ });
110
+
111
+ describe("EVM Family", () => {
112
+ describe("synchronization.ts", () => {
113
+ describe("getAccountShape", () => {
114
+ beforeEach(() => {
115
+ // Mocking getAccount to prevent network calls
116
+ jest.spyOn(rpcAPI, "getBalanceAndBlock").mockImplementation(() =>
117
+ Promise.resolve({
118
+ blockHeight: 10,
119
+ balance: new BigNumber(100),
120
+ })
121
+ );
122
+ jest.spyOn(rpcAPI, "getSubAccount").mockImplementation(() =>
123
+ Promise.resolve({
124
+ blockHeight: 10,
125
+ balance: new BigNumber(100),
126
+ nonce: 1,
127
+ })
128
+ );
129
+ });
130
+
131
+ afterAll(() => {
132
+ jest.restoreAllMocks();
133
+ });
134
+
135
+ it("should throw for currency without ethereumLikeInfo", async () => {
136
+ try {
137
+ await synchronization.getAccountShape(
138
+ {
139
+ ...getAccountShapeParameters,
140
+ currency: {
141
+ ...currency,
142
+ ethereumLikeInfo: undefined,
143
+ },
144
+ },
145
+ {} as any
146
+ );
147
+ fail("Promise should have been rejected");
148
+ } catch (e: any) {
149
+ if (e instanceof AssertionError) {
150
+ throw e;
151
+ }
152
+ expect(e.message).toEqual("API type not supported");
153
+ }
154
+ });
155
+
156
+ it("should throw for currency with unsupported explorer", async () => {
157
+ try {
158
+ await synchronization.getAccountShape(
159
+ {
160
+ ...getAccountShapeParameters,
161
+ currency: {
162
+ ...currency,
163
+ ethereumLikeInfo: {
164
+ chainId: 1,
165
+ explorer: {
166
+ uri: "http://nope.com",
167
+ type: "unsupported" as any,
168
+ },
169
+ },
170
+ },
171
+ },
172
+ {} as any
173
+ );
174
+ fail("Promise should have been rejected");
175
+ } catch (e: any) {
176
+ if (e instanceof AssertionError) {
177
+ throw e;
178
+ }
179
+ expect(e.message).toEqual("API type not supported");
180
+ }
181
+ });
182
+
183
+ describe("With no transactions fetched", () => {
184
+ beforeAll(() => {
185
+ jest
186
+ .spyOn(etherscanAPI, "getLastCoinOperations")
187
+ .mockImplementation(() => Promise.resolve([]));
188
+ jest
189
+ .spyOn(etherscanAPI?.default, "getLastCoinOperations")
190
+ .mockImplementation(() => Promise.resolve([]));
191
+ jest
192
+ .spyOn(etherscanAPI, "getLastTokenOperations")
193
+ .mockImplementation(() => Promise.resolve([]));
194
+ jest
195
+ .spyOn(etherscanAPI?.default, "getLastTokenOperations")
196
+ .mockImplementation(() => Promise.resolve([]));
197
+ });
198
+
199
+ afterAll(() => {
200
+ jest.restoreAllMocks();
201
+ });
202
+
203
+ it("should return an account with a valid id", async () => {
204
+ const account = await synchronization.getAccountShape(
205
+ getAccountShapeParameters,
206
+ {} as any
207
+ );
208
+ expect(decodeAccountId(account.id || "")).toEqual({
209
+ type: "js",
210
+ version: "2",
211
+ currencyId: currency.id,
212
+ xpubOrAddress: "0xkvn",
213
+ derivationMode: "",
214
+ });
215
+ });
216
+
217
+ it("should return an account with the correct balance", async () => {
218
+ const account = await synchronization.getAccountShape(
219
+ getAccountShapeParameters,
220
+ {} as any
221
+ );
222
+ expect(account.balance).toEqual(new BigNumber(100));
223
+ });
224
+
225
+ it("should return an account with the correct operations count", async () => {
226
+ const account = await synchronization.getAccountShape(
227
+ getAccountShapeParameters,
228
+ {} as any
229
+ );
230
+ expect(account.operationsCount).toBe(account.operations?.length);
231
+ });
232
+
233
+ it("should return an account with the correct block height", async () => {
234
+ const account = await synchronization.getAccountShape(
235
+ getAccountShapeParameters,
236
+ {} as any
237
+ );
238
+ expect(account.blockHeight).toBe(10);
239
+ });
240
+
241
+ it("should keep the operations from a sync to another", async () => {
242
+ const operations = [coinOperation1];
243
+ const tokenOperations = [tokenOperation1];
244
+ const accountWithSubAccount = await synchronization.getAccountShape(
245
+ {
246
+ ...getAccountShapeParameters,
247
+ initialAccount: {
248
+ ...account,
249
+ operations,
250
+ subAccounts: [{ ...tokenAccount, operations: tokenOperations }],
251
+ },
252
+ },
253
+ {} as any
254
+ );
255
+ expect(accountWithSubAccount.operations).toBe(operations);
256
+ expect(accountWithSubAccount?.subAccounts?.[0].operations).toBe(
257
+ tokenOperations
258
+ );
259
+ });
260
+
261
+ it("should do a full sync when syncHash changes", async () => {
262
+ jest
263
+ .spyOn(logic, "getSyncHash")
264
+ .mockImplementationOnce(() => "0xNope");
265
+
266
+ await synchronization.getAccountShape(
267
+ {
268
+ ...getAccountShapeParameters,
269
+ initialAccount: {
270
+ ...account,
271
+ operations: [coinOperation1],
272
+ subAccounts: [
273
+ { ...tokenAccount, operations: [tokenOperation1] },
274
+ ],
275
+ },
276
+ },
277
+ {} as any
278
+ );
279
+
280
+ expect(
281
+ etherscanAPI?.default.getLastCoinOperations
282
+ ).toHaveBeenCalledWith(
283
+ getAccountShapeParameters.currency,
284
+ getAccountShapeParameters.address,
285
+ account.id,
286
+ 0
287
+ );
288
+ expect(
289
+ etherscanAPI?.default.getLastTokenOperations
290
+ ).toHaveBeenCalledWith(
291
+ getAccountShapeParameters.currency,
292
+ getAccountShapeParameters.address,
293
+ account.id,
294
+ 0
295
+ );
296
+ });
297
+
298
+ it("should do a full sync when syncHash changes", async () => {
299
+ await synchronization.getAccountShape(
300
+ {
301
+ ...getAccountShapeParameters,
302
+ initialAccount: {
303
+ ...account,
304
+ operations: [coinOperation1],
305
+ subAccounts: [
306
+ { ...tokenAccount, operations: [tokenOperation1] },
307
+ ],
308
+ },
309
+ },
310
+ {} as any
311
+ );
312
+
313
+ expect(
314
+ etherscanAPI?.default.getLastCoinOperations
315
+ ).toHaveBeenCalledWith(
316
+ getAccountShapeParameters.currency,
317
+ getAccountShapeParameters.address,
318
+ account.id,
319
+ coinOperation1.blockHeight
320
+ );
321
+ expect(
322
+ etherscanAPI?.default.getLastTokenOperations
323
+ ).toHaveBeenCalledWith(
324
+ getAccountShapeParameters.currency,
325
+ getAccountShapeParameters.address,
326
+ account.id,
327
+ tokenOperation1.blockHeight
328
+ );
329
+ });
330
+ });
331
+
332
+ describe("With transactions fetched", () => {
333
+ beforeAll(() => {
334
+ jest
335
+ .spyOn(etherscanAPI?.default, "getLastCoinOperations")
336
+ .mockImplementation(() =>
337
+ Promise.resolve([coinOperation1, coinOperation2])
338
+ );
339
+ jest
340
+ .spyOn(etherscanAPI?.default, "getLastTokenOperations")
341
+ .mockImplementation(() =>
342
+ Promise.resolve([
343
+ {
344
+ tokenCurrency: tokenCurrency1,
345
+ operation: tokenOperation1,
346
+ },
347
+ {
348
+ tokenCurrency: tokenCurrency1,
349
+ operation: tokenOperation2,
350
+ },
351
+ ])
352
+ );
353
+ jest
354
+ .spyOn(rpcAPI, "getTokenBalance")
355
+ .mockImplementation(async (a, b, contractAddress) => {
356
+ if (contractAddress === tokenCurrency1.contractAddress) {
357
+ return new BigNumber(10000);
358
+ }
359
+ throw new Error(
360
+ "Shouldn't be trying to fetch this token balance"
361
+ );
362
+ });
363
+ });
364
+
365
+ afterAll(() => {
366
+ jest.restoreAllMocks();
367
+ });
368
+
369
+ it("should add the fetched transactions to the operations", async () => {
370
+ const accountShape = await synchronization.getAccountShape(
371
+ {
372
+ ...getAccountShapeParameters,
373
+ initialAccount: account,
374
+ },
375
+ {} as any
376
+ );
377
+ expect(accountShape.operations).toEqual([
378
+ coinOperation2,
379
+ coinOperation1,
380
+ ]);
381
+ expect(accountShape?.subAccounts?.[0]?.operations).toEqual([
382
+ tokenOperation2,
383
+ tokenOperation1,
384
+ ]);
385
+ });
386
+
387
+ it("should return a partial account based on blockHeight", async () => {
388
+ jest
389
+ .spyOn(etherscanAPI?.default, "getLastCoinOperations")
390
+ .mockImplementation(() => Promise.resolve([coinOperation3]));
391
+ const operations = [coinOperation2, coinOperation1];
392
+ const accountShape = await synchronization.getAccountShape(
393
+ {
394
+ ...getAccountShapeParameters,
395
+ initialAccount: {
396
+ ...account,
397
+ operations,
398
+ },
399
+ },
400
+ {} as any
401
+ );
402
+
403
+ expect(accountShape).toEqual({
404
+ type: "Account",
405
+ id: account.id,
406
+ syncHash: expect.stringMatching(/^0x[A-Fa-f0-9]{64}$/), // matching a sha256 hex
407
+ balance: new BigNumber(100),
408
+ spendableBalance: new BigNumber(100),
409
+ blockHeight: 10,
410
+ operations: [coinOperation3, coinOperation2, coinOperation1],
411
+ operationsCount: 3,
412
+ subAccounts: [
413
+ {
414
+ ...tokenAccount,
415
+ balance: new BigNumber(10000),
416
+ spendableBalance: new BigNumber(10000),
417
+ operations: [tokenOperation2, tokenOperation1],
418
+ operationsCount: 2,
419
+ },
420
+ ],
421
+ lastSyncDate: new Date("2014-04-21"),
422
+ });
423
+ });
424
+ });
425
+
426
+ describe("With pending operations", () => {
427
+ beforeAll(() => {
428
+ jest
429
+ .spyOn(etherscanAPI, "getLastCoinOperations")
430
+ .mockImplementation(() => Promise.resolve([]));
431
+ jest
432
+ .spyOn(etherscanAPI?.default, "getLastCoinOperations")
433
+ .mockImplementation(() => Promise.resolve([]));
434
+ jest
435
+ .spyOn(etherscanAPI, "getLastTokenOperations")
436
+ .mockImplementation(() => Promise.resolve([]));
437
+ jest
438
+ .spyOn(etherscanAPI?.default, "getLastTokenOperations")
439
+ .mockImplementation(() => Promise.resolve([]));
440
+ jest
441
+ .spyOn(synchronization, "getOperationStatus")
442
+ .mockImplementation((currency, op) =>
443
+ Promise.resolve(op.hash === "0xH4sH" ? coinOperation1 : null)
444
+ );
445
+ });
446
+
447
+ afterAll(() => {
448
+ jest.restoreAllMocks();
449
+ });
450
+
451
+ it("should add the confirmed pending operation to the operations", async () => {
452
+ const accountShape = await synchronization.getAccountShape(
453
+ {
454
+ ...getAccountShapeParameters,
455
+ initialAccount: {
456
+ ...account,
457
+ // 2 operations to confirm here, they're differenciated by id
458
+ pendingOperations: [
459
+ coinOperation1,
460
+ {
461
+ ...coinOperation1,
462
+ hash: "0xN0tH4sH",
463
+ id: "js:2:ethereum:0xkvn:-0xN0tH4sH-OUT",
464
+ },
465
+ ],
466
+ },
467
+ },
468
+ {} as any
469
+ );
470
+
471
+ expect(accountShape.operations).toEqual([coinOperation1]);
472
+ });
473
+ });
474
+ });
475
+
476
+ describe("getSubAccounts", () => {
477
+ beforeEach(() => {
478
+ jest
479
+ .spyOn(rpcAPI, "getTokenBalance")
480
+ .mockImplementation(async (a, b, contractAddress) => {
481
+ switch (contractAddress) {
482
+ case "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48": // usdc
483
+ return new BigNumber(1);
484
+ case "0xdAC17F958D2ee523a2206206994597C13D831ec7": // usdt
485
+ return new BigNumber(2);
486
+ default:
487
+ return new BigNumber(0);
488
+ }
489
+ });
490
+ });
491
+ afterEach(() => {
492
+ jest.restoreAllMocks();
493
+ });
494
+
495
+ it("should return the right subAccounts", async () => {
496
+ jest
497
+ .spyOn(etherscanAPI?.default, "getLastTokenOperations")
498
+ .mockImplementation(async () => [
499
+ { tokenCurrency: tokenCurrency1, operation: tokenOperation1 },
500
+ { tokenCurrency: tokenCurrency1, operation: tokenOperation2 },
501
+ { tokenCurrency: tokenCurrency2, operation: tokenOperation4 },
502
+ {
503
+ tokenCurrency: undefined as any,
504
+ operation: ignoredTokenOperation,
505
+ },
506
+ ]);
507
+
508
+ const tokenAccounts = await synchronization.getSubAccounts(
509
+ {
510
+ ...getAccountShapeParameters,
511
+ initialAccount: account,
512
+ },
513
+ account.id
514
+ );
515
+
516
+ const expectedUsdcAccount = {
517
+ ...tokenAccount,
518
+ balance: new BigNumber(1),
519
+ spendableBalance: new BigNumber(1),
520
+ operations: [tokenOperation1, tokenOperation2],
521
+ operationsCount: 2,
522
+ starred: undefined,
523
+ swapHistory: [],
524
+ };
525
+ const expectedUsdtAccount = {
526
+ ...makeTokenAccount(account.freshAddress, tokenCurrency2),
527
+ balance: new BigNumber(2),
528
+ spendableBalance: new BigNumber(2),
529
+ operations: [tokenOperation4],
530
+ operationsCount: 1,
531
+ starred: undefined,
532
+ swapHistory: [],
533
+ };
534
+
535
+ expect(tokenAccounts).toEqual([
536
+ expectedUsdcAccount,
537
+ expectedUsdtAccount,
538
+ ]);
539
+ });
540
+
541
+ it("should return a partial sub account based on blockHeight", async () => {
542
+ jest
543
+ .spyOn(etherscanAPI?.default, "getLastTokenOperations")
544
+ .mockImplementation(async () => [
545
+ { tokenCurrency: tokenCurrency1, operation: tokenOperation3 },
546
+ ]);
547
+
548
+ const incompleteUsdcAccount = {
549
+ ...tokenAccount,
550
+ balance: new BigNumber(0),
551
+ spendableBalance: new BigNumber(0),
552
+ operations: [tokenOperation1, tokenOperation2],
553
+ operationsCount: 1,
554
+ };
555
+ const accountWithIncompleteSubAccount = {
556
+ ...account,
557
+ subAccounts: [incompleteUsdcAccount],
558
+ };
559
+
560
+ const tokenAccounts = await synchronization.getSubAccounts(
561
+ {
562
+ ...getAccountShapeParameters,
563
+ initialAccount: accountWithIncompleteSubAccount,
564
+ },
565
+ account.id
566
+ );
567
+
568
+ const expectedUsdcAccount = {
569
+ ...incompleteUsdcAccount,
570
+ balance: new BigNumber(1),
571
+ spendableBalance: new BigNumber(1),
572
+ operations: [tokenOperation3],
573
+ operationsCount: 1,
574
+ starred: undefined,
575
+ swapHistory: [],
576
+ };
577
+
578
+ expect(tokenAccounts).toEqual([expectedUsdcAccount]);
579
+ // (currency, address, accountId, fromBlock)
580
+ expect(etherscanAPI.default.getLastTokenOperations).toBeCalledWith(
581
+ currency,
582
+ account.freshAddress,
583
+ account.id,
584
+ tokenOperation2.blockHeight
585
+ );
586
+ });
587
+
588
+ it("should throw for currency with unsupported explorer", async () => {
589
+ try {
590
+ await synchronization.getSubAccounts(
591
+ {
592
+ ...getAccountShapeParameters,
593
+ currency: {
594
+ ...currency,
595
+ ethereumLikeInfo: {
596
+ chainId: 1,
597
+ explorer: {
598
+ uri: "http://nope.com",
599
+ type: "unsupported" as any,
600
+ },
601
+ },
602
+ },
603
+ },
604
+ account.id
605
+ );
606
+ fail("Promise should have been rejected");
607
+ } catch (e: any) {
608
+ if (e instanceof AssertionError) {
609
+ throw e;
610
+ }
611
+ expect(e.message).toEqual("API type not supported");
612
+ }
613
+ });
614
+ });
615
+
616
+ describe("getSubAccountShape", () => {
617
+ beforeEach(() => {
618
+ jest
619
+ .spyOn(rpcAPI, "getTokenBalance")
620
+ .mockImplementation(async (a, b, contractAddress) => {
621
+ switch (contractAddress) {
622
+ case "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48": // usdc
623
+ return new BigNumber(1);
624
+ case "0xdAC17F958D2ee523a2206206994597C13D831ec7": // usdt
625
+ return new BigNumber(2);
626
+ default:
627
+ return new BigNumber(0);
628
+ }
629
+ });
630
+ });
631
+ afterEach(() => {
632
+ jest.restoreAllMocks();
633
+ });
634
+
635
+ it("should return a correct sub account shape", async () => {
636
+ const subAccount = await synchronization.getSubAccountShape(
637
+ currency,
638
+ account.id,
639
+ tokenCurrency1,
640
+ [tokenOperation1, tokenOperation2, tokenOperation3]
641
+ );
642
+
643
+ expect(subAccount).toEqual({
644
+ ...tokenAccount,
645
+ balance: new BigNumber(1),
646
+ spendableBalance: new BigNumber(1),
647
+ operations: [tokenOperation1, tokenOperation2, tokenOperation3],
648
+ operationsCount: 3,
649
+ starred: undefined,
650
+ swapHistory: [],
651
+ });
652
+ });
653
+ });
654
+
655
+ describe("getOperationStatus", () => {
656
+ it("should not throw on fail", async () => {
657
+ jest.spyOn(rpcAPI, "getTransaction").mockImplementationOnce(() => {
658
+ throw new Error();
659
+ });
660
+
661
+ expect(
662
+ await synchronization.getOperationStatus(currency, coinOperation1)
663
+ ).toBe(null);
664
+ });
665
+
666
+ it("should return null if retrieved transaction has no blockHeight", async () => {
667
+ jest.spyOn(rpcAPI, "getTransaction").mockImplementationOnce(
668
+ async () =>
669
+ ({
670
+ blockHash: "hash",
671
+ timestamp: 101010010,
672
+ nonce: 1,
673
+ } as any)
674
+ );
675
+
676
+ expect(
677
+ await synchronization.getOperationStatus(currency, coinOperation1)
678
+ ).toBe(null);
679
+ });
680
+
681
+ it("should return the retrieved operation with network properties", async () => {
682
+ jest.spyOn(rpcAPI, "getTransaction").mockImplementationOnce(
683
+ async () =>
684
+ ({
685
+ blockNumber: 10,
686
+ blockHash: "hash",
687
+ timestamp: Date.now() / 1000,
688
+ nonce: 123,
689
+ } as any)
690
+ );
691
+
692
+ expect(
693
+ await synchronization.getOperationStatus(currency, coinOperation1)
694
+ ).toEqual({
695
+ ...coinOperation1,
696
+ blockHash: "hash",
697
+ blockHeight: 10,
698
+ date: new Date(),
699
+ transactionSequenceNumber: 123,
700
+ });
701
+ });
702
+
703
+ it("should return the retrieved operation with network properties even if the rpc doesn't return timestamp", async () => {
704
+ jest.spyOn(rpcAPI, "getTransaction").mockImplementationOnce(
705
+ async () =>
706
+ ({
707
+ blockNumber: 10,
708
+ blockHash: "hash",
709
+ nonce: 123,
710
+ } as any)
711
+ );
712
+ jest
713
+ .spyOn(rpcAPI, "getBlock")
714
+ .mockImplementationOnce(
715
+ async () => ({ timestamp: Date.now() / 1000 } as any)
716
+ );
717
+
718
+ expect(
719
+ await synchronization.getOperationStatus(currency, coinOperation1)
720
+ ).toEqual({
721
+ ...coinOperation1,
722
+ blockHash: "hash",
723
+ blockHeight: 10,
724
+ date: new Date(),
725
+ transactionSequenceNumber: 123,
726
+ });
727
+ });
728
+ });
729
+
730
+ describe("postSync", () => {
731
+ it("should return the freshly synced subAccounts", () => {
732
+ const tokenAccountWithPending = {
733
+ ...tokenAccount,
734
+ pendingOperations: [pendingOperation],
735
+ };
736
+ const accountWithTokenAccount = {
737
+ ...account,
738
+ subAccounts: [tokenAccountWithPending],
739
+ };
740
+
741
+ expect(
742
+ synchronization.postSync(
743
+ { ...account, subAccounts: [] },
744
+ accountWithTokenAccount
745
+ )
746
+ ).toEqual(accountWithTokenAccount);
747
+ });
748
+
749
+ it("should remove pending operations if the main account has removed it", () => {
750
+ const tokenAccountWithPending = {
751
+ ...tokenAccount,
752
+ pendingOperations: [pendingOperation],
753
+ };
754
+ const accountWithPending = {
755
+ ...account,
756
+ subAccounts: [tokenAccountWithPending],
757
+ pendingOperations: [pendingOperation],
758
+ };
759
+
760
+ // should not change anything if we maintain the pending op
761
+ expect(
762
+ synchronization.postSync(accountWithPending, accountWithPending)
763
+ ).toEqual(accountWithPending);
764
+ // Should remove the pending from tokenAccount as well if removed from main account
765
+ expect(
766
+ synchronization.postSync(accountWithPending, {
767
+ ...accountWithPending,
768
+ pendingOperations: [],
769
+ })
770
+ ).toEqual(account);
771
+ });
772
+
773
+ it("should remove pending operation if the token account has confirmed it", () => {
774
+ const tokenAccountWithPending = {
775
+ ...tokenAccount,
776
+ pendingOperations: [pendingOperation],
777
+ };
778
+ const accountWithPending = {
779
+ ...account,
780
+ subAccounts: [tokenAccountWithPending],
781
+ pendingOperations: [pendingOperation],
782
+ };
783
+
784
+ // Should remove the pending from tokenAccount if it was confirmed in the tokenAccount ops
785
+ expect(
786
+ synchronization.postSync(accountWithPending, {
787
+ ...accountWithPending,
788
+ pendingOperations: [pendingOperation],
789
+ subAccounts: [
790
+ {
791
+ ...tokenAccountWithPending,
792
+ operations: [pendingOperation],
793
+ },
794
+ ],
795
+ })
796
+ ).toEqual({
797
+ ...accountWithPending,
798
+ subAccounts: [
799
+ {
800
+ ...tokenAccountWithPending,
801
+ operations: [pendingOperation],
802
+ pendingOperations: [],
803
+ },
804
+ ],
805
+ });
806
+ });
807
+
808
+ it("should remove pending operation if ", () => {
809
+ const latePending = {
810
+ ...pendingOperation,
811
+ date: new Date() + getEnv("OPERATION_OPTIMISTIC_RETENTION") + 1,
812
+ };
813
+ const tokenAccountWithPending = {
814
+ ...tokenAccount,
815
+ pendingOperations: [latePending],
816
+ };
817
+ const accountWithTokenAccount = {
818
+ ...account,
819
+ subAccounts: [tokenAccountWithPending],
820
+ };
821
+
822
+ // Should remove the pending from tokenAccount if it was confirmed in the tokenAccount ops
823
+ expect(
824
+ synchronization.postSync(
825
+ accountWithTokenAccount,
826
+ accountWithTokenAccount
827
+ )
828
+ ).toEqual(account);
829
+ });
830
+ });
831
+ });
832
+ });