UNPKG

@ledgerhq/coin-hedera

Version:
454 lines 20.1 kB
import { getCryptoAssetsStore } from "@ledgerhq/cryptoassets/state"; import { decodeTokenAccountId, emptyHistoryCache, encodeTokenAccountId, findSubAccountById, isTokenAccount, } from "@ledgerhq/ledger-wallet-framework/account"; import { mergeOps } from "@ledgerhq/ledger-wallet-framework/bridge/jsHelpers"; import { encodeOperationId } from "@ledgerhq/ledger-wallet-framework/operation"; import BigNumber from "bignumber.js"; import { HEDERA_OPERATION_TYPES } from "../constants"; import { estimateFees } from "../logic/estimateFees"; import { fromEVMAddress, toEVMAddress, getMemoFromBase64, isTokenAssociateTransaction, isValidExtra, base64ToUrlSafeBase64, } from "../logic/utils"; import { getERC20Operations, parseThirdwebTransactionParams } from "../network/utils"; import { estimateMaxSpendable } from "./estimateMaxSpendable"; const calculateCoinAmount = async ({ account, transaction, operationType, }) => { const estimatedFees = await estimateFees({ currency: account.currency, operationType }); const amount = transaction.useAllAmount ? await estimateMaxSpendable({ account, transaction }) : transaction.amount; return { amount, totalSpent: amount.plus(estimatedFees.tinybars), }; }; const calculateTokenAmount = async ({ account, tokenAccount, transaction, }) => { const amount = transaction.useAllAmount ? await estimateMaxSpendable({ account: tokenAccount, parentAccount: account, transaction }) : transaction.amount; return { amount, totalSpent: amount, }; }; export const calculateAmount = ({ account, transaction, }) => { const subAccount = findSubAccountById(account, transaction?.subAccountId || ""); const isTokenTransaction = isTokenAccount(subAccount); if (isTokenTransaction) { return calculateTokenAmount({ account, tokenAccount: subAccount, transaction }); } const operationType = isTokenAssociateTransaction(transaction) ? HEDERA_OPERATION_TYPES.TokenAssociate : HEDERA_OPERATION_TYPES.CryptoTransfer; return calculateCoinAmount({ account, transaction, operationType }); }; export const getSubAccounts = async ({ ledgerAccountId, latestTokenOperations, mirrorTokens, erc20Tokens, }) => { // Creating a Map of Operations by TokenCurrencies in order to know which TokenAccounts should be synced as well const operationsByToken = new Map(); const subAccounts = []; for (const tokenOperation of latestTokenOperations) { const { token } = await decodeTokenAccountId(tokenOperation.accountId); if (!token) continue; const isTokenListedInCAL = await getCryptoAssetsStore().findTokenByAddressInCurrency(token.contractAddress, token.parentCurrency.id); if (!isTokenListedInCAL) continue; if (!operationsByToken.has(token)) { operationsByToken.set(token, []); } operationsByToken.get(token)?.push(tokenOperation); } // extract token accounts from existing operations for (const [token, tokenOperations] of operationsByToken.entries()) { const parentAccountId = ledgerAccountId; let balance = null; if (token.tokenType === "erc20") { const rawBalance = erc20Tokens.find(t => t.token.contractAddress === token.contractAddress); balance = rawBalance === undefined ? null : new BigNumber(rawBalance.balance); } else { const rawBalance = mirrorTokens.find(t => t.token_id === token.contractAddress)?.balance; balance = rawBalance === undefined ? null : new BigNumber(rawBalance); } if (!balance) { continue; } subAccounts.push({ type: "TokenAccount", id: encodeTokenAccountId(parentAccountId, token), parentId: parentAccountId, token, balance, spendableBalance: balance, creationDate: tokenOperations.length > 0 ? tokenOperations[tokenOperations.length - 1].date : new Date(), operations: tokenOperations, operationsCount: tokenOperations.length, pendingOperations: [], balanceHistoryCache: emptyHistoryCache, swapHistory: [], }); } // extract token accounts existing in the account's balance, but with no recorded operations yet // e.g. hts tokens added via association flow, without any subsequent activity // or erc20 tokens received from 3rd party for (const rawToken of [...mirrorTokens, ...erc20Tokens]) { const parentAccountId = ledgerAccountId; const rawBalance = rawToken.balance; const balance = new BigNumber(rawBalance); const isERC20 = "token" in rawToken; const tokenAddress = isERC20 ? rawToken.token.contractAddress : rawToken.token_id; const token = await getCryptoAssetsStore().findTokenByAddressInCurrency(tokenAddress, "hedera"); if (!token) { continue; } const id = encodeTokenAccountId(parentAccountId, token); const operations = operationsByToken.get(token) ?? []; if (subAccounts.some(a => a.id === id)) { continue; } if (isERC20 && operations.length === 0 && balance.isZero()) { continue; } subAccounts.push({ type: "TokenAccount", id, parentId: parentAccountId, token, balance, spendableBalance: balance, creationDate: isERC20 ? new Date() : new Date(Number.parseFloat(rawToken.created_timestamp) * 1000), operations: [], operationsCount: 0, pendingOperations: [], balanceHistoryCache: emptyHistoryCache, swapHistory: [], }); } return subAccounts; }; // create NONE coin operation that will be a parent of an orphan child operation const makeCoinOperationForOrphanChildOperation = async (childOperation) => { const type = "NONE"; const { accountId } = await decodeTokenAccountId(childOperation.accountId); const id = encodeOperationId(accountId, childOperation.hash, type); return { id, hash: childOperation.hash, type, value: new BigNumber(0), fee: new BigNumber(0), senders: [], recipients: [], blockHeight: childOperation.blockHeight, blockHash: childOperation.blockHash, transactionSequenceNumber: childOperation.transactionSequenceNumber, subOperations: [], nftOperations: [], internalOperations: [], accountId: "", date: childOperation.date, extra: {}, }; }; // this util handles: // - linking sub operations with coin operations, e.g. token transfer with fee payment // - if possible, assigning `extra.associatedTokenId = mirrorToken.tokenId` based on operation's consensus timestamp export const prepareOperations = async (coinOperations, tokenOperations) => { const preparedCoinOperations = coinOperations.map(op => ({ ...op })); const preparedTokenOperations = tokenOperations.map(op => ({ ...op })); // loop through coin operations to prepare a map of hash => operations const coinOperationsByHash = {}; preparedCoinOperations.forEach(op => { if (!coinOperationsByHash[op.hash]) { coinOperationsByHash[op.hash] = []; } op.subOperations = []; coinOperationsByHash[op.hash].push(op); }); // loop through token operations to potentially copy them as a child operation of a coin operation for (const tokenOperation of preparedTokenOperations) { const { token } = await decodeTokenAccountId(tokenOperation.accountId); if (!token) continue; let mainOperations = coinOperationsByHash[tokenOperation.hash]; if (!mainOperations?.length) { const noneOperation = await makeCoinOperationForOrphanChildOperation(tokenOperation); mainOperations = [noneOperation]; preparedCoinOperations.push(noneOperation); } // ugly loop in loop but in theory, this can only be a 2 elements array maximum in the case of a self send for (const mainOperation of mainOperations) { mainOperation.subOperations.push(tokenOperation); } } return preparedCoinOperations; }; /** * List of properties of a sub account that can be updated when 2 "identical" accounts are found */ const updatableSubAccountProperties = [ { name: "balance", isOps: false }, { name: "spendableBalance", isOps: false }, { name: "balanceHistoryCache", isOps: false }, { name: "operations", isOps: true }, { name: "pendingOperations", isOps: true }, ]; /** * In charge of smartly merging sub accounts while maintaining references as much as possible */ export const mergeSubAccounts = (initialAccount, newSubAccounts) => { const oldSubAccounts = initialAccount?.subAccounts; if (!oldSubAccounts) { return newSubAccounts; } // map of already existing sub accounts by id const oldSubAccountsById = {}; for (const oldSubAccount of oldSubAccounts) { oldSubAccountsById[oldSubAccount.id] = oldSubAccount; } // looping through new sub accounts to compare them with already existing ones // already existing will be updated if necessary (see `updatableSubAccountProperties`) // new sub accounts will be added/pushed after already existing const newSubAccountsToAdd = []; for (const newSubAccount of newSubAccounts) { const duplicatedAccount = oldSubAccountsById[newSubAccount.id]; if (!duplicatedAccount) { newSubAccountsToAdd.push(newSubAccount); continue; } const updates = {}; for (const { name, isOps } of updatableSubAccountProperties) { if (!isOps) { if (newSubAccount[name] !== duplicatedAccount[name]) { // @ts-expect-error - TypeScript assumes all possible types could be assigned here updates[name] = newSubAccount[name]; } } else { updates[name] = mergeOps(duplicatedAccount[name], newSubAccount[name]); } } // update the operationsCount in case the mergeOps changed it updates.operationsCount = updates.operations?.length || duplicatedAccount?.operations?.length || 0; // modify the map with the updated sub account with a new ref oldSubAccountsById[newSubAccount.id] = { ...duplicatedAccount, ...updates, }; } const updatedSubAccounts = Object.values(oldSubAccountsById); return [...updatedSubAccounts, ...newSubAccountsToAdd]; }; export const applyPendingExtras = (existing, pending) => { const pendingOperationsByHash = new Map(pending.map(op => [op.hash, op])); return existing.map(op => { const pendingOp = pendingOperationsByHash.get(op.hash); if (!pendingOp) return op; if (!isValidExtra(op.extra)) return op; if (!isValidExtra(pendingOp.extra)) return op; return { ...op, extra: { ...pendingOp.extra, ...op.extra, }, }; }); }; export function patchOperationWithExtra(operation, extra) { return { ...operation, extra, subOperations: (operation.subOperations ?? []).map(op => ({ ...op, extra })), nftOperations: (operation.nftOperations ?? []).map(op => ({ ...op, extra })), }; } // TODO: remove once migration to new API is complete // filter out CONTRACT_CALL operations based on pending and already existing ERC20 operations to avoid duplicates export const removeDuplicatedContractCallOperations = (operations, pendingOperationHashes, erc20OperationHashes) => { return operations.filter(op => { if (op.type !== "CONTRACT_CALL") { return true; } const hashAlreadyExists = erc20OperationHashes.has(op.hash) || pendingOperationHashes.has(op.hash); return !hashAlreadyExists; }); }; // TODO: remove once migration to new API is complete // loop over latestERC20Operations and prepare lists of transactions that should be patched and added // - patching happens when we have a matching CONTRACT_CALL operation without blockHash set (mirror node transaction without ERC20 details) // - adding happens when we have no matching operation export const classifyERC20Operations = ({ latestERC20Operations, operationsByHash, evmAccountAddress, }) => { const erc20OperationsToPatch = new Map(); const erc20OperationsToAdd = new Map(); for (const erc20Operation of latestERC20Operations) { const hash = base64ToUrlSafeBase64(erc20Operation.mirrorTransaction.transaction_hash); const existingOp = operationsByHash.get(hash); const type = erc20Operation.thirdwebTransaction.decoded.params.from === evmAccountAddress ? "OUT" : "IN"; if (!existingOp) { erc20OperationsToAdd.set(hash, erc20Operation); continue; } if (existingOp.type === "CONTRACT_CALL" && type === "OUT" && !existingOp.blockHash) { erc20OperationsToPatch.set(hash, erc20Operation); continue; } } return { erc20OperationsToPatch, erc20OperationsToAdd }; }; // TODO: remove once migration to new API is complete // extracts common fields from an ERC20 operation const buildERC20OperationFields = ({ erc20Operation, relatedExistingOperation, variant, evmAddress, }) => { const decodedParams = parseThirdwebTransactionParams(erc20Operation.thirdwebTransaction); if (!decodedParams) { return null; } let type = "OUT"; const standard = "erc20"; const blockHeight = 5; const blockHash = erc20Operation.thirdwebTransaction.blockHash; const consensusTimestamp = erc20Operation.mirrorTransaction.consensus_timestamp; const timestamp = new Date(Number.parseInt(consensusTimestamp.split(".")[0], 10) * 1000); const fee = BigNumber(erc20Operation.mirrorTransaction.charged_tx_fee); const value = BigNumber(decodedParams.value); const senderAddress = fromEVMAddress(decodedParams.from) ?? decodedParams.from; const recipientAddress = fromEVMAddress(decodedParams.to) ?? decodedParams.to; const memo = getMemoFromBase64(erc20Operation.mirrorTransaction.memo_base64); const extra = { ...(isValidExtra(relatedExistingOperation?.extra) && relatedExistingOperation.extra), ...(memo && { memo }), consensusTimestamp: erc20Operation.contractCallResult.timestamp, transactionId: erc20Operation.mirrorTransaction.transaction_id, gasConsumed: erc20Operation.contractCallResult.gas_consumed, gasLimit: erc20Operation.contractCallResult.gas_limit, gasUsed: erc20Operation.contractCallResult.gas_used, }; if (variant === "add") { type = decodedParams.from === evmAddress ? "OUT" : "IN"; } return { date: timestamp, type, fee, value, senders: [senderAddress], recipients: [recipientAddress], blockHeight, blockHash, extra, standard, contract: erc20Operation.token.contractAddress, hasFailed: false, }; }; // TODO: remove once migration to new API is complete // patches an existing CONTRACT_CALL operation with ERC20 token operation details export const patchContractCallOperation = ({ relatedExistingOperation, ledgerAccountId, hash, erc20Fields, tokenOperation, }) => { Object.assign(relatedExistingOperation, { ...erc20Fields, id: encodeOperationId(ledgerAccountId, hash, "FEES"), type: "FEES", value: erc20Fields.fee, subOperations: [tokenOperation], }); }; // TODO: remove once migration to new API is complete export const integrateERC20Operations = async ({ ledgerAccountId, address, allOperations, latestERC20Transactions, pendingOperationHashes, erc20OperationHashes, }) => { const newERC20TokenOperations = []; const [latestERC20Operations, evmAddress] = await Promise.all([ getERC20Operations(latestERC20Transactions), toEVMAddress(address), ]); // avoid duplicated CONTRACT_CALL operations if ERC20 operations are already present const uniqueOperations = removeDuplicatedContractCallOperations(allOperations, pendingOperationHashes, erc20OperationHashes); // nothing to patch/add if no new ERC20 operations found if (latestERC20Operations.length === 0) { return { updatedOperations: uniqueOperations, newERC20TokenOperations, }; } // create copy to avoid mutating original array and index by hash for easy lookup const updatedOperations = uniqueOperations.map(op => ({ ...op })); const operationsByHash = updatedOperations.reduce((acc, curr) => { acc.set(curr.hash, curr); return acc; }, new Map()); // split erc20 operations into patch and add lists const { erc20OperationsToPatch, erc20OperationsToAdd } = classifyERC20Operations({ latestERC20Operations, operationsByHash, evmAccountAddress: evmAddress, }); // patch existing operations with data from thirdweb for (const [hash, erc20Operation] of erc20OperationsToPatch.entries()) { const relatedExistingOperation = operationsByHash.get(hash); if (!relatedExistingOperation) continue; const erc20Fields = buildERC20OperationFields({ variant: "patch", evmAddress, erc20Operation, relatedExistingOperation, }); if (!erc20Fields) continue; const encodedTokenAccountId = encodeTokenAccountId(ledgerAccountId, erc20Operation.token); const encodedOperationId = encodeOperationId(encodedTokenAccountId, hash, erc20Fields.type); const tokenOperation = { ...erc20Fields, id: encodedOperationId, accountId: encodedTokenAccountId, hash, }; patchContractCallOperation({ relatedExistingOperation, ledgerAccountId, hash, erc20Fields, tokenOperation, }); newERC20TokenOperations.push(tokenOperation); } // create new operations for remaining ERC20 operations for (const [hash, erc20Operation] of erc20OperationsToAdd.entries()) { const erc20Fields = buildERC20OperationFields({ variant: "add", evmAddress, erc20Operation, }); if (!erc20Fields) continue; const encodedTokenAccountId = encodeTokenAccountId(ledgerAccountId, erc20Operation.token); const encodedOperationId = encodeOperationId(encodedTokenAccountId, hash, erc20Fields.type); const tokenOperation = { ...erc20Fields, id: encodedOperationId, accountId: encodedTokenAccountId, hash, }; const coinOperation = erc20Fields.type === "OUT" ? { ...erc20Fields, id: encodeOperationId(ledgerAccountId, hash, "FEES"), accountId: ledgerAccountId, type: "FEES", value: erc20Fields.fee, hash, } : await makeCoinOperationForOrphanChildOperation(tokenOperation); coinOperation.subOperations = [tokenOperation]; updatedOperations.push(coinOperation); newERC20TokenOperations.push(tokenOperation); } // ensure operations lists are sorted correctly updatedOperations.sort((a, b) => b.date.getTime() - a.date.getTime()); newERC20TokenOperations.sort((a, b) => b.date.getTime() - a.date.getTime()); return { updatedOperations, newERC20TokenOperations, }; }; //# sourceMappingURL=utils.js.map