UNPKG

@ledgerhq/coin-hedera

Version:
563 lines (509 loc) 17 kB
import { getCryptoAssetsStore } from "@ledgerhq/cryptoassets/state"; import { encodeAccountId, encodeTokenAccountId, } from "@ledgerhq/ledger-wallet-framework/account/accountId"; import { encodeOperationId } from "@ledgerhq/ledger-wallet-framework/operation"; import { getEnv } from "@ledgerhq/live-env"; import type { CryptoCurrency } from "@ledgerhq/types-cryptoassets"; import type { Operation, OperationType } from "@ledgerhq/types-live"; import BigNumber from "bignumber.js"; import { HARDCODED_BLOCK_HEIGHT, HEDERA_TRANSACTION_NAMES } from "../constants"; import { apiClient } from "../network/api"; import { hgraphClient } from "../network/hgraph"; import { parseTransfers, enrichERC20Transfers } from "../network/utils"; import type { EnrichedERC20Transfer, HederaERC20TokenBalance, HederaMirrorToken, HederaMirrorTransaction, HederaOperationExtra, MergedTransaction, StakingAnalysis, } from "../types"; import { analyzeStakingOperation, base64ToUrlSafeBase64, createStakingRewardOperationHash, extractFeesPayer, getMemoFromBase64, getSyntheticBlock, mergeTransactionsFromDifferentSources, toEntityId, } from "./utils"; const txNameToCustomOperationType: Record<string, OperationType> = { TOKENASSOCIATE: "ASSOCIATE_TOKEN", CONTRACTCALL: "CONTRACT_CALL", CRYPTOUPDATEACCOUNT: "UPDATE_ACCOUNT", }; function getCommonMirrorOperationData( rawTx: HederaMirrorTransaction, useEncodedHash: boolean, useSyntheticBlocks: boolean, ) { const date = new Date(Number.parseInt(rawTx.consensus_timestamp.split(".")[0], 10) * 1000); const hash = useEncodedHash ? base64ToUrlSafeBase64(rawTx.transaction_hash) : rawTx.transaction_hash; const fee = new BigNumber(rawTx.charged_tx_fee); const hasFailed = rawTx.result !== "SUCCESS"; const syntheticBlock = getSyntheticBlock(rawTx.consensus_timestamp); const memo = getMemoFromBase64(rawTx.memo_base64); const feesPayer = extractFeesPayer(rawTx); const extra: HederaOperationExtra = { pagingToken: rawTx.consensus_timestamp, consensusTimestamp: rawTx.consensus_timestamp, transactionId: rawTx.transaction_id, feesPayer, ...(memo && { memo }), }; return { date, hash, fee, hasFailed, blockHeight: useSyntheticBlocks ? syntheticBlock.blockHeight : HARDCODED_BLOCK_HEIGHT, blockHash: useSyntheticBlocks ? syntheticBlock.blockHash : null, extra, }; } function calculateStakingReward(rawTx: HederaMirrorTransaction, address: string): BigNumber { return rawTx.staking_reward_transfers.reduce((acc, transfer) => { const transferAmount = new BigNumber(transfer.amount); return transfer.account === address ? acc.plus(transferAmount) : acc; }, new BigNumber(0)); } function createStakingRewardOperation({ stakingReward, address, ledgerAccountId, commonData, }: { stakingReward: BigNumber; address: string; ledgerAccountId: string; commonData: ReturnType<typeof getCommonMirrorOperationData>; }): Operation<HederaOperationExtra> | null { if (stakingReward.lte(0)) { return null; } const { hash, date, blockHeight, blockHash } = commonData; const stakingRewardHash = createStakingRewardOperationHash(hash); const stakingRewardType: OperationType = "REWARD"; // offset timestamp by +1ms so that, when operations are sorted newest-first, this reward appears just before the operation that triggered it const stakingRewardTimestamp = new Date(date.getTime() + 1); return { id: encodeOperationId(ledgerAccountId, stakingRewardHash, stakingRewardType), accountId: ledgerAccountId, type: stakingRewardType, value: stakingReward, recipients: [address], senders: [getEnv("HEDERA_STAKING_REWARD_ACCOUNT_ID")], hash: stakingRewardHash, fee: new BigNumber(0), date: stakingRewardTimestamp, blockHeight, blockHash, extra: commonData.extra, }; } function getOperationTypeFromERC20Details({ transferType, senderEvmAddress, evmAddress, }: { transferType: string; senderEvmAddress: string; evmAddress: string; }): OperationType { if (transferType === "mint") return "IN"; if (transferType === "burn") return "OUT"; return senderEvmAddress.toLowerCase() === evmAddress.toLowerCase() ? "OUT" : "IN"; } async function processERC20TokenTransfer({ enrichedERC20Transfer, evmAddress, ledgerAccountId, commonData, }: { enrichedERC20Transfer: EnrichedERC20Transfer; evmAddress: string; ledgerAccountId: string; commonData: ReturnType<typeof getCommonMirrorOperationData>; }): Promise<{ coinOperation: Operation<HederaOperationExtra> | undefined; tokenOperations: Operation<HederaOperationExtra>[]; }> { let coinOperation: Operation<HederaOperationExtra> | undefined; const tokenOperations: Operation<HederaOperationExtra>[] = []; for (const transfer of enrichedERC20Transfer.transfers) { const token = await getCryptoAssetsStore().findTokenByAddressInCurrency( transfer.token_evm_address, "hedera", ); if (!token) continue; const senderEvmAddress = transfer.sender_evm_address; const senderAddress = transfer.sender_account_id ? toEntityId({ num: transfer.sender_account_id }) : transfer.sender_evm_address; const recipientAddress = transfer.receiver_account_id ? toEntityId({ num: transfer.receiver_account_id }) : transfer.receiver_evm_address; // meaningful operation cannot be created without correct addresses, so we skip it if (!senderEvmAddress || !senderAddress || !recipientAddress) continue; const commonFields = { ...commonData, type: getOperationTypeFromERC20Details({ transferType: transfer.transfer_type, senderEvmAddress, evmAddress, }), contract: token.contractAddress, standard: "erc20", blockHeight: commonData.blockHeight, blockHash: commonData.blockHash, senders: [senderAddress], recipients: [recipientAddress], fee: new BigNumber(enrichedERC20Transfer.mirrorTransaction.charged_tx_fee), value: new BigNumber(transfer.amount), extra: { ...commonData.extra, gasConsumed: enrichedERC20Transfer.contractCallResult.gas_consumed, gasLimit: enrichedERC20Transfer.contractCallResult.gas_limit, gasUsed: enrichedERC20Transfer.contractCallResult.gas_used, }, } satisfies Partial<Operation<HederaOperationExtra>>; const encodedTokenAccountId = encodeTokenAccountId(ledgerAccountId, token); const encodedOperationId = encodeOperationId( encodedTokenAccountId, commonFields.hash, commonFields.type, ); const tokenOperation = { ...commonFields, id: encodedOperationId, accountId: encodedTokenAccountId, } satisfies Operation<HederaOperationExtra>; tokenOperations.push(tokenOperation); } // create FEES operation for outgoing ERC20 transfer const outgoingTransfer = tokenOperations.find(transfer => transfer.type === "OUT"); if (outgoingTransfer) { coinOperation = { ...commonData, id: encodeOperationId(ledgerAccountId, commonData.hash, "FEES"), accountId: ledgerAccountId, type: "FEES", ...(outgoingTransfer.contract && { contract: outgoingTransfer.contract }), ...(outgoingTransfer.standard && { standard: outgoingTransfer.standard }), blockHeight: outgoingTransfer.blockHeight, blockHash: outgoingTransfer.blockHash, senders: outgoingTransfer.senders, recipients: outgoingTransfer.recipients, fee: outgoingTransfer.fee, value: outgoingTransfer.fee, extra: outgoingTransfer.extra, } satisfies Operation<HederaOperationExtra>; } return { coinOperation, tokenOperations, }; } async function processHTSTokenTransfers({ rawTx, address, currency, ledgerAccountId, commonData, }: { rawTx: HederaMirrorTransaction; address: string; currency: CryptoCurrency; ledgerAccountId: string; commonData: ReturnType<typeof getCommonMirrorOperationData>; }): Promise<{ coinOperation: Operation<HederaOperationExtra> | undefined; tokenOperation: Operation<HederaOperationExtra>; } | null> { const tokenTransfers = rawTx.token_transfers ?? []; if (tokenTransfers.length === 0) return null; const tokenId = tokenTransfers[0].token_id; const token = await getCryptoAssetsStore().findTokenByAddressInCurrency(tokenId, currency.id); if (!token) return null; const encodedTokenId = encodeTokenAccountId(ledgerAccountId, token); const { type, value, senders, recipients } = parseTransfers(tokenTransfers, address); const { hash, fee, date, blockHeight, blockHash, hasFailed } = commonData; const extra = { ...commonData.extra }; let coinOperation: Operation<HederaOperationExtra> | undefined; // Add main FEES coin operation for send token transfer if (type === "OUT") { coinOperation = { id: encodeOperationId(ledgerAccountId, hash, "FEES"), accountId: ledgerAccountId, type: "FEES", value: fee, recipients, senders, hash, fee, date, blockHeight, blockHash, hasFailed, extra, } satisfies Operation<HederaOperationExtra>; } const tokenOperation = { id: encodeOperationId(encodedTokenId, hash, type), accountId: encodedTokenId, contract: token.contractAddress, standard: "hts", type, value, recipients, senders, hash, fee, date, blockHeight, blockHash, hasFailed, extra, } satisfies Operation<HederaOperationExtra>; return { coinOperation, tokenOperation, }; } function processCoinTransfers({ rawTx, address, ledgerAccountId, commonData, mirrorTokens, stakingReward, stakingAnalysis, }: { rawTx: HederaMirrorTransaction; address: string; ledgerAccountId: string; commonData: ReturnType<typeof getCommonMirrorOperationData>; mirrorTokens: HederaMirrorToken[]; stakingReward: BigNumber; stakingAnalysis: StakingAnalysis | null; }): Operation<HederaOperationExtra>[] { const coinOperations: Operation<HederaOperationExtra>[] = []; const transfers = rawTx.transfers ?? []; if (transfers.length === 0) { return []; } const { type, value, senders, recipients } = parseTransfers(transfers, address, stakingReward); const { hash, fee, date, blockHeight, blockHash, hasFailed } = commonData; const extra = { ...commonData.extra }; let operationType = txNameToCustomOperationType[rawTx.name] ?? type; // update operation type and extra fields if staking analysis is available if (stakingAnalysis) { operationType = stakingAnalysis.operationType; extra.previousStakingNodeId = stakingAnalysis.previousStakingNodeId; extra.targetStakingNodeId = stakingAnalysis.targetStakingNodeId; extra.stakedAmount = new BigNumber(stakingAnalysis.stakedAmount.toString()); } // if recipients array is empty, add the node where the transaction was submitted as recipient if (recipients.length === 0 && rawTx.node) { recipients.push(rawTx.node); } // try to enrich ASSOCIATE_TOKEN operation with extra.associatedTokenId // this value is used by custom OperationDetails components in Hedera family // accounts or contracts must first associate with an HTS token before they can receive or send that token; without association, token transfers fail if (operationType === "ASSOCIATE_TOKEN") { const relatedMirrorToken = mirrorTokens.find(t => { return t.created_timestamp === rawTx.consensus_timestamp; }); if (relatedMirrorToken) { extra.associatedTokenId = relatedMirrorToken.token_id; } } coinOperations.push({ id: encodeOperationId(ledgerAccountId, hash, operationType), accountId: ledgerAccountId, type: operationType, value, recipients, senders, hash, fee, date, blockHeight, blockHash, hasFailed, extra, }); return coinOperations; } async function processTransactionItem({ mergedTx, address, evmAddress, currency, ledgerAccountId, mirrorTokens, useEncodedHash, useSyntheticBlocks, }: { mergedTx: MergedTransaction; address: string; evmAddress: string; currency: CryptoCurrency; ledgerAccountId: string; mirrorTokens: HederaMirrorToken[]; useEncodedHash: boolean; useSyntheticBlocks: boolean; }): Promise<{ newCoinOperations: Operation<HederaOperationExtra>[]; newTokenOperations: Operation<HederaOperationExtra>[]; }> { const newCoinOperations: Operation<HederaOperationExtra>[] = []; const newTokenOperations: Operation<HederaOperationExtra>[] = []; const mirrorTx = mergedTx.type === "mirror" ? mergedTx.data : mergedTx.data.mirrorTransaction; const commonData = getCommonMirrorOperationData(mirrorTx, useEncodedHash, useSyntheticBlocks); const stakingReward = calculateStakingReward(mirrorTx, address); const rewardOp = createStakingRewardOperation({ stakingReward, address, ledgerAccountId, commonData, }); if (rewardOp) newCoinOperations.push(rewardOp); const stakingAnalysis = mirrorTx.name === HEDERA_TRANSACTION_NAMES.UpdateAccount ? await analyzeStakingOperation(address, mirrorTx) : null; if (mergedTx.type === "mirror") { const htsTokenResult = await processHTSTokenTransfers({ rawTx: mirrorTx, address, currency, ledgerAccountId, commonData, }); if (htsTokenResult?.tokenOperation) newTokenOperations.push(htsTokenResult.tokenOperation); if (htsTokenResult?.coinOperation) newCoinOperations.push(htsTokenResult.coinOperation); if (!htsTokenResult) { const coinOps = processCoinTransfers({ rawTx: mirrorTx, address, ledgerAccountId, commonData, mirrorTokens, stakingReward, stakingAnalysis, }); newCoinOperations.push(...coinOps); } } else { const erc20TokenResult = await processERC20TokenTransfer({ enrichedERC20Transfer: mergedTx.data, evmAddress, ledgerAccountId, commonData, }); if (erc20TokenResult.coinOperation) newCoinOperations.push(erc20TokenResult.coinOperation); newTokenOperations.push(...erc20TokenResult.tokenOperations); } return { newCoinOperations, newTokenOperations }; } export async function listOperationsV2({ currency, address, evmAddress, mirrorTokens, erc20Tokens, cursor, limit = 100, order = "desc", fetchAllPages, skipFeesForTokenOperations, useEncodedHash, useSyntheticBlocks, }: { currency: CryptoCurrency; address: string; evmAddress: string; mirrorTokens: HederaMirrorToken[]; erc20Tokens: HederaERC20TokenBalance[]; cursor?: string; limit?: number; order?: "asc" | "desc"; // options for compatibility with old bridge fetchAllPages: boolean; skipFeesForTokenOperations: boolean; useEncodedHash: boolean; useSyntheticBlocks: boolean; }): Promise<{ coinOperations: Operation<HederaOperationExtra>[]; tokenOperations: Operation<HederaOperationExtra>[]; nextCursor: string | null; }> { const coinOperations: Operation<HederaOperationExtra>[] = []; const tokenOperations: Operation<HederaOperationExtra>[] = []; const ledgerAccountId = encodeAccountId({ type: "js", version: "2", currencyId: currency.id, xpubOrAddress: address, derivationMode: "hederaBip44", }); // fetch transactions from both sources in parallel const [mirrorTransactions, enrichedERC20Transfers, latestHgraphIndexedTimestampNs] = await Promise.all([ apiClient.getAccountTransactions({ address, order, limit, fetchAllPages, pagingToken: cursor ?? null, }), hgraphClient .getERC20Transfers({ address, order, limit, fetchAllPages, tokenEvmAddresses: erc20Tokens.map(t => t.token.contractAddress.toLowerCase()), ...(cursor && { timestamp: cursor }), }) .then(erc20Transfers => enrichERC20Transfers(erc20Transfers)), hgraphClient.getLatestIndexedConsensusTimestamp(), ]); // merge transactions, ensuring no duplicates, correct ordering and pagination handling const mergeResult = mergeTransactionsFromDifferentSources({ mirrorTransactions: mirrorTransactions.transactions, enrichedERC20Transfers, order, limit, latestHgraphIndexedTimestampNs, fetchAllPages, }); for (const mergedTx of mergeResult.merged) { const result = await processTransactionItem({ mergedTx, address, evmAddress, currency, ledgerAccountId, mirrorTokens, useEncodedHash, useSyntheticBlocks, }); coinOperations.push(...result.newCoinOperations); tokenOperations.push(...result.newTokenOperations); } return { tokenOperations, coinOperations: skipFeesForTokenOperations ? coinOperations.filter(op => op.type !== "FEES") : coinOperations, nextCursor: mergeResult.nextCursor, }; }