UNPKG

@ledgerhq/coin-hedera

Version:
508 lines (420 loc) 18 kB
import { BalanceOptions, TransactionIntent } from "@ledgerhq/coin-module-framework/api/types"; import { InvalidParameterError } from "@ledgerhq/errors"; import BigNumber from "bignumber.js"; import coinConfig from "../config"; import { HARDCODED_BLOCK_HEIGHT, HEDERA_OPERATION_TYPES } from "../constants"; import * as logic from "../logic"; import * as logicUtils from "../logic/utils"; import { mapIntentToSDKOperation } from "../logic/utils"; import { apiClient } from "../network/api"; import * as networkUtils from "../network/utils"; import { getMockedConfig } from "../test/fixtures/config.fixture"; import { getMockedCurrency } from "../test/fixtures/currency.fixture"; import { getMockedOperation } from "../test/fixtures/operation.fixture"; import { HederaMemo } from "../types"; import { createApi } from "./index"; jest.mock("../logic"); jest.mock("../logic/utils"); jest.mock("../network/utils"); jest.mock("../network/api"); const mockExtractInitiator = jest.mocked(logicUtils.extractInitiator); const mockGetOperationValue = jest.mocked(logicUtils.getOperationValue); const mockMapIntentToSDKOperation = jest.mocked(mapIntentToSDKOperation); const mockToEVMAddress = jest.mocked(logicUtils.toEVMAddress); const mockGetAccountTokens = jest.mocked(apiClient.getAccountTokens); const mockGetERC20BalancesForAccountV2 = jest.mocked(networkUtils.getERC20BalancesForAccountV2); const mockBroadcast = jest.mocked(logic.broadcast); const mockCombine = jest.mocked(logic.combine); const mockCraftTransaction = jest.mocked(logic.craftTransaction); const mockEstimateFees = jest.mocked(logic.estimateFees); const mockGetBalance = jest.mocked(logic.getBalance); const mockLastBlockV2 = jest.mocked(logic.lastBlockV2); const mockGetBlockV2 = jest.mocked(logic.getBlockV2); const mockGetBlockInfo = jest.mocked(logic.getBlockInfo); const mockGetValidators = jest.mocked(logic.getValidators); const mockGetStakes = jest.mocked(logic.getStakes); const mockGetRewards = jest.mocked(logic.getRewards); const mockListOperationsV2 = jest.mocked(logic.listOperationsV2); describe("createApi", () => { let api: ReturnType<typeof createApi>; const mockConfig = { ...getMockedConfig(), useHgraphForErc20: true }; const mockCurrency = getMockedCurrency(); beforeEach(() => { jest.clearAllMocks(); api = createApi(mockConfig, mockCurrency.id); }); it("should set the coin config value", () => { const mockSetCoinConfig = jest.spyOn(coinConfig, "setCoinConfig"); createApi(mockConfig, mockCurrency.id); const config = coinConfig.getCoinConfig(mockCurrency.id); expect(mockSetCoinConfig).toHaveBeenCalled(); expect(config).toMatchObject({ status: { type: "active" }, }); }); it("should return an API object with coin module api methods", () => { expect(api.broadcast).toBeInstanceOf(Function); expect(api.combine).toBeInstanceOf(Function); expect(api.craftTransaction).toBeInstanceOf(Function); expect(api.estimateFees).toBeInstanceOf(Function); expect(api.getBalance).toBeInstanceOf(Function); expect(api.getBlock).toBeInstanceOf(Function); expect(api.getBlockInfo).toBeInstanceOf(Function); expect(api.getValidators).toBeInstanceOf(Function); expect(api.getStakes).toBeInstanceOf(Function); expect(api.getRewards).toBeInstanceOf(Function); expect(api.lastBlock).toBeInstanceOf(Function); expect(api.listOperations).toBeInstanceOf(Function); }); describe("broadcast", () => { it("should call broadcast from logic and return base64 hash", async () => { const fakeHash = new Uint8Array([1, 2, 3]); // @ts-expect-error - partial mock mockBroadcast.mockResolvedValue({ transactionHash: fakeHash }); const result = await api.broadcast("tx"); expect(mockBroadcast).toHaveBeenCalledTimes(1); expect(result).toBe(Buffer.from(fakeHash).toString("base64")); }); }); describe("combine", () => { it("should call combine from logic", () => { mockCombine.mockReturnValue("combined-tx"); const result = api.combine("tx", "sig", "pubkey"); expect(mockCombine).toHaveBeenCalledTimes(1); expect(result).toBe("combined-tx"); }); }); describe("craftTransaction", () => { it("should call craftTransaction from logic and return serializedTx", async () => { // @ts-expect-error - partial mock mockCraftTransaction.mockResolvedValue({ serializedTx: "serialized" }); // @ts-expect-error - partial intent const txIntent: TransactionIntent<HederaMemo> = { useAllAmount: false, recipient: "0.0.1234", amount: 100n, }; const result = await api.craftTransaction(txIntent); expect(mockCraftTransaction).toHaveBeenCalledTimes(1); expect(result).toEqual({ transaction: "serialized" }); }); it("should throw when craftTransaction is called with useAllAmount", async () => { // @ts-expect-error - testing unsupported useAllAmount const txIntent: TransactionIntent<HederaMemo> = { useAllAmount: true }; await expect(api.craftTransaction(txIntent)).rejects.toThrow("useAllAmount is not supported"); }); }); describe("craftRawTransaction", () => { it("should throw when called", () => { expect(() => api.craftRawTransaction("tx", "sender", "pubkey", 1n)).toThrow( "craftRawTransaction is not supported", ); }); }); describe("estimateFees", () => { it("should call estimateFees from logic and return FeeEstimation for non-ContractCall", async () => { // @ts-expect-error - testing with minimal required fields for TransactionIntent mockMapIntentToSDKOperation.mockReturnValue("CRYPTOTRANSFER"); mockEstimateFees.mockResolvedValue({ tinybars: new BigNumber(5000) }); // @ts-expect-error - testing with minimal required fields for TransactionIntent const txIntent: TransactionIntent<HederaMemo> = { recipient: "0.0.1234", amount: 100n }; const result = await api.estimateFees(txIntent); expect(result).toEqual({ value: BigInt("5000") }); expect(mockEstimateFees).toHaveBeenCalledWith( expect.objectContaining({ operationType: "CRYPTOTRANSFER" }), ); }); it("should pass txIntent in estimateFeesParams for ContractCall operation type", async () => { mockMapIntentToSDKOperation.mockReturnValue(HEDERA_OPERATION_TYPES.ContractCall); mockEstimateFees.mockResolvedValue({ tinybars: new BigNumber(9000) }); // @ts-expect-error - testing with minimal required fields for TransactionIntent const txIntent: TransactionIntent<HederaMemo> = { recipient: "0.0.1234", amount: 100n }; const result = await api.estimateFees(txIntent); expect(result).toEqual({ value: BigInt("9000") }); expect(mockEstimateFees).toHaveBeenCalledWith( expect.objectContaining({ operationType: HEDERA_OPERATION_TYPES.ContractCall, txIntent, }), ); }); }); describe("getBalance", () => { it("should call getBalance from logic", async () => { mockGetBalance.mockResolvedValue([{ value: 42n, asset: { type: "native" } }]); const result = await api.getBalance("0.0.1234"); expect(mockGetBalance).toHaveBeenCalledTimes(1); expect(result).toEqual([{ value: 42n, asset: { type: "native" } }]); }); it("should throw an exception when options is provided", async () => { await expect( api.getBalance("random address", {} as unknown as BalanceOptions), ).rejects.toThrow(InvalidParameterError); }); }); describe("lastBlock", () => { it("should call lastBlockV2 from logic", async () => { const mockBlock = { hash: "h", height: 1, time: new Date() }; mockLastBlockV2.mockResolvedValue(mockBlock); const result = await api.lastBlock(); expect(mockLastBlockV2).toHaveBeenCalledTimes(1); expect(result).toEqual(mockBlock); }); }); describe("getBlock", () => { it("should call getBlockV2 from logic", async () => { const mockBlock = { info: { hash: "h", height: 1, time: new Date() }, transactions: [] }; mockGetBlockV2.mockResolvedValue(mockBlock); const result = await api.getBlock(1); expect(mockGetBlockV2).toHaveBeenCalledTimes(1); expect(result).toEqual(mockBlock); }); }); describe("getBlockInfo", () => { it("should call getBlockInfo from logic", async () => { const mockBlockInfo = { hash: "h", height: 5, time: new Date() }; mockGetBlockInfo.mockResolvedValue(mockBlockInfo); const result = await api.getBlockInfo(5); expect(mockGetBlockInfo).toHaveBeenCalledTimes(1); expect(mockGetBlockInfo).toHaveBeenCalledWith(5); expect(result).toEqual(mockBlockInfo); }); }); describe("getValidators", () => { it("should call getValidators from logic", async () => { const mockValidators = { items: [], next: undefined }; mockGetValidators.mockResolvedValue(mockValidators); const result = await api.getValidators("cursor"); expect(mockGetValidators).toHaveBeenCalledTimes(1); expect(result).toEqual(mockValidators); }); }); describe("getStakes", () => { it("should call getStakes from logic", async () => { const mockStakes = { items: [ { uid: "s1", amount: 100n, address: "0.0.1234", state: "active" as const, asset: { type: "native" as const }, }, ], }; mockGetStakes.mockResolvedValue(mockStakes); const result = await api.getStakes("0.0.1234"); expect(mockGetStakes).toHaveBeenCalledTimes(1); expect(result).toEqual(mockStakes); }); }); describe("getRewards", () => { it("should call getRewards from logic", async () => { const mockRewards = { items: [ { amount: 50n, receivedAt: new Date(), stake: "s1", asset: { type: "native" as const } }, ], }; mockGetRewards.mockResolvedValue(mockRewards); const result = await api.getRewards("0.0.1234", "cursor"); expect(mockGetRewards).toHaveBeenCalledTimes(1); expect(result).toEqual(mockRewards); }); }); describe("listOperations", () => { const mockAddress = "0.0.1234"; const mockFeesPayer = "0.0.111"; const mockOptions = { limit: 10, order: "desc" as const, minHeight: 0, }; const mockOperation = getMockedOperation({ id: "op1", type: "IN", hash: "txhash", value: new BigNumber(100), fee: new BigNumber(10), extra: { transactionId: `${mockFeesPayer}-1234567890-1`, }, }); const mockTokenOperation = getMockedOperation({ type: "OUT", contract: "0.0.555", standard: "erc20", value: new BigNumber(100), }); const mockOperationOlder = getMockedOperation({ id: "older", date: new Date("2024-01-01T00:00:01Z"), extra: { consensusTimestamp: "1000.0", transactionId: "0.0.111-1000.0" }, }); const mockOperationNewer = getMockedOperation({ id: "newer", date: new Date("2024-01-01T00:00:02Z"), extra: { consensusTimestamp: "2000.0", transactionId: "0.0.111-2000.0" }, }); beforeEach(() => { mockExtractInitiator.mockReturnValue(mockFeesPayer); mockGetOperationValue.mockReturnValue(100n); mockToEVMAddress.mockResolvedValue("0xabc"); mockGetAccountTokens.mockResolvedValue([]); mockGetERC20BalancesForAccountV2.mockResolvedValue([]); }); it("should throw when minHeight is not 0", async () => { await expect( api.listOperations(mockAddress, { ...mockOptions, minHeight: 5 }), ).rejects.toThrow("minHeight is not supported"); }); it("should return mapped coin-framework operations with correct shape", async () => { mockListOperationsV2.mockResolvedValue({ coinOperations: [mockOperation], tokenOperations: [], nextCursor: "next123", }); const result = await api.listOperations(mockAddress, mockOptions); expect(mockListOperationsV2).toHaveBeenCalledTimes(1); expect(result.next).toBe("next123"); expect(result.items).toEqual([ expect.objectContaining({ id: "op1", type: "IN", asset: { type: "native" }, value: BigInt(mockOperation.value.toString()), tx: expect.objectContaining({ hash: mockOperation.hash, fees: BigInt(mockOperation.fee.toString()), feesPayer: mockFeesPayer, failed: false, }), }), ]); }); it("should map token operation contract to token asset and include assetAmount in details", async () => { mockListOperationsV2.mockResolvedValue({ coinOperations: [], tokenOperations: [mockTokenOperation], nextCursor: null, }); const result = await api.listOperations(mockAddress, mockOptions); expect(result.items[0].details).toMatchObject({ assetAmount: mockTokenOperation.value.toFixed(0), }); expect(result.items[0].asset).toEqual({ type: mockTokenOperation.standard, assetReference: mockTokenOperation.contract, assetOwner: mockAddress, }); }); it("should include stakedAmount in details when present in extra", async () => { mockListOperationsV2.mockResolvedValue({ coinOperations: [ getMockedOperation({ extra: { stakedAmount: new BigNumber(200) }, }), ], tokenOperations: [], nextCursor: null, }); const result = await api.listOperations(mockAddress, mockOptions); expect(result.items[0].details).toMatchObject({ stakedAmount: 200n }); }); it("should omit feesPayer when transactionId is absent", async () => { const mockOperationWithoutTransactionId = getMockedOperation({ extra: {}, }); mockListOperationsV2.mockResolvedValue({ coinOperations: [mockOperationWithoutTransactionId], tokenOperations: [], nextCursor: null, }); const result = await api.listOperations(mockAddress, mockOptions); expect(mockExtractInitiator).not.toHaveBeenCalled(); expect(result.items[0].tx).not.toHaveProperty("feesPayer"); }); it("should prefer feesPayer from operation extra over transactionId", async () => { const explicitFeesPayer = "0.0.9999"; const operationWithExplicitFeesPayer = getMockedOperation({ extra: { transactionId: "0.0.111-1234567890-1", feesPayer: explicitFeesPayer, }, }); mockListOperationsV2.mockResolvedValue({ coinOperations: [operationWithExplicitFeesPayer], tokenOperations: [], nextCursor: null, }); const result = await api.listOperations(mockAddress, mockOptions); expect(mockExtractInitiator).not.toHaveBeenCalled(); expect(result.items[0].tx.feesPayer).toBe(explicitFeesPayer); }); it.each([ ["desc" as const, [mockOperationOlder], [mockOperationNewer]], ["asc" as const, [mockOperationNewer], [mockOperationOlder]], ])("should sort by consensusTimestamp %s", async (order, coinOps, tokenOps) => { mockListOperationsV2.mockResolvedValue({ coinOperations: coinOps, tokenOperations: tokenOps, nextCursor: null, }); const result = await api.listOperations(mockAddress, { ...mockOptions, order }); const newId = mockOperationNewer.id; const oldId = mockOperationOlder.id; expect(result.items[0].id).toEqual(order === "desc" ? newId : oldId); expect(result.items[1].id).toEqual(order === "desc" ? oldId : newId); }); it("should fall back to date sort when consensusTimestamp is missing", async () => { mockListOperationsV2.mockResolvedValue({ coinOperations: [{ ...mockOperationOlder, extra: {} }], tokenOperations: [{ ...mockOperationNewer, extra: {} }], nextCursor: null, }); const result = await api.listOperations(mockAddress, { ...mockOptions, order: "desc" }); expect(result.items[0].id).toBe(mockOperationNewer.id); expect(result.items[1].id).toBe(mockOperationOlder.id); }); it("should return undefined next when nextCursor is null", async () => { mockListOperationsV2.mockResolvedValue({ coinOperations: [], tokenOperations: [], nextCursor: null, }); const result = await api.listOperations(mockAddress, mockOptions); expect(result.next).toBeUndefined(); }); it("should use HARDCODED_BLOCK_HEIGHT and getBlockHash when blockHeight is missing", async () => { mockListOperationsV2.mockResolvedValue({ coinOperations: [mockOperation], tokenOperations: [], nextCursor: null, }); const result = await api.listOperations(mockAddress, mockOptions); expect(mockListOperationsV2).toHaveBeenCalledTimes(1); expect(logicUtils.getBlockHash).toHaveBeenCalledTimes(1); expect(result.items[0].tx.block.height).toEqual(HARDCODED_BLOCK_HEIGHT); }); it("should throw when evm address is missing", async () => { mockToEVMAddress.mockResolvedValue(null); await expect(api.listOperations(mockAddress, mockOptions)).rejects.toThrow( "hedera: evm address is missing", ); }); }); describe("validateIntent", () => { it("should throw when called", async () => { // @ts-expect-error - testing unsupported method await expect(api.validateIntent({}, [], undefined)).rejects.toThrow( "validateIntent is not supported", ); }); }); describe("getNextSequence", () => { it("should throw when called", async () => { await expect(api.getNextSequence("0.0.1234")).rejects.toThrow( "getNextSequence is not supported", ); }); }); });