From effdf5254914548a81238298832000d086a4aff6 Mon Sep 17 00:00:00 2001 From: lambertkevin Date: Wed, 13 Dec 2023 16:56:28 +0100 Subject: [PATCH 1/8] Update fixtures --- .../src/__tests__/fixtures/common.fixtures.ts | 1 + .../__tests__/fixtures/etherscan.fixtures.ts | 51 +++++++++++++++++++ .../fixtures/synchronization.fixtures.ts | 43 ++++++++++++++++ 3 files changed, 95 insertions(+) diff --git a/libs/coin-evm/src/__tests__/fixtures/common.fixtures.ts b/libs/coin-evm/src/__tests__/fixtures/common.fixtures.ts index d6905965dc76..ada7486590cd 100644 --- a/libs/coin-evm/src/__tests__/fixtures/common.fixtures.ts +++ b/libs/coin-evm/src/__tests__/fixtures/common.fixtures.ts @@ -144,6 +144,7 @@ export const makeOperation = (partialOp?: Partial): Operation => { date: new Date(), nftOperations: [], subOperations: [], + internalOperations: [], extra: {}, ...partialOp, }); diff --git a/libs/coin-evm/src/__tests__/fixtures/etherscan.fixtures.ts b/libs/coin-evm/src/__tests__/fixtures/etherscan.fixtures.ts index 8ca76a57807c..887c9abc4f12 100644 --- a/libs/coin-evm/src/__tests__/fixtures/etherscan.fixtures.ts +++ b/libs/coin-evm/src/__tests__/fixtures/etherscan.fixtures.ts @@ -264,3 +264,54 @@ export const etherscanERC1155Operations = [ confirmations: "870255", }, ]; + +export const etherscanInternalOperations = [ + { + blockNumber: "14878012", + timeStamp: "1653990239", + hash: "0xb3effb3b6c52c719507f8219fe0dd2147a9f7ba366261ab43532efb0b9b01885", + from: "0xdef171fe48cf0115b1d80b88dc8eab59176fee57", + to: "0x6cbcd73cd8e8a42844662f0a0e76d7f79afd933d", + value: "66616263350003", + contractAddress: "", + input: "", + type: "call", + gas: "129878", + gasUsed: "0", + traceId: "0_1", + isError: "0", + errCode: "", + }, + { + blockNumber: "14914090", + timeStamp: "1654506123", + hash: "0xb3effb3b6c52c719507f8219fe0dd2147a9f7ba366261ab43532efb0b9b01885", + from: "0x283af0b28c62c092c9727f1ee09c02ca627eb7f5", + to: "0x6cbcd73cd8e8a42844662f0a0e76d7f79afd933d", + value: "2631018327985208", + contractAddress: "", + input: "", + type: "call", + gas: "2300", + gasUsed: "0", + traceId: "10", + isError: "0", + errCode: "", + }, + { + blockNumber: "15214745", + timeStamp: "1658792819", + hash: "0x3e85e486fbf92dd5dca1f7a29aed3324bdd78ce61e38fed7cec075de106987a1", + from: "0xdef171fe48cf0115b1d80b88dc8eab59176fee57", + to: "0x6cbcd73cd8e8a42844662f0a0e76d7f79afd933d", + value: "7431111373037", + contractAddress: "", + input: "", + type: "call", + gas: "152667", + gasUsed: "0", + traceId: "0_1_1", + isError: "0", + errCode: "", + }, +]; diff --git a/libs/coin-evm/src/__tests__/fixtures/synchronization.fixtures.ts b/libs/coin-evm/src/__tests__/fixtures/synchronization.fixtures.ts index ca39b97b45c4..9971e848e64b 100644 --- a/libs/coin-evm/src/__tests__/fixtures/synchronization.fixtures.ts +++ b/libs/coin-evm/src/__tests__/fixtures/synchronization.fixtures.ts @@ -4,6 +4,7 @@ import BigNumber from "bignumber.js"; import { getTokenById } from "@ledgerhq/cryptoassets/tokens"; import { CryptoCurrency } from "@ledgerhq/types-cryptoassets"; import { getCryptoCurrencyById } from "@ledgerhq/cryptoassets/currencies"; +import { encodeSubOperationId } from "@ledgerhq/coin-framework/operation"; import * as logic from "../../logic"; import { makeAccount, @@ -200,6 +201,48 @@ export const erc1155Operations = [ ), ]; +export const internalOperations = [ + makeOperation({ + hash: coinOperations[0].hash, // on purpose to make this internal op a subOp of coinOp 1 + accountId: coinOperations[0].accountId, + blockHash: coinOperations[0].blockHash, + recipients: ["0xB0B"], + senders: ["0x9b744C0451D73C0958d8aA566dAd33022E4Ee797"], // sbf.eth + value: new BigNumber(12), + fee: new BigNumber(0), + type: "NONE", + date: new Date(), + blockHeight: 10, + id: encodeSubOperationId(coinOperations[0].accountId, coinOperations[0].hash, "NONE", 0), + }), + makeOperation({ + hash: coinOperations[1].hash, // on purpose to make this internal op a subOp of coinOp 1 + accountId: coinOperations[1].accountId, + blockHash: coinOperations[1].blockHash, + recipients: ["0xB0B"], + senders: [coinOperations[1].recipients[0]], + value: new BigNumber(34), + fee: new BigNumber(0), + type: "OUT", + date: new Date(), + blockHeight: 11, + id: encodeSubOperationId(coinOperations[1].accountId, coinOperations[1].hash, "OUT", 0), + }), + makeOperation({ + hash: coinOperations[2].hash, // on purpose to make this internal op a subOp of coinOp 1 + accountId: coinOperations[2].accountId, + blockHash: coinOperations[2].blockHash, + recipients: [coinOperations[2].senders[0]], + senders: ["0x9b744C0451D73C0958d8aA566dAd33022E4Ee797"], // sbf.eth + value: new BigNumber(45), + fee: new BigNumber(0), + type: "IN", + date: new Date(), + blockHeight: 12, + id: encodeSubOperationId(coinOperations[2].accountId, coinOperations[2].hash, "IN", 0), + }), +]; + export const ignoredTokenOperation = makeOperation({ hash: "0xigN0r3Me", accountId: "js:2:ethereum:0xkvn:+ethereum%2Ferc20%2Fusd_tether__erc20_", From 21327be38e6d2cf4bb2313d84c5356e8d7756c89 Mon Sep 17 00:00:00 2001 From: lambertkevin Date: Wed, 13 Dec 2023 16:56:49 +0100 Subject: [PATCH 2/8] Improve `ExplorerApi` type readability --- libs/coin-evm/src/api/explorer/types.ts | 27 ++++++++----------------- 1 file changed, 8 insertions(+), 19 deletions(-) diff --git a/libs/coin-evm/src/api/explorer/types.ts b/libs/coin-evm/src/api/explorer/types.ts index 20a638ae64ce..817274141bdb 100644 --- a/libs/coin-evm/src/api/explorer/types.ts +++ b/libs/coin-evm/src/api/explorer/types.ts @@ -1,30 +1,19 @@ import { Operation } from "@ledgerhq/types-live"; import { CryptoCurrency, EthereumLikeInfo } from "@ledgerhq/types-cryptoassets"; -type ExplorerBasicRequest = ( - currency: CryptoCurrency, - address: string, - accountId: string, - fromBlock: number, - toBlock?: number, -) => Promise; - export type ExplorerApi = { - getLastOperations: (...args: Parameters) => Promise<{ + getLastOperations: ( + currency: CryptoCurrency, + address: string, + accountId: string, + fromBlock: number, + toBlock?: number, + ) => Promise<{ lastCoinOperations: Operation[]; lastTokenOperations: Operation[]; lastNftOperations: Operation[]; + lastInternalOperations: Operation[]; }>; - - // For now every other exported function should be considered as internal - // methods as they're unecessary to the synchronization it self. - // This can be updated with new sync requirements. - // & { [key in - // | "getLastCoinOperations" - // | "getLastTokenOperations" - // | "getLastERC721Operations" - // | "getLastERC1155Operations" - // | "getLastNftOperations"]: ExplorerBasicRequest; } }; type ExplorerConfig = EthereumLikeInfo["explorer"]; From 0f795b60c38987c6cbff36dc8fe7c94240aa06e2 Mon Sep 17 00:00:00 2001 From: lambertkevin Date: Wed, 13 Dec 2023 16:53:19 +0100 Subject: [PATCH 3/8] Add internal op adapters for Ledger & Etherscan --- .../unit/adapters/etherscan.unit.test.ts | 198 ++++++++++++++++++ .../unit/adapters/ledger.unit.test.ts | 189 ++++++++++++++++- libs/coin-evm/src/adapters/etherscan.ts | 58 ++++- libs/coin-evm/src/adapters/ledger.ts | 69 +++++- libs/coin-evm/src/types/etherscan.ts | 17 ++ 5 files changed, 522 insertions(+), 9 deletions(-) diff --git a/libs/coin-evm/src/__tests__/unit/adapters/etherscan.unit.test.ts b/libs/coin-evm/src/__tests__/unit/adapters/etherscan.unit.test.ts index 6c771cfcae70..78be8c2e3d12 100644 --- a/libs/coin-evm/src/__tests__/unit/adapters/etherscan.unit.test.ts +++ b/libs/coin-evm/src/__tests__/unit/adapters/etherscan.unit.test.ts @@ -6,12 +6,14 @@ import { etherscanERC1155EventToOperations, etherscanERC20EventToOperations, etherscanERC721EventToOperations, + etherscanInternalTransactionToOperations, etherscanOperationToOperations, } from "../../../adapters"; import { EtherscanERC1155Event, EtherscanERC20Event, EtherscanERC721Event, + EtherscanInternalTransaction, EtherscanOperation, } from "../../../types"; @@ -67,6 +69,7 @@ describe("EVM Family", () => { hasFailed: false, nftOperations: [], subOperations: [], + internalOperations: [], type: "FEES", extra: {}, }; @@ -124,6 +127,7 @@ describe("EVM Family", () => { hasFailed: false, nftOperations: [], subOperations: [], + internalOperations: [], type: "FEES", extra: {}, }; @@ -180,6 +184,7 @@ describe("EVM Family", () => { hasFailed: false, nftOperations: [], subOperations: [], + internalOperations: [], type: "OUT", extra: {}, }; @@ -236,6 +241,7 @@ describe("EVM Family", () => { hasFailed: false, nftOperations: [], subOperations: [], + internalOperations: [], type: "IN", extra: {}, }; @@ -292,6 +298,7 @@ describe("EVM Family", () => { hasFailed: false, nftOperations: [], subOperations: [], + internalOperations: [], type: "NONE", extra: {}, }; @@ -349,6 +356,7 @@ describe("EVM Family", () => { hasFailed: false, nftOperations: [], subOperations: [], + internalOperations: [], type: "IN", extra: {}, }, @@ -367,6 +375,7 @@ describe("EVM Family", () => { hasFailed: false, nftOperations: [], subOperations: [], + internalOperations: [], type: "OUT", extra: {}, }, @@ -1072,6 +1081,195 @@ describe("EVM Family", () => { ); }); }); + + describe("etherscanInternalTransactionToOperations", () => { + it("should convert a etherscan-like out internal transaction (from their API) to a Ledger Live Operation", () => { + const etherscanOp: EtherscanInternalTransaction = { + blockNumber: "14878012", + timeStamp: "1653990239", + hash: "0xb3effb3b6c52c719507f8219fe0dd2147a9f7ba366261ab43532efb0b9b01885", + from: "0x6cbcd73cd8e8a42844662f0a0e76d7f79afd933d", + to: "0xdef171fe48cf0115b1d80b88dc8eab59176fee57", + value: "66616263350003", + contractAddress: "", + input: "", + type: "call", + gas: "129878", + gasUsed: "0", + traceId: "0_1", + isError: "0", + errCode: "", + }; + + const accountId = encodeAccountId({ + type: "js", + version: "2", + currencyId: "ethereum", + xpubOrAddress: "0x6cbcd73cd8e8a42844662f0a0e76d7f79afd933d", + derivationMode: "", + }); + + const expectedOperation: Operation = { + id: "js:2:ethereum:0x6cbcd73cd8e8a42844662f0a0e76d7f79afd933d:-0xb3effb3b6c52c719507f8219fe0dd2147a9f7ba366261ab43532efb0b9b01885-OUT-i0", + hash: "0xb3effb3b6c52c719507f8219fe0dd2147a9f7ba366261ab43532efb0b9b01885", + accountId, + blockHeight: 14878012, + blockHash: undefined, + senders: ["0x6cBCD73CD8e8a42844662f0A0e76D7F79Afd933d"], + recipients: ["0xDEF171Fe48CF0115B1d80b88dc8eAB59176FEe57"], + value: new BigNumber("66616263350003"), + fee: new BigNumber("0"), + date: new Date("2022-05-31T09:43:59.000Z"), + type: "OUT", + hasFailed: false, + extra: {}, + }; + + expect(etherscanInternalTransactionToOperations(accountId, etherscanOp)).toEqual([ + expectedOperation, + ]); + }); + + it("should convert a etherscan-like in internal transaction (from their API) to a Ledger Live Operation", () => { + const etherscanOp: EtherscanInternalTransaction = { + blockNumber: "14878012", + timeStamp: "1653990239", + hash: "0xb3effb3b6c52c719507f8219fe0dd2147a9f7ba366261ab43532efb0b9b01885", + from: "0xdef171fe48cf0115b1d80b88dc8eab59176fee57", + to: "0x6cbcd73cd8e8a42844662f0a0e76d7f79afd933d", + value: "66616263350003", + contractAddress: "", + input: "", + type: "call", + gas: "129878", + gasUsed: "0", + traceId: "0_1", + isError: "0", + errCode: "", + }; + + const accountId = encodeAccountId({ + type: "js", + version: "2", + currencyId: "ethereum", + xpubOrAddress: "0x6cbcd73cd8e8a42844662f0a0e76d7f79afd933d", + derivationMode: "", + }); + + const expectedOperation: Operation = { + id: "js:2:ethereum:0x6cbcd73cd8e8a42844662f0a0e76d7f79afd933d:-0xb3effb3b6c52c719507f8219fe0dd2147a9f7ba366261ab43532efb0b9b01885-IN-i0", + hash: "0xb3effb3b6c52c719507f8219fe0dd2147a9f7ba366261ab43532efb0b9b01885", + accountId, + blockHeight: 14878012, + blockHash: undefined, + senders: ["0xDEF171Fe48CF0115B1d80b88dc8eAB59176FEe57"], + recipients: ["0x6cBCD73CD8e8a42844662f0A0e76D7F79Afd933d"], + value: new BigNumber("66616263350003"), + fee: new BigNumber("0"), + date: new Date("2022-05-31T09:43:59.000Z"), + type: "IN", + hasFailed: false, + extra: {}, + }; + + expect(etherscanInternalTransactionToOperations(accountId, etherscanOp)).toEqual([ + expectedOperation, + ]); + }); + + it("should convert a etherscan-like none internal transaction (from their API) to a Ledger Live Operation", () => { + const etherscanOp: EtherscanInternalTransaction = { + blockNumber: "14878012", + timeStamp: "1653990239", + hash: "0xb3effb3b6c52c719507f8219fe0dd2147a9f7ba366261ab43532efb0b9b01885", + from: "0xdef171fe48cf0115b1d80b88dc8eab59176fee57", + to: "0x3244100A07c7fEE9bDE409e877ed2e8Ff1EdeEda", // pdv.eth + value: "66616263350003", + contractAddress: "", + input: "", + type: "call", + gas: "129878", + gasUsed: "0", + traceId: "0_1", + isError: "0", + errCode: "", + }; + + const accountId = encodeAccountId({ + type: "js", + version: "2", + currencyId: "ethereum", + xpubOrAddress: "0x6cbcd73cd8e8a42844662f0a0e76d7f79afd933d", + derivationMode: "", + }); + + expect(etherscanInternalTransactionToOperations(accountId, etherscanOp)).toEqual([]); + }); + + it("should convert a etherscan-like self internal transaction (from their API) to 2 Ledger Live Operations", () => { + const etherscanOp: EtherscanInternalTransaction = { + blockNumber: "14878012", + timeStamp: "1653990239", + hash: "0xb3effb3b6c52c719507f8219fe0dd2147a9f7ba366261ab43532efb0b9b01885", + from: "0x6cbcd73cd8e8a42844662f0a0e76d7f79afd933d", + to: "0x6cbcd73cd8e8a42844662f0a0e76d7f79afd933d", + value: "66616263350003", + contractAddress: "", + input: "", + type: "call", + gas: "129878", + gasUsed: "0", + traceId: "0_1", + isError: "0", + errCode: "", + }; + + const accountId = encodeAccountId({ + type: "js", + version: "2", + currencyId: "ethereum", + xpubOrAddress: "0x6cbcd73cd8e8a42844662f0a0e76d7f79afd933d", + derivationMode: "", + }); + + const expectedOperations: Operation[] = [ + { + id: "js:2:ethereum:0x6cbcd73cd8e8a42844662f0a0e76d7f79afd933d:-0xb3effb3b6c52c719507f8219fe0dd2147a9f7ba366261ab43532efb0b9b01885-IN-i0", + hash: "0xb3effb3b6c52c719507f8219fe0dd2147a9f7ba366261ab43532efb0b9b01885", + accountId, + blockHeight: 14878012, + blockHash: undefined, + senders: ["0x6cBCD73CD8e8a42844662f0A0e76D7F79Afd933d"], + recipients: ["0x6cBCD73CD8e8a42844662f0A0e76D7F79Afd933d"], + value: new BigNumber("66616263350003"), + fee: new BigNumber("0"), + date: new Date("2022-05-31T09:43:59.000Z"), + type: "IN", + hasFailed: false, + extra: {}, + }, + { + id: "js:2:ethereum:0x6cbcd73cd8e8a42844662f0a0e76d7f79afd933d:-0xb3effb3b6c52c719507f8219fe0dd2147a9f7ba366261ab43532efb0b9b01885-OUT-i0", + hash: "0xb3effb3b6c52c719507f8219fe0dd2147a9f7ba366261ab43532efb0b9b01885", + accountId, + blockHeight: 14878012, + blockHash: undefined, + senders: ["0x6cBCD73CD8e8a42844662f0A0e76D7F79Afd933d"], + recipients: ["0x6cBCD73CD8e8a42844662f0A0e76D7F79Afd933d"], + value: new BigNumber("66616263350003"), + fee: new BigNumber("0"), + date: new Date("2022-05-31T09:43:59.000Z"), + type: "OUT", + hasFailed: false, + extra: {}, + }, + ]; + + expect(etherscanInternalTransactionToOperations(accountId, etherscanOp)).toEqual( + expectedOperations, + ); + }); + }); }); }); }); diff --git a/libs/coin-evm/src/__tests__/unit/adapters/ledger.unit.test.ts b/libs/coin-evm/src/__tests__/unit/adapters/ledger.unit.test.ts index f57e894a9ec9..f87fff8f762b 100644 --- a/libs/coin-evm/src/__tests__/unit/adapters/ledger.unit.test.ts +++ b/libs/coin-evm/src/__tests__/unit/adapters/ledger.unit.test.ts @@ -6,12 +6,14 @@ import { ledgerERC1155EventToOperations, ledgerERC20EventToOperations, ledgerERC721EventToOperations, + ledgerInternalTransactionToOperations, ledgerOperationToOperations, } from "../../../adapters"; import { LedgerExplorerER1155TransferEvent, LedgerExplorerER721TransferEvent, LedgerExplorerERC20TransferEvent, + LedgerExplorerInternalTransaction, LedgerExplorerOperation, } from "../../../types"; @@ -46,8 +48,8 @@ describe("EVM Family", () => { describe("adapters", () => { describe("ledger", () => { describe("ledgerOperationToOperations", () => { - it("should convert an etherscan-like smart contract creation operation (from their API) to a Ledger Live Operation", () => { - const etherscanOp: LedgerExplorerOperation = { + it("should convert a ledger explorer smart contract creation operation (from their API) to a Ledger Live Operation", () => { + const ledgerExplorerOp: LedgerExplorerOperation = { hash: "0xf350d4f8e910419e2d5cec294d44e69af8c6185b7089061d33bb4fc246cefb79", transaction_type: 2, nonce: "0x4b", @@ -108,11 +110,14 @@ describe("EVM Family", () => { hasFailed: false, nftOperations: [], subOperations: [], + internalOperations: [], type: "NONE", extra: {}, }; - expect(ledgerOperationToOperations(accountId, etherscanOp)).toEqual([expectedOperation]); + expect(ledgerOperationToOperations(accountId, ledgerExplorerOp)).toEqual([ + expectedOperation, + ]); }); it("should convert ledger explorer smart contract operation to a Ledger Live Operation", () => { @@ -168,6 +173,7 @@ describe("EVM Family", () => { hasFailed: false, nftOperations: [], subOperations: [], + internalOperations: [], type: "FEES", extra: {}, }; @@ -230,6 +236,7 @@ describe("EVM Family", () => { hasFailed: false, nftOperations: [], subOperations: [], + internalOperations: [], type: "OUT", extra: {}, }; @@ -292,6 +299,7 @@ describe("EVM Family", () => { hasFailed: false, nftOperations: [], subOperations: [], + internalOperations: [], type: "IN", extra: {}, }; @@ -354,6 +362,7 @@ describe("EVM Family", () => { hasFailed: false, nftOperations: [], subOperations: [], + internalOperations: [], type: "NONE", extra: {}, }; @@ -421,6 +430,7 @@ describe("EVM Family", () => { hasFailed: false, nftOperations: [], subOperations: [], + internalOperations: [], type: "NONE", extra: {}, }; @@ -483,6 +493,7 @@ describe("EVM Family", () => { hasFailed: false, nftOperations: [], subOperations: [], + internalOperations: [], type: "IN", extra: {}, }; @@ -501,6 +512,7 @@ describe("EVM Family", () => { hasFailed: false, nftOperations: [], subOperations: [], + internalOperations: [], type: "OUT", extra: {}, }; @@ -1032,6 +1044,177 @@ describe("EVM Family", () => { ]); }); }); + + describe("ledgerInternalTransactionToOperations", () => { + it("should convert a ledger explorer out action to a Ledger Live Operation", () => { + const ledgerAction: LedgerExplorerInternalTransaction = { + from: "0x6cbcd73cd8e8a42844662f0a0e76d7f79afd933d", + to: "0x49048044d57e1c92a77f79988d21fa8faf74e97e", + input: null, + value: "10000000000000000", + gas: "57090", + gas_used: "27485", + error: null, + }; + + const expectedOperation: Operation = { + id: "js:2:ethereum:0x6cbcd73cd8e8a42844662f0a0e76d7f79afd933d:-0xf350d4f8e910419e2d5cec294d44e69af8c6185b7089061d33bb4fc246cefb79-OUT-i0", + hash: "0xf350d4f8e910419e2d5cec294d44e69af8c6185b7089061d33bb4fc246cefb79", + accountId, + blockHash: "0xcbd52de09904fd89a94b0638a8e39107e247d761e92411fd5b7b7d8b88641ddd", + blockHeight: 38476740, + senders: ["0x6cBCD73CD8e8a42844662f0A0e76D7F79Afd933d"], + recipients: ["0x49048044D57e1C92A77f79988d21Fa8fAF74E97e"], + value: new BigNumber("10000000000000000"), + fee: new BigNumber("0"), + date: new Date("2023-01-24T17:11:45Z"), + type: "OUT", + hasFailed: false, + extra: {}, + }; + + expect(ledgerInternalTransactionToOperations(coinOperation, ledgerAction)).toEqual([ + expectedOperation, + ]); + }); + + it("should convert a ledger explorer in action to a Ledger Live Operation", () => { + const ledgerAction: LedgerExplorerInternalTransaction = { + from: "0x49048044d57e1c92a77f79988d21fa8faf74e97e", + to: "0x6cbcd73cd8e8a42844662f0a0e76d7f79afd933d", + input: null, + value: "10000000000000000", + gas: "57090", + gas_used: "27485", + error: null, + }; + + const expectedOperation: Operation = { + id: "js:2:ethereum:0x6cbcd73cd8e8a42844662f0a0e76d7f79afd933d:-0xf350d4f8e910419e2d5cec294d44e69af8c6185b7089061d33bb4fc246cefb79-IN-i0", + hash: "0xf350d4f8e910419e2d5cec294d44e69af8c6185b7089061d33bb4fc246cefb79", + accountId, + blockHash: "0xcbd52de09904fd89a94b0638a8e39107e247d761e92411fd5b7b7d8b88641ddd", + blockHeight: 38476740, + senders: ["0x49048044D57e1C92A77f79988d21Fa8fAF74E97e"], + recipients: ["0x6cBCD73CD8e8a42844662f0A0e76D7F79Afd933d"], + value: new BigNumber("10000000000000000"), + fee: new BigNumber("0"), + date: new Date("2023-01-24T17:11:45Z"), + type: "IN", + hasFailed: false, + extra: {}, + }; + + expect(ledgerInternalTransactionToOperations(coinOperation, ledgerAction)).toEqual([ + expectedOperation, + ]); + }); + + it("shoud ignore a ledger explorer action when identical to the Operation it's triggered by", () => { + const coinOperationFees = coinOperation; + const coinOperationOut = { + ...coinOperation, + type: "OUT" as const, + }; + const coinOperationIn = { + ...coinOperation, + senders: coinOperation.recipients, + recipients: coinOperation.senders, + type: "IN" as const, + }; + + const ledgerActionOutOrFees: LedgerExplorerInternalTransaction = { + from: coinOperationFees.senders[0], + to: coinOperationFees.recipients[0], + input: null, + value: coinOperationFees.value.minus(coinOperationFees.fee).toFixed(), + gas: "57090", + gas_used: "27485", + error: null, + }; + + const ledgerActionIn: LedgerExplorerInternalTransaction = { + ...ledgerActionOutOrFees, + from: coinOperationFees.recipients[0], + to: coinOperationFees.senders[0], + value: coinOperationIn.value.toFixed(), + }; + + expect( + ledgerInternalTransactionToOperations(coinOperationFees, ledgerActionOutOrFees), + ).toEqual([]); + expect( + ledgerInternalTransactionToOperations(coinOperationOut, ledgerActionOutOrFees), + ).toEqual([]); + + expect(ledgerInternalTransactionToOperations(coinOperationIn, ledgerActionIn)).toEqual( + [], + ); + }); + + it("should convert a ledger explorer none action to a Ledger Live Operation", () => { + const ledgerAction: LedgerExplorerInternalTransaction = { + from: "0x49048044d57e1c92a77f79988d21fa8faf74e97e", + to: "0x3244100A07c7fEE9bDE409e877ed2e8Ff1EdeEda", // pdv.eth + input: null, + value: "10000000000000000", + gas: "57090", + gas_used: "27485", + error: null, + }; + + expect(ledgerInternalTransactionToOperations(coinOperation, ledgerAction)).toEqual([]); + }); + + it("should convert a ledger explorer self action to 2 Ledger Live Operations", () => { + const ledgerAction: LedgerExplorerInternalTransaction = { + from: "0x6cBCD73CD8e8a42844662f0A0e76D7F79Afd933d", + to: "0x6cBCD73CD8e8a42844662f0A0e76D7F79Afd933d", // pdv.eth + input: null, + value: "10000000000000000", + gas: "57090", + gas_used: "27485", + error: null, + }; + + const expectedOperations: Operation[] = [ + { + id: "js:2:ethereum:0x6cbcd73cd8e8a42844662f0a0e76d7f79afd933d:-0xf350d4f8e910419e2d5cec294d44e69af8c6185b7089061d33bb4fc246cefb79-IN-i0", + hash: "0xf350d4f8e910419e2d5cec294d44e69af8c6185b7089061d33bb4fc246cefb79", + accountId, + blockHash: "0xcbd52de09904fd89a94b0638a8e39107e247d761e92411fd5b7b7d8b88641ddd", + blockHeight: 38476740, + senders: ["0x6cBCD73CD8e8a42844662f0A0e76D7F79Afd933d"], + recipients: ["0x6cBCD73CD8e8a42844662f0A0e76D7F79Afd933d"], + value: new BigNumber("10000000000000000"), + fee: new BigNumber("0"), + date: new Date("2023-01-24T17:11:45Z"), + type: "IN", + hasFailed: false, + extra: {}, + }, + { + id: "js:2:ethereum:0x6cbcd73cd8e8a42844662f0a0e76d7f79afd933d:-0xf350d4f8e910419e2d5cec294d44e69af8c6185b7089061d33bb4fc246cefb79-OUT-i0", + hash: "0xf350d4f8e910419e2d5cec294d44e69af8c6185b7089061d33bb4fc246cefb79", + accountId, + blockHash: "0xcbd52de09904fd89a94b0638a8e39107e247d761e92411fd5b7b7d8b88641ddd", + blockHeight: 38476740, + senders: ["0x6cBCD73CD8e8a42844662f0A0e76D7F79Afd933d"], + recipients: ["0x6cBCD73CD8e8a42844662f0A0e76D7F79Afd933d"], + value: new BigNumber("10000000000000000"), + fee: new BigNumber("0"), + date: new Date("2023-01-24T17:11:45Z"), + type: "OUT", + hasFailed: false, + extra: {}, + }, + ]; + + expect(ledgerInternalTransactionToOperations(coinOperation, ledgerAction)).toEqual( + expectedOperations, + ); + }); + }); }); }); }); diff --git a/libs/coin-evm/src/adapters/etherscan.ts b/libs/coin-evm/src/adapters/etherscan.ts index affce5616319..9f756a7302e5 100644 --- a/libs/coin-evm/src/adapters/etherscan.ts +++ b/libs/coin-evm/src/adapters/etherscan.ts @@ -9,13 +9,14 @@ import { encodeNftId } from "@ledgerhq/coin-framework/nft/nftId"; import { findTokenByAddressInCurrency } from "@ledgerhq/cryptoassets"; import { decodeAccountId, encodeTokenAccountId } from "@ledgerhq/coin-framework/account/index"; import { encodeOperationId, encodeSubOperationId } from "@ledgerhq/coin-framework/operation"; +import { safeEncodeEIP55 } from "../logic"; import { EtherscanOperation, EtherscanERC20Event, EtherscanERC721Event, EtherscanERC1155Event, + EtherscanInternalTransaction, } from "../types"; -import { safeEncodeEIP55 } from "../logic"; /** * Adapter to convert an Etherscan operation into Ledger Live Operations. @@ -50,7 +51,7 @@ export const etherscanOperationToOperations = ( ({ id: encodeOperationId(accountId, etherscanOp.hash, type), hash: etherscanOp.hash, - type: type, + type, value: type === "OUT" || type === "FEES" ? value.plus(fee) : hasFailed ? fee : value, fee, senders: [from], @@ -62,6 +63,7 @@ export const etherscanOperationToOperations = ( date: new Date(parseInt(etherscanOp.timeStamp, 10) * 1000), subOperations: [], nftOperations: [], + internalOperations: [], hasFailed, extra: {}, }) as Operation, @@ -103,7 +105,7 @@ export const etherscanERC20EventToOperations = ( ({ id: encodeSubOperationId(tokenAccountId, event.hash, type, index), hash: event.hash, - type: type, + type, value, fee, senders: [from], @@ -153,7 +155,7 @@ export const etherscanERC721EventToOperations = ( ({ id: encodeERC721OperationId(nftId, event.hash, type, index), hash: event.hash, - type: type, + type, fee, senders: [from], recipients: [to], @@ -203,7 +205,7 @@ export const etherscanERC1155EventToOperations = ( ({ id: encodeERC1155OperationId(nftId, event.hash, type, index), hash: event.hash, - type: type, + type, fee, senders: [from], recipients: [to], @@ -220,3 +222,49 @@ export const etherscanERC1155EventToOperations = ( }) as Operation, ); }; + +/** + * Adapter to convert an internal transaction + * on etherscan APIs into LL Operations + */ +export const etherscanInternalTransactionToOperations = ( + accountId: string, + internalTx: EtherscanInternalTransaction, + index = 0, +): Operation[] => { + const { hash, blockNumber, timeStamp, isError } = internalTx; + const { xpubOrAddress: address } = decodeAccountId(accountId); + + const checksummedAddress = eip55.encode(address); + const from = safeEncodeEIP55(internalTx.from); + const to = safeEncodeEIP55(internalTx.to); + const value = new BigNumber(internalTx.value); + const types: OperationType[] = []; + const hasFailed = isError === "1"; + + if (to === checksummedAddress) { + types.push("IN"); + } + if (from === checksummedAddress) { + types.push("OUT"); + } + + return types.map( + type => + ({ + id: encodeSubOperationId(accountId, hash, type, index), + hash: hash, + type, + fee: new BigNumber(0), // unecessary as it's already contained in the fees of the main op + senders: [from], + recipients: [to], + blockHeight: parseInt(blockNumber, 10), + blockHash: undefined, // not made directly available by etherscan, only blockNumber is provided + accountId, + value, + date: new Date(parseInt(timeStamp, 10) * 1000), + hasFailed, + extra: {}, + }) as Operation, + ); +}; diff --git a/libs/coin-evm/src/adapters/ledger.ts b/libs/coin-evm/src/adapters/ledger.ts index 03fdcf98cb53..f9281158f7c1 100644 --- a/libs/coin-evm/src/adapters/ledger.ts +++ b/libs/coin-evm/src/adapters/ledger.ts @@ -14,6 +14,7 @@ import { LedgerExplorerERC20TransferEvent, LedgerExplorerER721TransferEvent, LedgerExplorerER1155TransferEvent, + LedgerExplorerInternalTransaction, } from "../types"; import { safeEncodeEIP55 } from "../logic"; @@ -58,10 +59,11 @@ export const ledgerOperationToOperations = ( blockHeight: ledgerOp.block.height, blockHash: ledgerOp.block.hash, transactionSequenceNumber: ledgerOp.nonce_value, - accountId: accountId, + accountId, date, subOperations: [], nftOperations: [], + internalOperations: [], hasFailed, extra: {}, }) as Operation, @@ -222,3 +224,68 @@ export const ledgerERC1155EventToOperations = ( ); }); }; + +/** + * Adapter to convert an internal transaction + * on Ledger explorers into LL Operations + */ +export const ledgerInternalTransactionToOperations = ( + coinOperation: Operation, + action: LedgerExplorerInternalTransaction, + index = 0, +): Operation[] => { + const { hash, blockHeight, blockHash, date, accountId } = coinOperation; + const { xpubOrAddress: address } = decodeAccountId(accountId); + const from = safeEncodeEIP55(action.from); + const to = safeEncodeEIP55(action.to); + const checksummedAddress = eip55.encode(address); + const hasFailed = !!action.error; // AFAIK this is not working, all actions contain error = null even when it reverted + const value = new BigNumber(action.value); + const types: OperationType[] = []; + + // Ledger explorers are indexing the first `CALL` opcode of a smart contract transaction as an + // internal transaction which is wrong. Only children `CALL` opcode should be indexed, + // therefore we need to filter those "actions" to prevent duplicating ops. + if (from === coinOperation.senders[0] && to === coinOperation.recipients[0]) { + const coinOpValueWithoutFees = coinOperation.value.minus(coinOperation.fee); + const coinOpValueWithFees = coinOperation.value; + const coinTypeOutOrFees = coinOperation.type === "OUT" || coinOperation.type === "FEES"; + const coinTypeIn = coinOperation.type === "IN"; + + // Detecting if an action value is identical to its coin op value + // (which is modified in the live depending on its type) + // in order to ignore the action completely + if ( + (coinTypeOutOrFees && value.isEqualTo(coinOpValueWithoutFees)) || + (coinTypeIn && value.isEqualTo(coinOpValueWithFees)) + ) { + return []; + } + } + + if (to === checksummedAddress) { + types.push("IN"); + } + if (from === checksummedAddress) { + types.push("OUT"); + } + + return types.map( + type => + ({ + id: encodeSubOperationId(accountId, hash, type, index), + hash: hash, + type: type, + value, + fee: new BigNumber(0), // unecessary as it's already contained in the fees of the main op + senders: [from], + recipients: [to], + blockHeight, + blockHash, + accountId, + date, + hasFailed, + extra: {}, + }) as Operation, + ); +}; diff --git a/libs/coin-evm/src/types/etherscan.ts b/libs/coin-evm/src/types/etherscan.ts index c0eda512b081..f74df2f8085b 100644 --- a/libs/coin-evm/src/types/etherscan.ts +++ b/libs/coin-evm/src/types/etherscan.ts @@ -87,3 +87,20 @@ export type EtherscanERC1155Event = { tokenSymbol: string; confirmations: string; }; + +export type EtherscanInternalTransaction = { + blockNumber: string; + timeStamp: string; + hash: string; + from: string; + to: string; + value: string; + contractAddress: string; + input: string; + type: string; + gas: string; + gasUsed: string; + traceId: string; + isError: string; + errCode: string; +}; From 1f1f54ae51c7844ed11edf3eaf2f1ea261eb3c6a Mon Sep 17 00:00:00 2001 From: lambertkevin Date: Wed, 13 Dec 2023 16:53:58 +0100 Subject: [PATCH 4/8] Add internal op request to Etherscan explorer --- .../unit/api/explorer/etherscan.unit.test.ts | 140 +++++++++++++++++- libs/coin-evm/src/api/explorer/etherscan.ts | 71 ++++++++- 2 files changed, 206 insertions(+), 5 deletions(-) diff --git a/libs/coin-evm/src/__tests__/unit/api/explorer/etherscan.unit.test.ts b/libs/coin-evm/src/__tests__/unit/api/explorer/etherscan.unit.test.ts index bdf180e82e51..14e4f368928b 100644 --- a/libs/coin-evm/src/__tests__/unit/api/explorer/etherscan.unit.test.ts +++ b/libs/coin-evm/src/__tests__/unit/api/explorer/etherscan.unit.test.ts @@ -3,21 +3,23 @@ import axios from "axios"; import { delay } from "@ledgerhq/live-promise"; import { CryptoCurrency, EthereumLikeInfo } from "@ledgerhq/types-cryptoassets"; import { getCryptoCurrencyById } from "@ledgerhq/cryptoassets/currencies"; +import { EtherscanLikeExplorerUsedIncorrectly } from "../../../../errors"; import * as ETHERSCAN_API from "../../../../api/explorer/etherscan"; import { makeAccount } from "../../../fixtures/common.fixtures"; import { etherscanCoinOperations, etherscanERC1155Operations, etherscanERC721Operations, + etherscanInternalOperations, etherscanTokenOperations, } from "../../../fixtures/etherscan.fixtures"; import { etherscanERC1155EventToOperations, etherscanERC20EventToOperations, etherscanERC721EventToOperations, + etherscanInternalTransactionToOperations, etherscanOperationToOperations, } from "../../../../adapters"; -import { EtherscanLikeExplorerUsedIncorrectly } from "../../../../errors"; jest.mock("axios"); jest.mock("@ledgerhq/live-promise"); @@ -664,5 +666,141 @@ describe("EVM Family", () => { ); }); }); + + describe("getLastInternalOperations", () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + it("should throw if the currency is misconfigured", async () => { + jest.spyOn(axios, "request").mockImplementation(async () => ({ + data: { + result: etherscanInternalOperations, + }, + })); + + try { + await ETHERSCAN_API.getLastInternalOperations( + { + ...currency, + ethereumLikeInfo: { + chainId: 1, + // no explorer + } as EthereumLikeInfo, + }, + account.freshAddress, + account.id, + 0, + ); + fail("Promise should have been rejected"); + } catch (e) { + if (e instanceof AssertionError) { + throw e; + } + expect(e).toBeInstanceOf(EtherscanLikeExplorerUsedIncorrectly); + } + }); + + it("should return a flat list of internal transactions from block 0", async () => { + const spy = jest.spyOn(axios, "request").mockImplementation(async () => ({ + data: { + result: etherscanInternalOperations, + }, + })); + + const response = await ETHERSCAN_API.getLastInternalOperations( + currency, + account.freshAddress, + account.id, + 0, + ); + + expect(response).toEqual( + [ + etherscanInternalTransactionToOperations(account.id, etherscanInternalOperations[0], 0), + etherscanInternalTransactionToOperations(account.id, etherscanInternalOperations[1], 1), + etherscanInternalTransactionToOperations(account.id, etherscanInternalOperations[2], 0), + ].flat(), + ); + expect(spy).toBeCalledWith({ + method: "GET", + url: `mock/api?module=account&action=txlistinternal&address=${account.freshAddress}`, + params: { + tag: "latest", + page: 1, + sort: "desc", + startBlock: 0, + }, + }); + }); + + it("should return a flat list of internal transactions from block 50", async () => { + const spy = jest.spyOn(axios, "request").mockImplementation(async () => ({ + data: { + result: etherscanInternalOperations, + }, + })); + + const response = await ETHERSCAN_API.getLastInternalOperations( + currency, + account.freshAddress, + account.id, + 50, + ); + + expect(response).toEqual( + [ + etherscanInternalTransactionToOperations(account.id, etherscanInternalOperations[0], 0), + etherscanInternalTransactionToOperations(account.id, etherscanInternalOperations[1], 1), + etherscanInternalTransactionToOperations(account.id, etherscanInternalOperations[2], 0), + ].flat(), + ); + expect(spy).toBeCalledWith({ + method: "GET", + url: `mock/api?module=account&action=txlistinternal&address=${account.freshAddress}`, + params: { + tag: "latest", + page: 1, + sort: "desc", + startBlock: 50, + }, + }); + }); + + it("should return a flat list of internal transactions from block 50 to block 100", async () => { + const spy = jest.spyOn(axios, "request").mockImplementation(async () => ({ + data: { + result: etherscanInternalOperations, + }, + })); + + const response = await ETHERSCAN_API.getLastInternalOperations( + currency, + account.freshAddress, + account.id, + 50, + 100, + ); + + expect(response).toEqual( + [ + etherscanInternalTransactionToOperations(account.id, etherscanInternalOperations[0], 0), + etherscanInternalTransactionToOperations(account.id, etherscanInternalOperations[1], 1), + etherscanInternalTransactionToOperations(account.id, etherscanInternalOperations[2], 0), + ].flat(), + ); + expect(spy).toBeCalledWith({ + method: "GET", + url: `mock/api?module=account&action=txlistinternal&address=${account.freshAddress}`, + params: { + tag: "latest", + page: 1, + sort: "desc", + startBlock: 50, + endBlock: 100, + }, + }); + }); + }); }); }); diff --git a/libs/coin-evm/src/api/explorer/etherscan.ts b/libs/coin-evm/src/api/explorer/etherscan.ts index 733296ab0468..eb8a76ab2196 100644 --- a/libs/coin-evm/src/api/explorer/etherscan.ts +++ b/libs/coin-evm/src/api/explorer/etherscan.ts @@ -10,11 +10,13 @@ import { etherscanERC20EventToOperations, etherscanERC721EventToOperations, etherscanERC1155EventToOperations, + etherscanInternalTransactionToOperations, } from "../../adapters"; import { EtherscanERC1155Event, EtherscanERC20Event, EtherscanERC721Event, + EtherscanInternalTransaction, EtherscanOperation, } from "../../types"; import { ExplorerApi, isEtherscanLikeExplorerConfig } from "./types"; @@ -53,7 +55,7 @@ export async function fetchWithRetries( } /** - * Get all the last "normal" transactions (no tokens / NFTs) + * Get all the latest "normal" transactions (no tokens / NFTs) */ export const getLastCoinOperations = async ( currency: CryptoCurrency, @@ -83,7 +85,7 @@ export const getLastCoinOperations = async ( }; /** - * Get all the last ERC20 transactions + * Get all the latest ERC20 transactions */ export const getLastTokenOperations = async ( currency: CryptoCurrency, @@ -134,7 +136,7 @@ export const getLastTokenOperations = async ( }; /** - * Get all the last ERC721 transactions + * Get all the latest ERC721 transactions */ export const getLastERC721Operations = async ( currency: CryptoCurrency, @@ -185,7 +187,7 @@ export const getLastERC721Operations = async ( }; /** - * Get all the last ERC1155 transactions + * Get all the latest ERC1155 transactions */ export const getLastERC1155Operations = async ( currency: CryptoCurrency, @@ -260,6 +262,57 @@ export const getLastNftOperations = async ( ); }; +/** + * Get all the latest internal transactions + */ + +export const getLastInternalOperations = async ( + currency: CryptoCurrency, + address: string, + accountId: string, + fromBlock: number, + toBlock?: number, +): Promise => { + const { explorer } = currency.ethereumLikeInfo || /* istanbul ignore next */ {}; + if (!isEtherscanLikeExplorerConfig(explorer)) { + throw new EtherscanLikeExplorerUsedIncorrectly(); + } + + const ops = await fetchWithRetries({ + method: "GET", + url: `${explorer.uri}/api?module=account&action=txlistinternal&address=${address}`, + params: { + tag: "latest", + page: 1, + sort: "desc", + startBlock: fromBlock, + endBlock: toBlock, + }, + }); + + // Why this thing ? + // Multiple internal transactions can be executed from + // a single "normal" transaction with a same hash. + // Grouping them here helps differenciate the + // `Operation` ids which would be identical + // otherwise without a notion of index. + const opsByHash: Record = {}; + for (const op of ops) { + if (!opsByHash[op.hash]) { + opsByHash[op.hash] = []; + } + opsByHash[op.hash].push(op); + } + + return Object.values(opsByHash) + .map(internalTxs => + internalTxs.map((internalTx, index) => + etherscanInternalTransactionToOperations(accountId, internalTx, index), + ), + ) + .flat(2); +}; + /** * Wrapper around all operation types' requests * @@ -279,6 +332,7 @@ export const getLastOperations: ExplorerApi["getLastOperations"] = makeLRUCache< lastCoinOperations: Operation[]; lastTokenOperations: Operation[]; lastNftOperations: Operation[]; + lastInternalOperations: Operation[]; } >( async (currency, address, accountId, fromBlock, toBlock) => { @@ -290,6 +344,14 @@ export const getLastOperations: ExplorerApi["getLastOperations"] = makeLRUCache< toBlock, ); + const lastInternalOperations = await getLastInternalOperations( + currency, + address, + accountId, + fromBlock, + toBlock, + ); + const lastTokenOperations = await getLastTokenOperations( currency, address, @@ -306,6 +368,7 @@ export const getLastOperations: ExplorerApi["getLastOperations"] = makeLRUCache< lastCoinOperations, lastTokenOperations, lastNftOperations, + lastInternalOperations, }; }, (currency, address, accountId, fromBlock, toBlock) => accountId + fromBlock + toBlock, From 8492dcccd9c4637ddf4ebb054bdf2c04e30d2fbd Mon Sep 17 00:00:00 2001 From: lambertkevin Date: Wed, 13 Dec 2023 16:54:19 +0100 Subject: [PATCH 5/8] Add internal op request to Ledger explorer --- .../unit/api/explorer/ledger.unit.test.ts | 23 ++++++++++++++++++- libs/coin-evm/src/api/explorer/ledger.ts | 7 ++++++ 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/libs/coin-evm/src/__tests__/unit/api/explorer/ledger.unit.test.ts b/libs/coin-evm/src/__tests__/unit/api/explorer/ledger.unit.test.ts index 5c54b6d4e143..d35d2a56a4bb 100644 --- a/libs/coin-evm/src/__tests__/unit/api/explorer/ledger.unit.test.ts +++ b/libs/coin-evm/src/__tests__/unit/api/explorer/ledger.unit.test.ts @@ -6,8 +6,8 @@ import { delay } from "@ledgerhq/live-promise"; import { getEnv, setEnv } from "@ledgerhq/live-env"; import { encodeAccountId } from "@ledgerhq/coin-framework/account/index"; import { CryptoCurrency, CryptoCurrencyId } from "@ledgerhq/types-cryptoassets"; -import * as LEDGER_API from "../../../../api/explorer/ledger"; import { LedgerExplorerUsedIncorrectly } from "../../../../errors"; +import * as LEDGER_API from "../../../../api/explorer/ledger"; import { coinOperation1, coinOperation2, @@ -196,6 +196,7 @@ describe("EVM Family", () => { hasFailed: false, nftOperations: [], subOperations: [], + internalOperations: [], recipients: [eip55.encode(coinOperation1.to)], senders: [eip55.encode(coinOperation1.from)], transactionSequenceNumber: coinOperation1.nonce_value, @@ -216,6 +217,7 @@ describe("EVM Family", () => { hasFailed: false, nftOperations: [], subOperations: [], + internalOperations: [], recipients: [eip55.encode(coinOperation2.to)], senders: [eip55.encode(coinOperation2.from)], transactionSequenceNumber: coinOperation2.nonce_value, @@ -236,6 +238,7 @@ describe("EVM Family", () => { hasFailed: false, nftOperations: [], subOperations: [], + internalOperations: [], recipients: [eip55.encode(coinOperation3.to)], senders: [eip55.encode(coinOperation3.from)], transactionSequenceNumber: coinOperation3.nonce_value, @@ -254,6 +257,7 @@ describe("EVM Family", () => { hasFailed: false, nftOperations: [], subOperations: [], + internalOperations: [], recipients: [eip55.encode(coinOperation4.to)], senders: [eip55.encode(coinOperation4.from)], transactionSequenceNumber: coinOperation4.nonce_value, @@ -338,6 +342,23 @@ describe("EVM Family", () => { value: new BigNumber("100000000000000"), }, ], + lastInternalOperations: [ + { + id: "js:2:ethereum:0x6cBCD73CD8e8a42844662f0A0e76D7F79Afd933d:-0xf350d4f8e910419e2d5cec294d44e69af8c6185b7089061d33bb4fc246cefb79-IN-i0", + accountId, + blockHash: coinOperation2.block.hash, + blockHeight: coinOperation2.block.height, + date: new Date(coinOperation2.block.time), + extra: {}, + fee: new BigNumber("0"), + hasFailed: false, + hash: coinOperation2.hash, + recipients: [eip55.encode(coinOperation2.actions[0].to)], + senders: [eip55.encode(coinOperation2.actions[0].from)], + type: "IN", + value: new BigNumber(coinOperation2.actions[0].value), + }, + ], }); }); }); diff --git a/libs/coin-evm/src/api/explorer/ledger.ts b/libs/coin-evm/src/api/explorer/ledger.ts index 1261ec83ffe3..758e3f68d12c 100644 --- a/libs/coin-evm/src/api/explorer/ledger.ts +++ b/libs/coin-evm/src/api/explorer/ledger.ts @@ -10,6 +10,7 @@ import { ledgerERC1155EventToOperations, ledgerERC20EventToOperations, ledgerERC721EventToOperations, + ledgerInternalTransactionToOperations, ledgerOperationToOperations, } from "../../adapters/index"; import { ExplorerApi, isLedgerExplorerConfig } from "./types"; @@ -112,6 +113,7 @@ export const getLastOperations: ExplorerApi["getLastOperations"] = async ( const lastCoinOperations: Operation[] = []; const lastTokenOperations: Operation[] = []; const lastNftOperations: Operation[] = []; + const lastInternalOperations: Operation[] = []; ledgerExplorerOps.forEach(ledgerOp => { const coinOps = ledgerOperationToOperations(accountId, ledgerOp); @@ -142,17 +144,22 @@ export const getLastOperations: ExplorerApi["getLastOperations"] = async ( ledgerERC1155EventToOperations(coinOps[0], event, index), ) : []; + const internalOps = ledgerOp.actions.flatMap((action, index) => + ledgerInternalTransactionToOperations(coinOps[0], action, index), + ); lastCoinOperations.push(...coinOps); lastTokenOperations.push(...erc20Ops); lastNftOperations.push(...erc721Ops); lastNftOperations.push(...erc1155Ops); + lastInternalOperations.push(...internalOps); }); return { lastCoinOperations, lastTokenOperations, lastNftOperations, + lastInternalOperations, }; }; From 6eb8ca643bf25b1688c70a87df834f7382e86ff8 Mon Sep 17 00:00:00 2001 From: lambertkevin Date: Wed, 13 Dec 2023 16:55:42 +0100 Subject: [PATCH 6/8] Add internal op handling in `attachOperations` --- .../src/__tests__/unit/logic.unit.test.ts | 24 ++++++++++++++++--- libs/coin-evm/src/logic.ts | 21 +++++++++++++++- 2 files changed, 41 insertions(+), 4 deletions(-) diff --git a/libs/coin-evm/src/__tests__/unit/logic.unit.test.ts b/libs/coin-evm/src/__tests__/unit/logic.unit.test.ts index 688a09c9111f..07200b08ba2b 100644 --- a/libs/coin-evm/src/__tests__/unit/logic.unit.test.ts +++ b/libs/coin-evm/src/__tests__/unit/logic.unit.test.ts @@ -611,12 +611,23 @@ describe("EVM Family", () => { type: "NFT_IN", }), ]; + const internalOperations = [ + makeOperation({ + accountId: coinOperation.accountId, + value: new BigNumber(5), + type: "OUT", + hash: coinOperation.hash, + }), + ]; - expect(attachOperations([coinOperation], tokenOperations, nftOperations)).toEqual([ + expect( + attachOperations([coinOperation], tokenOperations, nftOperations, internalOperations), + ).toEqual([ { ...coinOperation, subOperations: [tokenOperations[0], tokenOperations[1]], nftOperations: [nftOperations[0], nftOperations[1]], + internalOperations: [internalOperations[0]], }, { ...tokenOperations[2], @@ -628,6 +639,7 @@ describe("EVM Family", () => { recipients: [], nftOperations: [], subOperations: [tokenOperations[2]], + internalOperations: [], accountId: "", contract: undefined, }, @@ -641,6 +653,7 @@ describe("EVM Family", () => { recipients: [], nftOperations: [nftOperations[2]], subOperations: [], + internalOperations: [], accountId: "", contract: undefined, }, @@ -669,9 +682,14 @@ describe("EVM Family", () => { type: "NFT_OUT", }), ]); - expect( + const internalOperations = deepFreeze([ + makeOperation({ + hash: "0xCoinOpInternal", + }), + ]); + expect(() => // @ts-expect-error purposely ignore readonly ts issue for this - () => attachOperations(coinOperations, tokenOperations, nftOperations), + attachOperations(coinOperations, tokenOperations, nftOperations, internalOperations), ).not.toThrow(); // mutation prevented by deepFreeze method }); }); diff --git a/libs/coin-evm/src/logic.ts b/libs/coin-evm/src/logic.ts index a1f95eeea546..5106e18e6e6b 100644 --- a/libs/coin-evm/src/logic.ts +++ b/libs/coin-evm/src/logic.ts @@ -239,14 +239,16 @@ export const attachOperations = ( _coinOperations: Operation[], _tokenOperations: Operation[], _nftOperations: Operation[], + _internalOperations: Operation[], ): Operation[] => { // Creating deep copies of each Operation[] to prevent mutating the originals const coinOperations = _coinOperations.map(op => ({ ...op })); const tokenOperations = _tokenOperations.map(op => ({ ...op })); const nftOperations = _nftOperations.map(op => ({ ...op })); + const internalOperations = _internalOperations.map(op => ({ ...op })); type OperationWithRequiredChildren = Operation & - Required>; + Required>; // Helper to create a coin operation with type NONE as a parent of an orphan child operation const makeCoinOpForOrphanChildOp = (childOp: Operation): OperationWithRequiredChildren => { @@ -267,6 +269,7 @@ export const attachOperations = ( transactionSequenceNumber: childOp.transactionSequenceNumber, subOperations: [], nftOperations: [], + internalOperations: [], accountId: "", date: childOp.date, extra: {}, @@ -284,6 +287,7 @@ export const attachOperations = ( // by the adapters so it should never be needed op.subOperations = []; op.nftOperations = []; + op.internalOperations = []; coinOperationsByHash[op.hash].push(op as OperationWithRequiredChildren); }); @@ -317,6 +321,21 @@ export const attachOperations = ( } } + // Looping through internal operations to potentially copy them as a child operation of a coin operation + for (const internalOperation of internalOperations) { + let mainOperations = coinOperationsByHash[internalOperation.hash]; + if (!mainOperations?.length) { + const noneOperation = makeCoinOpForOrphanChildOp(internalOperation); + mainOperations = [noneOperation]; + coinOperations.push(noneOperation); + } + + // Ugly loop in loop but in theory, this can only be a 2 elements array maximum in the case of a self send + for (const mainOperation of mainOperations) { + mainOperation.internalOperations.push(internalOperation); + } + } + return coinOperations; }; From 640d2dc0e54530b5d7fe1baffc11f76f01b4e853 Mon Sep 17 00:00:00 2001 From: lambertkevin Date: Wed, 13 Dec 2023 16:56:18 +0100 Subject: [PATCH 7/8] Add internal op handling to sync --- .../unit/synchronization.unit.test.ts | 34 +++++++++++++ libs/coin-evm/src/synchronization.ts | 49 +++++++++---------- 2 files changed, 58 insertions(+), 25 deletions(-) diff --git a/libs/coin-evm/src/__tests__/unit/synchronization.unit.test.ts b/libs/coin-evm/src/__tests__/unit/synchronization.unit.test.ts index bc2c2b0a1731..fc6cb9257a73 100644 --- a/libs/coin-evm/src/__tests__/unit/synchronization.unit.test.ts +++ b/libs/coin-evm/src/__tests__/unit/synchronization.unit.test.ts @@ -17,6 +17,7 @@ import { tokenAccount, tokenCurrencies, tokenOperations, + internalOperations, } from "../fixtures/synchronization.fixtures"; import { UnknownNode } from "../../errors"; import { getEnv } from "../../../../env"; @@ -105,6 +106,7 @@ describe("EVM Family", () => { lastCoinOperations: [], lastTokenOperations: [], lastNftOperations: [], + lastInternalOperations: [], }), ); jest.spyOn(etherscanAPI?.default, "getLastOperations").mockImplementation(() => @@ -112,6 +114,7 @@ describe("EVM Family", () => { lastCoinOperations: [], lastTokenOperations: [], lastNftOperations: [], + lastInternalOperations: [], }), ); }); @@ -246,6 +249,15 @@ describe("EVM Family", () => { { ...erc1155Operations[1] }, ]), ); + jest + .spyOn(etherscanAPI, "getLastInternalOperations") + .mockImplementation(() => + Promise.resolve([ + { ...internalOperations[0] }, + { ...internalOperations[1] }, + { ...internalOperations[2] }, + ]), + ); jest .spyOn(nodeApi, "getTokenBalance") .mockImplementation(async (a, b, contractAddress) => { @@ -273,6 +285,7 @@ describe("EVM Family", () => { { ...coinOperations[1], nftOperations: [erc721Operations[1], erc721Operations[2], erc1155Operations[1]], + internalOperations: [internalOperations[1]], }, { ...tokenOperations[1], @@ -290,6 +303,25 @@ describe("EVM Family", () => { ...coinOperations[0], subOperations: [tokenOperations[0]], nftOperations: [erc721Operations[0], erc1155Operations[0]], + internalOperations: [internalOperations[0]], + }, + { + id: `js:2:ethereum:0xkvn:-${internalOperations[2].hash}-NONE`, + type: "NONE", + hash: internalOperations[2].hash, + value: new BigNumber(0), + fee: new BigNumber(0), + recipients: [], + senders: [], + accountId: "", + blockHash: null, + blockHeight: internalOperations[2].blockHeight, + subOperations: [], + nftOperations: [], + internalOperations: [internalOperations[2]], + date: internalOperations[2].date, + transactionSequenceNumber: 0, + extra: {}, }, ]); @@ -316,6 +348,7 @@ describe("EVM Family", () => { lastTokenOperations: [], lastNftOperations: [], lastCoinOperations: [coinOperations[2]], + lastInternalOperations: [], })); const operations = [ { @@ -361,6 +394,7 @@ describe("EVM Family", () => { lastCoinOperations: [], lastTokenOperations: [], lastNftOperations: [], + lastInternalOperations: [], }), ); jest diff --git a/libs/coin-evm/src/synchronization.ts b/libs/coin-evm/src/synchronization.ts index 0113852aee1d..7fa23a06cf39 100644 --- a/libs/coin-evm/src/synchronization.ts +++ b/libs/coin-evm/src/synchronization.ts @@ -18,6 +18,7 @@ import { decodeOperationId } from "@ledgerhq/coin-framework/operation"; import { nftsFromOperations } from "@ledgerhq/coin-framework/nft/helpers"; import { CryptoCurrency, TokenCurrency } from "@ledgerhq/types-cryptoassets"; import { attachOperations, getSyncHash, mergeSubAccounts } from "./logic"; +import { ExplorerApi } from "./api/explorer/types"; import { getExplorerApi } from "./api/explorer"; import { getNodeApi } from "./api/node/index"; @@ -60,31 +61,28 @@ export const getAccountShape: GetAccountShape = async infos => { return (acc?.blockHeight || 0) > (curr?.blockHeight || 0) ? acc : curr; }, null); - const { lastCoinOperations, lastTokenOperations, lastNftOperations } = await (async (): Promise<{ - lastCoinOperations: Operation[]; - lastTokenOperations: Operation[]; - lastNftOperations: Operation[]; - }> => { - try { - const { getLastOperations } = getExplorerApi(currency); - return await getLastOperations( - currency, - address, - accountId, - latestSyncedOperation?.blockHeight - ? Math.max(latestSyncedOperation.blockHeight - SAFE_REORG_THRESHOLD, 0) - : 0, - blockHeight, - ); - } catch (e) { - log("EVM Family", "Failed to get latest transactions", { - address, - currency, - error: e, - }); - throw e; - } - })(); + const { lastCoinOperations, lastTokenOperations, lastNftOperations, lastInternalOperations } = + await (async (): ReturnType => { + try { + const { getLastOperations } = getExplorerApi(currency); + return await getLastOperations( + currency, + address, + accountId, + latestSyncedOperation?.blockHeight + ? Math.max(latestSyncedOperation.blockHeight - SAFE_REORG_THRESHOLD, 0) + : 0, + blockHeight, + ); + } catch (e) { + log("EVM Family", "Failed to get latest transactions", { + address, + currency, + error: e, + }); + throw e; + } + })(); const newSubAccounts = await getSubAccounts(infos, accountId, lastTokenOperations); const subAccounts = shouldSyncFromScratch @@ -105,6 +103,7 @@ export const getAccountShape: GetAccountShape = async infos => { lastCoinOperations, lastTokenOperations, lastNftOperations, + lastInternalOperations, ); const newOperations = [...confirmedOperations, ...lastCoinOperationsWithAttachements]; const operations = From e8128d7a06c9b18c535b4e9788661d882087ed99 Mon Sep 17 00:00:00 2001 From: lambertkevin Date: Wed, 13 Dec 2023 17:00:31 +0100 Subject: [PATCH 8/8] changeset --- .changeset/red-sloths-do.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/red-sloths-do.md diff --git a/.changeset/red-sloths-do.md b/.changeset/red-sloths-do.md new file mode 100644 index 000000000000..6632f31159b4 --- /dev/null +++ b/.changeset/red-sloths-do.md @@ -0,0 +1,5 @@ +--- +"@ledgerhq/coin-evm": minor +--- + +Add support for internal transactions in transactions' history