UNPKG

@ledgerhq/coin-hedera

Version:
262 lines (234 loc) 8.02 kB
import type { AssetInfo, Block, BlockOperation, BlockTransaction, } from "@ledgerhq/coin-module-framework/api/types"; import { FINALITY_MS, HEDERA_TRANSACTION_NAMES } from "../constants"; import { apiClient } from "../network/api"; import { hgraphClient } from "../network/hgraph"; import { enrichERC20Transfers } from "../network/utils"; import type { ERC20TokenTransfer, HederaMirrorCoinTransfer, HederaMirrorTokenTransfer, HederaMirrorTransaction, MergedTransaction, } from "../types"; import { getBlockInfo } from "./getBlockInfo"; import { getMemoFromBase64, analyzeStakingOperation, getDateRangeFromBlockHeight, mergeTransactionsFromDifferentSources, toEntityId, extractFeesPayer, millisToSeconds, secondsToNanos, } from "./utils"; function isStakingTransactionType( item: MergedTransaction, ): item is Extract<MergedTransaction, { type: "mirror" }> { return item.type === "mirror" && item.data.name === HEDERA_TRANSACTION_NAMES.UpdateAccount; } function getMirrorTransaction(item: MergedTransaction): HederaMirrorTransaction { return item.type === "mirror" ? item.data : item.data.mirrorTransaction; } function createBlockOperationFromCoinTransfer({ payerAccount, chargedFee, transfer, rewardTransfers, }: { payerAccount: string; chargedFee: number; transfer: HederaMirrorCoinTransfer; rewardTransfers: HederaMirrorTransaction["staking_reward_transfers"]; }): BlockOperation { const address = transfer.account; const reward = rewardTransfers.find(r => r.account === address); const asset: AssetInfo = { type: "native", }; // adjust the transfer amount: // - exclude fee from payer's operation amount (fees are accounted for separately, so operations must not represent fees) // - subtract staking rewards from the amount as they are represented as separate operations const feeAdjustment = payerAccount === address ? BigInt(chargedFee) : BigInt(0); const rewardAdjustment = BigInt(reward?.amount ?? 0); const amount = BigInt(transfer.amount) + feeAdjustment - rewardAdjustment; return { type: "transfer", address, asset, amount, }; } function createBlockOperationFromHTSTokenTransfer({ transfer, }: { transfer: HederaMirrorTokenTransfer; }): BlockOperation { const amount = BigInt(transfer.amount); const address = transfer.account; const asset: AssetInfo = { type: "hts", assetReference: transfer.token_id, }; return { type: "transfer", address, asset, amount, }; } function createBlockOperationFromERC20TokenTransfer({ transfer, }: { transfer: ERC20TokenTransfer; }): BlockOperation[] { const amount = BigInt(transfer.amount); const recipient = transfer.receiver_account_id ? toEntityId({ num: transfer.receiver_account_id }) : transfer.receiver_evm_address; const sender = transfer.sender_account_id ? toEntityId({ num: transfer.sender_account_id }) : transfer.sender_evm_address; const asset: AssetInfo = { type: "erc20", assetReference: transfer.token_evm_address, }; // if we don't have either sender or recipient info, we cannot create a meaningful operation, so we skip it if (!sender || !recipient) { return []; } return [ { type: "transfer", address: recipient, asset, amount, }, { type: "transfer", address: sender, asset, amount: -amount, }, ]; } function createStakingRewardOperations(tx: HederaMirrorTransaction): BlockOperation[] { return tx.staking_reward_transfers.map(rewardTransfer => ({ type: "transfer", address: rewardTransfer.account, asset: { type: "native" }, amount: BigInt(rewardTransfer.amount), })); } export async function getBlockV2(height: number): Promise<Block> { const { start, end } = getDateRangeFromBlockHeight(height); // block data should be immutable: do not allow querying blocks on non-finalized time range if (end.getTime() > Date.now() - FINALITY_MS) { throw new Error(`Block ${height} is not available yet`); } const latestHgraphIndexedTimestampNs = await hgraphClient.getLatestIndexedConsensusTimestamp(); const startSeconds = millisToSeconds(start.getTime()); const endSeconds = millisToSeconds(end.getTime()); const endNanos = secondsToNanos(endSeconds); const limit = 100; const order = "desc"; // do not allow querying blocks if hgraph is not fully synced up to the end of the block time range if (latestHgraphIndexedTimestampNs.lt(endNanos)) { throw new Error(`Block ${height} has no ERC20 synced yet (${latestHgraphIndexedTimestampNs})`); } const [blockInfo, mirrorTransactions, enrichedERC20Transfers] = await Promise.all([ getBlockInfo(height), apiClient.getTransactionsByTimestampRange({ startTimestamp: `gte:${startSeconds}`, endTimestamp: `lt:${endSeconds}`, limit, order, }), hgraphClient .getERC20TransfersByTimestampRange({ startTimestamp: startSeconds.toFixed(9), endTimestamp: endSeconds.toFixed(9), limit, order, }) .then(erc20Transfers => enrichERC20Transfers(erc20Transfers)), ]); const mergeResult = mergeTransactionsFromDifferentSources({ mirrorTransactions, enrichedERC20Transfers, order, limit, latestHgraphIndexedTimestampNs, fetchAllPages: true, }); // analyze CRYPTOUPDATEACCOUNT transactions to distinguish staking operations from regular account updates. // this creates a map of transaction_hash -> StakingAnalysis to avoid repeated lookups. const stakingAnalyses = await Promise.all( mergeResult.merged.filter(isStakingTransactionType).map(async item => { const payerAccount = extractFeesPayer(item.data); const analysis = await analyzeStakingOperation(payerAccount, item.data); return [item.data.transaction_hash, analysis] as const; }), ); const stakingAnalysisMap = new Map(stakingAnalyses); const blockTransactions: BlockTransaction[] = mergeResult.merged.map(item => { const mirrorTx = getMirrorTransaction(item); const payerAccount = extractFeesPayer(mirrorTx); const stakingAnalysis = stakingAnalysisMap.get(mirrorTx.transaction_hash); let operations: BlockOperation[]; if (stakingAnalysis) { operations = [ { type: "other", operationType: stakingAnalysis.operationType, stakedNodeId: stakingAnalysis.targetStakingNodeId, previousStakedNodeId: stakingAnalysis.previousStakingNodeId, stakedAmount: stakingAnalysis.stakedAmount, }, ]; } else { const allTransfers: ( | HederaMirrorCoinTransfer | HederaMirrorTokenTransfer | ERC20TokenTransfer )[] = [ ...mirrorTx.transfers, ...mirrorTx.token_transfers, ...(item.type === "erc20" ? item.data.transfers : []), ]; operations = allTransfers.flatMap(transfer => { if ("token_evm_address" in transfer) { return createBlockOperationFromERC20TokenTransfer({ transfer }); } else if ("token_id" in transfer) { return createBlockOperationFromHTSTokenTransfer({ transfer }); } else { return createBlockOperationFromCoinTransfer({ payerAccount, transfer, chargedFee: mirrorTx.charged_tx_fee, rewardTransfers: mirrorTx.staking_reward_transfers, }); } }); } // add staking reward operations if present (can occur on any transaction type) const rewardOperations = createStakingRewardOperations(mirrorTx); operations.push(...rewardOperations); return { hash: mirrorTx.transaction_hash, failed: mirrorTx.result !== "SUCCESS", operations, fees: BigInt(mirrorTx.charged_tx_fee), feesPayer: payerAccount, details: { memo: getMemoFromBase64(mirrorTx.memo_base64) }, }; }); return { info: blockInfo, transactions: blockTransactions, }; }