UNPKG

@ledgerhq/coin-hedera

Version:
681 lines (600 loc) 23.4 kB
import { setupCalClientStore } from "@ledgerhq/cryptoassets/cal-client/test-helpers"; import { getCryptoAssetsStore } from "@ledgerhq/cryptoassets/state"; import { encodeTokenAccountId } from "@ledgerhq/ledger-wallet-framework/account"; import BigNumber from "bignumber.js"; import { HEDERA_OPERATION_TYPES, HEDERA_TRANSACTION_MODES } from "../constants"; import { estimateFees } from "../logic/estimateFees"; import { apiClient } from "../network/api"; import { getMockedAccount, getMockedTokenAccount } from "../test/fixtures/account.fixture"; import { getMockedHTSTokenCurrency, getTokenCurrencyFromCAL, getTokenCurrencyFromCALByType, } from "../test/fixtures/currency.fixture"; import { getMockedMirrorToken } from "../test/fixtures/mirror.fixture"; import { getMockedOperation } from "../test/fixtures/operation.fixture"; import { getMockedThirdwebTransaction } from "../test/fixtures/thirdweb.fixture"; import { getMockedTransaction } from "../test/fixtures/transaction.fixture"; import type { EstimateFeesResult } from "../types"; import { calculateAmount, getSubAccounts, integrateERC20Operations } from "./utils"; describe("utils", () => { beforeAll(() => { // Setup CAL client store (automatically set as global store) setupCalClientStore(); }); describe("calculateAmount", () => { let estimatedFees: Record<"crypto" | "associate", EstimateFeesResult>; beforeAll(async () => { const mockedAccount = getMockedAccount(); const [crypto, associate] = await Promise.all([ estimateFees({ currency: mockedAccount.currency, operationType: HEDERA_OPERATION_TYPES.CryptoTransfer, }), estimateFees({ currency: mockedAccount.currency, operationType: HEDERA_OPERATION_TYPES.TokenAssociate, }), ]); estimatedFees = { crypto, associate }; }); it("HBAR transfer, useAllAmount = true", async () => { const mockedAccount = getMockedAccount(); const mockedTransaction = getMockedTransaction({ useAllAmount: true }); const amount = mockedAccount.balance.minus(estimatedFees.crypto.tinybars); const totalSpent = amount.plus(estimatedFees.crypto.tinybars); const result = await calculateAmount({ account: mockedAccount, transaction: mockedTransaction, }); expect(result).toEqual({ amount, totalSpent }); }); it("HBAR transfer, useAllAmount = false", async () => { const mockedAccount = getMockedAccount(); const mockedTransaction = getMockedTransaction({ useAllAmount: false, amount: new BigNumber(1000000), }); const amount = mockedTransaction.amount; const totalSpent = amount.plus(estimatedFees.crypto.tinybars); const result = await calculateAmount({ account: mockedAccount, transaction: mockedTransaction, }); expect(result).toEqual({ amount, totalSpent }); }); it("token transfer, useAllAmount = true", async () => { const mockedTokenCurrency = getMockedHTSTokenCurrency(); const mockedTokenAccount = getMockedTokenAccount(mockedTokenCurrency); const mockedAccount = getMockedAccount({ subAccounts: [mockedTokenAccount] }); const mockedTransaction = getMockedTransaction({ useAllAmount: true, subAccountId: mockedTokenAccount.id, }); const amount = mockedTokenAccount.balance; const totalSpent = amount; const result = await calculateAmount({ account: mockedAccount, transaction: mockedTransaction, }); expect(result).toEqual({ amount, totalSpent }); }); it("token transfer, useAllAmount = false", async () => { const mockedTokenCurrency = getMockedHTSTokenCurrency(); const mockedTokenAccount = getMockedTokenAccount(mockedTokenCurrency); const mockedAccount = getMockedAccount({ subAccounts: [mockedTokenAccount] }); const mockedTransaction = getMockedTransaction({ useAllAmount: false, amount: new BigNumber(1), subAccountId: mockedTokenAccount.id, }); const amount = mockedTransaction.amount; const totalSpent = amount; const result = await calculateAmount({ account: mockedAccount, transaction: mockedTransaction, }); expect(result).toEqual({ amount, totalSpent }); }); it("token associate operation uses TokenAssociate fee", async () => { const mockedTokenCurrency = getMockedHTSTokenCurrency(); const mockedTokenAccount = getMockedTokenAccount(mockedTokenCurrency); const mockedAccount = getMockedAccount({ subAccounts: [mockedTokenAccount] }); const mockedTransaction = getMockedTransaction({ useAllAmount: false, amount: new BigNumber(1), mode: HEDERA_TRANSACTION_MODES.TokenAssociate, properties: { token: mockedTokenCurrency, }, }); const amount = mockedTransaction.amount; const totalSpent = amount.plus(estimatedFees.associate.tinybars); const result = await calculateAmount({ account: mockedAccount, transaction: mockedTransaction, }); expect(result).toEqual({ amount, totalSpent }); }); }); describe("getSubAccounts", () => { it("returns sub account based on operations and mirror tokens", async () => { const firstTokenCurrencyFromCAL = getTokenCurrencyFromCAL(0); const secondTokenCurrencyFromCAL = getTokenCurrencyFromCAL(1); const mockedAccount = getMockedAccount(); const mockedMirrorToken1 = getMockedMirrorToken({ token_id: firstTokenCurrencyFromCAL.contractAddress, balance: 10, }); const mockedMirrorToken2 = getMockedMirrorToken({ token_id: secondTokenCurrencyFromCAL.contractAddress, balance: 0, }); // Fetch actual tokens from CAL to get the real format const firstTokenFromCAL = await getCryptoAssetsStore().findTokenByAddressInCurrency( firstTokenCurrencyFromCAL.contractAddress, "hedera", ); const secondTokenFromCAL = await getCryptoAssetsStore().findTokenByAddressInCurrency( secondTokenCurrencyFromCAL.contractAddress, "hedera", ); if (!firstTokenFromCAL || !secondTokenFromCAL) { throw new Error("Tokens not found in CAL"); } const mockedOperation1 = getMockedOperation({ accountId: encodeTokenAccountId(mockedAccount.id, firstTokenFromCAL), }); const mockedOperation2 = getMockedOperation({ accountId: encodeTokenAccountId(mockedAccount.id, secondTokenFromCAL), }); const result = await getSubAccounts({ ledgerAccountId: mockedAccount.id, latestTokenOperations: [mockedOperation1, mockedOperation2], mirrorTokens: [mockedMirrorToken1, mockedMirrorToken2], erc20Tokens: [], }); const uniqueSubAccountIds = new Set(result.map(sa => sa.id)); expect(uniqueSubAccountIds.size).toBe(result.length); expect(result).toHaveLength(2); expect(result).toMatchObject([ { token: firstTokenCurrencyFromCAL, balance: new BigNumber(10), operations: [mockedOperation1], }, { token: secondTokenCurrencyFromCAL, balance: new BigNumber(0), operations: [mockedOperation2], }, ]); }); it("ignores operation if token is not listed in CAL", async () => { const mockedTokenCurrency = getMockedHTSTokenCurrency(); const mockedAccount = getMockedAccount(); const mockedOperation = getMockedOperation({ accountId: encodeTokenAccountId(mockedAccount.id, mockedTokenCurrency), }); const result = await getSubAccounts({ ledgerAccountId: mockedAccount.id, latestTokenOperations: [mockedOperation], mirrorTokens: [], erc20Tokens: [], }); expect(result).toEqual([]); }); it("returns sub account for mirror token with no operations yet (e.g. right after association)", async () => { const tokenCurrencyFromCAL = getTokenCurrencyFromCAL(0); const mockedAccount = getMockedAccount(); const mockedTokenHTS = getMockedMirrorToken({ token_id: tokenCurrencyFromCAL.contractAddress, balance: 42, }); const result = await getSubAccounts({ ledgerAccountId: mockedAccount.id, latestTokenOperations: [], mirrorTokens: [mockedTokenHTS], erc20Tokens: [], }); expect(result).toMatchObject([ { token: tokenCurrencyFromCAL, operations: [], balance: new BigNumber(42), }, ]); }); it("returns sub account for erc20 token with no operations yet", async () => { const tokenCurrencyFromCAL = getTokenCurrencyFromCALByType("erc20"); const mockedAccount = getMockedAccount(); const result = await getSubAccounts({ ledgerAccountId: mockedAccount.id, latestTokenOperations: [], mirrorTokens: [], erc20Tokens: [{ balance: new BigNumber(42), token: tokenCurrencyFromCAL }], }); expect(result).toMatchObject([ { token: tokenCurrencyFromCAL, operations: [], balance: new BigNumber(42), }, ]); }); }); describe("integrateERC20Operations", () => { const address = "0.0.12345"; const evmAddress = "0x0000000000000000000000000000000000003039"; const ledgerAccountId = `js:2:hedera:${address}:`; const tokenCurrency = getTokenCurrencyFromCALByType("erc20"); afterEach(() => { jest.restoreAllMocks(); }); it("creates new operation for erc20 in transfer", async () => { const mockGetContractCallResult = jest.spyOn(apiClient, "getContractCallResult"); const mockFindTransactionByContractCall = jest.spyOn( apiClient, "findTransactionByContractCall", ); const incomingTxConsensusTimestamp = `1705836000.000000000`; const incomingTxHash = "incoming_erc20"; const incomingTxValue = "3000000"; const incomingTxFrom = "0xSENDER"; const incomingTxTo = evmAddress; const incomingERC20Transaction = getMockedThirdwebTransaction({ transactionHash: incomingTxHash, address: tokenCurrency.contractAddress, blockHash: "0xINCOMING_BLOCK", blockNumber: 12345, decoded: { name: "Transfer", signature: "Transfer(address,address,uint256)", params: { from: incomingTxFrom, to: incomingTxTo, value: incomingTxValue, }, }, }); const oldMirrorOperations = [ getMockedOperation({ hash: "normal_tx", type: "IN", date: new Date("2024-01-20T10:00:00Z"), }), ]; mockGetContractCallResult.mockResolvedValue({ timestamp: incomingTxConsensusTimestamp, contract_id: tokenCurrency.contractAddress, } as any); mockFindTransactionByContractCall.mockResolvedValue({ transaction_hash: incomingTxHash, consensus_timestamp: incomingTxConsensusTimestamp, } as any); const { updatedOperations, newERC20TokenOperations } = await integrateERC20Operations({ ledgerAccountId, address, allOperations: oldMirrorOperations, latestERC20Transactions: [incomingERC20Transaction], pendingOperationHashes: new Set(), erc20OperationHashes: new Set(), }); const incomingOp = updatedOperations.find(op => op.hash === incomingTxHash); expect(incomingOp).toMatchObject({ type: "NONE", hash: incomingTxHash, blockHash: incomingERC20Transaction.blockHash, }); expect(incomingOp?.subOperations).toMatchObject([ { type: "IN", hash: incomingTxHash, blockHash: incomingERC20Transaction.blockHash, standard: "erc20", value: new BigNumber(incomingTxValue), senders: [incomingTxFrom], recipients: [address], }, ]); expect(newERC20TokenOperations).toMatchObject([incomingOp?.subOperations?.[0]]); expect(updatedOperations).toHaveLength(oldMirrorOperations.length + 1); }); it("creates new operation for erc20 out transfer (not made by user)", async () => { const mockGetContractCallResult = jest.spyOn(apiClient, "getContractCallResult"); const mockFindTransactionByContractCall = jest.spyOn( apiClient, "findTransactionByContractCall", ); const allowanceTxConsensusTimestamp = "1705922400.000000000"; const allowanceTxHash = "transfer_by_allowance"; const allowanceTxValue = "2000000"; const allowanceTxFrom = evmAddress; const allowanceTxTo = "0xRECIPIENT"; const oldMirrorOperations = [ getMockedOperation({ hash: "normal_tx", type: "OUT", date: new Date("2024-01-20T10:00:00Z"), }), ]; const allowanceERC20Transaction = getMockedThirdwebTransaction({ transactionHash: allowanceTxHash, address: tokenCurrency.contractAddress, blockHash: "0xALLOWANCE_BLOCK", blockNumber: 12346, decoded: { name: "Transfer", signature: "Transfer(address,address,uint256)", params: { from: allowanceTxFrom, to: allowanceTxTo, value: allowanceTxValue, }, }, }); mockGetContractCallResult.mockResolvedValue({ timestamp: allowanceTxConsensusTimestamp, contract_id: tokenCurrency.contractAddress, } as any); mockFindTransactionByContractCall.mockResolvedValue({ transaction_hash: allowanceTxHash, consensus_timestamp: allowanceTxConsensusTimestamp, } as any); const { updatedOperations, newERC20TokenOperations } = await integrateERC20Operations({ ledgerAccountId, address, allOperations: oldMirrorOperations, latestERC20Transactions: [allowanceERC20Transaction], pendingOperationHashes: new Set(), erc20OperationHashes: new Set(), }); const allowanceOp = updatedOperations.find(op => op.hash === allowanceTxHash); expect(allowanceOp).toMatchObject({ type: "FEES", hash: allowanceTxHash, blockHash: allowanceERC20Transaction.blockHash, standard: "erc20", }); expect(allowanceOp?.subOperations).toMatchObject([ { type: "OUT", hash: allowanceTxHash, blockHash: allowanceERC20Transaction.blockHash, standard: "erc20", value: new BigNumber(allowanceTxValue), senders: [address], recipients: [allowanceTxTo], }, ]); expect(newERC20TokenOperations).toMatchObject([allowanceOp?.subOperations?.[0]]); expect(updatedOperations).toHaveLength(oldMirrorOperations.length + 1); }); it("avoids duplicated CONTRACT_CALL operation if confirmed erc20 operation exists", async () => { const mockGetContractCallResult = jest.spyOn(apiClient, "getContractCallResult"); const mockFindTransactionByContractCall = jest.spyOn( apiClient, "findTransactionByContractCall", ); const duplicateTxConsensusTimestamp = "1705836000.000000000"; const duplicateTxHash = "duplicate_tx"; const operationsWithDuplicate = [ getMockedOperation({ hash: duplicateTxHash, type: "FEES", standard: "erc20", date: new Date("2024-01-20T10:00:00Z"), blockHash: "0xBLOCK", subOperations: [ getMockedOperation({ type: "OUT", standard: "erc20", hash: duplicateTxHash, accountId: encodeTokenAccountId(ledgerAccountId, tokenCurrency), }), ], }), getMockedOperation({ hash: duplicateTxHash, type: "CONTRACT_CALL", date: new Date("2024-01-20T10:00:00Z"), }), getMockedOperation({ hash: "unique_tx", type: "OUT", date: new Date("2024-01-19T10:00:00Z"), }), ]; const duplicateERC20Transaction = getMockedThirdwebTransaction({ transactionHash: duplicateTxHash, address: tokenCurrency.contractAddress, blockHash: "0xBLOCK", decoded: { name: "Transfer", signature: "Transfer(address,address,uint256)", params: { from: evmAddress, to: "0xRECIPIENT", value: "1000000", }, }, }); mockGetContractCallResult.mockResolvedValue({ timestamp: duplicateTxConsensusTimestamp, contract_id: tokenCurrency.contractAddress, } as any); mockFindTransactionByContractCall.mockResolvedValue({ transaction_hash: duplicateTxHash, consensus_timestamp: duplicateTxConsensusTimestamp, } as any); const { updatedOperations } = await integrateERC20Operations({ ledgerAccountId, address, allOperations: operationsWithDuplicate, latestERC20Transactions: [duplicateERC20Transaction], pendingOperationHashes: new Set(), erc20OperationHashes: new Set([duplicateTxHash]), }); const duplicatedContractCalls = updatedOperations.filter( op => op.type === "CONTRACT_CALL" && op.hash === duplicateTxHash, ); const feesOps = updatedOperations.filter( op => op.type === "FEES" && op.hash === duplicateTxHash, ); expect(updatedOperations).toHaveLength(2); expect(duplicatedContractCalls).toEqual([]); expect(feesOps).toHaveLength(1); expect(feesOps).toMatchObject([{ blockHash: "0xBLOCK" }]); }); it("avoids duplicated CONTRACT_CALL operation if erc20 operation is pending", async () => { const pendingTxHash = "pending_erc20"; const operationsWithPending = [ getMockedOperation({ hash: pendingTxHash, type: "CONTRACT_CALL", date: new Date("2024-01-20T10:00:00Z"), }), getMockedOperation({ hash: "confirmed_tx", type: "OUT", date: new Date("2024-01-19T10:00:00Z"), }), ]; const { updatedOperations } = await integrateERC20Operations({ ledgerAccountId, address, allOperations: operationsWithPending, latestERC20Transactions: [], pendingOperationHashes: new Set([pendingTxHash]), erc20OperationHashes: new Set(), }); const pendingOp = updatedOperations.find(op => op.hash === pendingTxHash); expect(pendingOp).toBeUndefined(); expect(updatedOperations).toHaveLength(1); expect(updatedOperations).toMatchObject([{ hash: "confirmed_tx" }]); }); /** * Timeline: * - Tuesday: Normal transactions * - Wednesday: ERC20 transfer (Mirror + Thirdweb in sync) * - Thursday: Normal transaction * - Friday: ERC20 transfer (Thirdweb stuck - no event) * - Saturday: Normal transaction * * SYNC 1 (Friday): * - Mirror Node shows CONTRACT_CALL without blockHash * - Thirdweb has no event yet (indexer stuck) * - Operation remains as CONTRACT_CALL (not enriched) * * SYNC 2 (Saturday): * - Thirdweb catches up and returns Friday's event * - CONTRACT_CALL should get patched to FEES with ERC20 sub-operation */ it("handles delayed thirdweb indexer", async () => { const mockGetContractCallResult = jest.spyOn(apiClient, "getContractCallResult"); const mockFindTransactionByContractCall = jest.spyOn( apiClient, "findTransactionByContractCall", ); const fridayTxConsensusTimestamp = `1705678200.000000000`; // sync 1 from Friday: thirdweb hasn't indexed yet const fridaySyncOperations = [ getMockedOperation({ hash: "saturday_tx", type: "OUT", date: new Date("2024-01-20T10:00:00Z"), }), getMockedOperation({ hash: "friday_erc20", type: "CONTRACT_CALL", date: new Date("2024-01-19T15:30:00Z"), }), getMockedOperation({ hash: "thursday_tx", type: "OUT", date: new Date("2024-01-18T12:00:00Z"), }), getMockedOperation({ hash: "wednesday_erc20", type: "FEES", date: new Date("2024-01-17T09:00:00Z"), standard: "erc20", blockHash: "0xWEDNESDAY_BLOCK", subOperations: [ getMockedOperation({ type: "OUT", standard: "erc20", hash: "wednesday_erc20", accountId: encodeTokenAccountId(ledgerAccountId, tokenCurrency), }), ], }), getMockedOperation({ hash: "tuesday_tx", type: "OUT", date: new Date("2024-01-16T08:00:00Z"), }), ]; // thirdweb catches up with Friday's event const lateERC20Transaction = getMockedThirdwebTransaction({ transactionHash: "friday_erc20", address: tokenCurrency.contractAddress, decoded: { name: "Transfer", signature: "Transfer(address,address,uint256)", params: { from: evmAddress, to: "0xRECIPIENT", value: "5000000", }, }, }); mockGetContractCallResult.mockResolvedValue({ timestamp: fridayTxConsensusTimestamp, contract_id: tokenCurrency.contractAddress, } as any); mockFindTransactionByContractCall.mockResolvedValue({ transaction_hash: lateERC20Transaction.transactionHash, consensus_timestamp: fridayTxConsensusTimestamp, } as any); const { updatedOperations, newERC20TokenOperations } = await integrateERC20Operations({ ledgerAccountId, address, allOperations: fridaySyncOperations, latestERC20Transactions: [lateERC20Transaction], pendingOperationHashes: new Set(), erc20OperationHashes: new Set(["wednesday_erc20"]), }); // check if friday operation got patched const wednesdayOp = updatedOperations.find(op => op.hash === "wednesday_erc20"); const fridayOp = updatedOperations.find( op => op.hash === lateERC20Transaction.transactionHash, ); expect(fridayOp).toMatchObject({ type: "FEES", standard: "erc20", hash: lateERC20Transaction.transactionHash, subOperations: [ { type: "OUT", standard: "erc20", }, ], }); expect(newERC20TokenOperations).toMatchObject([ { type: "OUT", hash: lateERC20Transaction.transactionHash, accountId: encodeTokenAccountId(ledgerAccountId, tokenCurrency), }, ]); expect(wednesdayOp).toMatchObject({ type: "FEES", blockHash: "0xWEDNESDAY_BLOCK", }); expect(updatedOperations[0]).toMatchObject({ hash: "saturday_tx" }); expect(updatedOperations.at(-1)).toMatchObject({ hash: "tuesday_tx" }); expect(updatedOperations).toHaveLength(fridaySyncOperations.length); }); }); });