UNPKG

@ledgerhq/coin-hedera

Version:
742 lines (649 loc) 24.2 kB
import { LedgerAPI4xx } from "@ledgerhq/errors"; import network from "@ledgerhq/live-network"; import BigNumber from "bignumber.js"; import { resolveConfig } from "../logic/utils"; import { getMockedConfig } from "../test/fixtures/config.fixture"; import { getMockResponse } from "../test/fixtures/network.fixture"; import type { HederaMirrorContractCallResult, HederaMirrorNetworkFees, HederaMirrorTransaction, } from "../types"; import { apiClient } from "./api"; jest.mock("@ledgerhq/live-network"); jest.mock("../logic/utils", () => ({ ...jest.requireActual("../logic/utils"), resolveConfig: jest.fn(), })); const mockedNetwork = jest.mocked(network); const mockedResolveConfig = jest.mocked(resolveConfig); describe("apiClient", () => { const mockAddress = "0.0.1234"; const mockConfig = getMockedConfig(); beforeEach(() => { jest.resetAllMocks(); mockedResolveConfig.mockReturnValue(mockConfig); }); describe("getAccountsForPublicKey", () => { it("should call the correct endpoint and return accounts", async () => { const mockAccounts = [{ account: "0.0.1234" }, { account: "0.0.5678" }]; mockedNetwork.mockResolvedValueOnce(getMockResponse({ accounts: mockAccounts })); const result = await apiClient.getAccountsForPublicKey({ configOrCurrencyId: mockConfig, publicKey: "abc123", }); const requestUrl = mockedNetwork.mock.calls[0][0].url; expect(result).toEqual(mockAccounts); expect(requestUrl).toContain("/api/v1/accounts"); expect(requestUrl).toContain("account.publicKey=abc123"); expect(requestUrl).toContain("balance=true"); expect(requestUrl).toContain("limit=100"); expect(mockedNetwork).toHaveBeenCalledTimes(1); }); it("should return empty array on LedgerAPI4xx error", async () => { mockedNetwork.mockRejectedValueOnce(new LedgerAPI4xx()); const result = await apiClient.getAccountsForPublicKey({ configOrCurrencyId: mockConfig, publicKey: "abc123", }); expect(result).toEqual([]); }); it("should rethrow non-LedgerAPI4xx errors", async () => { const error = new Error("network error"); mockedNetwork.mockRejectedValueOnce(error); await expect( apiClient.getAccountsForPublicKey({ configOrCurrencyId: mockConfig, publicKey: "abc123", }), ).rejects.toThrow(error); }); }); describe("getAccountTransactions", () => { it("should include 'account.id', 'limit=100' and 'order=desc' query params", async () => { mockedNetwork.mockResolvedValueOnce( getMockResponse({ transactions: [], links: { next: null } }), ); await apiClient.getAccountTransactions({ configOrCurrencyId: mockConfig, address: "0.0.1234", pagingToken: null, fetchAllPages: true, }); const requestUrl = mockedNetwork.mock.calls[0][0].url; expect(requestUrl).toContain("account.id=0.0.1234"); expect(requestUrl).toContain("limit=100"); expect(requestUrl).toContain("order=desc"); }); it("should keep fetching if fetchAllPages is set and links.next is present", async () => { mockedNetwork .mockResolvedValueOnce( getMockResponse({ transactions: [{ consensus_timestamp: "1" }], links: { next: "/next-1" }, }), ) .mockResolvedValueOnce( getMockResponse({ transactions: [], links: { next: "/next-2" }, }), ) .mockResolvedValueOnce( getMockResponse({ transactions: [{ consensus_timestamp: "3" }], links: { next: "/next-3" }, }), ) .mockResolvedValueOnce( getMockResponse({ transactions: [{ consensus_timestamp: "4" }], links: { next: "/next-4" }, }), ) .mockResolvedValueOnce( getMockResponse({ transactions: [], links: { next: null }, }), ); const result = await apiClient.getAccountTransactions({ configOrCurrencyId: mockConfig, address: "0.0.1234", pagingToken: null, fetchAllPages: true, }); expect(result.transactions.map(tx => tx.consensus_timestamp)).toEqual(["1", "3", "4"]); expect(result.nextCursor).toBeNull(); expect(mockedNetwork).toHaveBeenCalledTimes(5); }); it("should paginate if fetchAllPages is not set", async () => { mockedNetwork .mockResolvedValueOnce( getMockResponse({ transactions: [{ consensus_timestamp: "1" }], links: { next: "/next-1" }, }), ) .mockResolvedValueOnce( getMockResponse({ transactions: [], links: { next: "/next-2" }, }), ) .mockResolvedValueOnce( getMockResponse({ transactions: [{ consensus_timestamp: "3" }], links: { next: "/next-3" }, }), ) .mockResolvedValueOnce( getMockResponse({ transactions: [{ consensus_timestamp: "4" }], links: { next: "/next-4" }, }), ) .mockResolvedValueOnce( getMockResponse({ transactions: [], links: { next: null }, }), ); const result = await apiClient.getAccountTransactions({ configOrCurrencyId: mockConfig, address: "0.0.1234", pagingToken: null, limit: 2, fetchAllPages: false, }); expect(result.transactions.map(tx => tx.consensus_timestamp)).toEqual(["1", "3"]); expect(result.nextCursor).toBe("3"); expect(mockedNetwork).toHaveBeenCalledTimes(3); }); }); describe("getAccount", () => { it("should call the correct endpoint and return account data", async () => { mockedNetwork.mockResolvedValueOnce( getMockResponse({ account: mockAddress, max_automatic_token_associations: 0, balance: { balance: 1000, timestamp: "1749047764.000113442", tokens: [], }, }), ); const result = await apiClient.getAccount({ configOrCurrencyId: mockConfig, address: mockAddress, }); const requestUrl = mockedNetwork.mock.calls[0][0].url; expect(result.account).toEqual(mockAddress); expect(requestUrl).toContain(`/api/v1/accounts/${mockAddress}?transactions=false`); expect(mockedNetwork).toHaveBeenCalledTimes(1); }); it("supports timestamp filter", async () => { const mockAccount = { account: mockAddress, staked_node_id: null }; const timestamp = "lt:1762202064.065172388"; (network as jest.Mock).mockResolvedValueOnce({ data: mockAccount }); const result = await apiClient.getAccount({ configOrCurrencyId: mockConfig, address: mockAddress, timestamp, }); const requestUrl = mockedNetwork.mock.calls[0][0].url; expect(mockedNetwork).toHaveBeenCalledTimes(1); expect(result).toEqual(mockAccount); expect(requestUrl).toContain( `/api/v1/accounts/${mockAddress}?transactions=false&timestamp=${encodeURIComponent(timestamp)}`, ); }); }); describe("getAccountTokens", () => { it("should return all tokens if only one page is needed", async () => { mockedNetwork.mockResolvedValueOnce( getMockResponse({ tokens: [ { token_id: "0.0.1001", balance: 10 }, { token_id: "0.0.1002", balance: 20 }, ], links: { next: null }, }), ); const result = await apiClient.getAccountTokens({ configOrCurrencyId: mockConfig, address: "0.0.1234", }); const requestUrl = mockedNetwork.mock.calls[0][0].url; expect(result.map(t => t.token_id)).toEqual(["0.0.1001", "0.0.1002"]); expect(requestUrl).toContain("/api/v1/accounts/0.0.1234/tokens"); expect(requestUrl).toContain("limit=100"); expect(mockedNetwork).toHaveBeenCalledTimes(1); }); it("should keep fetching if links.next is present and new tokens are returned", async () => { mockedNetwork .mockResolvedValueOnce( getMockResponse({ tokens: [{ token_id: "0.0.1001", balance: 10 }], links: { next: "/next-1" }, }), ) .mockResolvedValueOnce( getMockResponse({ tokens: [{ token_id: "0.0.1002", balance: 20 }], links: { next: null }, }), ); const result = await apiClient.getAccountTokens({ configOrCurrencyId: mockConfig, address: "0.0.1234", }); expect(result.map(t => t.token_id)).toEqual(["0.0.1001", "0.0.1002"]); expect(mockedNetwork).toHaveBeenCalledTimes(2); }); }); describe("getNetworkFees", () => { it("should call the correct endpoint and return network fees", async () => { const mockedResults: HederaMirrorNetworkFees = { fees: [{ gas: 39, transaction_type: "ContractCall" }], timestamp: "1758733200.632122898", }; mockedNetwork.mockResolvedValueOnce(getMockResponse(mockedResults)); const result = await apiClient.getNetworkFees({ configOrCurrencyId: mockConfig }); const requestUrl = mockedNetwork.mock.calls[0][0].url; expect(result).toEqual(mockedResults); expect(requestUrl).toContain("/api/v1/network/fees"); expect(mockedNetwork).toHaveBeenCalledTimes(1); }); }); describe("getLatestBlock", () => { beforeEach(() => { jest.clearAllMocks(); }); it("should return latest block", async () => { const latestBlock = { timestamp: { from: "1758733199.000000000", to: "1758733200.632122898" }, }; mockedNetwork.mockResolvedValueOnce( getMockResponse({ blocks: [latestBlock], links: { next: null }, }), ); const result = await apiClient.getLatestBlock({ configOrCurrencyId: mockConfig, }); expect(result).toEqual(latestBlock); expect(mockedNetwork).toHaveBeenCalledTimes(1); expect(mockedNetwork.mock.calls[0][0].url).toContain("/api/v1/blocks"); expect(mockedNetwork.mock.calls[0][0].url).toContain("limit=1"); expect(mockedNetwork.mock.calls[0][0].url).toContain("order=desc"); }); it("should throw when no blocks are returned", async () => { mockedNetwork.mockResolvedValueOnce( getMockResponse({ blocks: [], links: { next: null }, }), ); await expect(apiClient.getLatestBlock({ configOrCurrencyId: mockConfig })).rejects.toThrow( "No blocks found on the Hedera network", ); expect(mockedNetwork).toHaveBeenCalledTimes(1); }); }); describe("getContractCallResult", () => { beforeEach(() => { jest.clearAllMocks(); }); it("should call the correct endpoint and return results for contract call", async () => { const mockedResults: HederaMirrorContractCallResult = { contract_id: "0.0.4321", block_gas_used: 100, block_hash: "0xabc", gas_consumed: 200, gas_limit: 10000, gas_used: 150, timestamp: "xxxxxxxxx", }; mockedNetwork.mockResolvedValueOnce( getMockResponse({ contract_id: "0.0.4321", block_gas_used: 100, block_hash: "0xabc", gas_consumed: 200, gas_limit: 10000, gas_used: 150, timestamp: "xxxxxxxxx", }), ); const result = await apiClient.getContractCallResult({ configOrCurrencyId: mockConfig, transactionHash: "0xa9059cbb000000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000186a0", }); const requestUrl = mockedNetwork.mock.calls[0][0].url; expect(result).toEqual(mockedResults); expect(requestUrl).toContain("/api/v1/contracts/results"); expect(mockedNetwork).toHaveBeenCalledTimes(1); }); }); describe("findTransactionByContractCallV2", () => { it("should call the correct endpoint and return transaction details", async () => { const mockConsensusTimestamp = "1758733200.632122898"; const mockPayerAddress = "0.0.1234"; const mockedResults: HederaMirrorTransaction = { transfers: [], token_transfers: [], staking_reward_transfers: [], charged_tx_fee: 100, transaction_hash: "", result: "", consensus_timestamp: mockConsensusTimestamp, entity_id: "0.0.1", transaction_id: `${mockPayerAddress}-${mockConsensusTimestamp}`, name: "CONTRACTCALL", node: null, nonce: 0, parent_consensus_timestamp: null, }; mockedNetwork.mockResolvedValueOnce( getMockResponse({ transactions: [mockedResults], }), ); const result = await apiClient.findTransactionByContractCallV2({ configOrCurrencyId: mockConfig, timestamp: mockConsensusTimestamp, payerAddress: mockPayerAddress, }); const requestUrl = mockedNetwork.mock.calls[0][0].url; expect(result).toEqual(mockedResults); expect(requestUrl).toContain("/api/v1/transactions?limit=100&order=desc"); expect(requestUrl).toContain("timestamp=gte"); expect(requestUrl).toContain("timestamp=lte"); expect(mockedNetwork).toHaveBeenCalledTimes(1); }); it("should return null for non existing contract calls", async () => { const mockConsensusTimestamp1 = "1758733200.632122898"; const mockConsensusTimestamp2 = "1758733300.632122898"; const mockPayerAddress1 = "0.0.1234"; const mockPayerAddress2 = "0.0.4321"; mockedNetwork.mockResolvedValueOnce( getMockResponse({ transactions: [ { transfers: [], token_transfers: [], staking_reward_transfers: [], charged_tx_fee: 100, transaction_hash: "zzz", transaction_id: `${mockPayerAddress1}-${mockConsensusTimestamp1}`, consensus_timestamp: mockConsensusTimestamp1, result: "", entity_id: "0.0.1", name: "NOT_CONTRACTCALL", }, { transfers: [], token_transfers: [], staking_reward_transfers: [], charged_tx_fee: 100, transaction_hash: "yyy", transaction_id: `${mockPayerAddress2}-${mockConsensusTimestamp2}`, consensus_timestamp: mockConsensusTimestamp2, result: "", entity_id: "0.0.2", name: "CONTRACTCALL", }, ] satisfies Partial<HederaMirrorTransaction>[], }), ); const result = await apiClient.findTransactionByContractCallV2({ configOrCurrencyId: mockConfig, timestamp: mockConsensusTimestamp1, payerAddress: mockPayerAddress1, }); expect(result).toEqual(null); }); it("should call the correct endpoint and return null for empty transactions list", async () => { const mockConsensusTimestamp = "1758733200.632122898"; const mockPayerAddress = "0.0.1234"; mockedNetwork.mockResolvedValueOnce( getMockResponse({ transactions: [], }), ); const result = await apiClient.findTransactionByContractCallV2({ configOrCurrencyId: mockConfig, timestamp: mockConsensusTimestamp, payerAddress: mockPayerAddress, }); expect(result).toEqual(null); }); }); describe("estimateContractCallGas", () => { it("should call the correct endpoint and return estimated contract call gas", async () => { mockedNetwork.mockResolvedValueOnce( getMockResponse({ result: "1000000000", }), ); const result = await apiClient.estimateContractCallGas({ configOrCurrencyId: mockConfig, senderEvmAddress: "0x0000000000000000000000000000000000000001", recipientEvmAddress: "0x0000000000000000000000000000000000000002", contractEvmAddress: "0x0000000000000000000000000000000000000002", amount: BigInt(1000), }); const requestUrl = mockedNetwork.mock.calls[0][0].url; expect(result).toEqual(BigNumber("1000000000")); expect(requestUrl).toContain("/api/v1/contracts/call"); expect(mockedNetwork).toHaveBeenCalledTimes(1); }); }); describe("getTransactionsByTimestampRange", () => { it("should include account.id query param if address is provided", async () => { mockedNetwork.mockResolvedValueOnce( getMockResponse({ transactions: [], links: { next: null } }), ); await apiClient.getTransactionsByTimestampRange({ configOrCurrencyId: mockConfig, address: "0.0.1234", startTimestamp: "gte:1000.000000000", endTimestamp: "lt:2000.000000000", }); const requestUrl = mockedNetwork.mock.calls[0][0].url; expect(requestUrl).toContain("account.id=0.0.1234"); }); it("should include correct query params with timestamp range", async () => { mockedNetwork.mockResolvedValueOnce( getMockResponse({ transactions: [], links: { next: null } }), ); await apiClient.getTransactionsByTimestampRange({ configOrCurrencyId: mockConfig, startTimestamp: "gte:1000.000000000", endTimestamp: "lt:2000.000000000", }); const requestUrl = mockedNetwork.mock.calls[0][0].url; expect(requestUrl).not.toContain("account.id="); expect(requestUrl).toContain("timestamp=gte%3A1000.000000000"); expect(requestUrl).toContain("timestamp=lt%3A2000.000000000"); expect(requestUrl).toContain("limit=100"); expect(requestUrl).toContain("order=desc"); }); it("should return empty array when no transactions found", async () => { mockedNetwork.mockResolvedValueOnce( getMockResponse({ transactions: [], links: { next: null } }), ); const result = await apiClient.getTransactionsByTimestampRange({ configOrCurrencyId: mockConfig, startTimestamp: "gte:1000.000000000", endTimestamp: "lt:2000.000000000", }); expect(result).toEqual([]); expect(mockedNetwork).toHaveBeenCalledTimes(1); }); it("should return all transactions when only one page is needed", async () => { mockedNetwork.mockResolvedValueOnce( getMockResponse({ transactions: [ { consensus_timestamp: "1500.123456789" }, { consensus_timestamp: "1750.987654321" }, ], links: { next: null }, }), ); const result = await apiClient.getTransactionsByTimestampRange({ configOrCurrencyId: mockConfig, startTimestamp: "gte:1000.000000000", endTimestamp: "lt:2000.000000000", }); expect(result.map(tx => tx.consensus_timestamp)).toEqual([ "1500.123456789", "1750.987654321", ]); expect(mockedNetwork).toHaveBeenCalledTimes(1); }); it("should keep fetching all pages when links.next is present", async () => { mockedNetwork .mockResolvedValueOnce( getMockResponse({ transactions: [{ consensus_timestamp: "1100.000000000" }], links: { next: "/next-1" }, }), ) .mockResolvedValueOnce( getMockResponse({ transactions: [{ consensus_timestamp: "1200.000000000" }], links: { next: "/next-2" }, }), ) .mockResolvedValueOnce( getMockResponse({ transactions: [{ consensus_timestamp: "1300.000000000" }], links: { next: null }, }), ); const result = await apiClient.getTransactionsByTimestampRange({ configOrCurrencyId: mockConfig, startTimestamp: "gte:1000.000000000", endTimestamp: "lt:2000.000000000", }); expect(result.map(tx => tx.consensus_timestamp)).toEqual([ "1100.000000000", "1200.000000000", "1300.000000000", ]); expect(mockedNetwork).toHaveBeenCalledTimes(3); }); it("should handle empty pages and continue fetching", async () => { mockedNetwork .mockResolvedValueOnce( getMockResponse({ transactions: [{ consensus_timestamp: "1100.000000000" }], links: { next: "/next-1" }, }), ) .mockResolvedValueOnce( getMockResponse({ transactions: [], links: { next: "/next-2" }, }), ) .mockResolvedValueOnce( getMockResponse({ transactions: [{ consensus_timestamp: "1300.000000000" }], links: { next: null }, }), ); const result = await apiClient.getTransactionsByTimestampRange({ configOrCurrencyId: mockConfig, startTimestamp: "gte:1000.000000000", endTimestamp: "lt:2000.000000000", }); expect(result.map(tx => tx.consensus_timestamp)).toEqual([ "1100.000000000", "1300.000000000", ]); expect(mockedNetwork).toHaveBeenCalledTimes(3); }); }); describe("getNodes", () => { it("should return all nodes if only one page is needed", async () => { mockedNetwork.mockResolvedValueOnce( getMockResponse({ nodes: [ { node_id: 0, node_account_id: "0.0.3" }, { node_id: 1, node_account_id: "0.0.4" }, ], links: { next: null }, }), ); const result = await apiClient.getNodes({ configOrCurrencyId: mockConfig, fetchAllPages: true, }); const requestUrl = mockedNetwork.mock.calls[0][0].url; expect(result.nodes.map(n => n.node_id)).toEqual([0, 1]); expect(requestUrl).toContain("/api/v1/network/nodes"); expect(requestUrl).toContain("limit=100"); expect(requestUrl).toContain("order=desc"); expect(mockedNetwork).toHaveBeenCalledTimes(1); }); it("should keep fetching if fetchAllPages and links.next is present", async () => { mockedNetwork .mockResolvedValueOnce( getMockResponse({ nodes: [{ node_id: 0, node_account_id: "0.0.3" }], links: { next: "/next-1" }, }), ) .mockResolvedValueOnce( getMockResponse({ nodes: [{ node_id: 1, node_account_id: "0.0.4" }], links: { next: "/next-2" }, }), ) .mockResolvedValueOnce( getMockResponse({ nodes: [{ node_id: 2, node_account_id: "0.0.5" }], links: { next: null }, }), ); const result = await apiClient.getNodes({ configOrCurrencyId: mockConfig, fetchAllPages: true, }); expect(result.nodes.map(n => n.node_id)).toEqual([0, 1, 2]); expect(mockedNetwork).toHaveBeenCalledTimes(3); }); it("should paginate if fetchAllPages is not set", async () => { mockedNetwork .mockResolvedValueOnce( getMockResponse({ nodes: [ { node_id: 0, node_account_id: "0.0.3" }, { node_id: 1, node_account_id: "0.0.4" }, ], links: { next: "/next-1" }, }), ) .mockResolvedValueOnce( getMockResponse({ nodes: [{ node_id: 2, node_account_id: "0.0.5" }], links: { next: null }, }), ); const result = await apiClient.getNodes({ configOrCurrencyId: mockConfig, limit: 2, fetchAllPages: false, }); expect(result.nodes.map(tx => tx.node_id)).toEqual([0, 1]); expect(result.nextCursor).toBe("1"); expect(mockedNetwork).toHaveBeenCalledTimes(1); }); }); });