@ledgerhq/coin-hedera
Version:
Ledger Hedera Coin integration
1,414 lines (1,168 loc) • 65.3 kB
text/typescript
import { createHash } from "crypto";
import { Transaction as SDKTransaction, TransactionId } from "@hashgraph/sdk";
import type { AssetInfo, TransactionIntent } from "@ledgerhq/coin-module-framework/api/types";
import { getCryptoCurrencyById } from "@ledgerhq/cryptoassets/currencies";
import { InvalidAddress } from "@ledgerhq/errors";
import { getEnv, setEnv } from "@ledgerhq/live-env";
import BigNumber from "bignumber.js";
import {
HEDERA_OPERATION_TYPES,
HEDERA_TRANSACTION_MODES,
SYNTHETIC_BLOCK_WINDOW_SECONDS,
OP_TYPES_EXCLUDING_FEES,
STAKING_REWARD_HASH_SUFFIX,
} from "../constants";
import { HederaRecipientInvalidChecksum } from "../errors";
import { apiClient } from "../network/api";
import { rpcClient } from "../network/rpc";
// Mock preloadData module before importing
jest.mock("../preload-data", () => ({
...jest.requireActual("../preload-data"),
getCurrentHederaPreloadData: jest.fn(),
}));
import * as preloadData from "../preload-data";
const mockGetCurrentHederaPreloadData = preloadData.getCurrentHederaPreloadData as jest.Mock;
import { getMockedAccount, getMockedTokenAccount } from "../test/fixtures/account.fixture";
import { getMockedEnrichedERC20Transfer } from "../test/fixtures/common.fixture";
import {
getMockedERC20TokenCurrency,
getMockedHTSTokenCurrency,
} from "../test/fixtures/currency.fixture";
import {
getMockedMirrorAccount,
getMockedMirrorTransaction,
} from "../test/fixtures/mirror.fixture";
import { getMockedOperation } from "../test/fixtures/operation.fixture";
import type {
HederaAccount,
HederaMemo,
HederaMirrorTransaction,
HederaPreloadData,
HederaTxData,
HederaValidator,
Transaction,
} from "../types";
import {
serializeSignature,
deserializeSignature,
serializeTransaction,
deserializeTransaction,
extractInitiator,
extractFeesPayer,
getOperationValue,
getMemoFromBase64,
sendRecipientCanNext,
isValidExtra,
isTokenAssociationRequired,
isAutoTokenAssociationEnabled,
isTokenAssociateTransaction,
getTransactionExplorer,
checkAccountTokenAssociationStatus,
safeParseAccountId,
getSyntheticBlock,
fromEVMAddress,
toEVMAddress,
formatTransactionId,
getDateRangeFromBlockHeight,
getBlockHash,
isStakingTransaction,
extractCompanyFromNodeDescription,
sortValidators,
getValidatorFromAccount,
getDefaultValidator,
getDelegationStatus,
filterValidatorBySearchTerm,
hasSpecificIntentData,
getChecksum,
mapIntentToSDKOperation,
getOperationDetailsExtraFields,
calculateAPY,
analyzeStakingOperation,
calculateUncommittedBalanceChange,
toEntityId,
mergeTransactionsFromDifferentSources,
millisToSeconds,
nanosToSeconds,
secondsToNanos,
toTimestamp,
createStakingRewardOperationHash,
} from "./utils";
jest.mock("../network/api");
describe("logic utils", () => {
let oldStakingLedgerNodeIdEnv: number;
beforeEach(() => {
jest.clearAllMocks();
});
afterEach(() => {
setEnv("HEDERA_STAKING_LEDGER_NODE_ID", oldStakingLedgerNodeIdEnv);
});
beforeAll(() => {
oldStakingLedgerNodeIdEnv = getEnv("HEDERA_STAKING_LEDGER_NODE_ID");
});
afterAll(async () => {
await rpcClient._resetInstance();
});
describe("signature serialization", () => {
it("should serialize a signature to base64", () => {
const signature = new Uint8Array([1, 2, 3, 4, 5]);
const serialized = serializeSignature(signature);
expect(serialized).toBe("AQIDBAU=");
});
it("should deserialize a base64 signature to Uint8Array", () => {
const base64Signature = "AQIDBAU=";
const deserialized = deserializeSignature(base64Signature);
expect(deserialized).toEqual(Buffer.from([1, 2, 3, 4, 5]));
});
});
describe("transaction serialization", () => {
beforeEach(() => {
jest.spyOn(SDKTransaction, "fromBytes");
});
afterEach(() => {
jest.restoreAllMocks();
});
it("should serialize a transaction to hex", () => {
const mockTransaction = {
toBytes: jest.fn().mockReturnValue(Buffer.from([10, 20, 30, 40, 50])),
} as unknown as SDKTransaction;
const serialized = serializeTransaction(mockTransaction);
expect(serialized).toBe("0a141e2832");
expect(mockTransaction.toBytes).toHaveBeenCalled();
});
it("should deserialize a hex string to a Transaction", () => {
const mockTransaction = { id: "mock-transaction-id" };
(SDKTransaction.fromBytes as jest.Mock).mockReturnValue(mockTransaction);
const hexTransaction = "0a141e2832";
const deserialized = deserializeTransaction(hexTransaction);
const hexTransactionBuffer = Buffer.from([10, 20, 30, 40, 50]);
expect(SDKTransaction.fromBytes).toHaveBeenCalledTimes(1);
expect(SDKTransaction.fromBytes).toHaveBeenCalledWith(hexTransactionBuffer);
expect(deserialized).toBe(mockTransaction);
});
});
describe("getOperationValue", () => {
const nativeAsset: AssetInfo = { type: "native" };
const tokenAsset: AssetInfo = { type: "hts", assetReference: "0.0.1234" };
it("should return 0 for FEES operations", () => {
const operation = getMockedOperation({
type: "FEES",
value: BigNumber(0),
fee: BigNumber(100),
});
expect(getOperationValue({ asset: nativeAsset, operation })).toBe(BigInt(0));
expect(getOperationValue({ asset: tokenAsset, operation })).toBe(BigInt(0));
});
it("should subtract fee from native operations that exclude fees", () => {
OP_TYPES_EXCLUDING_FEES.forEach(type => {
const operation = getMockedOperation({
type,
value: BigNumber(1000),
fee: BigNumber(100),
});
expect(getOperationValue({ asset: nativeAsset, operation })).toBe(BigInt(900));
});
});
it("should return value for other operations", () => {
const operationOut = getMockedOperation({
type: "OUT",
value: BigNumber(500),
fee: BigNumber(20),
});
const operationIn = getMockedOperation({
type: "IN",
value: BigNumber(800),
fee: BigNumber(30),
});
expect(getOperationValue({ asset: tokenAsset, operation: operationOut })).toBe(BigInt(500));
expect(getOperationValue({ asset: tokenAsset, operation: operationIn })).toBe(BigInt(800));
expect(getOperationValue({ asset: nativeAsset, operation: operationIn })).toBe(BigInt(800));
});
});
describe("mapIntentToSDKOperation", () => {
it("should return TokenAssociate for TokenAssociate intent", () => {
const txIntent = {
type: HEDERA_TRANSACTION_MODES.TokenAssociate,
} as TransactionIntent;
expect(mapIntentToSDKOperation(txIntent)).toBe(HEDERA_OPERATION_TYPES.TokenAssociate);
});
it("should return TokenTransfer for Send intent with HTS asset", () => {
const txIntent = {
type: HEDERA_TRANSACTION_MODES.Send,
asset: { type: "hts", assetReference: "0.0.1234" },
} as TransactionIntent;
expect(mapIntentToSDKOperation(txIntent)).toBe(HEDERA_OPERATION_TYPES.TokenTransfer);
});
it("should return ContractCall for Send intent with ERC20 asset", () => {
const txIntent = {
type: HEDERA_TRANSACTION_MODES.Send,
asset: { type: "erc20", assetReference: "0x1234" },
} as TransactionIntent;
expect(mapIntentToSDKOperation(txIntent)).toBe(HEDERA_OPERATION_TYPES.ContractCall);
});
it("should return CryptoUpdate for Delegate intent", () => {
const txIntent = {
type: HEDERA_TRANSACTION_MODES.Delegate,
} as TransactionIntent;
expect(mapIntentToSDKOperation(txIntent)).toBe(HEDERA_OPERATION_TYPES.CryptoUpdate);
});
it("should return CryptoUpdate for Undelegate intent", () => {
const txIntent = {
type: HEDERA_TRANSACTION_MODES.Undelegate,
} as TransactionIntent;
expect(mapIntentToSDKOperation(txIntent)).toBe(HEDERA_OPERATION_TYPES.CryptoUpdate);
});
it("should return CryptoUpdate for Redelegate intent", () => {
const txIntent = {
type: HEDERA_TRANSACTION_MODES.Redelegate,
} as TransactionIntent;
expect(mapIntentToSDKOperation(txIntent)).toBe(HEDERA_OPERATION_TYPES.CryptoUpdate);
});
it("should return CryptoTransfer for Send intent with native asset", () => {
const txIntent = {
type: HEDERA_TRANSACTION_MODES.Send,
asset: { type: "native" },
} as TransactionIntent;
expect(mapIntentToSDKOperation(txIntent)).toBe(HEDERA_OPERATION_TYPES.CryptoTransfer);
});
it("should return CryptoTransfer for other intent types", () => {
const txIntent = {
type: HEDERA_TRANSACTION_MODES.ClaimRewards,
} as TransactionIntent;
expect(mapIntentToSDKOperation(txIntent)).toBe(HEDERA_OPERATION_TYPES.CryptoTransfer);
});
});
describe("getMemoFromBase64", () => {
it("decodes a simple base64 string", () => {
expect(getMemoFromBase64("YnJkZw==")).toBe("brdg");
});
it("decodes an empty string", () => {
expect(getMemoFromBase64("")).toBe("");
});
it("decodes a base64 string with spaces", () => {
const input = Buffer.from("hello world", "utf-8").toString("base64");
expect(getMemoFromBase64(input)).toBe("hello world");
});
it("decodes special characters", () => {
const input = Buffer.from("😀✨", "utf-8").toString("base64");
expect(getMemoFromBase64(input)).toBe("😀✨");
});
it("returns null for bad input", () => {
expect(getMemoFromBase64(undefined)).toBeNull();
expect(getMemoFromBase64(null as unknown as string)).toBeNull();
expect(getMemoFromBase64({} as unknown as string)).toBeNull();
expect(getMemoFromBase64(10 as unknown as string)).toBeNull();
});
});
describe("extractInitiator", () => {
it("returns Hedera account ID from valid transaction_id", () => {
expect(extractInitiator("0.0.12345-1625097600-000")).toBe("0.0.12345");
});
});
describe("extractFeesPayer", () => {
it("returns transfer account that paid exactly charged fee", () => {
expect(
extractFeesPayer({
transaction_id: "0.0.10067173-1761755118-730000493",
charged_tx_fee: 40743,
transfers: [
{ account: "0.0.23", amount: -40743 },
{ account: "0.0.801", amount: 40743 },
],
}),
).toBe("0.0.23");
});
it("falls back to transaction initiator when that account is debited", () => {
expect(
extractFeesPayer({
transaction_id: "0.0.8835924-1760510872-123456789",
charged_tx_fee: 1176695,
transfers: [
{ account: "0.0.15", amount: 55631 },
{ account: "0.0.801", amount: 1121064 },
{ account: "0.0.8835924", amount: -3176695 },
{ account: "0.0.9124531", amount: 1000000 },
{ account: "0.0.9169746", amount: 1000000 },
],
}),
).toBe("0.0.8835924");
});
it("falls back to transaction initiator when no other transfer can be used to identify the fee payer", () => {
expect(
extractFeesPayer({
transaction_id: "0.0.10067173-1761755118-029000738",
charged_tx_fee: 42782,
transfers: [
{ account: "0.0.34", amount: 2039 },
{ account: "0.0.801", amount: 40743 },
{ account: "0.0.10067174", amount: -50000 },
{ account: "0.0.10067175", amount: 7218 },
],
}),
).toBe("0.0.10067173");
});
});
describe("getTransactionExplorer", () => {
it("Tx explorer URL is converted from hash to consensus timestamp", async () => {
const explorerView = getCryptoCurrencyById("hedera").explorerViews[0];
expect(explorerView).toEqual({
tx: expect.any(String),
address: expect.any(String),
});
const mockedOperation = getMockedOperation({
extra: { consensusTimestamp: "1.2.3.4" },
});
const newUrl = getTransactionExplorer(explorerView, mockedOperation);
expect(newUrl).toBe("https://hashscan.io/mainnet/transaction/1.2.3.4");
});
it("Tx explorer URL is based on transaction id if consensus timestamp is not available", async () => {
const explorerView = getCryptoCurrencyById("hedera").explorerViews[0];
expect(explorerView).toEqual({
tx: expect.any(String),
address: expect.any(String),
});
const mockedOperation = getMockedOperation({
extra: { transactionId: "0.0.1234567-123-123" },
});
const newUrl = getTransactionExplorer(explorerView, mockedOperation);
expect(newUrl).toBe("https://hashscan.io/mainnet/transaction/0.0.1234567-123-123");
});
});
describe("isTokenAssociateTransaction", () => {
it("returns correct value based on tx.properties", () => {
expect(
isTokenAssociateTransaction({ mode: HEDERA_TRANSACTION_MODES.TokenAssociate } as any),
).toBe(true);
expect(isTokenAssociateTransaction({ mode: HEDERA_TRANSACTION_MODES.Send } as any)).toBe(
false,
);
expect(isTokenAssociateTransaction({} as any)).toBe(false);
});
});
describe("isAutoTokenAssociationEnabled", () => {
it("returns value based on isAutoTokenAssociationEnabled flag", () => {
expect(
isAutoTokenAssociationEnabled({
hederaResources: { isAutoTokenAssociationEnabled: true },
} as any),
).toBe(true);
expect(
isAutoTokenAssociationEnabled({
hederaResources: { isAutoTokenAssociationEnabled: false },
} as any),
).toBe(false);
expect(isAutoTokenAssociationEnabled({} as any)).toBe(false);
});
});
describe("isTokenAssociationRequired", () => {
it("should return false if token is already associated (token account exists)", () => {
const mockedTokenCurrency = getMockedHTSTokenCurrency();
const mockedTokenAccount = getMockedTokenAccount(mockedTokenCurrency);
const mockedAccount = getMockedAccount({ subAccounts: [mockedTokenAccount] });
expect(isTokenAssociationRequired(mockedAccount, mockedTokenCurrency)).toBe(false);
});
it("should return false if auto token associations are enabled", () => {
const mockedTokenCurrency = getMockedHTSTokenCurrency();
const mockedAccount = getMockedAccount({
subAccounts: [],
hederaResources: {
maxAutomaticTokenAssociations: -1,
isAutoTokenAssociationEnabled: true,
delegation: null,
},
});
expect(isTokenAssociationRequired(mockedAccount, mockedTokenCurrency)).toBe(false);
});
it("should return true if token is not associated and auto associations are disabled", () => {
const mockedTokenCurrency = getMockedHTSTokenCurrency();
const mockedAccount = getMockedAccount({ subAccounts: [] });
expect(isTokenAssociationRequired(mockedAccount, mockedTokenCurrency)).toBe(true);
});
it("should return false for erc20 token", () => {
const mockedTokenCurrency = getMockedERC20TokenCurrency();
const mockedAccount = getMockedAccount({ subAccounts: [] });
expect(isTokenAssociationRequired(mockedAccount, mockedTokenCurrency)).toBe(false);
});
it("should return false if token is undefined", () => {
const mockedAccount = getMockedAccount({ subAccounts: [] });
expect(isTokenAssociationRequired(mockedAccount, undefined)).toBe(false);
});
it("should return false for legacy accounts without subAccounts or hederaResources", () => {
const mockedTokenCurrency = getMockedHTSTokenCurrency();
const mockedAccount = getMockedAccount();
delete mockedAccount.subAccounts;
delete mockedAccount.hederaResources;
expect(isTokenAssociationRequired(mockedAccount, mockedTokenCurrency)).toBe(true);
});
});
describe("isValidExtra", () => {
it("returns true for object and false for invalid types", () => {
expect(isValidExtra({ some: "value" })).toBe(true);
expect(isValidExtra(null)).toBe(false);
expect(isValidExtra(undefined)).toBe(false);
expect(isValidExtra("string")).toBe(false);
expect(isValidExtra(123)).toBe(false);
expect(isValidExtra([])).toBe(false);
});
});
describe("sendRecipientCanNext", () => {
it("handles association warnings", () => {
expect(sendRecipientCanNext({ warnings: {} } as any)).toBe(true);
expect(sendRecipientCanNext({ warnings: { missingAssociation: new Error() } } as any)).toBe(
false,
);
expect(
sendRecipientCanNext({ warnings: { unverifiedAssociation: new Error() } } as any),
).toBe(false);
});
});
describe("checkAccountTokenAssociationStatus", () => {
const accountId = "0.0.1234";
const htsToken = getMockedHTSTokenCurrency({ contractAddress: "0.0.1234", tokenType: "hts" });
const erc20Token = getMockedHTSTokenCurrency({
contractAddress: "0.0.4321",
tokenType: "erc20",
});
beforeEach(() => {
jest.clearAllMocks();
// reset LRU cache to make sure all tests receive correct mocks from mockedGetAccount
checkAccountTokenAssociationStatus.clear(`${accountId}-${htsToken.contractAddress}`);
});
it("returns true if max_automatic_token_associations === -1", async () => {
(apiClient.getAccount as jest.Mock).mockResolvedValueOnce({
account: accountId,
max_automatic_token_associations: -1,
balance: {
balance: 0,
timestamp: "",
tokens: [],
},
});
const result = await checkAccountTokenAssociationStatus(accountId, htsToken);
expect(result).toBe(true);
});
it("returns true if token is already associated", async () => {
(apiClient.getAccount as jest.Mock).mockResolvedValueOnce({
account: accountId,
max_automatic_token_associations: 0,
balance: {
balance: 1,
timestamp: "",
tokens: [{ token_id: htsToken.contractAddress, balance: 1 }],
},
});
const result = await checkAccountTokenAssociationStatus(accountId, htsToken);
expect(result).toBe(true);
});
it("returns false if token is not associated", async () => {
(apiClient.getAccount as jest.Mock).mockResolvedValueOnce({
account: accountId,
max_automatic_token_associations: 0,
balance: {
balance: 1,
timestamp: "",
tokens: [{ token_id: "0.1234", balance: 1 }],
},
});
const result = await checkAccountTokenAssociationStatus(accountId, htsToken);
expect(result).toBe(false);
});
it("returns true for erc20 tokens", async () => {
const result = await checkAccountTokenAssociationStatus(accountId, erc20Token);
expect(apiClient.getAccount as jest.Mock).not.toHaveBeenCalled();
expect(result).toBe(true);
});
it("supports addresses with checksum", async () => {
const addressWithChecksum = "0.0.9124531-xrxlv";
(apiClient.getAccount as jest.Mock).mockResolvedValueOnce({
account: accountId,
max_automatic_token_associations: 0,
balance: {
balance: 1,
timestamp: "",
tokens: [{ token_id: htsToken.contractAddress, balance: 1 }],
},
});
await checkAccountTokenAssociationStatus(addressWithChecksum, htsToken);
expect(apiClient.getAccount).toHaveBeenCalledTimes(1);
expect(apiClient.getAccount).toHaveBeenCalledWith("0.0.9124531");
});
});
describe("getChecksum", () => {
it("should return correct checksum for valid account ID", () => {
const accountId = "0.0.9124531-xrxlv";
const checksum = getChecksum(accountId);
expect(checksum).toBe("xrxlv");
});
it("should return null for invalid account ID", () => {
const accountId = "invalid-account-id";
const checksum = getChecksum(accountId);
expect(checksum).toBeNull();
});
});
describe("safeParseAccountId", () => {
it("returns account id and no checksum for valid address without checksum", async () => {
const [error, result] = await safeParseAccountId("0.0.9124531");
expect(error).toBeNull();
expect(result?.accountId).toBe("0.0.9124531");
expect(result?.checksum).toBeNull();
});
it("returns account id and checksum for valid address with correct checksum", async () => {
const [error, result] = await safeParseAccountId("0.0.9124531-xrxlv");
expect(error).toBeNull();
expect(result?.accountId).toBe("0.0.9124531");
expect(result?.checksum).toBe("xrxlv");
});
it("returns error for valid address with incorrect checksum", async () => {
const [error, accountId] = await safeParseAccountId("0.0.9124531-invld");
expect(error).toBeInstanceOf(HederaRecipientInvalidChecksum);
expect(accountId).toBeNull();
});
it("returns error for invalid address format", async () => {
const [error, accountId] = await safeParseAccountId("not-a-valid-address");
expect(error).toBeInstanceOf(InvalidAddress);
expect(accountId).toBeNull();
});
});
describe("getSyntheticBlock", () => {
it("calculates correct blockHeight and blockHash for typical timestamp, with default block window", () => {
const consensusTimestamp = "1760523159.854347000";
const blockWindowSeconds = SYNTHETIC_BLOCK_WINDOW_SECONDS;
const expectedSeconds = Math.floor(Number(consensusTimestamp));
const expectedBlockHeight = Math.floor(expectedSeconds / blockWindowSeconds);
const expectedBlockHash = createHash("sha256")
.update(expectedBlockHeight.toString())
.digest("hex");
const result = getSyntheticBlock(consensusTimestamp);
expect(result.blockHeight).toBe(expectedBlockHeight);
expect(result.blockHash).toBe(expectedBlockHash);
});
it("supports custom blockWindowSeconds", () => {
const consensusTimestamp = "1760523159.854347000";
const blockWindowSeconds = 3600;
const expectedSeconds = Math.floor(Number(consensusTimestamp));
const expectedBlockHeight = Math.floor(expectedSeconds / blockWindowSeconds);
const expectedBlockHash = createHash("sha256")
.update(expectedBlockHeight.toString())
.digest("hex");
const result = getSyntheticBlock(consensusTimestamp, blockWindowSeconds);
expect(result.blockHeight).toBe(expectedBlockHeight);
expect(result.blockHash).toBe(expectedBlockHash);
});
it("throws error for invalid consensusTimestamp", () => {
expect(() => getSyntheticBlock("not_a_number")).toThrow();
expect(() => getSyntheticBlock("")).toThrow();
});
});
describe("formatTransactionId", () => {
it("converts SDK TransactionId format to mirror node format", () => {
const mockTransactionId = {
toString: () => "0.0.8835924@1759825731.231952875",
} as TransactionId;
const result = formatTransactionId(mockTransactionId);
expect(result).toBe("0.0.8835924-1759825731-231952875");
});
it("handles different account ID formats", () => {
const mockTransactionId = {
toString: () => "0.0.1@1234567890.987654321",
} as TransactionId;
const result = formatTransactionId(mockTransactionId);
expect(result).toBe("0.0.1-1234567890-987654321");
});
});
describe("toEVMAddress", () => {
const mockMirrorAccount = {
account: "0.0.12345",
evm_address: "0x0000000000000000000000000000000000003039",
};
it("returns correct EVM address for valid Hedera account ID", async () => {
(apiClient.getAccount as jest.Mock).mockResolvedValueOnce(mockMirrorAccount);
const evmAddress = await toEVMAddress(mockMirrorAccount.account);
expect(apiClient.getAccount).toHaveBeenCalledTimes(1);
expect(apiClient.getAccount).toHaveBeenCalledWith(mockMirrorAccount.account);
expect(evmAddress).toBe(mockMirrorAccount.evm_address);
});
it("returns null when API call fails", async () => {
(apiClient.getAccount as jest.Mock).mockRejectedValueOnce(new Error("API error"));
const evmAddress = await toEVMAddress(mockMirrorAccount.account);
expect(apiClient.getAccount).toHaveBeenCalledTimes(1);
expect(evmAddress).toBeNull();
});
});
describe("fromEVMAddress", () => {
it("should convert a long-zero EVM address to Hedera account ID", () => {
const evmAddress = "0x00000000000000000000000000000000008b3ab3";
const result = fromEVMAddress(evmAddress);
expect(result).toBe("0.0.9124531");
});
it("should return null for non-long-zero EVM address", () => {
const evmAddress = "0xae2e616828973ec543bbce40cf640c012c5a3805";
const result = fromEVMAddress(evmAddress, 0, 0);
expect(result).toBeNull();
});
it("should handle custom shard and realm values", () => {
const evmAddress = "0x0000000000000000000000000000000000000064";
const result = fromEVMAddress(evmAddress, 1, 2);
expect(result).toBe("1.2.100");
});
it("should return null for invalid EVM addresses", () => {
expect(fromEVMAddress("not-an-address")).toBeNull();
expect(fromEVMAddress("0xInvalid")).toBeNull();
expect(fromEVMAddress("")).toBeNull();
expect(fromEVMAddress("1234567890")).toBeNull();
expect(fromEVMAddress(undefined as unknown as string)).toBeNull();
});
});
describe("getBlockHash", () => {
it("produces consistent 64-character hex hash", () => {
const hash = getBlockHash(12345);
expect(hash).toMatch(/^[0-9a-f]{64}$/);
});
it("produces same hash for same block height", () => {
const hash1 = getBlockHash(100);
const hash2 = getBlockHash(100);
expect(hash1).toBe(hash2);
});
it("produces different hashes for different block heights", () => {
const hash1 = getBlockHash(100);
const hash2 = getBlockHash(101);
expect(hash1).not.toBe(hash2);
});
});
describe("getDateRangeFromBlockHeight", () => {
it("calculates consensus timestamp for block height 0 with default window", () => {
const result = getDateRangeFromBlockHeight(0);
expect(result).toEqual({
start: new Date(0),
end: new Date(10000),
});
});
it("calculates consensus timestamp for block height 1 with default window", () => {
const result = getDateRangeFromBlockHeight(1);
expect(result).toEqual({
start: new Date(10000),
end: new Date(20000),
});
});
it("calculates consensus timestamp with custom block window of 1 second", () => {
const result = getDateRangeFromBlockHeight(42, 1);
expect(result).toEqual({
start: new Date(42000),
end: new Date(43000),
});
});
it("handles large block heights correctly", () => {
const result = getDateRangeFromBlockHeight(1000000);
expect(result).toEqual({
start: new Date("1970-04-26T17:46:40.000Z"),
end: new Date("1970-04-26T17:46:50.000Z"),
});
});
it("ensures start and end timestamps are within the same block window", () => {
const blockHeight = 50;
const blockWindowSeconds = 10;
const result = getDateRangeFromBlockHeight(blockHeight, blockWindowSeconds);
const startSeconds = result.start.getTime() / 1000;
const endSeconds = result.end.getTime() / 1000;
expect(endSeconds - startSeconds).toBe(blockWindowSeconds);
});
it("does not use sub second precision", () => {
const result = getDateRangeFromBlockHeight(123);
expect(result.start.getMilliseconds()).toEqual(0);
expect(result.end.getMilliseconds()).toEqual(0);
});
});
describe("toTimestamp", () => {
it("should parse seconds and nanos from consensus timestamp", () => {
const result = toTimestamp("1758733200.632122898");
expect(result.seconds.toString()).toBe("1758733200");
expect(result.nanos.toString()).toBe("632122898");
});
it("should support nanos shorter than 9 digits", () => {
const result = toTimestamp("1758733200.6");
expect(result.seconds.toString()).toBe("1758733200");
expect(result.nanos.toString()).toBe("600000000");
});
});
describe("isStakingTransaction", () => {
it("returns correct value based on tx.mode", () => {
const stakingDelegateTx = { mode: HEDERA_TRANSACTION_MODES.Delegate } as Transaction;
const stakingUndelegateTx = { mode: HEDERA_TRANSACTION_MODES.Undelegate } as Transaction;
const stakingRedelegateTx = { mode: HEDERA_TRANSACTION_MODES.Redelegate } as Transaction;
const stakingClaimRewardsTx = { mode: HEDERA_TRANSACTION_MODES.ClaimRewards } as Transaction;
const transferTx = { recipient: "", amount: new BigNumber(1) } as Transaction;
const emptyTx = {} as Transaction;
expect(isStakingTransaction(stakingDelegateTx)).toBe(true);
expect(isStakingTransaction(stakingUndelegateTx)).toBe(true);
expect(isStakingTransaction(stakingRedelegateTx)).toBe(true);
expect(isStakingTransaction(stakingClaimRewardsTx)).toBe(true);
expect(isStakingTransaction(transferTx)).toBe(false);
expect(isStakingTransaction(emptyTx)).toBe(false);
});
it("returns false for undefined or null transactions", () => {
expect(isStakingTransaction(undefined as unknown as Transaction)).toBe(false);
expect(isStakingTransaction(null as unknown as Transaction)).toBe(false);
});
});
describe("extractCompanyFromNodeDescription", () => {
it("extracts company name from description", () => {
expect(extractCompanyFromNodeDescription("Hosted by Ledger | Paris, France")).toBe("Ledger");
expect(extractCompanyFromNodeDescription("Hosted by LG | Seoul, South Korea")).toBe("LG");
expect(extractCompanyFromNodeDescription("TestCompany | something else")).toBe("TestCompany");
expect(extractCompanyFromNodeDescription("NoSeparator ")).toBe("NoSeparator");
});
});
describe("sortValidators", () => {
it("sorts validators by active stake DESC, Ledger node first if set", () => {
setEnv("HEDERA_STAKING_LEDGER_NODE_ID", 2);
const validators = [
{ nodeId: 3, activeStake: new BigNumber(1000) },
{ nodeId: 2, activeStake: new BigNumber(2000) },
{ nodeId: 1, activeStake: new BigNumber(3000) },
] as HederaValidator[];
const sorted = sortValidators(validators);
expect(sorted[0].nodeId).toBe(2);
expect(sorted[1].nodeId).toBe(1);
expect(sorted[2].nodeId).toBe(3);
});
});
describe("getValidatorFromAccount", () => {
const mockValidator = { nodeId: 1 };
const mockPreload = { validators: [mockValidator] } as HederaPreloadData;
beforeEach(() => {
jest.clearAllMocks();
mockGetCurrentHederaPreloadData.mockReturnValue(mockPreload);
});
it("returns validator matching delegation nodeId", () => {
const mockAccount = {
currency: "hedera",
hederaResources: { delegation: { nodeId: 1 } },
} as unknown as HederaAccount;
expect(getValidatorFromAccount(mockAccount)).toEqual(mockValidator);
});
it("returns null if no delegation", () => {
const mockAccount = {
currency: "hedera",
hederaResources: {},
} as unknown as HederaAccount;
expect(getValidatorFromAccount(mockAccount)).toBeNull();
});
});
describe("getDefaultValidator", () => {
const mockValidators = [
{ nodeId: 1, activeStake: new BigNumber(2000) },
{ nodeId: 2, activeStake: new BigNumber(1000) },
{ nodeId: 3, activeStake: new BigNumber(10000) },
] as HederaValidator[];
it("returns Ledger validator if present", () => {
setEnv("HEDERA_STAKING_LEDGER_NODE_ID", 2);
expect(getDefaultValidator(mockValidators)?.nodeId).toBe(2);
});
it("returns null if no Ledger validator is present", () => {
expect(getDefaultValidator([])).toBeNull();
});
});
describe("getDelegationStatus", () => {
const mockValidator = { address: "0.0.3", overstaked: false } as HederaValidator;
const mockOverstakedValidator = { address: "0.0.3", overstaked: true } as HederaValidator;
it("returns inactive if validator or validator's address is missing", () => {
expect(getDelegationStatus(null)).toBe("inactive");
expect(getDelegationStatus({ address: "" } as any)).toBe("inactive");
});
it("returns overstaked if validator.overstaked is true", () => {
expect(getDelegationStatus(mockOverstakedValidator)).toBe("overstaked");
});
it("returns active otherwise", () => {
expect(getDelegationStatus(mockValidator)).toBe("active");
});
});
describe("filterValidatorBySearchTerm", () => {
const mockValidator: HederaValidator = {
nodeId: 123,
name: "Validator Test",
address: "0.0.456",
addressChecksum: "abcde",
minStake: new BigNumber(0),
maxStake: new BigNumber(0),
activeStake: new BigNumber(0),
activeStakePercentage: new BigNumber(0),
overstaked: false,
};
it("should match by nodeId", () => {
expect(filterValidatorBySearchTerm(mockValidator, "123")).toBe(true);
});
it("should match by name with case insensitivity", () => {
expect(filterValidatorBySearchTerm(mockValidator, "validator")).toBe(true);
expect(filterValidatorBySearchTerm(mockValidator, "VALIDATOR")).toBe(true);
expect(filterValidatorBySearchTerm(mockValidator, "test")).toBe(true);
expect(filterValidatorBySearchTerm(mockValidator, "unknown")).toBe(false);
});
it("should match by address", () => {
expect(filterValidatorBySearchTerm(mockValidator, "0.0.456")).toBe(true);
expect(filterValidatorBySearchTerm(mockValidator, "456")).toBe(true);
expect(filterValidatorBySearchTerm(mockValidator, "789")).toBe(false);
});
it("should match by address with checksum", () => {
expect(filterValidatorBySearchTerm(mockValidator, "0.0.456-abcde")).toBe(true);
expect(filterValidatorBySearchTerm(mockValidator, "abcde")).toBe(true);
expect(filterValidatorBySearchTerm(mockValidator, "ABC")).toBe(true);
});
it("should handle validator without checksum", () => {
const validatorWithoutChecksum = { ...mockValidator, addressChecksum: null };
expect(filterValidatorBySearchTerm(validatorWithoutChecksum, "0.0.456")).toBe(true);
expect(filterValidatorBySearchTerm(validatorWithoutChecksum, "abcde")).toBe(false);
});
it("should handle empty search term", () => {
expect(filterValidatorBySearchTerm(mockValidator, "")).toBe(true);
});
it("should handle partial matches", () => {
expect(filterValidatorBySearchTerm(mockValidator, "valid")).toBe(true);
expect(filterValidatorBySearchTerm(mockValidator, "0.0")).toBe(true);
expect(filterValidatorBySearchTerm(mockValidator, "12")).toBe(true);
});
});
describe("hasSpecificIntentData", () => {
it("should return true when txIntent has data matching expected type", () => {
const stakingTxIntent = {
data: { type: "staking" as const },
} as TransactionIntent<HederaMemo, HederaTxData>;
const erc20TxIntent = {
data: { type: "erc20" as const },
} as TransactionIntent<HederaMemo, HederaTxData>;
expect(hasSpecificIntentData(stakingTxIntent, "staking")).toBe(true);
expect(hasSpecificIntentData(erc20TxIntent, "erc20")).toBe(true);
});
it("should return false when txIntent has invalid data", () => {
const txIntentNoData = {} as TransactionIntent<HederaMemo, HederaTxData>;
const txIntentUnknown = {
data: { type: "unknown" as const },
} as unknown as TransactionIntent<HederaMemo, HederaTxData>;
expect(hasSpecificIntentData(txIntentUnknown, "erc20")).toBe(false);
expect(hasSpecificIntentData(txIntentNoData, "erc20")).toBe(false);
});
});
describe("getOperationDetailsExtraFields", () => {
it("should return empty array when no fields are present", () => {
const result = getOperationDetailsExtraFields({});
expect(result).toEqual([]);
});
it("should handle zero values correctly", () => {
const result = getOperationDetailsExtraFields({
gasConsumed: 0,
targetStakingNodeId: 0,
});
expect(result).toEqual([
{ key: "targetStakingNodeId", value: "0" },
{ key: "gasConsumed", value: "0" },
]);
});
it("should return all fields when all are present", () => {
const result = getOperationDetailsExtraFields({
memo: "complete",
associatedTokenId: "123",
targetStakingNodeId: 5,
previousStakingNodeId: 3,
gasConsumed: 1000,
gasUsed: 950,
gasLimit: 2000,
});
expect(result).toEqual([
{ key: "memo", value: "complete" },
{ key: "associatedTokenId", value: "123" },
{ key: "targetStakingNodeId", value: "5" },
{ key: "previousStakingNodeId", value: "3" },
{ key: "gasConsumed", value: "1000" },
{ key: "gasUsed", value: "950" },
{ key: "gasLimit", value: "2000" },
]);
});
});
describe("calculateAPY", () => {
it("should calculate APY correctly for a typical reward rate", () => {
const result = calculateAPY(3538);
expect(result).toBeCloseTo(0.01291, 5);
});
it("should return 0 for zero reward rate", () => {
const result = calculateAPY(0);
expect(result).toBe(0);
});
});
describe("calculateUncommittedBalanceChange", () => {
const mockAddress = "0.0.12345";
const mockStartTimestamp = "1762202064.065172388";
const mockEndTimestamp = "1762202074.065172388";
beforeEach(() => {
jest.clearAllMocks();
});
it("should return 0 when there are no transactions in the time range", async () => {
(apiClient.getTransactionsByTimestampRange as jest.Mock).mockResolvedValueOnce([]);
const result = await calculateUncommittedBalanceChange({
address: mockAddress,
startTimestamp: mockStartTimestamp,
endTimestamp: mockEndTimestamp,
});
expect(result).toEqual(new BigNumber(0));
expect(apiClient.getTransactionsByTimestampRange).toHaveBeenCalledTimes(1);
expect(apiClient.getTransactionsByTimestampRange).toHaveBeenCalledWith({
address: mockAddress,
startTimestamp: `gt:${mockStartTimestamp}`,
endTimestamp: `lte:${mockEndTimestamp}`,
});
});
it("should calculate balance change with mixed incoming and outgoing transfers", async () => {
const mockTransactions = [
{
consensus_timestamp: "1762202065.000000000",
transfers: [
{ account: mockAddress, amount: 2000 },
{ account: "0.0.98", amount: -2000 },
],
},
{
consensus_timestamp: "1762202070.000000000",
transfers: [
{ account: mockAddress, amount: -500 },
{ account: "0.0.99", amount: 500 },
],
},
{
consensus_timestamp: "1762202072.000000000",
transfers: [
{ account: mockAddress, amount: 300 },
{ account: "0.0.100", amount: -300 },
],
},
] as HederaMirrorTransaction[];
(apiClient.getTransactionsByTimestampRange as jest.Mock).mockResolvedValueOnce(
mockTransactions,
);
const result = await calculateUncommittedBalanceChange({
address: mockAddress,
startTimestamp: mockStartTimestamp,
endTimestamp: mockEndTimestamp,
});
expect(result).toEqual(new BigNumber(1800)); // 2000 - 500 + 300
});
it("should ignore transfers for other accounts", async () => {
const mockTransactions = [
{
consensus_timestamp: "1762202065.000000000",
transfers: [
{ account: "0.0.98", amount: 5000 },
{ account: "0.0.99", amount: -5000 },
],
},
{
consensus_timestamp: "1762202070.000000000",
transfers: [
{ account: mockAddress, amount: 1000 },
{ account: "0.0.100", amount: -1000 },
],
},
] as HederaMirrorTransaction[];
(apiClient.getTransactionsByTimestampRange as jest.Mock).mockResolvedValueOnce(
mockTransactions,
);
const result = await calculateUncommittedBalanceChange({
address: mockAddress,
startTimestamp: mockStartTimestamp,
endTimestamp: mockEndTimestamp,
});
expect(result).toEqual(new BigNumber(1000));
});
it("should return 0 when timestamps are equal or invalid", async () => {
(apiClient.getTransactionsByTimestampRange as jest.Mock).mockResolvedValueOnce([]);
const [resultEqual, resultInvalid] = await Promise.all([
calculateUncommittedBalanceChange({
address: mockAddress,
startTimestamp: mockStartTimestamp,
endTimestamp: mockStartTimestamp,
}),
calculateUncommittedBalanceChange({
address: mockAddress,
startTimestamp: mockEndTimestamp,
endTimestamp: mockStartTimestamp,
}),
]);
expect(resultEqual).toEqual(new BigNumber(0));
expect(resultInvalid).toEqual(new BigNumber(0));
});
});
describe("analyzeStakingOperation", () => {
const mockAddress = "0.0.12345";
const mockTimestamp = "1762202064.065172388";
const mockTx = {
consensus_timestamp: mockTimestamp,
name: "CRYPTOUPDATEACCOUNT",
} as HederaMirrorTransaction;
beforeEach(() => {
jest.resetAllMocks();
});
it("detects DELEGATE operation when staking starts", async () => {
const accountBefore = getMockedMirrorAccount({ staked_node_id: null });
const accountAfter = getMockedMirrorAccount({ staked_node_id: 5 });
(apiClient.getTransactionsByTimestampRange as jest.Mock).mockResolvedValueOnce([]);
(apiClient.getAccount as jest.Mock)
.mockResolvedValueOnce(accountBefore)
.mockResolvedValueOnce(accountAfter);
const result = await analyzeStakingOperation(mockAddress, mockTx);
expect(result).toEqual({
operationType: "DELEGATE",
previousStakingNodeId: null,
targetStakingNodeId: 5,
stakedAmount: BigInt(1000),
});
expect(apiClient.getAccount).toHaveBeenCalledTimes(2);
expect(apiClient.getAccount).toHaveBeenCalledWith(mockAddress, `lt:${mockTimestamp}`);
expect(apiClient.getAccount).toHaveBeenCalledWith(mockAddress, `eq:${mockTimestamp}`);
});
it("detects UNDELEGATE operation when staking stops", async () => {
const accountBefore = getMockedMirrorAccount({ staked_node_id: 5 });
const accountAfter = getMockedMirrorAccount({ staked_node_id: null });
(apiClient.getTransactionsByTimestampRange as jest.Mock).mockResolvedValueOnce([]);
(apiClient.getAccount as jest.Mock)
.mockResolvedValueOnce(accountBefore)
.mockResolvedValueOnce(accountAfter);
const result = await analyzeStakingOperation(mockAddress, mockTx);
expect(result).toEqual({
operationType: "UNDELEGATE",
previousStakingNodeId: 5,
targetStakingNodeId: null,
stakedAmount: BigInt(1000),
});
});
it("detects REDELEGATE operation when changing nodes", async () => {
const accountBefore = getMockedMirrorAccount({ staked_node_id: 3 });
const accountAfter = getMockedMirrorAccount({ staked_node_id: 10 });
(apiClient.getTransactionsByTimestampRange as jest.Mock).mockResolvedValueOnce([]);
(apiClient.getAccount as jest.Mock)
.mockResolvedValueOnce(accountBefore)
.mockResolvedValueOnce(accountAfter);
const result = await analyzeStakingOperation(mockAddress, mockTx);
expect(result).toEqual({
operationType: "REDELEGATE",
previousStakingNodeId: 3,
targetStakingNodeId: 10,
stakedAmount: BigInt(1000),
});
});
it("calculates correct staked amount with uncommitted transactions", async () => {
const mockBalance = { balance: 1000000, timestamp: "1762202060.000000000", tokens: [] };
const mockAccountBefore = getMockedMirrorAccount({
account: mockAddress,
staked_node_id: null,
balance: mockBalance,
});
const mockAccountAfter = getMockedMirrorAccount({
account: mockAddress,
staked_node_id: 5,
balance: mockBalance,
});
const mockTransactionsMissingInBalance = [
{
consensus_timestamp: `${Math.floor(Number(mockBalance.timestamp)) + 5}.000000000`,
transfers: [
{ account: mockAddress, amount: -100000 },
{ account: "0.0.98", amount: 100000 },
],
},
] as HederaMirrorTransaction[];
(apiClient.getTransactionsByTimestampRange as jest.Mock).mockResolvedValueOnce(
mockTransactionsMissingInBalance,
);
(apiClient.getAccount as jest.Mock)
.mockResolvedValueOnce(mockAccountBefore)
.mockResolvedValueOnce(mockAccountAfter);
const result = await analyzeStakingOperation(mockAddress, mockTx);
expect(apiClient.getTransactionsByTimestampRange).toHaveBeenCalledTimes(1);
expect(apiClient.getTransactionsByTimestampRange).toHaveBeenCalledWith({
address: mockAddress,
startTimestamp: `gt:${mockAccountBefore.balance.timestamp}`,
endTimestamp: `lte:${mockTimestamp}`,
});
expect(result).toEqual({
operationType: "DELEGATE",
previousStakingNodeId: null,
targetStakingNodeId: 5,
stakedAmount: BigInt(900000),
});
});
it("returns null for regular account update (both null)", async () => {
const accountBefore = getMockedMirrorAccount({ staked_node_id: null });
const accountAfter = getMockedMirrorAccount({ staked_node_id: null });
(apiClient.getAccount as jest.Mock)
.mockResolvedValueOnce(accountBefore)
.mockResolvedValueOnce(accountAfter);
const result = await analyzeStakingOperation(mockAddress, mockTx);
expect(result).toBeNull();
});
it("returns null when staked node doesn't change", async () => {
const accountBefore = getMockedMirrorAccount({ staked_node_id: 5 });
const accountAfter = getMockedMirrorAccount({ staked_node_id: 5 });
(apiClient.getAccount as jest.Mock)
.mockResolvedValueOnce(accountBefore)
.mockResolvedValueOnce(accountAfter);
const result = await analyzeStakingOperation(mockAddress, mockTx);
expect(result).toBeNull();
});
});
describe("toEntityId", () => {
it("should format entity ID with default shard and realm", () => {
expect(toEntityId({ num: 12345 })).toBe("0.0.12345");
});
it("should format entity ID with custom shard and realm", () => {
expect(toEntityId({ num: 12345, shard: 1, realm: 2 })).toBe("1.2.12345");
});
it("should throw error for negative values", () => {
expect(() => toEntityId({ num: -1 })).toThrow("invalid account num: -1");
expect(() => toEntityId({ num: 123, shard: -1 })).toThrow("invalid account shard: -1");
expect(() => toEntityId({ num: 123, realm: -1 })).toThrow("invalid account realm: -1");
});
it("should throw error for non-integers", () => {
expect(() => toEntityId({ num: 1.5 })).toThrow("invalid account num: 1.5");
expect(() => toEntityId({ num: 123, shard: 1.5 })).toThrow("invalid account shard: 1.5");
expect(() => toEntityId({ num: 123, realm: 1.5 })).toThrow("invalid account realm: 1.5");
});
});
describe("mergeTransactionsFromDifferentSources", () => {
it("should merge mirror transactions and erc20 transfers", () => {
const mockMirrorTx = getMockedMirrorTransaction({ consensus_timestamp: "1000.000000000" });
const mockEnrichedERC20Transfer = getMockedEnrichedERC20Transfer({
mirrorTransaction: getMockedMirrorTransaction({ consensus_timestamp: "2000.000000000" }),
});
const result = mergeTransactionsFromDifferentSources({
mirrorTransactions: [mockMirrorTx],
enrichedERC20Transfers: [mockEnrichedERC20Transfer],
order: "desc",
limit: 10,
latestHgraphIndexedTimestampNs: secondsToNanos(new BigNumber("3000.000000000")),
fetchAllPages: false,
});
expect(result.merged.map(tx => tx.type)).toEqual(["erc20", "mirror"]);
});
it("should handle multiple ERC20 transfers with deduplication correctly", () => {
const sharedHash1 = "hash-1";
const sharedHash2 = "hash-2";
const mockMirrorTx1 = getMockedMirrorTransaction({
consensus_timestamp: "1000.000000000",
transaction_hash: sharedHash1,
name: "CONTRACTCALL",
});
const mockMirrorTx2 = getMockedMirrorTransaction({
consensus_timestamp: "2000.000000000",
transaction_hash: sharedHash2,
name: "CONTRACTCALL",
});
const mockMirrorTx3 = getMockedMirrorTransaction({
consensus_timestamp: "3000.000000000",
transaction_hash: "unique-hash",
name: "CONTRACTCALL",
});
const mockERC20_1 = getMockedEnrichedERC20Transfer({
mirrorTransaction: getMockedMirrorTransaction({
consensus_timestamp: "1000.000000000",
transaction_hash: sharedHash1,
}),
});
const mockERC20_2 = getMockedEnrichedERC20Transfer({
mirrorTransaction: getMockedMirrorTransaction({
consensus_timestamp: "