@ledgerhq/coin-hedera
Version:
Ledger Hedera Coin integration
371 lines • 15.2 kB
JavaScript
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 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 { analyzeStakingOperation, base64ToUrlSafeBase64, createStakingRewardOperationHash, extractFeesPayer, getMemoFromBase64, getSyntheticBlock, mergeTransactionsFromDifferentSources, toEntityId, } from "./utils";
const txNameToCustomOperationType = {
TOKENASSOCIATE: "ASSOCIATE_TOKEN",
CONTRACTCALL: "CONTRACT_CALL",
CRYPTOUPDATEACCOUNT: "UPDATE_ACCOUNT",
};
function getCommonMirrorOperationData(rawTx, useEncodedHash, useSyntheticBlocks) {
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 = {
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, address) {
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, }) {
if (stakingReward.lte(0)) {
return null;
}
const { hash, date, blockHeight, blockHash } = commonData;
const stakingRewardHash = createStakingRewardOperationHash(hash);
const stakingRewardType = "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, }) {
if (transferType === "mint")
return "IN";
if (transferType === "burn")
return "OUT";
return senderEvmAddress.toLowerCase() === evmAddress.toLowerCase() ? "OUT" : "IN";
}
async function processERC20TokenTransfer({ enrichedERC20Transfer, evmAddress, ledgerAccountId, commonData, }) {
let coinOperation;
const tokenOperations = [];
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,
},
};
const encodedTokenAccountId = encodeTokenAccountId(ledgerAccountId, token);
const encodedOperationId = encodeOperationId(encodedTokenAccountId, commonFields.hash, commonFields.type);
const tokenOperation = {
...commonFields,
id: encodedOperationId,
accountId: encodedTokenAccountId,
};
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,
};
}
return {
coinOperation,
tokenOperations,
};
}
async function processHTSTokenTransfers({ rawTx, address, currency, ledgerAccountId, commonData, }) {
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;
// 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,
};
}
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,
};
return {
coinOperation,
tokenOperation,
};
}
function processCoinTransfers({ rawTx, address, ledgerAccountId, commonData, mirrorTokens, stakingReward, stakingAnalysis, }) {
const coinOperations = [];
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, }) {
const newCoinOperations = [];
const newTokenOperations = [];
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, }) {
const coinOperations = [];
const tokenOperations = [];
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,
};
}
//# sourceMappingURL=listOperations.v2.js.map