@ledgerhq/coin-hedera
Version:
Ledger Hedera Coin integration
465 lines (404 loc) • 14.1 kB
text/typescript
import { FINALITY_MS, HEDERA_TRANSACTION_NAMES } from "../constants";
import { apiClient } from "../network/api";
import type { StakingAnalysis } from "../types";
import { getBlock } from "./getBlock";
import { getBlockInfo } from "./getBlockInfo";
import { analyzeStakingOperation, getDateRangeFromBlockHeight } from "./utils";
jest.mock("./getBlockInfo");
jest.mock("../network/api");
// mock all functions in utils except extractFeesPayer
jest.mock("./utils", () => ({
...jest.requireActual("./utils"),
analyzeStakingOperation: jest.fn(),
getDateRangeFromBlockHeight: jest.fn(),
getMemoFromBase64: jest.fn(),
}));
describe("getBlock", () => {
const mockBlockInfo = {
height: 100,
hash: "mock_hash",
time: new Date("2024-01-01T00:00:00Z"),
};
const mockDateRange = {
start: new Date(1704067200123),
end: new Date(1704067260456),
};
beforeEach(() => {
jest.clearAllMocks();
(getBlockInfo as jest.Mock).mockResolvedValue(mockBlockInfo);
(getDateRangeFromBlockHeight as jest.Mock).mockReturnValue(mockDateRange);
(analyzeStakingOperation as jest.Mock).mockResolvedValue(null);
});
it("should return empty block when no transactions exist", async () => {
(apiClient.getTransactionsByTimestampRange as jest.Mock).mockResolvedValue([]);
const result = await getBlock(100);
expect(result).toEqual({
info: mockBlockInfo,
transactions: [],
});
});
it("should call dependencies with correct parameters", async () => {
(apiClient.getTransactionsByTimestampRange as jest.Mock).mockResolvedValue([]);
await getBlock(42);
expect(getDateRangeFromBlockHeight).toHaveBeenCalledWith(42);
expect(getBlockInfo).toHaveBeenCalledWith(42);
expect(apiClient.getTransactionsByTimestampRange).toHaveBeenCalledTimes(1);
expect(apiClient.getTransactionsByTimestampRange).toHaveBeenCalledWith({
startTimestamp: `gte:1704067200.123`,
endTimestamp: `lt:1704067260.456`,
});
});
it("should extract fee payer from transaction_id by default", async () => {
const mockTx = {
transaction_id: "0.0.999-1234567890-000000000",
transaction_hash: "hash",
name: "CRYPTOTRANSFER",
result: "SUCCESS",
charged_tx_fee: 100000,
staking_reward_transfers: [],
transfers: [],
token_transfers: [],
};
(apiClient.getTransactionsByTimestampRange as jest.Mock).mockResolvedValue([mockTx]);
const result = await getBlock(100);
expect(result.transactions[0].feesPayer).toBe("0.0.999");
});
it("should infer fee payer from transfers when initiator is not debited", async () => {
const mockTx = {
transaction_id: "0.0.10067173-1761755118-730000493",
transaction_hash: "hash",
name: "CRYPTOTRANSFER",
result: "INSUFFICIENT_PAYER_BALANCE",
charged_tx_fee: 40743,
staking_reward_transfers: [],
transfers: [
{ account: "0.0.23", amount: -40743 },
{ account: "0.0.801", amount: 40743 },
],
token_transfers: [],
};
(apiClient.getTransactionsByTimestampRange as jest.Mock).mockResolvedValue([mockTx]);
const result = await getBlock(100);
const payerOperation = result.transactions[0].operations.find(op => op.address === "0.0.23");
expect(result.transactions[0].feesPayer).toBe("0.0.23");
expect(payerOperation).toMatchObject({
address: "0.0.23",
amount: BigInt(0),
});
});
it("should exclude fee from payer's operation amount", async () => {
const mockTx = {
transaction_id: "0.0.999-1234567890-000000000",
transaction_hash: "hash",
name: "CRYPTOTRANSFER",
result: "SUCCESS",
charged_tx_fee: 67179,
staking_reward_transfers: [],
transfers: [
{
account: "0.0.999",
amount: -567179,
},
{
account: "0.0.1001",
amount: 500000,
},
],
token_transfers: [],
};
(apiClient.getTransactionsByTimestampRange as jest.Mock).mockResolvedValue([mockTx]);
const result = await getBlock(100);
const senderOperation = result.transactions[0].operations.find(op => op.address === "0.0.999");
expect(senderOperation).toMatchObject({
address: "0.0.999",
amount: BigInt(-567179 + 67179),
});
});
it("should handle token transfers", async () => {
const mockTx = {
transaction_id: "0.0.999-1234567890-000000000",
transaction_hash: "hash",
name: "CRYPTOTRANSFER",
result: "SUCCESS",
charged_tx_fee: 100000,
staking_reward_transfers: [],
transfers: [],
token_transfers: [
{
token_id: "0.0.12345",
account: "0.0.999",
amount: -1000,
},
{
token_id: "0.0.12345",
account: "0.0.1001",
amount: 1000,
},
],
};
(apiClient.getTransactionsByTimestampRange as jest.Mock).mockResolvedValue([mockTx]);
const result = await getBlock(100);
expect(result.transactions[0].operations).toEqual([
{
type: "transfer",
address: "0.0.999",
asset: {
type: "hts",
assetReference: "0.0.12345",
},
amount: BigInt(-1000),
},
{
type: "transfer",
address: "0.0.1001",
asset: {
type: "hts",
assetReference: "0.0.12345",
},
amount: BigInt(1000),
},
]);
});
it("should mark failed transactions", async () => {
const mockTx = {
transaction_id: "0.0.999-1234567890-000000000",
transaction_hash: "hash",
name: "CRYPTOTRANSFER",
result: "INSUFFICIENT_ACCOUNT_BALANCE",
charged_tx_fee: 100000,
staking_reward_transfers: [],
transfers: [],
token_transfers: [],
};
(apiClient.getTransactionsByTimestampRange as jest.Mock).mockResolvedValue([mockTx]);
const result = await getBlock(100);
expect(result.transactions[0].failed).toBe(true);
});
it("should analyze CRYPTOUPDATEACCOUNT transactions for staking", async () => {
const mockTx = {
transaction_id: "0.0.999-1234567890-000000000",
transaction_hash: "hash_update",
name: HEDERA_TRANSACTION_NAMES.UpdateAccount,
result: "SUCCESS",
charged_tx_fee: 22000,
consensus_timestamp: "1704067210.123456789",
staking_reward_transfers: [],
transfers: [],
token_transfers: [],
};
const mockStakingAnalysis: StakingAnalysis = {
operationType: "DELEGATE",
targetStakingNodeId: 5,
previousStakingNodeId: null,
stakedAmount: BigInt(100),
};
(apiClient.getTransactionsByTimestampRange as jest.Mock).mockResolvedValue([mockTx]);
(analyzeStakingOperation as jest.Mock).mockResolvedValue(mockStakingAnalysis);
const result = await getBlock(100);
expect(analyzeStakingOperation).toHaveBeenCalledTimes(1);
expect(analyzeStakingOperation).toHaveBeenCalledWith("0.0.999", mockTx);
expect(result.transactions[0].operations).toHaveLength(1);
expect(result.transactions[0].operations[0]).toEqual({
type: "other",
operationType: mockStakingAnalysis.operationType,
stakedNodeId: mockStakingAnalysis.targetStakingNodeId,
previousStakedNodeId: mockStakingAnalysis.previousStakingNodeId,
stakedAmount: mockStakingAnalysis.stakedAmount,
});
});
it("should handle UNDELEGATE staking operation", async () => {
const mockTx = {
transaction_id: "0.0.999-1234567890-000000000",
transaction_hash: "hash_undelegate",
name: HEDERA_TRANSACTION_NAMES.UpdateAccount,
result: "SUCCESS",
charged_tx_fee: 22000,
consensus_timestamp: "1704067210.123456789",
staking_reward_transfers: [],
transfers: [],
token_transfers: [],
};
const mockStakingAnalysis: StakingAnalysis = {
operationType: "UNDELEGATE",
targetStakingNodeId: null,
previousStakingNodeId: 3,
stakedAmount: BigInt(100),
};
(apiClient.getTransactionsByTimestampRange as jest.Mock).mockResolvedValue([mockTx]);
(analyzeStakingOperation as jest.Mock).mockResolvedValue(mockStakingAnalysis);
const result = await getBlock(100);
expect(result.transactions[0].operations[0]).toEqual({
type: "other",
operationType: mockStakingAnalysis.operationType,
stakedNodeId: mockStakingAnalysis.targetStakingNodeId,
previousStakedNodeId: mockStakingAnalysis.previousStakingNodeId,
stakedAmount: mockStakingAnalysis.stakedAmount,
});
});
it("should handle REDELEGATE staking operation", async () => {
const mockTx = {
transaction_id: "0.0.999-1234567890-000000000",
transaction_hash: "hash_redelegate",
name: HEDERA_TRANSACTION_NAMES.UpdateAccount,
result: "SUCCESS",
charged_tx_fee: 22000,
consensus_timestamp: "1704067210.123456789",
staking_reward_transfers: [],
transfers: [],
token_transfers: [],
};
const mockStakingAnalysis: StakingAnalysis = {
operationType: "REDELEGATE",
targetStakingNodeId: 10,
previousStakingNodeId: 5,
stakedAmount: BigInt(100),
};
(apiClient.getTransactionsByTimestampRange as jest.Mock).mockResolvedValue([mockTx]);
(analyzeStakingOperation as jest.Mock).mockResolvedValue(mockStakingAnalysis);
const result = await getBlock(100);
expect(result.transactions[0].operations).toEqual([
{
type: "other",
operationType: mockStakingAnalysis.operationType,
stakedNodeId: mockStakingAnalysis.targetStakingNodeId,
previousStakedNodeId: mockStakingAnalysis.previousStakingNodeId,
stakedAmount: mockStakingAnalysis.stakedAmount,
},
]);
});
it("should create CLAIM_REWARDS operations for staking reward transfers", async () => {
const mockTx = {
transaction_id: "0.0.999-1234567890-000000000",
transaction_hash: "hash",
name: "CRYPTOTRANSFER",
result: "SUCCESS",
charged_tx_fee: 100000,
staking_reward_transfers: [
{
account: "0.0.999",
amount: 100000,
},
{
account: "0.0.1001",
amount: 200000,
},
],
transfers: [
{
account: "0.0.999",
amount: -600000,
},
{
account: "0.0.1001",
amount: 500000,
},
],
token_transfers: [],
};
(apiClient.getTransactionsByTimestampRange as jest.Mock).mockResolvedValue([mockTx]);
const result = await getBlock(100);
expect(result.transactions[0].operations).toEqual([
{
type: "transfer",
address: "0.0.999",
asset: { type: "native" },
amount: BigInt(-500000),
},
{
type: "transfer",
address: "0.0.1001",
asset: { type: "native" },
amount: BigInt(500000),
},
{
type: "transfer",
address: "0.0.999",
asset: { type: "native" },
amount: BigInt(100000),
},
{
type: "transfer",
address: "0.0.1001",
asset: { type: "native" },
amount: BigInt(200000),
},
]);
});
it("should handle CRYPTOUPDATEACCOUNT if it's not related to staking", async () => {
const mockTx = {
transaction_id: "0.0.999-1234567890-000000000",
transaction_hash: "hash_regular_update",
name: HEDERA_TRANSACTION_NAMES.UpdateAccount,
result: "SUCCESS",
charged_tx_fee: 22000,
consensus_timestamp: "1704067210.123456789",
staking_reward_transfers: [],
transfers: [
{
account: "0.0.999",
amount: -23000,
},
{
account: "0.0.1000",
amount: 1000,
},
],
token_transfers: [],
};
(apiClient.getTransactionsByTimestampRange as jest.Mock).mockResolvedValue([mockTx]);
(analyzeStakingOperation as jest.Mock).mockResolvedValue(null);
const result = await getBlock(100);
expect(result.transactions[0].operations).toEqual([
{
type: "transfer",
address: "0.0.999",
asset: { type: "native" },
amount: BigInt(-1000),
},
{
type: "transfer",
address: "0.0.1000",
asset: { type: "native" },
amount: BigInt(1000),
},
]);
});
it("should throw error when querying a block in the future", async () => {
const now = Date.now();
const futureRange = {
start: new Date(now + 60_000),
end: new Date(now + 120_000),
};
(getDateRangeFromBlockHeight as jest.Mock).mockReturnValue(futureRange);
await expect(getBlock(999)).rejects.toThrow("Block 999 is not available yet");
expect(getBlockInfo).not.toHaveBeenCalled();
expect(apiClient.getTransactionsByTimestampRange).not.toHaveBeenCalled();
});
it("should throw error when querying a block overlapping the non-finalized window", async () => {
const now = Date.now();
const overlappingRange = {
start: new Date(now - FINALITY_MS - 1_000),
end: new Date(now - FINALITY_MS / 2),
};
(getDateRangeFromBlockHeight as jest.Mock).mockReturnValue(overlappingRange);
await expect(getBlock(998)).rejects.toThrow("Block 998 is not available yet");
expect(getBlockInfo).not.toHaveBeenCalled();
expect(apiClient.getTransactionsByTimestampRange).not.toHaveBeenCalled();
});
it("should succeed when querying the finalized window", async () => {
const now = Date.now();
const finalizedRange = {
start: new Date(now - FINALITY_MS - 120_000),
end: new Date(now - FINALITY_MS - 60_000),
};
(getDateRangeFromBlockHeight as jest.Mock).mockReturnValue(finalizedRange);
(apiClient.getTransactionsByTimestampRange as jest.Mock).mockResolvedValue([]);
const result = await getBlock(100);
expect(result).toEqual({
info: mockBlockInfo,
transactions: [],
});
expect(getBlockInfo).toHaveBeenCalledWith(100);
expect(apiClient.getTransactionsByTimestampRange).toHaveBeenCalled();
});
});