@ledgerhq/coin-hedera
Version:
Ledger Hedera Coin integration
346 lines • 15.1 kB
JavaScript
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.analyzeStakingOperation = exports.calculateUncommittedBalanceChange = exports.toEVMAddress = exports.safeParseAccountId = exports.checkAccountTokenAssociationStatus = exports.getCurrencyToUSDRate = exports.enrichERC20Transfers = void 0;
exports.createTransactionId = createTransactionId;
exports.parseTransfers = parseTransfers;
exports.getERC20BalancesForAccountV2 = getERC20BalancesForAccountV2;
const invariant_1 = __importDefault(require("invariant"));
const sdk_1 = require("@hashgraph/sdk");
const currencies_1 = require("@ledgerhq/cryptoassets/currencies");
const fiats_1 = require("@ledgerhq/cryptoassets/fiats");
const state_1 = require("@ledgerhq/cryptoassets/state");
const errors_1 = require("@ledgerhq/errors");
const index_1 = __importDefault(require("@ledgerhq/live-countervalues/api/index"));
const live_env_1 = require("@ledgerhq/live-env");
const cache_1 = require("@ledgerhq/live-network/cache");
const bignumber_js_1 = __importDefault(require("bignumber.js"));
const constants_1 = require("../constants");
const errors_2 = require("../errors");
const utils_1 = require("../logic/utils");
const api_1 = require("./api");
const hgraph_1 = require("./hgraph");
const rpc_1 = require("./rpc");
async function createTransactionId(accountId, config) {
if (!config.useNetworkTimestamp) {
return sdk_1.TransactionId.generate(accountId);
}
try {
const lastBlock = await api_1.apiClient.getLatestBlock({ configOrCurrencyId: config });
const validStart = (0, utils_1.toTimestamp)(lastBlock.timestamp.to ?? lastBlock.timestamp.from);
return sdk_1.TransactionId.withValidStart(sdk_1.AccountId.fromString(accountId), validStart);
}
catch {
return sdk_1.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;
}
function parseTransfers(mirrorTransfers, address, stakingReward = new bignumber_js_1.default(0)) {
let value = new bignumber_js_1.default(0);
let type = "NONE";
const senders = [];
const recipients = [];
const rewardPayerAddress = (0, live_env_1.getEnv)("HEDERA_STAKING_REWARD_ACCOUNT_ID");
for (const transfer of mirrorTransfers) {
const amount = new bignumber_js_1.default(transfer.amount);
const accountId = sdk_1.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,
};
}
async function getERC20BalancesForAccountV2({ configOrCurrencyId, address, }) {
const balances = [];
const rawBalances = await hgraph_1.hgraphClient.getERC20Balances({ configOrCurrencyId, address });
for (const rawBalance of rawBalances) {
const rawBalanceTokenId = (0, utils_1.toEntityId)({ num: rawBalance.token_id });
const supportedToken = constants_1.SUPPORTED_ERC20_TOKENS.find(token => {
return token.tokenId === rawBalanceTokenId;
});
if (!supportedToken) {
continue;
}
const calToken = await (0, state_1.getCryptoAssetsStore)().findTokenById(supportedToken.id);
if (!calToken) {
continue;
}
balances.push({
token: calToken,
balance: new bignumber_js_1.default(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
*/
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 = (0, utils_1.toEntityId)({ num: transfers[0].payer_account_id });
const inaccurateConsensusTimestampNs = new bignumber_js_1.default(transfers[0].consensus_timestamp);
const inaccurateConsensusTimestamp = (0, utils_1.nanosToSeconds)(inaccurateConsensusTimestampNs).toFixed(9);
const [contractCallResult, mirrorTransaction] = await Promise.all([
api_1.apiClient.getContractCallResult({ configOrCurrencyId, transactionHash: txHash }),
api_1.apiClient.findTransactionByContractCallV2({
configOrCurrencyId,
payerAddress,
timestamp: inaccurateConsensusTimestamp,
}),
]);
if (!mirrorTransaction) {
continue;
}
enrichedTransfers.push({
transfers,
contractCallResult,
mirrorTransaction,
});
}
return enrichedTransfers;
};
exports.enrichERC20Transfers = enrichERC20Transfers;
// note: this is currently called frequently by getTransactionStatus; LRU cache prevents duplicated requests
exports.getCurrencyToUSDRate = (0, cache_1.makeLRUCache)(async (currency) => {
try {
const [rate] = await index_1.default.fetchLatest([
{
from: currency,
to: (0, fiats_1.getFiatCurrencyByTicker)("USD"),
startDate: new Date(),
},
]);
(0, invariant_1.default)(rate, "no value returned from cvs api");
return new bignumber_js_1.default(rate);
}
catch {
return null;
}
}, currency => currency.ticker, (0, cache_1.seconds)(3));
exports.checkAccountTokenAssociationStatus = (0, cache_1.makeLRUCache)(async (address, token) => {
if (token.tokenType !== "hts") {
return true;
}
const [parsingError, parsingResult] = await (0, exports.safeParseAccountId)({
configOrCurrencyId: token.parentCurrencyId,
address,
});
if (parsingError) {
throw parsingError;
}
const addressWithoutChecksum = parsingResult.accountId;
const mirrorAccount = await api_1.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}`, (0, cache_1.seconds)(30));
const safeParseAccountId = async ({ configOrCurrencyId, address, }) => {
const currency = (0, currencies_1.getCryptoCurrencyById)("hedera");
const currencyName = currency?.name ?? "Hedera";
try {
const accountId = sdk_1.AccountId.fromString(address);
const checksum = (0, utils_1.getChecksum)(address);
if (checksum) {
const client = await rpc_1.rpcClient.getInstance(configOrCurrencyId);
const expectedChecksum = accountId.toStringWithChecksum(client).split("-")[1];
if (checksum !== expectedChecksum) {
return [new errors_2.HederaRecipientInvalidChecksum(), null];
}
}
const result = {
accountId: accountId.toString(),
checksum,
};
return [null, result];
}
catch {
return [new errors_1.InvalidAddress("", { currencyName }), null];
}
};
exports.safeParseAccountId = safeParseAccountId;
/**
* 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
*/
const toEVMAddress = async ({ configOrCurrencyId, accountId, }) => {
try {
const account = await api_1.apiClient.getAccount({
configOrCurrencyId,
address: accountId,
});
return account.evm_address;
}
catch {
return null;
}
};
exports.toEVMAddress = toEVMAddress;
/**
* 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)
*/
const calculateUncommittedBalanceChange = async ({ configOrCurrencyId, address, startTimestamp, endTimestamp, }) => {
if (Number(startTimestamp) >= Number(endTimestamp)) {
return new bignumber_js_1.default(0);
}
const uncommittedTransactions = await api_1.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_js_1.default(0));
return total.plus(netChange);
}, new bignumber_js_1.default(0));
return uncommittedBalanceChange;
};
exports.calculateUncommittedBalanceChange = calculateUncommittedBalanceChange;
/**
* 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.
*/
const analyzeStakingOperation = async ({ configOrCurrencyId, address, mirrorTx, }) => {
const [accountBefore, accountAfter] = await Promise.all([
api_1.apiClient.getAccount({
configOrCurrencyId,
address,
timestamp: `lt:${mirrorTx.consensus_timestamp}`,
}),
api_1.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 (0, exports.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)
};
};
exports.analyzeStakingOperation = analyzeStakingOperation;
//# sourceMappingURL=utils.js.map