UNPKG

@ledgerhq/coin-hedera

Version:
470 lines 21.6 kB
"use strict"; var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.integrateERC20Operations = exports.patchContractCallOperation = exports.classifyERC20Operations = exports.removeDuplicatedContractCallOperations = exports.applyPendingExtras = exports.mergeSubAccounts = exports.prepareOperations = exports.getSubAccounts = exports.calculateAmount = void 0; exports.patchOperationWithExtra = patchOperationWithExtra; const state_1 = require("@ledgerhq/cryptoassets/state"); const account_1 = require("@ledgerhq/ledger-wallet-framework/account"); const jsHelpers_1 = require("@ledgerhq/ledger-wallet-framework/bridge/jsHelpers"); const operation_1 = require("@ledgerhq/ledger-wallet-framework/operation"); const bignumber_js_1 = __importDefault(require("bignumber.js")); const constants_1 = require("../constants"); const estimateFees_1 = require("../logic/estimateFees"); const utils_1 = require("../logic/utils"); const utils_2 = require("../network/utils"); const estimateMaxSpendable_1 = require("./estimateMaxSpendable"); const calculateCoinAmount = async ({ account, transaction, operationType, }) => { const estimatedFees = await (0, estimateFees_1.estimateFees)({ currency: account.currency, operationType }); const amount = transaction.useAllAmount ? await (0, estimateMaxSpendable_1.estimateMaxSpendable)({ account, transaction }) : transaction.amount; return { amount, totalSpent: amount.plus(estimatedFees.tinybars), }; }; const calculateTokenAmount = async ({ account, tokenAccount, transaction, }) => { const amount = transaction.useAllAmount ? await (0, estimateMaxSpendable_1.estimateMaxSpendable)({ account: tokenAccount, parentAccount: account, transaction }) : transaction.amount; return { amount, totalSpent: amount, }; }; const calculateAmount = ({ account, transaction, }) => { const subAccount = (0, account_1.findSubAccountById)(account, transaction?.subAccountId || ""); const isTokenTransaction = (0, account_1.isTokenAccount)(subAccount); if (isTokenTransaction) { return calculateTokenAmount({ account, tokenAccount: subAccount, transaction }); } const operationType = (0, utils_1.isTokenAssociateTransaction)(transaction) ? constants_1.HEDERA_OPERATION_TYPES.TokenAssociate : constants_1.HEDERA_OPERATION_TYPES.CryptoTransfer; return calculateCoinAmount({ account, transaction, operationType }); }; exports.calculateAmount = calculateAmount; 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 (0, account_1.decodeTokenAccountId)(tokenOperation.accountId); if (!token) continue; const isTokenListedInCAL = await (0, state_1.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_js_1.default(rawBalance.balance); } else { const rawBalance = mirrorTokens.find(t => t.token_id === token.contractAddress)?.balance; balance = rawBalance === undefined ? null : new bignumber_js_1.default(rawBalance); } if (!balance) { continue; } subAccounts.push({ type: "TokenAccount", id: (0, account_1.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: account_1.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_js_1.default(rawBalance); const isERC20 = "token" in rawToken; const tokenAddress = isERC20 ? rawToken.token.contractAddress : rawToken.token_id; const token = await (0, state_1.getCryptoAssetsStore)().findTokenByAddressInCurrency(tokenAddress, "hedera"); if (!token) { continue; } const id = (0, account_1.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: account_1.emptyHistoryCache, swapHistory: [], }); } return subAccounts; }; exports.getSubAccounts = getSubAccounts; // create NONE coin operation that will be a parent of an orphan child operation const makeCoinOperationForOrphanChildOperation = async (childOperation) => { const type = "NONE"; const { accountId } = await (0, account_1.decodeTokenAccountId)(childOperation.accountId); const id = (0, operation_1.encodeOperationId)(accountId, childOperation.hash, type); return { id, hash: childOperation.hash, type, value: new bignumber_js_1.default(0), fee: new bignumber_js_1.default(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 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 (0, account_1.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; }; exports.prepareOperations = prepareOperations; /** * 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 */ 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] = (0, jsHelpers_1.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]; }; exports.mergeSubAccounts = mergeSubAccounts; 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 (!(0, utils_1.isValidExtra)(op.extra)) return op; if (!(0, utils_1.isValidExtra)(pendingOp.extra)) return op; return { ...op, extra: { ...pendingOp.extra, ...op.extra, }, }; }); }; exports.applyPendingExtras = applyPendingExtras; 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 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; }); }; exports.removeDuplicatedContractCallOperations = removeDuplicatedContractCallOperations; // 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 const classifyERC20Operations = ({ latestERC20Operations, operationsByHash, evmAccountAddress, }) => { const erc20OperationsToPatch = new Map(); const erc20OperationsToAdd = new Map(); for (const erc20Operation of latestERC20Operations) { const hash = (0, utils_1.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 }; }; exports.classifyERC20Operations = classifyERC20Operations; // TODO: remove once migration to new API is complete // extracts common fields from an ERC20 operation const buildERC20OperationFields = ({ erc20Operation, relatedExistingOperation, variant, evmAddress, }) => { const decodedParams = (0, utils_2.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 = (0, bignumber_js_1.default)(erc20Operation.mirrorTransaction.charged_tx_fee); const value = (0, bignumber_js_1.default)(decodedParams.value); const senderAddress = (0, utils_1.fromEVMAddress)(decodedParams.from) ?? decodedParams.from; const recipientAddress = (0, utils_1.fromEVMAddress)(decodedParams.to) ?? decodedParams.to; const memo = (0, utils_1.getMemoFromBase64)(erc20Operation.mirrorTransaction.memo_base64); const extra = { ...((0, utils_1.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 const patchContractCallOperation = ({ relatedExistingOperation, ledgerAccountId, hash, erc20Fields, tokenOperation, }) => { Object.assign(relatedExistingOperation, { ...erc20Fields, id: (0, operation_1.encodeOperationId)(ledgerAccountId, hash, "FEES"), type: "FEES", value: erc20Fields.fee, subOperations: [tokenOperation], }); }; exports.patchContractCallOperation = patchContractCallOperation; // TODO: remove once migration to new API is complete const integrateERC20Operations = async ({ ledgerAccountId, address, allOperations, latestERC20Transactions, pendingOperationHashes, erc20OperationHashes, }) => { const newERC20TokenOperations = []; const [latestERC20Operations, evmAddress] = await Promise.all([ (0, utils_2.getERC20Operations)(latestERC20Transactions), (0, utils_1.toEVMAddress)(address), ]); // avoid duplicated CONTRACT_CALL operations if ERC20 operations are already present const uniqueOperations = (0, exports.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 } = (0, exports.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 = (0, account_1.encodeTokenAccountId)(ledgerAccountId, erc20Operation.token); const encodedOperationId = (0, operation_1.encodeOperationId)(encodedTokenAccountId, hash, erc20Fields.type); const tokenOperation = { ...erc20Fields, id: encodedOperationId, accountId: encodedTokenAccountId, hash, }; (0, exports.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 = (0, account_1.encodeTokenAccountId)(ledgerAccountId, erc20Operation.token); const encodedOperationId = (0, operation_1.encodeOperationId)(encodedTokenAccountId, hash, erc20Fields.type); const tokenOperation = { ...erc20Fields, id: encodedOperationId, accountId: encodedTokenAccountId, hash, }; const coinOperation = erc20Fields.type === "OUT" ? { ...erc20Fields, id: (0, operation_1.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, }; }; exports.integrateERC20Operations = integrateERC20Operations; //# sourceMappingURL=utils.js.map