@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.
- package/.eslintrc.js +57 -0
- package/.turbo/turbo-build.log +4 -0
- package/CHANGELOG.md +18 -0
- package/jest.config.js +6 -0
- package/package.json +102 -0
- package/src/__tests__/adapters.unit.test.ts +527 -0
- package/src/__tests__/broadcast.unit.test.ts +181 -0
- package/src/__tests__/buildOptimisticOperation.unit.test.ts +182 -0
- package/src/__tests__/createTransaction.unit.test.ts +52 -0
- package/src/__tests__/deviceTransactionConfig.unit.test.ts +245 -0
- package/src/__tests__/estimateMaxSpendable.unit.test.ts +123 -0
- package/src/__tests__/getTransactionStatus.unit.test.ts +355 -0
- package/src/__tests__/hw-getAddress.unit.test.ts +24 -0
- package/src/__tests__/logic.unit.test.ts +406 -0
- package/src/__tests__/preload.unit.test.ts +139 -0
- package/src/__tests__/prepareTransaction.unit.test.ts +394 -0
- package/src/__tests__/rpc.unit.test.ts +532 -0
- package/src/__tests__/signOperation.unit.test.ts +157 -0
- package/src/__tests__/synchronization.unit.test.ts +832 -0
- package/src/__tests__/transaction.unit.test.ts +196 -0
- package/src/abis/erc20.abi.json +230 -0
- package/src/abis/optimismGasPriceOracle.abi.json +252 -0
- package/src/adapters.ts +148 -0
- package/src/api/etherscan.ts +124 -0
- package/src/api/rpc.common.ts +354 -0
- package/src/api/rpc.native.ts +5 -0
- package/src/api/rpc.ts +2 -0
- package/src/bridge/js.ts +77 -0
- package/src/bridge.integration.test.ts +93 -0
- package/src/broadcast.ts +40 -0
- package/src/buildOptimisticOperation.ts +113 -0
- package/src/cli-transaction.ts +11 -0
- package/src/createTransaction.ts +25 -0
- package/src/datasets/ethereum.scanAccounts.1.ts +48 -0
- package/src/datasets/ethereum1.ts +20 -0
- package/src/datasets/ethereum2.ts +20 -0
- package/src/datasets/ethereum_classic.ts +68 -0
- package/src/deviceTransactionConfig.ts +64 -0
- package/src/errors.ts +5 -0
- package/src/estimateMaxSpendable.ts +19 -0
- package/src/getTransactionStatus.ts +186 -0
- package/src/hw-getAddress.ts +24 -0
- package/src/logic.ts +149 -0
- package/src/preload.ts +54 -0
- package/src/prepareTransaction.ts +176 -0
- package/src/signOperation.ts +127 -0
- package/src/specs.ts +344 -0
- package/src/speculos-deviceActions.ts +83 -0
- package/src/synchronization.ts +317 -0
- package/src/testUtils.ts +153 -0
- package/src/transaction.ts +193 -0
- package/src/types.ts +132 -0
- 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
|
+
});
|