UNPKG

@ledgerhq/coin-hedera

Version:
405 lines (348 loc) 14.8 kB
import { setupMockCryptoAssetsStore } from "@ledgerhq/cryptoassets/cal-client/test-helpers"; import { encodeTokenAccountId } from "@ledgerhq/ledger-wallet-framework/account"; import { encodeOperationId } from "@ledgerhq/ledger-wallet-framework/operation"; import BigNumber from "bignumber.js"; import { getMockedAccount, getMockedTokenAccount } from "../test/fixtures/account.fixture"; import { getMockERC20Fields, getMockERC20Operation } from "../test/fixtures/common.fixture"; import { getMockedHTSTokenCurrency, getTokenCurrencyFromCALByType, } from "../test/fixtures/currency.fixture"; import { getMockedOperation } from "../test/fixtures/operation.fixture"; import type { HederaOperationExtra } from "../types"; import { applyPendingExtras, classifyERC20Operations, mergeSubAccounts, patchContractCallOperation, patchOperationWithExtra, prepareOperations, removeDuplicatedContractCallOperations, } from "./utils"; describe("bridge utils", () => { describe("prepareOperations", () => { const tokenCurrencyFromCAL = getTokenCurrencyFromCALByType("hts"); beforeAll(() => { setupMockCryptoAssetsStore({ findTokenByAddressInCurrency: jest .fn() .mockImplementation(async () => tokenCurrencyFromCAL), }); }); it("links token operation to existing coin operation with matching hash", async () => { const mockedTokenAccount = getMockedTokenAccount(tokenCurrencyFromCAL); const mockedCoinOperation = getMockedOperation({ hash: "shared" }); const mockedTokenOperation = getMockedOperation({ hash: "shared", accountId: encodeTokenAccountId(mockedTokenAccount.parentId, tokenCurrencyFromCAL), }); const result = await prepareOperations([mockedCoinOperation], [mockedTokenOperation]); expect(result).toHaveLength(1); expect(result[0].subOperations).toEqual([mockedTokenOperation]); }); it("creates NONE coin operation as parent if no coin op with matching hash exists", async () => { const mockedTokenAccount = getMockedTokenAccount(tokenCurrencyFromCAL); const mockedOrphanTokenOperation = getMockedOperation({ hash: "unknown-hash", accountId: encodeTokenAccountId(mockedTokenAccount.parentId, tokenCurrencyFromCAL), }); const result = await prepareOperations([], [mockedOrphanTokenOperation]); const noneOp = result.find(op => op.type === "NONE"); expect(typeof noneOp).toBe("object"); expect(noneOp).not.toBeNull(); expect(noneOp?.subOperations?.[0]).toEqual(mockedOrphanTokenOperation); expect(noneOp?.hash).toBe("unknown-hash"); }); }); describe("mergeSubAccounts", () => { it("returns newSubAccounts if no initial account exists", () => { const mockedTokenCurrency1 = getMockedHTSTokenCurrency({ id: "token1" }); const mockedTokenCurrency2 = getMockedHTSTokenCurrency({ id: "token2" }); const mockedTokenAccount1 = getMockedTokenAccount(mockedTokenCurrency1, { id: "ta1" }); const mockedTokenAccount2 = getMockedTokenAccount(mockedTokenCurrency2, { id: "ta2" }); const initialAccount = undefined; const newSubAccounts = [mockedTokenAccount1, mockedTokenAccount2]; const result = mergeSubAccounts(initialAccount, newSubAccounts); expect(result).toEqual(newSubAccounts); }); it("merges operations and updates only changed fields", () => { const mockedTokenCurrency = getMockedHTSTokenCurrency(); const existingOperation = getMockedOperation({ id: "op1" }); const newOperation = getMockedOperation({ id: "op2" }); const newPendingOperation = getMockedOperation({ id: "op3" }); const existingTokenAccount = getMockedTokenAccount(mockedTokenCurrency, { id: "tokenaccount", balance: new BigNumber(1000), creationDate: new Date(), operations: [existingOperation], pendingOperations: [], }); const updatedTokenAccount = getMockedTokenAccount(mockedTokenCurrency, { id: "tokenaccount", balance: new BigNumber(2000), creationDate: new Date(Date.now() - 24 * 60 * 60 * 1000), operations: [newOperation], pendingOperations: [newPendingOperation], }); const mockedAccount = getMockedAccount({ subAccounts: [existingTokenAccount] }); const result = mergeSubAccounts(mockedAccount, [updatedTokenAccount]); const merged = result[0]; expect(result).toHaveLength(1); expect(merged.creationDate).toEqual(existingTokenAccount.creationDate); expect(merged.balance).toEqual(new BigNumber(2000)); expect(merged.pendingOperations.map(op => op.id)).toEqual(["op3"]); expect(merged.operations.map(op => op.id)).toEqual(["op2", "op1"]); expect(merged.operationsCount).toEqual(2); }); it("adds new sub accounts that are not present in initial account", () => { const existingToken = getMockedHTSTokenCurrency({ id: "token1" }); const newToken = getMockedHTSTokenCurrency({ id: "token2" }); const existingTokenAccount = getMockedTokenAccount(existingToken, { id: "ta1" }); const newTokenAccount = getMockedTokenAccount(newToken, { id: "ta2" }); const mockedAccount = getMockedAccount({ subAccounts: [existingTokenAccount] }); const result = mergeSubAccounts(mockedAccount, [existingTokenAccount, newTokenAccount]); expect(result.map(sa => sa.id)).toEqual(["ta1", "ta2"]); }); }); describe("applyPendingExtras", () => { it("merges valid extras from pending operations", () => { const opExtra1: HederaOperationExtra = { consensusTimestamp: "1.2.3.4" }; const pendingExtra1: HederaOperationExtra = { associatedTokenId: "0.0.1234" }; const mockedOperation1 = getMockedOperation({ hash: "op1", extra: opExtra1 }); const mockedPendingOperation1 = getMockedOperation({ hash: "op1", extra: pendingExtra1 }); const result = applyPendingExtras([mockedOperation1], [mockedPendingOperation1]); expect(result).toHaveLength(1); expect(result[0].extra).toEqual({ ...mockedOperation1.extra, ...mockedPendingOperation1.extra, }); }); it("returns original operation if no matching pending is found", () => { const opExtra: HederaOperationExtra = { consensusTimestamp: "1.2.3.4" }; const pendingExtra: HederaOperationExtra = { associatedTokenId: "0.0.1234" }; const mockedOperation = getMockedOperation({ hash: "unknown", extra: opExtra }); const mockedPendingOperation = getMockedOperation({ hash: "op1", extra: pendingExtra }); const result = applyPendingExtras([mockedOperation], [mockedPendingOperation]); expect(result).toHaveLength(1); expect(result[0].extra).toEqual(mockedOperation.extra); }); }); describe("patchOperationWithExtra", () => { it("adds extra to operation and nested sub operations", () => { const mockedOperation = getMockedOperation({ hash: "parent", extra: {}, subOperations: [getMockedOperation({ hash: "sub1", extra: {} })], }); const extra: HederaOperationExtra = { consensusTimestamp: "12345", associatedTokenId: "0.0.1001", }; const patched = patchOperationWithExtra(mockedOperation, extra); expect(patched.extra).toEqual(extra); expect(patched.subOperations).toHaveLength(1); expect(patched.subOperations?.[0].extra).toEqual(extra); }); }); describe("removeDuplicatedContractCallOperations", () => { it("keeps non-CONTRACT_CALL operations", () => { const operations = [ getMockedOperation({ type: "OUT", hash: "hash1" }), getMockedOperation({ type: "IN", hash: "hash2" }), getMockedOperation({ type: "FEES", hash: "hash3" }), ]; const pendingOperationHashes = new Set<string>(); const erc20OperationHashes = new Set<string>(); const result = removeDuplicatedContractCallOperations( operations, pendingOperationHashes, erc20OperationHashes, ); expect(result).toHaveLength(3); expect(result.map(op => op.hash)).toEqual(["hash1", "hash2", "hash3"]); }); it("removes CONTRACT_CALL if hash exists in ERC20 operations", () => { const operations = [ getMockedOperation({ type: "CONTRACT_CALL", hash: "duplicate_erc20" }), getMockedOperation({ type: "OUT", hash: "unique" }), ]; const pendingOperationHashes = new Set<string>(); const erc20OperationHashes = new Set(["duplicate_erc20"]); const result = removeDuplicatedContractCallOperations( operations, pendingOperationHashes, erc20OperationHashes, ); expect(result).toHaveLength(1); expect(result).toMatchObject([ { hash: "unique", type: "OUT", }, ]); }); it("removes CONTRACT_CALL if hash exists in pending operations", () => { const operations = [ getMockedOperation({ type: "CONTRACT_CALL", hash: "duplicate_pending" }), getMockedOperation({ type: "OUT", hash: "confirmed" }), ]; const pendingOperationHashes = new Set(["duplicate_pending"]); const erc20OperationHashes = new Set<string>(); const result = removeDuplicatedContractCallOperations( operations, pendingOperationHashes, erc20OperationHashes, ); expect(result).toHaveLength(1); expect(result[0].hash).toBe("confirmed"); }); it("keeps CONTRACT_CALL if hash is unique", () => { const operations = [ getMockedOperation({ type: "CONTRACT_CALL", hash: "unique_contract_call" }), getMockedOperation({ type: "OUT", hash: "regular_out" }), ]; const pendingOperationHashes = new Set<string>(); const erc20OperationHashes = new Set<string>(); const result = removeDuplicatedContractCallOperations( operations, pendingOperationHashes, erc20OperationHashes, ); expect(result).toHaveLength(2); expect(result.find(op => op.type === "CONTRACT_CALL")?.hash).toBe("unique_contract_call"); }); }); describe("classifyERC20Operations", () => { const evmAddress = "0x0000000000000000000000000000000000003039"; const tokenCurrency = getTokenCurrencyFromCALByType("erc20"); it("classifies to 'add' when no matching operation exists", () => { const mockERC20Operations = [ getMockERC20Operation({ hash: "newTxHash", from: "0xOTHER", to: evmAddress, token: tokenCurrency, }), ]; const operationsByHash = new Map(); const { erc20OperationsToPatch, erc20OperationsToAdd } = classifyERC20Operations({ latestERC20Operations: mockERC20Operations, operationsByHash, evmAccountAddress: evmAddress, }); expect(erc20OperationsToPatch.size).toBe(0); expect(erc20OperationsToAdd.size).toBe(1); expect(erc20OperationsToAdd.has("newTxHash")).toBe(true); }); it("classifies to 'patch' when CONTRACT_CALL exists without blockHash", () => { const hash = "patchableHash"; const erc20Ops = [ getMockERC20Operation({ hash, from: evmAddress, to: "0xRECIPIENT", token: tokenCurrency, }), ]; const existingOp = getMockedOperation({ type: "CONTRACT_CALL", hash, blockHash: undefined, }); const operationsByHash = new Map([[hash, existingOp]]); const { erc20OperationsToPatch, erc20OperationsToAdd } = classifyERC20Operations({ latestERC20Operations: erc20Ops, operationsByHash, evmAccountAddress: evmAddress, }); expect(erc20OperationsToPatch.size).toBe(1); expect(erc20OperationsToPatch.has(hash)).toBe(true); expect(erc20OperationsToAdd.size).toBe(0); }); it("does NOT patch if operation already has blockHash", () => { const hash = "alreadyEnriched"; const erc20Ops = [ getMockERC20Operation({ hash, from: evmAddress, to: "0xRECIPIENT", token: tokenCurrency, }), ]; const existingOp = getMockedOperation({ type: "CONTRACT_CALL", hash, blockHash: "0xEXISTING_BLOCK", }); const operationsByHash = new Map([[hash, existingOp]]); const { erc20OperationsToPatch, erc20OperationsToAdd } = classifyERC20Operations({ latestERC20Operations: erc20Ops, operationsByHash, evmAccountAddress: evmAddress, }); expect(erc20OperationsToPatch.size).toBe(0); expect(erc20OperationsToAdd.size).toBe(0); }); it("does NOT patch IN operations (only OUT)", () => { const hash = "incomingTx"; const erc20Ops = [ getMockERC20Operation({ hash, from: "0xSENDER", to: evmAddress, token: tokenCurrency, }), ]; const existingOp = getMockedOperation({ type: "CONTRACT_CALL", hash, blockHash: undefined, }); const operationsByHash = new Map([[hash, existingOp]]); const { erc20OperationsToPatch, erc20OperationsToAdd } = classifyERC20Operations({ latestERC20Operations: erc20Ops, operationsByHash, evmAccountAddress: evmAddress, }); expect(erc20OperationsToPatch.size).toBe(0); expect(erc20OperationsToAdd.size).toBe(0); }); }); describe("patchContractCallOperation", () => { it("patches all required fields on existing operation", () => { const ledgerAccountId = "js:2:hedera:0.0.12345:"; const hash = "txHash123"; const erc20Fields = getMockERC20Fields(); const tokenOperation = getMockedOperation({ type: "OUT", hash, standard: "erc20" }); const existingOp = getMockedOperation({ type: "CONTRACT_CALL", hash, blockHash: undefined, extra: { memo: "original memo" }, }); patchContractCallOperation({ relatedExistingOperation: existingOp, ledgerAccountId, hash, erc20Fields, tokenOperation, }); expect(existingOp).toMatchObject({ type: "FEES", id: encodeOperationId(ledgerAccountId, hash, "FEES"), hash, value: erc20Fields.fee, fee: erc20Fields.fee, recipients: erc20Fields.recipients, senders: erc20Fields.senders, blockHash: erc20Fields.blockHash, blockHeight: erc20Fields.blockHeight, date: erc20Fields.date, extra: erc20Fields.extra, standard: "erc20", hasFailed: false, subOperations: [tokenOperation], }); }); }); });