@ledgerhq/coin-hedera
Version:
Ledger Hedera Coin integration
332 lines • 13.9 kB
JavaScript
import invariant from "invariant";
import { AccountId, TransactionId } from "@hashgraph/sdk";
import { getCryptoCurrencyById } from "@ledgerhq/cryptoassets/currencies";
import { getFiatCurrencyByTicker } from "@ledgerhq/cryptoassets/fiats";
import { getCryptoAssetsStore } from "@ledgerhq/cryptoassets/state";
import { InvalidAddress } from "@ledgerhq/errors";
import cvsApi from "@ledgerhq/live-countervalues/api/index";
import { getEnv } from "@ledgerhq/live-env";
import { makeLRUCache, seconds } from "@ledgerhq/live-network/cache";
import BigNumber from "bignumber.js";
import { SUPPORTED_ERC20_TOKENS } from "../constants";
import { HederaRecipientInvalidChecksum } from "../errors";
import { getChecksum, nanosToSeconds, toEntityId, toTimestamp } from "../logic/utils";
import { apiClient } from "./api";
import { hgraphClient } from "./hgraph";
import { rpcClient } from "./rpc";
export async function createTransactionId(accountId, config) {
if (!config.useNetworkTimestamp) {
return TransactionId.generate(accountId);
}
try {
const lastBlock = await apiClient.getLatestBlock({ configOrCurrencyId: config });
const validStart = toTimestamp(lastBlock.timestamp.to ?? lastBlock.timestamp.from);
return TransactionId.withValidStart(AccountId.fromString(accountId), validStart);
}
catch {
return TransactionId.generate(accountId);
}
}
function isValidRecipient(accountId, recipients) {
if (accountId.shard.eq(0) && accountId.realm.eq(0)) {
// account is a node, only add to list if we have none
if (accountId.num.lt(100)) {
return recipients.length === 0;
}
// account is a system account that is not a node, do NOT add
if (accountId.num.lt(1000)) {
return false;
}
}
return true;
}
export function parseTransfers(mirrorTransfers, address, stakingReward = new BigNumber(0)) {
let value = new BigNumber(0);
let type = "NONE";
const senders = [];
const recipients = [];
const rewardPayerAddress = getEnv("HEDERA_STAKING_REWARD_ACCOUNT_ID");
for (const transfer of mirrorTransfers) {
const amount = new BigNumber(transfer.amount);
const accountId = AccountId.fromString(transfer.account);
// staking reward is included in transfer, so it can be positive even if user sent less HBARs than the reward is
const amountWithoutReward = transfer.account === address ? amount.minus(stakingReward) : amount;
if (transfer.account === address) {
value = amountWithoutReward.abs();
type = amountWithoutReward.isNegative() ? "OUT" : "IN";
}
if (amountWithoutReward.isNegative()) {
// exclude reward payer from senders list, because rewards are shown as separate operations
const shouldIgnoreAddress = transfer.account === rewardPayerAddress && stakingReward.gt(0);
if (shouldIgnoreAddress) {
continue;
}
senders.push(transfer.account);
}
else if (isValidRecipient(accountId, recipients)) {
recipients.push(transfer.account);
}
}
// NOTE: earlier addresses are the "fee" addresses
senders.reverse();
recipients.reverse();
return {
type,
value,
senders,
recipients,
};
}
export async function getERC20BalancesForAccountV2({ configOrCurrencyId, address, }) {
const balances = [];
const rawBalances = await hgraphClient.getERC20Balances({ configOrCurrencyId, address });
for (const rawBalance of rawBalances) {
const rawBalanceTokenId = toEntityId({ num: rawBalance.token_id });
const supportedToken = SUPPORTED_ERC20_TOKENS.find(token => {
return token.tokenId === rawBalanceTokenId;
});
if (!supportedToken) {
continue;
}
const calToken = await getCryptoAssetsStore().findTokenById(supportedToken.id);
if (!calToken) {
continue;
}
balances.push({
token: calToken,
balance: new BigNumber(rawBalance.balance),
});
}
return balances;
}
/**
* Enriches raw ERC20 transfers from Hgraph with additional data needed for operations:
* - fetches contract call result containing gas metrics and block hash
* - finds the corresponding Mirror Node transaction by consensus timestamp
*
* @param erc20Transfers - Raw ERC20 transfers from Hgraph API
* @returns Array of enriched transfers with complete operation data, filtered to supported tokens only
*/
export const enrichERC20Transfers = async ({ configOrCurrencyId, erc20Transfers, }) => {
const enrichedTransfers = [];
// with hgraph we can get two different transfers with the same transaction hash
const groupedByTxHash = new Map();
for (const transfer of erc20Transfers) {
const group = groupedByTxHash.get(transfer.transaction_hash);
if (!group) {
groupedByTxHash.set(transfer.transaction_hash, [transfer]);
continue;
}
group.push(transfer);
}
for (const [txHash, transfers] of groupedByTxHash.entries()) {
const payerAddress = toEntityId({ num: transfers[0].payer_account_id });
const inaccurateConsensusTimestampNs = new BigNumber(transfers[0].consensus_timestamp);
const inaccurateConsensusTimestamp = nanosToSeconds(inaccurateConsensusTimestampNs).toFixed(9);
const [contractCallResult, mirrorTransaction] = await Promise.all([
apiClient.getContractCallResult({ configOrCurrencyId, transactionHash: txHash }),
apiClient.findTransactionByContractCallV2({
configOrCurrencyId,
payerAddress,
timestamp: inaccurateConsensusTimestamp,
}),
]);
if (!mirrorTransaction) {
continue;
}
enrichedTransfers.push({
transfers,
contractCallResult,
mirrorTransaction,
});
}
return enrichedTransfers;
};
// note: this is currently called frequently by getTransactionStatus; LRU cache prevents duplicated requests
export const getCurrencyToUSDRate = makeLRUCache(async (currency) => {
try {
const [rate] = await cvsApi.fetchLatest([
{
from: currency,
to: getFiatCurrencyByTicker("USD"),
startDate: new Date(),
},
]);
invariant(rate, "no value returned from cvs api");
return new BigNumber(rate);
}
catch {
return null;
}
}, currency => currency.ticker, seconds(3));
export const checkAccountTokenAssociationStatus = makeLRUCache(async (address, token) => {
if (token.tokenType !== "hts") {
return true;
}
const [parsingError, parsingResult] = await safeParseAccountId({
configOrCurrencyId: token.parentCurrencyId,
address,
});
if (parsingError) {
throw parsingError;
}
const addressWithoutChecksum = parsingResult.accountId;
const mirrorAccount = await apiClient.getAccount({
configOrCurrencyId: token.parentCurrencyId,
address: addressWithoutChecksum,
});
// auto association is enabled
if (mirrorAccount.max_automatic_token_associations === -1) {
return true;
}
const isTokenAssociated = mirrorAccount.balance.tokens.some(t => {
return t.token_id === token.contractAddress;
});
return isTokenAssociated;
}, (accountId, token) => `${accountId}-${token.contractAddress}`, seconds(30));
export const safeParseAccountId = async ({ configOrCurrencyId, address, }) => {
const currency = getCryptoCurrencyById("hedera");
const currencyName = currency?.name ?? "Hedera";
try {
const accountId = AccountId.fromString(address);
const checksum = getChecksum(address);
if (checksum) {
const client = await rpcClient.getInstance(configOrCurrencyId);
const expectedChecksum = accountId.toStringWithChecksum(client).split("-")[1];
if (checksum !== expectedChecksum) {
return [new HederaRecipientInvalidChecksum(), null];
}
}
const result = {
accountId: accountId.toString(),
checksum,
};
return [null, result];
}
catch {
return [new InvalidAddress("", { currencyName }), null];
}
};
/**
* Fetches EVM address for given Hedera account ID (e.g. "0.0.1234").
* It returns null if the fetch fails.
*
* @param accountId - Hedera account ID in the format `shard.realm.num`
* @returns EVM address (`0x...`) or null if fetch fails
*/
export const toEVMAddress = async ({ configOrCurrencyId, accountId, }) => {
try {
const account = await apiClient.getAccount({
configOrCurrencyId,
address: accountId,
});
return account.evm_address;
}
catch {
return null;
}
};
/**
* Calculates the uncommitted balance change for an account between two timestamps.
*
* This function handles the timing mismatch between Mirror Node balance snapshots and actual transactions.
* Balance snapshots are taken at regular intervals, not at every transaction, so querying by exact timestamp
* may return a snapshot from before moment you need.
*
* @param address - Hedera account ID (e.g., "0.0.12345")
* @param startTimestamp - Start of the time range (exclusive, format: "1234567890.123456789")
* @param endTimestamp - End of the time range (inclusive, format: "1234567890.123456789")
* @returns The net balance change as BigInt (sum of all transfers to/from the account)
*/
export const calculateUncommittedBalanceChange = async ({ configOrCurrencyId, address, startTimestamp, endTimestamp, }) => {
if (Number(startTimestamp) >= Number(endTimestamp)) {
return new BigNumber(0);
}
const uncommittedTransactions = await apiClient.getTransactionsByTimestampRange({
configOrCurrencyId,
address,
startTimestamp: `gt:${startTimestamp}`,
endTimestamp: `lte:${endTimestamp}`,
});
// Sum all balance changes from transfers related to this account
const uncommittedBalanceChange = uncommittedTransactions.reduce((total, tx) => {
const transfers = tx.transfers ?? [];
const relevantTransfers = transfers.filter(t => t.account === address);
const netChange = relevantTransfers.reduce((sum, t) => sum.plus(t.amount), new BigNumber(0));
return total.plus(netChange);
}, new BigNumber(0));
return uncommittedBalanceChange;
};
/**
* Hedera uses the AccountUpdateTransaction for multiple purposes, including staking operations.
* Mirror node classifies all such transactions under the same name: "CRYPTOUPDATEACCOUNT".
*
* This function distinguishes between:
* - DELEGATE: Account started staking (staked_node_id changed from null to a node ID)
* - UNDELEGATE: Account stopped staking (staked_node_id changed from a node ID to null)
* - REDELEGATE: Account changed staking node (staked_node_id changed from one node to another)
*
* The analysis works by:
* 1. Fetching the account state BEFORE the transaction (using lt: timestamp filter)
* 2. Fetching the account state AFTER the transaction (using eq: timestamp filter)
* 3. Comparing the staked_node_id field to determine what changed
* 4. Calculating the actual staked amount by replaying uncommitted transactions between
* the latest balance snapshot and the staking operation to handle snapshot timing mismatches
*
* @performance
* Makes 3 API calls per operation:
* - account state before
* - account state after
* - transaction history based on latest balance snapshot
*
* Batching would complicate code for minimal gain given low staking op frequency.
*/
export const analyzeStakingOperation = async ({ configOrCurrencyId, address, mirrorTx, }) => {
const [accountBefore, accountAfter] = await Promise.all([
apiClient.getAccount({
configOrCurrencyId,
address,
timestamp: `lt:${mirrorTx.consensus_timestamp}`,
}),
apiClient.getAccount({
configOrCurrencyId,
address,
timestamp: `eq:${mirrorTx.consensus_timestamp}`,
}),
]);
let operationType = null;
const previousStakingNodeId = accountBefore.staked_node_id;
const targetStakingNodeId = accountAfter.staked_node_id;
// stake: node id changed from null -> not null
if (previousStakingNodeId === null && targetStakingNodeId !== null) {
operationType = "DELEGATE";
}
// unstake: node id changed from not null -> null
else if (previousStakingNodeId !== null && targetStakingNodeId === null) {
operationType = "UNDELEGATE";
}
// restake: node id changed from not null -> different not null
else if (previousStakingNodeId !== null &&
targetStakingNodeId !== null &&
previousStakingNodeId !== targetStakingNodeId) {
operationType = "REDELEGATE";
}
if (!operationType) {
return null;
}
// calculate uncommitted balance changes between the last snapshot and the staking tx
const uncommittedBalanceChange = await calculateUncommittedBalanceChange({
configOrCurrencyId,
address,
startTimestamp: accountAfter.balance.timestamp,
endTimestamp: mirrorTx.consensus_timestamp,
});
const actualStakedAmount = uncommittedBalanceChange.plus(accountAfter.balance.balance);
return {
operationType,
previousStakingNodeId,
targetStakingNodeId,
stakedAmount: BigInt(actualStakedAmount.toString()), // always entire balance on Hedera (fully liquid)
};
};
//# sourceMappingURL=utils.js.map