@ledgerhq/coin-hedera
Version:
Ledger Hedera Coin integration
508 lines (420 loc) • 18 kB
text/typescript
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",
);
});
});
});