@ledgerhq/coin-hedera
Version:
Ledger Hedera Coin integration
470 lines • 21.6 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.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