@ledgerhq/coin-filecoin 1.14.1-nightly.20251114023758 → 1.15.0-nightly.20251118023800

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 (56) hide show
  1. package/.unimportedrc.json +2 -0
  2. package/CHANGELOG.md +11 -7
  3. package/lib/api/api.d.ts +5 -3
  4. package/lib/api/api.d.ts.map +1 -1
  5. package/lib/api/api.js +66 -21
  6. package/lib/api/api.js.map +1 -1
  7. package/lib/common-logic/index.d.ts +1 -1
  8. package/lib/common-logic/index.d.ts.map +1 -1
  9. package/lib/common-logic/index.js +1 -3
  10. package/lib/common-logic/index.js.map +1 -1
  11. package/lib/common-logic/utils.d.ts +0 -2
  12. package/lib/common-logic/utils.d.ts.map +1 -1
  13. package/lib/common-logic/utils.js +16 -68
  14. package/lib/common-logic/utils.js.map +1 -1
  15. package/lib/erc20/tokenAccounts.d.ts +2 -3
  16. package/lib/erc20/tokenAccounts.d.ts.map +1 -1
  17. package/lib/erc20/tokenAccounts.js +46 -32
  18. package/lib/erc20/tokenAccounts.js.map +1 -1
  19. package/lib/test/fixtures.d.ts +87 -0
  20. package/lib/test/fixtures.d.ts.map +1 -0
  21. package/lib/test/fixtures.js +311 -0
  22. package/lib/test/fixtures.js.map +1 -0
  23. package/lib/types/common.d.ts +22 -2
  24. package/lib/types/common.d.ts.map +1 -1
  25. package/lib-es/api/api.d.ts +5 -3
  26. package/lib-es/api/api.d.ts.map +1 -1
  27. package/lib-es/api/api.js +61 -18
  28. package/lib-es/api/api.js.map +1 -1
  29. package/lib-es/common-logic/index.d.ts +1 -1
  30. package/lib-es/common-logic/index.d.ts.map +1 -1
  31. package/lib-es/common-logic/index.js +1 -1
  32. package/lib-es/common-logic/index.js.map +1 -1
  33. package/lib-es/common-logic/utils.d.ts +0 -2
  34. package/lib-es/common-logic/utils.d.ts.map +1 -1
  35. package/lib-es/common-logic/utils.js +16 -66
  36. package/lib-es/common-logic/utils.js.map +1 -1
  37. package/lib-es/erc20/tokenAccounts.d.ts +2 -3
  38. package/lib-es/erc20/tokenAccounts.d.ts.map +1 -1
  39. package/lib-es/erc20/tokenAccounts.js +47 -33
  40. package/lib-es/erc20/tokenAccounts.js.map +1 -1
  41. package/lib-es/test/fixtures.d.ts +87 -0
  42. package/lib-es/test/fixtures.d.ts.map +1 -0
  43. package/lib-es/test/fixtures.js +297 -0
  44. package/lib-es/test/fixtures.js.map +1 -0
  45. package/lib-es/types/common.d.ts +22 -2
  46. package/lib-es/types/common.d.ts.map +1 -1
  47. package/package.json +6 -6
  48. package/src/api/api.ts +107 -26
  49. package/src/api/api.unit.test.ts +217 -0
  50. package/src/common-logic/index.ts +0 -2
  51. package/src/common-logic/utils.ts +19 -90
  52. package/src/common-logic/utils.unit.test.ts +429 -0
  53. package/src/erc20/tokenAccounts.ts +59 -34
  54. package/src/erc20/tokenAccounts.unit.test.ts +73 -0
  55. package/src/test/fixtures.ts +342 -0
  56. package/src/types/common.ts +24 -2
@@ -0,0 +1,429 @@
1
+ import {
2
+ mapTxToOps,
3
+ getAddress,
4
+ getTxToBroadcast,
5
+ getAccountShape,
6
+ getSubAccount,
7
+ valueFromUnit,
8
+ } from "./utils";
9
+ import {
10
+ createMockAccount,
11
+ createMockTransactionResponse,
12
+ createMockOperation,
13
+ createMockTransaction,
14
+ TEST_ADDRESSES,
15
+ createMockBalanceResponse,
16
+ createMockTokenAccount,
17
+ TEST_BLOCK_HEIGHTS,
18
+ } from "../test/fixtures";
19
+ import { TxStatus } from "../types";
20
+ import BigNumber from "bignumber.js";
21
+ import { DerivationMode } from "@ledgerhq/types-live";
22
+ import * as api from "../api/api";
23
+ import * as tokenAccounts from "../erc20/tokenAccounts";
24
+
25
+ // Mock API and token account modules
26
+ jest.mock("../api/api");
27
+ jest.mock("../erc20/tokenAccounts");
28
+
29
+ describe("common-logic/utils", () => {
30
+ describe("mapTxToOps", () => {
31
+ const createAccountShapeInfo = (address: string) => ({
32
+ address,
33
+ currency: createMockAccount().currency,
34
+ index: 0,
35
+ derivationPath: "44'/461'/0'/0/0",
36
+ derivationMode: "" as DerivationMode,
37
+ });
38
+
39
+ it("should convert send transaction to OUT operation", () => {
40
+ const account = createMockAccount({
41
+ freshAddress: TEST_ADDRESSES.F1_ADDRESS,
42
+ });
43
+
44
+ const tx = createMockTransactionResponse({
45
+ from: TEST_ADDRESSES.F1_ADDRESS,
46
+ to: TEST_ADDRESSES.RECIPIENT_F1,
47
+ amount: "100000000000000000",
48
+ status: TxStatus.Ok,
49
+ });
50
+
51
+ const mapper = mapTxToOps(account.id, createAccountShapeInfo(TEST_ADDRESSES.F1_ADDRESS));
52
+ const ops = mapper(tx);
53
+
54
+ expect(ops).toHaveLength(1);
55
+ expect(ops[0].type).toBe("OUT");
56
+ expect(ops[0].value.gt(new BigNumber("100000000000000000"))).toBe(true); // includes fees
57
+ expect(ops[0].hasFailed).toBe(false);
58
+ });
59
+
60
+ it("should convert receive transaction to IN operation", () => {
61
+ const account = createMockAccount({
62
+ freshAddress: TEST_ADDRESSES.RECIPIENT_F1,
63
+ });
64
+
65
+ const tx = createMockTransactionResponse({
66
+ from: TEST_ADDRESSES.F1_ADDRESS,
67
+ to: TEST_ADDRESSES.RECIPIENT_F1,
68
+ amount: "100000000000000000",
69
+ });
70
+
71
+ const mapper = mapTxToOps(account.id, createAccountShapeInfo(TEST_ADDRESSES.RECIPIENT_F1));
72
+ const ops = mapper(tx);
73
+
74
+ expect(ops).toHaveLength(1);
75
+ expect(ops[0].type).toBe("IN");
76
+ expect(ops[0].value.isEqualTo(new BigNumber("100000000000000000"))).toBe(true);
77
+ });
78
+
79
+ it("should handle zero amount transaction as FEES", () => {
80
+ const account = createMockAccount({
81
+ freshAddress: TEST_ADDRESSES.F1_ADDRESS,
82
+ });
83
+
84
+ const tx = createMockTransactionResponse({
85
+ from: TEST_ADDRESSES.F1_ADDRESS,
86
+ to: TEST_ADDRESSES.RECIPIENT_F1,
87
+ amount: "0",
88
+ });
89
+
90
+ const mapper = mapTxToOps(account.id, createAccountShapeInfo(TEST_ADDRESSES.F1_ADDRESS));
91
+ const ops = mapper(tx);
92
+
93
+ expect(ops).toHaveLength(1);
94
+ expect(ops[0].type).toBe("FEES");
95
+ });
96
+
97
+ it("should mark failed transactions", () => {
98
+ const account = createMockAccount({
99
+ freshAddress: TEST_ADDRESSES.F1_ADDRESS,
100
+ });
101
+
102
+ const tx = createMockTransactionResponse({
103
+ from: TEST_ADDRESSES.F1_ADDRESS,
104
+ to: TEST_ADDRESSES.RECIPIENT_F1,
105
+ status: "Fail",
106
+ });
107
+
108
+ const mapper = mapTxToOps(account.id, createAccountShapeInfo(TEST_ADDRESSES.F1_ADDRESS));
109
+ const ops = mapper(tx);
110
+
111
+ expect(ops[0].hasFailed).toBe(true);
112
+ });
113
+
114
+ it("should handle self-transfer (both send and receive)", () => {
115
+ const account = createMockAccount({
116
+ freshAddress: TEST_ADDRESSES.F1_ADDRESS,
117
+ });
118
+
119
+ const tx = createMockTransactionResponse({
120
+ from: TEST_ADDRESSES.F1_ADDRESS,
121
+ to: TEST_ADDRESSES.F1_ADDRESS,
122
+ amount: "100000000000000000",
123
+ });
124
+
125
+ const mapper = mapTxToOps(account.id, createAccountShapeInfo(TEST_ADDRESSES.F1_ADDRESS));
126
+ const ops = mapper(tx);
127
+
128
+ expect(ops).toHaveLength(2);
129
+ expect(ops.some(op => op.type === "OUT")).toBe(true);
130
+ expect(ops.some(op => op.type === "IN")).toBe(true);
131
+ });
132
+ });
133
+
134
+ describe("getAddress", () => {
135
+ it("should extract address and derivation path from account", () => {
136
+ const account = createMockAccount({
137
+ freshAddress: TEST_ADDRESSES.F1_ADDRESS,
138
+ freshAddressPath: "44'/461'/0'/0/0",
139
+ });
140
+
141
+ const result = getAddress(account);
142
+
143
+ expect(result.address).toBe(TEST_ADDRESSES.F1_ADDRESS);
144
+ expect(result.derivationPath).toBe("44'/461'/0'/0/0");
145
+ });
146
+ });
147
+
148
+ describe("getTxToBroadcast", () => {
149
+ it("should format transaction for broadcasting", () => {
150
+ const account = createMockAccount();
151
+ const transaction = createMockTransaction();
152
+ const operation = createMockOperation(account, transaction);
153
+
154
+ const rawData = {
155
+ sender: TEST_ADDRESSES.F1_ADDRESS,
156
+ recipient: TEST_ADDRESSES.RECIPIENT_F1,
157
+ gasLimit: new BigNumber(1000000),
158
+ gasFeeCap: new BigNumber("100000"),
159
+ gasPremium: new BigNumber("100000"),
160
+ method: 0,
161
+ version: 0,
162
+ nonce: 5,
163
+ signatureType: 1,
164
+ params: "",
165
+ value: "100000000000000000",
166
+ };
167
+
168
+ const result = getTxToBroadcast(operation, "signature_data", rawData);
169
+
170
+ expect(result.message.from).toBe(TEST_ADDRESSES.F1_ADDRESS);
171
+ expect(result.message.to).toBe(TEST_ADDRESSES.RECIPIENT_F1);
172
+ expect(result.message.gaslimit).toBe(1000000);
173
+ expect(result.message.gasfeecap).toBe("100000");
174
+ expect(result.message.gaspremium).toBe("100000");
175
+ expect(result.message.nonce).toBe(5);
176
+ expect(result.signature.type).toBe(1);
177
+ expect(result.signature.data).toBe("signature_data");
178
+ });
179
+
180
+ it("should handle ERC20 contract calls with params", () => {
181
+ const operation = createMockOperation(createMockAccount(), createMockTransaction());
182
+
183
+ const rawData = {
184
+ sender: TEST_ADDRESSES.F4_ADDRESS,
185
+ recipient: TEST_ADDRESSES.ERC20_CONTRACT,
186
+ gasLimit: new BigNumber(2000000),
187
+ gasFeeCap: new BigNumber("200000"),
188
+ gasPremium: new BigNumber("150000"),
189
+ method: 3844450837,
190
+ version: 0,
191
+ nonce: 10,
192
+ signatureType: 1,
193
+ params: "base64encodedparams",
194
+ value: "0",
195
+ };
196
+
197
+ const result = getTxToBroadcast(operation, "sig", rawData);
198
+
199
+ expect(result.message.params).toBe("base64encodedparams");
200
+ expect(result.message.method).toBe(3844450837);
201
+ expect(result.message.value).toBe("0");
202
+ });
203
+ });
204
+
205
+ describe("getAccountShape", () => {
206
+ const mockedFetchBlockHeight = api.fetchBlockHeight as jest.MockedFunction<
207
+ typeof api.fetchBlockHeight
208
+ >;
209
+ const mockedFetchBalances = api.fetchBalances as jest.MockedFunction<typeof api.fetchBalances>;
210
+ const mockedFetchTxsWithPages = api.fetchTxsWithPages as jest.MockedFunction<
211
+ typeof api.fetchTxsWithPages
212
+ >;
213
+ const mockedBuildTokenAccounts = tokenAccounts.buildTokenAccounts as jest.MockedFunction<
214
+ typeof tokenAccounts.buildTokenAccounts
215
+ >;
216
+
217
+ const mockSyncConfig = {
218
+ paginationConfig: {},
219
+ blacklistedTokenIds: [],
220
+ };
221
+
222
+ beforeEach(() => {
223
+ jest.clearAllMocks();
224
+ });
225
+
226
+ it("should fetch and build account shape with balances and token accounts", async () => {
227
+ const mockAccount = createMockAccount({
228
+ freshAddress: TEST_ADDRESSES.F1_ADDRESS,
229
+ blockHeight: TEST_BLOCK_HEIGHTS.CURRENT,
230
+ });
231
+
232
+ const mockBalance = createMockBalanceResponse({
233
+ total_balance: "1000000000000000000",
234
+ spendable_balance: "900000000000000000",
235
+ });
236
+
237
+ const mockBlockHeight = {
238
+ current_block_identifier: {
239
+ index: TEST_BLOCK_HEIGHTS.CURRENT,
240
+ hash: "current_block_hash",
241
+ },
242
+ genesis_block_identifier: {
243
+ index: 0,
244
+ hash: "genesis",
245
+ },
246
+ current_block_timestamp: Date.now(),
247
+ };
248
+
249
+ const mockTxs = [
250
+ createMockTransactionResponse({
251
+ from: TEST_ADDRESSES.F1_ADDRESS,
252
+ to: TEST_ADDRESSES.RECIPIENT_F1,
253
+ }),
254
+ ];
255
+
256
+ const mockParentAccount = createMockAccount();
257
+ const mockTokenAccounts = [
258
+ createMockTokenAccount(mockParentAccount, {
259
+ balance: new BigNumber("5000000000000000000"),
260
+ }),
261
+ ];
262
+
263
+ mockedFetchBlockHeight.mockResolvedValue(mockBlockHeight);
264
+ mockedFetchBalances.mockResolvedValue(mockBalance);
265
+ mockedFetchTxsWithPages.mockResolvedValue(mockTxs);
266
+ mockedBuildTokenAccounts.mockResolvedValue(mockTokenAccounts);
267
+
268
+ const info = {
269
+ address: TEST_ADDRESSES.F1_ADDRESS,
270
+ currency: mockAccount.currency,
271
+ derivationMode: "" as DerivationMode,
272
+ initialAccount: mockAccount,
273
+ index: 0,
274
+ derivationPath: "44'/461'/0'/0/0",
275
+ };
276
+
277
+ const result = await getAccountShape(info, mockSyncConfig);
278
+
279
+ expect(result.balance?.isEqualTo(new BigNumber("1000000000000000000"))).toBe(true);
280
+ expect(result.spendableBalance?.isEqualTo(new BigNumber("900000000000000000"))).toBe(true);
281
+ expect(result.blockHeight).toBe(TEST_BLOCK_HEIGHTS.CURRENT);
282
+ expect(result.subAccounts).toEqual(mockTokenAccounts);
283
+ expect(result.operations).toBeDefined();
284
+ });
285
+
286
+ it("should handle block height safe delta correctly", async () => {
287
+ const mockAccount = createMockAccount({
288
+ blockHeight: 500, // Less than blockSafeDelta (1200)
289
+ });
290
+
291
+ const mockBalance = createMockBalanceResponse();
292
+ const mockBlockHeight = {
293
+ current_block_identifier: {
294
+ index: TEST_BLOCK_HEIGHTS.CURRENT,
295
+ hash: "hash",
296
+ },
297
+ genesis_block_identifier: {
298
+ index: 0,
299
+ hash: "genesis",
300
+ },
301
+ current_block_timestamp: Date.now(),
302
+ };
303
+
304
+ mockedFetchBlockHeight.mockResolvedValue(mockBlockHeight);
305
+ mockedFetchBalances.mockResolvedValue(mockBalance);
306
+ mockedFetchTxsWithPages.mockResolvedValue([]);
307
+ mockedBuildTokenAccounts.mockResolvedValue([]);
308
+
309
+ const info = {
310
+ address: TEST_ADDRESSES.F1_ADDRESS,
311
+ currency: mockAccount.currency,
312
+ derivationMode: "" as DerivationMode,
313
+ initialAccount: mockAccount,
314
+ index: 0,
315
+ derivationPath: "44'/461'/0'/0/0",
316
+ };
317
+
318
+ await getAccountShape(info, mockSyncConfig);
319
+
320
+ // Should call fetchTxsWithPages with lastHeight = 0 (not negative)
321
+ expect(mockedFetchTxsWithPages).toHaveBeenCalledWith(TEST_ADDRESSES.F1_ADDRESS, 0);
322
+ });
323
+
324
+ it("should sort operations by date descending", async () => {
325
+ const now = Math.floor(Date.now() / 1000);
326
+ const mockBalance = createMockBalanceResponse();
327
+ const mockBlockHeight = {
328
+ current_block_identifier: {
329
+ index: TEST_BLOCK_HEIGHTS.CURRENT,
330
+ hash: "hash",
331
+ },
332
+ genesis_block_identifier: {
333
+ index: 0,
334
+ hash: "genesis",
335
+ },
336
+ current_block_timestamp: Date.now(),
337
+ };
338
+
339
+ const mockTxs = [
340
+ createMockTransactionResponse({
341
+ hash: "tx1",
342
+ timestamp: now - 1000,
343
+ from: TEST_ADDRESSES.F1_ADDRESS,
344
+ to: TEST_ADDRESSES.RECIPIENT_F1,
345
+ }),
346
+ createMockTransactionResponse({
347
+ hash: "tx2",
348
+ timestamp: now - 500,
349
+ from: TEST_ADDRESSES.F1_ADDRESS,
350
+ to: TEST_ADDRESSES.RECIPIENT_F1,
351
+ }),
352
+ createMockTransactionResponse({
353
+ hash: "tx3",
354
+ timestamp: now - 2000,
355
+ from: TEST_ADDRESSES.F1_ADDRESS,
356
+ to: TEST_ADDRESSES.RECIPIENT_F1,
357
+ }),
358
+ ];
359
+
360
+ mockedFetchBlockHeight.mockResolvedValue(mockBlockHeight);
361
+ mockedFetchBalances.mockResolvedValue(mockBalance);
362
+ mockedFetchTxsWithPages.mockResolvedValue(mockTxs);
363
+ mockedBuildTokenAccounts.mockResolvedValue([]);
364
+
365
+ const info = {
366
+ address: TEST_ADDRESSES.F1_ADDRESS,
367
+ currency: createMockAccount().currency,
368
+ derivationMode: "" as DerivationMode,
369
+ initialAccount: undefined,
370
+ index: 0,
371
+ derivationPath: "44'/461'/0'/0/0",
372
+ };
373
+
374
+ const result = await getAccountShape(info, mockSyncConfig);
375
+
376
+ // Operations should be sorted newest first
377
+ expect(result.operations?.[0].hash).toBe("tx2");
378
+ expect(result.operations?.[1].hash).toBe("tx1");
379
+ expect(result.operations?.[2].hash).toBe("tx3");
380
+ });
381
+ });
382
+
383
+ describe("getSubAccount", () => {
384
+ it("should return sub account when transaction has subAccountId", () => {
385
+ const account = createMockAccount();
386
+ const subAccount = createMockTokenAccount(account, {
387
+ id: "subaccount123",
388
+ });
389
+
390
+ const accountWithSub = createMockAccount({
391
+ subAccounts: [subAccount],
392
+ });
393
+
394
+ const transaction = createMockTransaction({
395
+ subAccountId: "subaccount123",
396
+ });
397
+
398
+ const result = getSubAccount(accountWithSub, transaction);
399
+
400
+ expect(result).toEqual(subAccount);
401
+ });
402
+ });
403
+
404
+ describe("valueFromUnit", () => {
405
+ it("should convert value with unit magnitude", () => {
406
+ const unit = {
407
+ name: "FIL",
408
+ code: "FIL",
409
+ magnitude: 18,
410
+ };
411
+
412
+ const result = valueFromUnit(new BigNumber(1), unit);
413
+
414
+ expect(result.isEqualTo(new BigNumber("1000000000000000000"))).toBe(true);
415
+ });
416
+
417
+ it("should handle decimal values", () => {
418
+ const unit = {
419
+ name: "FIL",
420
+ code: "FIL",
421
+ magnitude: 18,
422
+ };
423
+
424
+ const result = valueFromUnit(new BigNumber("0.5"), unit);
425
+
426
+ expect(result.isEqualTo(new BigNumber("500000000000000000"))).toBe(true);
427
+ });
428
+ });
429
+ });
@@ -1,6 +1,6 @@
1
1
  import cbor from "@zondax/cbor";
2
2
  import { Account, Operation, TokenAccount } from "@ledgerhq/types-live";
3
- import { fetchERC20TokenBalance, fetchERC20Transactions } from "../api";
3
+ import { fetchERC20TokenBalance, fetchERC20TransactionsWithPages } from "../api";
4
4
  import invariant from "invariant";
5
5
  import { ERC20Transfer, TxStatus } from "../types";
6
6
  import { emptyHistoryCache, encodeTokenAccountId } from "@ledgerhq/coin-framework/account/index";
@@ -12,19 +12,17 @@ import { convertAddressFilToEth } from "../network";
12
12
  import { ethers } from "ethers";
13
13
  import contractABI from "./ERC20.json";
14
14
  import { RecipientRequired } from "@ledgerhq/errors";
15
- import { Unit } from "@ledgerhq/types-cryptoassets";
16
15
  import { AccountType } from "../bridge/utils";
17
- import { valueFromUnit } from "../common-logic/utils";
16
+ import { mergeOps } from "@ledgerhq/coin-framework/bridge/jsHelpers";
18
17
 
19
18
  export const erc20TxnToOperation = (
20
19
  tx: ERC20Transfer,
21
20
  address: string,
22
21
  accountId: string,
23
- unit: Unit,
24
22
  ): Operation[] => {
25
23
  try {
26
24
  const { to, from, timestamp, tx_hash, tx_cid, amount, height, status } = tx;
27
- const value = valueFromUnit(new BigNumber(amount), unit);
25
+ const txAmount = new BigNumber(amount);
28
26
 
29
27
  const isSending = address.toLowerCase() === from.toLowerCase();
30
28
  const isReceiving = address.toLowerCase() === to.toLowerCase();
@@ -41,7 +39,7 @@ export const erc20TxnToOperation = (
41
39
  id: encodeOperationId(accountId, hash, "OUT"),
42
40
  hash,
43
41
  type: "OUT",
44
- value: value,
42
+ value: txAmount,
45
43
  fee,
46
44
  blockHeight: height,
47
45
  blockHash: "",
@@ -59,7 +57,7 @@ export const erc20TxnToOperation = (
59
57
  id: encodeOperationId(accountId, hash, "IN"),
60
58
  hash,
61
59
  type: "IN",
62
- value,
60
+ value: txAmount,
63
61
  fee,
64
62
  blockHeight: height,
65
63
  blockHash: "",
@@ -83,51 +81,71 @@ export const erc20TxnToOperation = (
83
81
 
84
82
  export async function buildTokenAccounts(
85
83
  filAddr: string,
84
+ lastHeight: number,
86
85
  parentAccountId: string,
87
86
  initialAccount?: Account,
88
87
  ): Promise<TokenAccount[]> {
89
88
  try {
90
- const transfers = await fetchERC20Transactions(filAddr);
91
- const transfersUntangled: { [addr: string]: ERC20Transfer[] } = transfers.reduce(
92
- (prev: { [addr: string]: ERC20Transfer[] }, curr: ERC20Transfer) => {
93
- curr.contract_address = curr.contract_address.toLowerCase();
94
- if (prev[curr.contract_address]) {
95
- prev[curr.contract_address] = [...prev[curr.contract_address], curr];
96
- } else {
97
- prev[curr.contract_address] = [curr];
89
+ const transfers = await fetchERC20TransactionsWithPages(filAddr, lastHeight);
90
+
91
+ if (!transfers.length) {
92
+ return initialAccount?.subAccounts ?? [];
93
+ }
94
+
95
+ // Group transfers by contract address (normalized to lowercase)
96
+ const transfersByContract = transfers.reduce<Record<string, ERC20Transfer[]>>(
97
+ (acc, transfer) => {
98
+ const contractAddr = transfer.contract_address.toLowerCase();
99
+ transfer.contract_address = contractAddr;
100
+
101
+ if (!acc[contractAddr]) {
102
+ acc[contractAddr] = [];
98
103
  }
99
- return prev;
104
+ acc[contractAddr].push(transfer);
105
+ return acc;
100
106
  },
101
107
  {},
102
108
  );
103
109
 
104
- const subs: TokenAccount[] = [];
105
- for (const [cAddr, txns] of Object.entries(transfersUntangled)) {
106
- const token = await getCryptoAssetsStore().findTokenByAddressInCurrency(cAddr, "filecoin");
110
+ // Create lookup map for existing sub-accounts
111
+ const existingSubAccounts = new Map(
112
+ initialAccount?.subAccounts?.map(sa => [sa.token.contractAddress.toLowerCase(), sa]) ?? [],
113
+ );
114
+
115
+ // Track which existing accounts we've processed
116
+ const processedContracts = new Set<string>();
117
+ const tokenAccounts: TokenAccount[] = [];
118
+
119
+ // Process accounts with new transfers
120
+ for (const [contractAddr, txns] of Object.entries(transfersByContract)) {
121
+ processedContracts.add(contractAddr);
122
+
123
+ const token = await getCryptoAssetsStore().findTokenByAddressInCurrency(
124
+ contractAddr,
125
+ "filecoin",
126
+ );
107
127
  if (!token) {
108
- log("error", `filecoin token not found, addr: ${cAddr}`);
128
+ log("error", `filecoin token not found, addr: ${contractAddr}`);
109
129
  continue;
110
130
  }
111
131
 
112
- const balance = await fetchERC20TokenBalance(filAddr, cAddr);
113
- const bnBalance = new BigNumber(balance.toString());
132
+ const balance = await fetchERC20TokenBalance(filAddr, contractAddr);
133
+ const bnBalance = new BigNumber(balance);
114
134
  const tokenAccountId = encodeTokenAccountId(parentAccountId, token);
115
135
 
116
136
  const operations = txns
117
- .flatMap(txn => erc20TxnToOperation(txn, filAddr, tokenAccountId, token.units[0]))
137
+ .flatMap(txn => erc20TxnToOperation(txn, filAddr, tokenAccountId))
118
138
  .flat()
119
139
  .sort((a, b) => b.date.getTime() - a.date.getTime());
120
140
 
141
+ // Skip if no operations and zero balance
121
142
  if (operations.length === 0 && bnBalance.isZero()) {
122
143
  continue;
123
144
  }
124
145
 
125
- const maybeExistingSubAccount =
126
- initialAccount &&
127
- initialAccount.subAccounts &&
128
- initialAccount.subAccounts.find(a => a.id === tokenAccountId);
146
+ const existingAccount = existingSubAccounts.get(contractAddr);
129
147
 
130
- const sub: TokenAccount = {
148
+ const tokenAccount: TokenAccount = {
131
149
  type: AccountType.TokenAccount,
132
150
  id: tokenAccountId,
133
151
  parentId: parentAccountId,
@@ -135,17 +153,24 @@ export async function buildTokenAccounts(
135
153
  balance: bnBalance,
136
154
  spendableBalance: bnBalance,
137
155
  operationsCount: txns.length,
138
- operations,
139
- pendingOperations: maybeExistingSubAccount ? maybeExistingSubAccount.pendingOperations : [],
140
- creationDate: operations.length > 0 ? operations[0].date : new Date(),
141
- swapHistory: maybeExistingSubAccount ? maybeExistingSubAccount.swapHistory : [],
156
+ operations: mergeOps(existingAccount?.operations ?? [], operations),
157
+ pendingOperations: existingAccount?.pendingOperations ?? [],
158
+ creationDate: operations[operations.length - 1]?.date ?? new Date(),
159
+ swapHistory: existingAccount?.swapHistory ?? [],
142
160
  balanceHistoryCache: emptyHistoryCache, // calculated in the jsHelpers
143
161
  };
144
162
 
145
- subs.push(sub);
163
+ tokenAccounts.push(tokenAccount);
164
+ }
165
+
166
+ // Add existing accounts that didn't have new transfers
167
+ for (const [contractAddr, existingAccount] of existingSubAccounts) {
168
+ if (!processedContracts.has(contractAddr)) {
169
+ tokenAccounts.push(existingAccount);
170
+ }
146
171
  }
147
172
 
148
- return subs;
173
+ return tokenAccounts;
149
174
  } catch (e) {
150
175
  log("error", "filecoin error building token accounts", e);
151
176
  return [];
@@ -0,0 +1,73 @@
1
+ import { erc20TxnToOperation } from "./tokenAccounts";
2
+ import { createMockERC20Transfer, TEST_ADDRESSES } from "../test/fixtures";
3
+ import BigNumber from "bignumber.js";
4
+ import { TxStatus } from "../types";
5
+
6
+ jest.mock("@ledgerhq/logs", () => ({
7
+ log: jest.fn(),
8
+ }));
9
+
10
+ jest.mock("@ledgerhq/coin-framework/crypto-assets/index", () => ({
11
+ getCryptoAssetsStore: () => ({
12
+ findTokenByAddress: jest.fn(),
13
+ }),
14
+ }));
15
+
16
+ describe("erc20/tokenAccounts", () => {
17
+ describe("erc20TxnToOperation", () => {
18
+ it("should convert ERC20 send transaction to OUT operation", () => {
19
+ const tx = createMockERC20Transfer({
20
+ from: TEST_ADDRESSES.F4_ADDRESS,
21
+ to: TEST_ADDRESSES.RECIPIENT_F4,
22
+ amount: "1000000000000000000",
23
+ status: TxStatus.Ok,
24
+ });
25
+
26
+ const accountId = "accountId123";
27
+ const ops = erc20TxnToOperation(tx, TEST_ADDRESSES.F4_ADDRESS, accountId);
28
+
29
+ expect(ops).toHaveLength(1);
30
+ expect(ops[0].type).toBe("OUT");
31
+ expect(ops[0].value.isEqualTo(new BigNumber("1000000000000000000"))).toBe(true);
32
+ expect(ops[0].accountId).toBe(accountId);
33
+ });
34
+
35
+ it("should convert ERC20 receive transaction to IN operation", () => {
36
+ const tx = createMockERC20Transfer({
37
+ from: TEST_ADDRESSES.F4_ADDRESS,
38
+ to: TEST_ADDRESSES.RECIPIENT_F4,
39
+ amount: "500000000000000000",
40
+ });
41
+
42
+ const accountId = "accountId123";
43
+ const ops = erc20TxnToOperation(tx, TEST_ADDRESSES.RECIPIENT_F4, accountId);
44
+
45
+ expect(ops).toHaveLength(1);
46
+ expect(ops[0].type).toBe("IN");
47
+ expect(ops[0].value.isEqualTo(new BigNumber("500000000000000000"))).toBe(true);
48
+ });
49
+
50
+ it("should handle failed transaction", () => {
51
+ const tx = createMockERC20Transfer({
52
+ status: "Fail",
53
+ });
54
+
55
+ const ops = erc20TxnToOperation(tx, TEST_ADDRESSES.F4_ADDRESS, "accountId");
56
+
57
+ expect(ops[0].hasFailed).toBe(true);
58
+ });
59
+
60
+ it("should handle self-transfer (both send and receive)", () => {
61
+ const tx = createMockERC20Transfer({
62
+ from: TEST_ADDRESSES.F4_ADDRESS,
63
+ to: TEST_ADDRESSES.F4_ADDRESS,
64
+ });
65
+
66
+ const ops = erc20TxnToOperation(tx, TEST_ADDRESSES.F4_ADDRESS, "accountId");
67
+
68
+ expect(ops).toHaveLength(2);
69
+ expect(ops.some(op => op.type === "OUT")).toBe(true);
70
+ expect(ops.some(op => op.type === "IN")).toBe(true);
71
+ });
72
+ });
73
+ });