@ledgerhq/coin-hedera
Version:
Ledger Hedera Coin integration
377 lines (325 loc) • 12.4 kB
text/typescript
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 type { TokenCurrency } from "@ledgerhq/types-cryptoassets";
import type { Account, Operation, TokenAccount } from "@ledgerhq/types-live";
import BigNumber from "bignumber.js";
import { HEDERA_OPERATION_TYPES } from "../constants";
import { estimateFees } from "../logic/estimateFees";
import { isTokenAssociateTransaction, isValidExtra } from "../logic/utils";
import type {
HederaOperationExtra,
Transaction,
HederaMirrorToken,
HederaERC20TokenBalance,
} from "../types";
import { estimateMaxSpendable } from "./estimateMaxSpendable";
interface CalculateAmountResult {
amount: BigNumber;
totalSpent: BigNumber;
}
const calculateCoinAmount = async ({
account,
transaction,
operationType,
}: {
account: Account;
transaction: Transaction;
operationType: Exclude<HEDERA_OPERATION_TYPES, HEDERA_OPERATION_TYPES.ContractCall>;
}): Promise<CalculateAmountResult> => {
const estimatedFees = await estimateFees({ currencyId: account.currency.id, operationType });
const amount = transaction.useAllAmount
? await estimateMaxSpendable({ account, transaction })
: transaction.amount;
return {
amount,
totalSpent: amount.plus(estimatedFees.tinybars),
};
};
const calculateTokenAmount = async ({
account,
tokenAccount,
transaction,
}: {
account: Account;
tokenAccount: TokenAccount;
transaction: Transaction;
}): Promise<CalculateAmountResult> => {
const amount = transaction.useAllAmount
? await estimateMaxSpendable({ account: tokenAccount, parentAccount: account, transaction })
: transaction.amount;
return {
amount,
totalSpent: amount,
};
};
export const calculateAmount = ({
account,
transaction,
}: {
account: Account;
transaction: Transaction;
}): Promise<CalculateAmountResult> => {
const subAccount = findSubAccountById(account, transaction?.subAccountId || "");
const isTokenTransaction = isTokenAccount(subAccount);
if (isTokenTransaction) {
return calculateTokenAmount({ account, tokenAccount: subAccount, transaction });
}
const operationType: HEDERA_OPERATION_TYPES = isTokenAssociateTransaction(transaction)
? HEDERA_OPERATION_TYPES.TokenAssociate
: HEDERA_OPERATION_TYPES.CryptoTransfer;
return calculateCoinAmount({ account, transaction, operationType });
};
export const getSubAccounts = async ({
ledgerAccountId,
latestTokenOperations,
mirrorTokens,
erc20Tokens,
}: {
ledgerAccountId: string;
latestTokenOperations: Operation[];
mirrorTokens: HederaMirrorToken[];
erc20Tokens: HederaERC20TokenBalance[];
}): Promise<TokenAccount[]> => {
// Creating a Map of Operations by TokenCurrencies in order to know which TokenAccounts should be synced as well
const operationsByToken = new Map<TokenCurrency, Operation[]>();
const subAccounts: TokenAccount[] = [];
for (const tokenOperation of latestTokenOperations) {
const { token } = await decodeTokenAccountId(tokenOperation.accountId);
if (!token) continue;
const isTokenListedInCAL = await getCryptoAssetsStore().findTokenByAddressInCurrency(
token.contractAddress,
token.parentCurrencyId,
);
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: BigNumber | null = 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;
};
type CoinOperationForOrphanChildOperation = Operation<HederaOperationExtra> &
Required<Pick<Operation, "subOperations">>;
// create NONE coin operation that will be a parent of an orphan child operation
const makeCoinOperationForOrphanChildOperation = async (
childOperation: Operation<HederaOperationExtra>,
): Promise<CoinOperationForOrphanChildOperation> => {
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: Operation<HederaOperationExtra>[],
tokenOperations: Operation<HederaOperationExtra>[],
): Promise<Operation<HederaOperationExtra>[]> => {
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: Record<string, CoinOperationForOrphanChildOperation[]> = {};
preparedCoinOperations.forEach(op => {
if (!coinOperationsByHash[op.hash]) {
coinOperationsByHash[op.hash] = [];
}
op.subOperations = [];
coinOperationsByHash[op.hash].push(op as CoinOperationForOrphanChildOperation);
});
// 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 },
] as const satisfies { name: string; isOps: boolean }[];
/**
* In charge of smartly merging sub accounts while maintaining references as much as possible
*/
export const mergeSubAccounts = (
initialAccount: Account | undefined,
newSubAccounts: TokenAccount[],
): Array<TokenAccount> => {
const oldSubAccounts: Array<TokenAccount> | undefined = initialAccount?.subAccounts;
if (!oldSubAccounts) {
return newSubAccounts;
}
// map of already existing sub accounts by id
const oldSubAccountsById: Record<string, TokenAccount> = {};
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: TokenAccount[] = [];
for (const newSubAccount of newSubAccounts) {
const duplicatedAccount: TokenAccount | undefined = oldSubAccountsById[newSubAccount.id];
if (!duplicatedAccount) {
newSubAccountsToAdd.push(newSubAccount);
continue;
}
const updates: Partial<TokenAccount> = {};
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: Operation[], pending: Operation[]) => {
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: Operation,
extra: HederaOperationExtra,
): Operation {
return {
...operation,
extra,
subOperations: (operation.subOperations ?? []).map(op => ({ ...op, extra })),
nftOperations: (operation.nftOperations ?? []).map(op => ({ ...op, extra })),
};
}