@ledgerhq/coin-hedera
Version:
Ledger Hedera Coin integration
454 lines • 20.1 kB
JavaScript
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