UNPKG

@ledgerhq/coin-hedera

Version:
342 lines (304 loc) 13.1 kB
import { encodeAccountId, getSyncHash } from "@ledgerhq/ledger-wallet-framework/account"; import type { GetAccountShape, IterateResultBuilder, } from "@ledgerhq/ledger-wallet-framework/bridge/jsHelpers"; import { mergeOps } from "@ledgerhq/ledger-wallet-framework/bridge/jsHelpers"; import type { Result } from "@ledgerhq/ledger-wallet-framework/derivation"; import { getDerivationScheme, runDerivationScheme, } from "@ledgerhq/ledger-wallet-framework/derivation"; import type { CryptoCurrency } from "@ledgerhq/types-cryptoassets"; import type { Account, Operation } from "@ledgerhq/types-live"; import { BigNumber } from "bignumber.js"; import invariant from "invariant"; import hederaCoinConfig from "../config"; import { HARDCODED_BLOCK_HEIGHT } from "../constants"; import { listOperations, listOperationsV2 } from "../logic"; import { toEVMAddress } from "../logic/utils"; import { apiClient } from "../network/api"; import { thirdwebClient } from "../network/thirdweb"; import { getERC20BalancesForAccount, getERC20BalancesForAccountV2 } from "../network/utils"; import type { HederaAccount } from "../types"; import { getSubAccounts, prepareOperations, applyPendingExtras, mergeSubAccounts, integrateERC20Operations, } from "./utils"; const getAccountShapeV2 = async ({ address, evmAddress, liveAccountId, currency, initialAccount, blacklistedTokenIds, }: { address: string; evmAddress: string; liveAccountId: string; currency: CryptoCurrency; initialAccount: HederaAccount | undefined; blacklistedTokenIds: string[] | undefined; }): Promise<Partial<HederaAccount>> => { // get current account balance and tokens // tokens are fetched with separate requests to get "created_timestamp" for each token // based on this, ASSOCIATE_TOKEN operations can be connected with tokens const [mirrorAccount, mirrorTokens, erc20Tokens] = await Promise.all([ apiClient.getAccount(address), apiClient.getAccountTokens(address), getERC20BalancesForAccountV2(address), ]); const accountBalance = new BigNumber(mirrorAccount.balance.balance); // we should sync again when new tokens are added or blacklist changes const syncHash = await getSyncHash(currency.id, blacklistedTokenIds); const shouldSyncFromScratch = !initialAccount || syncHash !== initialAccount?.syncHash; const pendingOperations = shouldSyncFromScratch ? [] : (initialAccount?.pendingOperations ?? []); const oldOperations = shouldSyncFromScratch ? [] : (initialAccount?.operations ?? []); const latestOperation = oldOperations[0]; // grab latest operation timestamps for incremental sync let latestOperationTimestamp: string | null = null; if (!shouldSyncFromScratch && latestOperation) { const timestamp = Math.floor(latestOperation.date.getTime() / 1000); latestOperationTimestamp = new BigNumber(timestamp).toFixed(9); } const latestAccountOperations = await listOperationsV2({ currency, address, evmAddress, mirrorTokens, erc20Tokens, ...(latestOperationTimestamp && { cursor: latestOperationTimestamp }), fetchAllPages: true, skipFeesForTokenOperations: false, useEncodedHash: true, useSyntheticBlocks: false, }); const newOperations = await prepareOperations( latestAccountOperations.coinOperations, latestAccountOperations.tokenOperations, ); const enrichedNewOperations = applyPendingExtras(newOperations, pendingOperations); const operations = shouldSyncFromScratch ? enrichedNewOperations : mergeOps(oldOperations, enrichedNewOperations); const delegation = typeof mirrorAccount.staked_node_id === "number" ? { nodeId: mirrorAccount.staked_node_id, delegated: accountBalance, pendingReward: new BigNumber(mirrorAccount.pending_reward), } : null; const newSubAccounts = await getSubAccounts({ ledgerAccountId: liveAccountId, latestTokenOperations: latestAccountOperations.tokenOperations, mirrorTokens, erc20Tokens, }); const subAccounts = shouldSyncFromScratch ? newSubAccounts : mergeSubAccounts(initialAccount, newSubAccounts); return { id: liveAccountId, freshAddress: address, syncHash, lastSyncDate: new Date(), balance: accountBalance, spendableBalance: accountBalance, operations: operations, operationsCount: operations.length, // NOTE: there are no "blocks" in hedera // set a value just so that operations are considered confirmed according to isConfirmedOperation blockHeight: HARDCODED_BLOCK_HEIGHT, subAccounts, hederaResources: { maxAutomaticTokenAssociations: mirrorAccount.max_automatic_token_associations, isAutoTokenAssociationEnabled: mirrorAccount.max_automatic_token_associations === -1, delegation, }, }; }; export const getAccountShape: GetAccountShape<HederaAccount> = async ( info, { blacklistedTokenIds }, ): Promise<Partial<HederaAccount>> => { const { currency, derivationMode, address, initialAccount } = info; invariant(address, "hedera: address is expected"); const evmAddress = await toEVMAddress(address); invariant(evmAddress, `hedera: evm address is missing for ${address}`); const coinConfig = hederaCoinConfig.getCoinConfig(currency.id); const liveAccountId = encodeAccountId({ type: "js", version: "2", currencyId: currency.id, xpubOrAddress: address, derivationMode, }); if (coinConfig.useHgraphForErc20) { return getAccountShapeV2({ address, evmAddress, liveAccountId, currency, initialAccount, blacklistedTokenIds, }); } // get current account balance and tokens // tokens are fetched with separate requests to get "created_timestamp" for each token // based on this, ASSOCIATE_TOKEN operations can be connected with tokens const [mirrorAccount, mirrorTokens, erc20TokenBalances] = await Promise.all([ apiClient.getAccount(address), apiClient.getAccountTokens(address), getERC20BalancesForAccount(evmAddress), ]); const accountBalance = new BigNumber(mirrorAccount.balance.balance); // we should sync again when new tokens are added or blacklist changes const syncHash = await getSyncHash(currency.id, blacklistedTokenIds); const shouldSyncFromScratch = !initialAccount || syncHash !== initialAccount?.syncHash; const pendingOperations = shouldSyncFromScratch ? [] : (initialAccount?.pendingOperations ?? []); const oldOperations = shouldSyncFromScratch ? [] : (initialAccount?.operations ?? []); const oldERC20Operations = oldOperations.filter(item => item.standard === "erc20"); const latestOperation = oldOperations[0]; const erc20LatestOperation = oldERC20Operations[0]; const pendingOperationHashes = new Set(pendingOperations.map(op => op.hash)); const erc20OperationHashes = new Set(oldERC20Operations.map(op => op.hash)); // grab latest operation timestamps for incremental sync let latestOperationTimestamp: string | null = null; let erc20LatestOperationTimestamp: string | null = null; if (!shouldSyncFromScratch && latestOperation) { const timestamp = Math.floor(latestOperation.date.getTime() / 1000); latestOperationTimestamp = new BigNumber(timestamp).toString(); } if (!shouldSyncFromScratch && erc20LatestOperation) { const timestamp = Math.floor(erc20LatestOperation.date.getTime() / 1000); erc20LatestOperationTimestamp = new BigNumber(timestamp).toString(); } const [latestAccountOperations, erc20Transactions] = await Promise.all([ listOperations({ currency, address, mirrorTokens, cursor: latestOperationTimestamp?.toString(), fetchAllPages: true, skipFeesForTokenOperations: false, useEncodedHash: true, useSyntheticBlocks: false, }), thirdwebClient.getERC20TransactionsForAccount({ address, contractAddresses: erc20TokenBalances.map(token => token.token.contractAddress), since: erc20LatestOperationTimestamp, }), ]); const newOperations = await prepareOperations( latestAccountOperations.coinOperations, latestAccountOperations.tokenOperations, ); const enrichedNewOperations = applyPendingExtras(newOperations, pendingOperations); const operations = shouldSyncFromScratch ? enrichedNewOperations : mergeOps(oldOperations, enrichedNewOperations); const delegation = typeof mirrorAccount.staked_node_id === "number" ? { nodeId: mirrorAccount.staked_node_id, delegated: accountBalance, pendingReward: new BigNumber(mirrorAccount.pending_reward), } : null; // how ERC20 operations are handled: // - mirror node doesn't include "IN" erc20 token transactions // - mirror node returns "CONTRACT_CALL" (OUT) erc20 token transactions made from given account address // - mirror node doesn't return "CONTRACT_CALL" (OUT) erc20 token transactions made from 3rd party with allowance // // 1. mirror node transactions are already transformed into operations at this point + we have raw erc20 transactions fetched from thirdweb // 2. related mirror node transaction must be fetched for each erc20 transaction (to get fee and consensus timestamp) // 3. CONTRACTCALL operations must be removed if existing operations already include erc20 operation with the same tx.hash // 4. ERC20 transactions must be classified into two groups: patchList and addList // - patchList: transactions which are already present in mirror operations (we can have CONTRACT_CALL from mirror node that we can transform into "FEES") // - addList should include transactions which are missing in mirror operations (e.g. "IN" erc20 token transaction and "OUT" made by 3rd party with allowance) // 5. list of all operations must be updated based on prepared `patchList` and `addList` // 6. sub accounts must get erc20 tokens and erc20 operations in addition to hts tokens and hts operations // 7. postSync must remove pending operations that are already confirmed as erc20 operations const { updatedOperations, newERC20TokenOperations } = await integrateERC20Operations({ ledgerAccountId: liveAccountId, address, allOperations: operations, latestERC20Transactions: erc20Transactions, pendingOperationHashes, erc20OperationHashes, }); const newSubAccounts = await getSubAccounts({ ledgerAccountId: liveAccountId, latestTokenOperations: [...latestAccountOperations.tokenOperations, ...newERC20TokenOperations], mirrorTokens, erc20Tokens: erc20TokenBalances, }); const subAccounts = shouldSyncFromScratch ? newSubAccounts : mergeSubAccounts(initialAccount, newSubAccounts); return { id: liveAccountId, freshAddress: address, syncHash, lastSyncDate: new Date(), balance: accountBalance, spendableBalance: accountBalance, operations: updatedOperations, operationsCount: updatedOperations.length, // NOTE: there are no "blocks" in hedera // Set a value just so that operations are considered confirmed according to isConfirmedOperation blockHeight: HARDCODED_BLOCK_HEIGHT, subAccounts, hederaResources: { maxAutomaticTokenAssociations: mirrorAccount.max_automatic_token_associations, isAutoTokenAssociationEnabled: mirrorAccount.max_automatic_token_associations === -1, delegation, }, }; }; export const buildIterateResult: IterateResultBuilder = async ({ result: rootResult }) => { const mirrorAccounts = await apiClient.getAccountsForPublicKey(rootResult.publicKey); const addresses = mirrorAccounts.map(a => a.account); return async ({ currency, derivationMode, index }) => { const derivationScheme = getDerivationScheme({ derivationMode, currency, }); const freshAddressPath = runDerivationScheme(derivationScheme, currency, { account: index, }); return addresses[index] ? ({ address: addresses[index], publicKey: addresses[index], path: freshAddressPath, } satisfies Result) : null; }; }; // TODO: remove once migration to new API is complete // it might be necessary to remove pending operations after ERC20 patching done by `integrateERC20Operations` export const postSync = (_initial: Account, synced: Account): Account => { const coinConfig = hederaCoinConfig.getCoinConfig(synced.currency.id); if (coinConfig.useHgraphForErc20) { return synced; } const erc20Operations = synced.operations.filter(op => op.standard === "erc20"); const erc20Hashes = new Set(erc20Operations.map(op => op.hash)); const excludeConfirmedERC20Operations = (o: Operation) => !erc20Hashes.has(o.hash); return { ...synced, pendingOperations: synced.pendingOperations.filter(excludeConfirmedERC20Operations), subAccounts: (synced.subAccounts ?? []).map(subAccount => { return { ...subAccount, pendingOperations: subAccount.pendingOperations.filter(excludeConfirmedERC20Operations), }; }), }; };