@ledgerhq/coin-hedera
Version:
Ledger Hedera Coin integration
235 lines • 9.89 kB
JavaScript
import { promiseAllBatched } from "@ledgerhq/live-promise";
import { FINALITY_MS, HEDERA_TRANSACTION_NAMES } from "../constants";
import { apiClient } from "../network/api";
import { hgraphClient } from "../network/hgraph";
import { enrichERC20Transfers, analyzeStakingOperation } from "../network/utils";
import { getBlockInfo } from "./getBlockInfo";
import { getMemoFromBase64, getDateRangeFromBlockHeight, mergeTransactionsFromDifferentSources, toEntityId, extractFeesPayer, millisToSeconds, secondsToNanos, } from "./utils";
function isStakingTransactionType(item) {
return item.type === "mirror" && item.data.name === HEDERA_TRANSACTION_NAMES.UpdateAccount;
}
function isTokenAssociateTransactionType(item) {
return item.type === "mirror" && item.data.name === HEDERA_TRANSACTION_NAMES.TokenAssociate;
}
function getMirrorTransaction(item) {
return item.type === "mirror" ? item.data : item.data.mirrorTransaction;
}
function createBlockOperationFromCoinTransfer({ payerAccount, chargedFee, transfer, rewardTransfers, }) {
const address = transfer.account;
const reward = rewardTransfers.find(r => r.account === address);
const asset = {
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, }) {
const amount = BigInt(transfer.amount);
const address = transfer.account;
const asset = {
type: "hts",
assetReference: transfer.token_id,
};
return {
type: "transfer",
address,
asset,
amount,
};
}
function createBlockOperationFromERC20TokenTransfer({ transfer, }) {
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 = {
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) {
return tx.staking_reward_transfers.map(rewardTransfer => ({
type: "transfer",
address: rewardTransfer.account,
asset: { type: "native" },
amount: BigInt(rewardTransfer.amount),
}));
}
export async function getBlockV2({ configOrCurrencyId, height, }) {
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({
configOrCurrencyId,
});
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({
configOrCurrencyId,
startTimestamp: `gte:${startSeconds}`,
endTimestamp: `lt:${endSeconds}`,
limit,
order,
}),
hgraphClient
.getERC20TransfersByTimestampRange({
configOrCurrencyId,
startTimestamp: startSeconds.toFixed(9),
endTimestamp: endSeconds.toFixed(9),
limit,
order,
})
.then(erc20Transfers => enrichERC20Transfers({ configOrCurrencyId, 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 promiseAllBatched(4, mergeResult.merged.filter(isStakingTransactionType), async (item) => {
const payerAccount = extractFeesPayer(item.data);
const analysis = await analyzeStakingOperation({
configOrCurrencyId,
address: payerAccount,
mirrorTx: item.data,
});
return [item.data.transaction_hash, analysis];
});
const stakingAnalysisMap = new Map(stakingAnalyses);
// prepare map with token IDs for TOKENASSOCIATE transactions by fetching payerAccount tokens and matching created_timestamp
const tokenAssociateEntries = await promiseAllBatched(4, mergeResult.merged.filter(isTokenAssociateTransactionType), async (item) => {
const mirrorTx = item.data;
const payerAccount = extractFeesPayer(mirrorTx);
const tokens = await apiClient.getAccountTokens({
configOrCurrencyId,
address: payerAccount,
});
const relatedToken = tokens.find(t => t.created_timestamp === mirrorTx.consensus_timestamp);
const tokenId = relatedToken?.token_id ?? null;
return [mirrorTx.transaction_hash, tokenId];
});
const tokenAssociateMap = new Map(tokenAssociateEntries);
const blockTransactions = mergeResult.merged.map(item => {
const mirrorTx = getMirrorTransaction(item);
const memo = getMemoFromBase64(mirrorTx.memo_base64);
const payerAccount = extractFeesPayer(mirrorTx);
const stakingAnalysis = stakingAnalysisMap.get(mirrorTx.transaction_hash);
let operations;
if (stakingAnalysis) {
operations = [
{
type: "other",
ledgerOpType: stakingAnalysis.operationType,
targetStakingNodeId: stakingAnalysis.targetStakingNodeId,
previousStakingNodeId: stakingAnalysis.previousStakingNodeId,
stakedAmount: stakingAnalysis.stakedAmount,
},
];
}
else if (isTokenAssociateTransactionType(item)) {
const associatedTokenId = tokenAssociateMap.get(mirrorTx.transaction_hash) ?? null;
operations = [
{
type: "other",
ledgerOpType: "ASSOCIATE_TOKEN",
...(associatedTokenId && { associatedTokenId }),
},
];
}
else {
const allTransfers = [
...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: {
consensusTimestamp: mirrorTx.consensus_timestamp,
transactionId: mirrorTx.transaction_id,
...(memo && { memo }),
...(item.type === "erc20" && {
gasUsed: item.data.contractCallResult.gas_used,
gasLimit: item.data.contractCallResult.gas_limit,
gasConsumed: item.data.contractCallResult.gas_consumed,
}),
},
};
});
return {
info: blockInfo,
transactions: blockTransactions,
};
}
//# sourceMappingURL=getBlock.v2.js.map