@ledgerhq/coin-hedera
Version:
Ledger Hedera Coin integration
397 lines (351 loc) • 12.4 kB
text/typescript
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);
});
});