UNPKG

@ledgerhq/coin-hedera

Version:
397 lines (351 loc) 12.4 kB
import * as sdk from "@hashgraph/sdk"; import type { FeeEstimation, TransactionIntent } from "@ledgerhq/coin-module-framework/api/index"; import invariant from "invariant"; import { getMockedConfig } from "../test/fixtures/config.fixture"; import { HEDERA_TRANSACTION_MODES, TINYBAR_SCALE, TRANSACTION_VALID_DURATION_SECONDS, } from "../constants"; import { apiClient } from "../network/api"; import { rpcClient } from "../network/rpc"; import type { HederaMemo, HederaTxData } from "../types"; import { craftTransaction } from "./craftTransaction"; import { serializeTransaction, toEVMAddress } from "./utils"; jest.mock("./utils"); describe("craftTransaction", () => { const defaultConfig = getMockedConfig(); beforeEach(() => { jest.clearAllMocks(); (serializeTransaction as jest.Mock).mockReturnValue("serialized-transaction"); }); afterAll(async () => { await rpcClient._resetInstance(); }); afterEach(() => { jest.useRealTimers(); }); it("should craft a native HBAR transfer transaction", async () => { const txIntent = { intentType: "transaction", type: HEDERA_TRANSACTION_MODES.Send, amount: BigInt(1 * 10 ** TINYBAR_SCALE), recipient: "0.0.12345", sender: "0.0.54321", asset: { type: "native", }, memo: { kind: "text", type: "string", value: "Hbar transfer", }, } satisfies TransactionIntent<HederaMemo>; const result = await craftTransaction({ txIntent, config: defaultConfig }); expect(result.tx).toBeInstanceOf(sdk.TransferTransaction); invariant(result.tx instanceof sdk.TransferTransaction, "TransferTransaction type guard"); const senderTransfer = result.tx.hbarTransfers?.get(txIntent.sender); const recipientTransfer = result.tx.hbarTransfers?.get(txIntent.recipient); expect(senderTransfer).toEqual(sdk.Hbar.fromTinybars((-txIntent.amount).toString())); expect(recipientTransfer).toEqual(sdk.Hbar.fromTinybars(txIntent.amount.toString())); expect(result.tx.transactionMemo).toBe(txIntent.memo.value); expect(result.tx.transactionValidDuration).toEqual(TRANSACTION_VALID_DURATION_SECONDS); expect(serializeTransaction).toHaveBeenCalled(); expect(result).toEqual({ tx: expect.any(Object), serializedTx: "serialized-transaction", }); }); it("should craft HTS token transfer transaction", async () => { const txIntent = { intentType: "transaction", type: HEDERA_TRANSACTION_MODES.Send, amount: BigInt(1000), recipient: "0.0.12345", sender: "0.0.54321", asset: { type: "hts", assetReference: "0.0.7890", }, memo: { kind: "text", type: "string", value: "Token transfer", }, } satisfies TransactionIntent<HederaMemo>; const result = await craftTransaction({ txIntent, config: defaultConfig }); expect(result.tx).toBeInstanceOf(sdk.TransferTransaction); invariant(result.tx instanceof sdk.TransferTransaction, "TransferTransaction type guard"); const tokenTransfers = result.tx.tokenTransfers.get(txIntent.asset.assetReference); const senderTransfer = tokenTransfers?.get(txIntent.sender); const recipientTransfer = tokenTransfers?.get(txIntent.recipient); expect(senderTransfer).toEqual(sdk.Long.fromBigInt(-txIntent.amount)); expect(recipientTransfer).toEqual(sdk.Long.fromBigInt(txIntent.amount)); expect(result.tx.transactionMemo).toBe("Token transfer"); expect(serializeTransaction).toHaveBeenCalled(); expect(result).toEqual({ tx: expect.any(Object), serializedTx: "serialized-transaction", }); }); it("should craft ERC20 token transfer transaction", async () => { (toEVMAddress as jest.Mock).mockResolvedValue("0x0000000000000000000000000000000000003039"); const txIntent = { intentType: "transaction", type: HEDERA_TRANSACTION_MODES.Send, amount: BigInt(1000), recipient: "0.0.12345", sender: "0.0.54321", asset: { type: "erc20", assetReference: "0x39ceba2b467fa987546000eb5d1373acf1f3a2e1", }, memo: { kind: "text", type: "string", value: "Token transfer", }, data: { type: "erc20", gasLimit: BigInt(100000), }, } satisfies TransactionIntent<HederaMemo, HederaTxData>; const result = await craftTransaction({ txIntent, config: defaultConfig }); expect(result.tx).toBeInstanceOf(sdk.ContractExecuteTransaction); invariant( result.tx instanceof sdk.ContractExecuteTransaction, "ContractExecuteTransaction type guard", ); expect(result.tx.gas).toEqual(sdk.Long.fromBigInt(txIntent.data.gasLimit)); expect(result.tx.contractId).toEqual( sdk.ContractId.fromEvmAddress(0, 0, txIntent.asset.assetReference), ); expect(result.tx.transactionMemo).toBe("Token transfer"); expect(serializeTransaction).toHaveBeenCalled(); expect(result).toEqual({ tx: expect.any(Object), serializedTx: "serialized-transaction", }); }); it("should craft a token associate transaction", async () => { const txIntent = { intentType: "transaction", type: HEDERA_TRANSACTION_MODES.TokenAssociate, amount: BigInt(0), recipient: "", sender: "0.0.54321", asset: { type: "hts", assetReference: "0.0.7890", }, memo: { kind: "text", type: "string", value: "Token association", }, } satisfies TransactionIntent<HederaMemo>; const result = await craftTransaction({ txIntent, config: defaultConfig }); expect(result.tx).toBeInstanceOf(sdk.TokenAssociateTransaction); invariant( result.tx instanceof sdk.TokenAssociateTransaction, "TokenAssociateTransaction type guard", ); expect(result.tx.accountId).toEqual(sdk.AccountId.fromString(txIntent.sender)); expect(result.tx.tokenIds).toEqual([sdk.TokenId.fromString(txIntent.asset.assetReference)]); expect(result.tx.transactionMemo).toBe("Token association"); expect(serializeTransaction).toHaveBeenCalled(); expect(result).toEqual({ tx: expect.any(Object), serializedTx: "serialized-transaction", }); }); it("should apply custom fees when provided", async () => { const customFees: FeeEstimation = { value: BigInt(50000), }; const result = await craftTransaction({ txIntent: { intentType: "transaction", type: HEDERA_TRANSACTION_MODES.Send, amount: BigInt(1000000), recipient: "0.0.12345", sender: "0.0.54321", asset: { type: "native", }, memo: { kind: "text", type: "string", value: "Test memo with custom fee", }, }, customFees, config: defaultConfig, }); expect(result.tx).toBeInstanceOf(sdk.TransferTransaction); invariant(result.tx instanceof sdk.TransferTransaction, "TransferTransaction type guard"); expect(result.tx.maxTransactionFee).toEqual(sdk.Hbar.fromTinybars(customFees.value.toString())); }); it("should throw error when token associate transaction has invalid asset type", async () => { const txIntent = { intentType: "transaction", type: HEDERA_TRANSACTION_MODES.TokenAssociate, amount: BigInt(0), recipient: "", sender: "0.0.54321", asset: { type: "native", }, memo: { kind: "text", type: "string", value: "Invalid token association", }, } satisfies TransactionIntent<HederaMemo>; await expect(craftTransaction({ txIntent, config: defaultConfig })).rejects.toThrow( "hedera: invalid asset type", ); }); it("should throw error when token associate transaction has missing assetReference", async () => { const txIntent = { intentType: "transaction", type: HEDERA_TRANSACTION_MODES.TokenAssociate, amount: BigInt(0), recipient: "", sender: "0.0.54321", asset: { type: "hts", }, memo: { kind: "text", type: "string", value: "Missing asset reference", }, } satisfies TransactionIntent<HederaMemo>; await expect(craftTransaction({ txIntent, config: defaultConfig })).rejects.toThrow( "hedera: assetReference is missing", ); }); it("should throw error when token transfer transaction has missing assetReference", async () => { const txIntent = { intentType: "transaction", type: HEDERA_TRANSACTION_MODES.Send, amount: BigInt(1000), recipient: "0.0.12345", sender: "0.0.54321", asset: { type: "hts", }, memo: { kind: "text", type: "string", value: "Missing token reference", }, } satisfies TransactionIntent<HederaMemo>; await expect(craftTransaction({ txIntent, config: defaultConfig })).rejects.toThrow( "hedera: no assetReference in token transfer", ); }); it("should use mirror node timestamp when feature flag is enabled", async () => { const mockGetLatestBlock = jest.spyOn(apiClient, "getLatestBlock"); mockGetLatestBlock.mockResolvedValue({ timestamp: { from: "1758733200.632122898", to: null }, }); const txIntent = { intentType: "transaction", type: HEDERA_TRANSACTION_MODES.Send, amount: BigInt(1 * 10 ** TINYBAR_SCALE), recipient: "0.0.12345", sender: "0.0.54321", asset: { type: "native", }, memo: { kind: "text", type: "string", value: "Hbar transfer", }, } satisfies TransactionIntent<HederaMemo>; const result = await craftTransaction({ txIntent, config: { ...defaultConfig, useNetworkTimestamp: true, }, }); expect(mockGetLatestBlock).toHaveBeenCalledTimes(1); expect(result.tx).toBeInstanceOf(sdk.TransferTransaction); }); it("should fallback to system timestamp when latest block cannot be fetched", async () => { const mockGetLatestBlock = jest.spyOn(apiClient, "getLatestBlock"); mockGetLatestBlock.mockRejectedValue(new Error("mirror unavailable")); const txIntent = { intentType: "transaction", type: HEDERA_TRANSACTION_MODES.Send, amount: BigInt(1 * 10 ** TINYBAR_SCALE), recipient: "0.0.12345", sender: "0.0.54321", asset: { type: "native", }, memo: { kind: "text", type: "string", value: "Hbar transfer", }, } satisfies TransactionIntent<HederaMemo>; const result = await craftTransaction({ txIntent, config: { ...defaultConfig, useNetworkTimestamp: true, }, }); expect(mockGetLatestBlock).toHaveBeenCalledTimes(1); expect(result.tx).toBeInstanceOf(sdk.TransferTransaction); }); it("should use mirror timestamp when system clock is skewed", async () => { jest.useFakeTimers(); jest.setSystemTime(new Date("2000-01-01T00:00:00.000Z")); const mockGetLatestBlock = jest.spyOn(apiClient, "getLatestBlock"); mockGetLatestBlock.mockResolvedValue({ timestamp: { from: "1758733200.632122898", to: null }, }); const txIntent = { intentType: "transaction", type: HEDERA_TRANSACTION_MODES.Send, amount: BigInt(1 * 10 ** TINYBAR_SCALE), recipient: "0.0.12345", sender: "0.0.54321", asset: { type: "native", }, memo: { kind: "text", type: "string", value: "Hbar transfer", }, } satisfies TransactionIntent<HederaMemo>; const [withoutMirror, withMirror] = await Promise.all([ craftTransaction({ txIntent, config: { ...defaultConfig, useNetworkTimestamp: false, }, }), craftTransaction({ txIntent, config: { ...defaultConfig, useNetworkTimestamp: true, }, }), ]); const localSkewSeconds = Number(withoutMirror.tx.transactionId?.validStart?.seconds.toString()); expect(localSkewSeconds).toBeGreaterThanOrEqual(946684700); expect(localSkewSeconds).toBeLessThanOrEqual(946684800); expect(mockGetLatestBlock).toHaveBeenCalledTimes(1); expect(withMirror.tx).toBeInstanceOf(sdk.TransferTransaction); }); });