@ledgerhq/coin-stacks
Version:
Ledger Stacks Coin integration
230 lines (198 loc) • 6.92 kB
text/typescript
import {
emptyHistoryCache,
encodeAccountId,
encodeTokenAccountId,
} from "@ledgerhq/coin-framework/account/index";
import { GetAccountShape, makeSync } from "@ledgerhq/coin-framework/bridge/jsHelpers";
import { Account, TokenAccount } from "@ledgerhq/types-live";
import { getAddressFromPublicKey } from "@stacks/transactions";
import BigNumber from "bignumber.js";
import invariant from "invariant";
import {
fetchAllTokenBalances,
fetchBalances,
fetchBlockHeight,
fetchFullMempoolTxs,
fetchFullTxs,
} from "../network/api";
import {
mapPendingTxToOps,
mapTxToOps,
reconciliatePublicKey,
sip010TxnToOperation,
sip010OpToParentOp,
} from "./utils/misc";
import { log } from "@ledgerhq/logs";
import { getCryptoAssetsStore } from "@ledgerhq/cryptoassets/state";
import { TransactionResponse } from "../network";
/**
* Calculates the spendable balance by subtracting pending transactions from the total balance
*/
export function calculateSpendableBalance(
totalBalance: BigNumber,
pendingTxs: Array<{ fee_rate: string; token_transfer: { amount: string } }>,
): BigNumber {
let spendableBalance = totalBalance;
for (const tx of pendingTxs) {
spendableBalance = spendableBalance
.minus(new BigNumber(tx.fee_rate))
.minus(new BigNumber(tx.token_transfer.amount));
}
return spendableBalance;
}
/**
* Creates a token account for a specific token
*/
export async function createTokenAccount(
address: string,
parentAccountId: string,
tokenId: string,
tokenBalance: string,
transactionsList: TransactionResponse[],
initialAccount?: Account,
): Promise<TokenAccount | null> {
try {
const token = await getCryptoAssetsStore().findTokenByAddressInCurrency(tokenId, "stacks");
if (!tokenId || !token) {
log("error", `stacks token not found, addr: ${tokenId}`);
return null;
}
const bnBalance = new BigNumber(tokenBalance || "0");
const tokenAccountId = encodeTokenAccountId(parentAccountId, token);
// Process operations for this token
const operations = transactionsList
.flatMap(txn => sip010TxnToOperation(txn, address, tokenAccountId))
.flat()
.sort((a, b) => b.date.getTime() - a.date.getTime());
// Skip empty accounts with zero balance and no operations
if (operations.length === 0 && bnBalance.isZero()) {
return null;
}
// Preserve existing pending operations if available
const maybeExistingSubAccount = initialAccount?.subAccounts?.find(a => a.id === tokenAccountId);
const tokenAccount: TokenAccount = {
type: "TokenAccount",
id: tokenAccountId,
parentId: parentAccountId,
token,
balance: bnBalance,
spendableBalance: bnBalance,
operationsCount: operations.length,
operations,
pendingOperations: maybeExistingSubAccount?.pendingOperations ?? [],
creationDate: operations.length > 0 ? operations[operations.length - 1].date : new Date(),
swapHistory: maybeExistingSubAccount?.swapHistory ?? [],
balanceHistoryCache: emptyHistoryCache, // calculated in the jsHelpers
};
return tokenAccount;
} catch (e) {
log("error", "stacks error creating token account", e);
return null;
}
}
/**
* Builds token accounts for all tokens with transactions or balances
*/
export async function buildTokenAccounts(
address: string,
parentAccountId: string,
tokenTxs: Record<string, TransactionResponse[]>,
tokenBalances: Record<string, string>,
initialAccount?: Account,
): Promise<TokenAccount[]> {
try {
const tokenAccounts: TokenAccount[] = [];
// Process all tokens that have transactions
for (const [tokenId, transactions] of Object.entries(tokenTxs)) {
const balance = tokenBalances[tokenId] || "0";
const tokenAccount = await createTokenAccount(
address,
parentAccountId,
tokenId,
balance,
transactions,
initialAccount,
);
if (tokenAccount) {
tokenAccounts.push(tokenAccount);
}
}
// Process any tokens with balances but no transactions
for (const [tokenId, balance] of Object.entries(tokenBalances)) {
// Skip tokens we've already processed
if (tokenTxs[tokenId]) continue;
// Skip zero balances
if (new BigNumber(balance).isZero()) continue;
const tokenAccount = await createTokenAccount(
address,
parentAccountId,
tokenId,
balance,
[], // No transactions
initialAccount,
);
if (tokenAccount) {
tokenAccounts.push(tokenAccount);
}
}
return tokenAccounts;
} catch (e) {
log("error", "stacks error building token accounts", e);
return [];
}
}
export const getAccountShape: GetAccountShape = async info => {
const { initialAccount, currency, rest = {}, derivationMode } = info;
// for bridge tests specifically the `rest` object is empty and therefore the publicKey is undefined
// reconciliatePublicKey tries to get pubKey from rest object and then from accountId
const pubKey = reconciliatePublicKey(rest.publicKey, initialAccount);
invariant(pubKey, "publicKey is required");
const accountId: string = encodeAccountId({
type: "js",
version: "2",
currencyId: currency.id,
xpubOrAddress: pubKey,
derivationMode,
});
const address = getAddressFromPublicKey(pubKey);
// Make API calls in parallel for better performance
const [blockHeight, balanceResp, txsResult, tokenBalances, mempoolTxs] = await Promise.all([
fetchBlockHeight(),
fetchBalances(address),
fetchFullTxs(address),
fetchAllTokenBalances(address),
fetchFullMempoolTxs(address),
]);
const [rawTxs, tokenTxs] = txsResult;
const balance = new BigNumber(balanceResp.balance);
// Calculate spendable balance by considering pending transactions
const spendableBalance = calculateSpendableBalance(balance, mempoolTxs);
// Process pending operations
const pendingOperations = mempoolTxs.flatMap(mapPendingTxToOps(accountId, address));
// Process operations from confirmed transactions
const operations = pendingOperations.concat(rawTxs.flatMap(mapTxToOps(accountId, address)));
// Build token sub-accounts
const tokenAccounts = await buildTokenAccounts(
address,
accountId,
tokenTxs,
tokenBalances,
initialAccount,
);
const result: Partial<Account> = {
id: accountId,
subAccounts: tokenAccounts,
xpub: pubKey,
freshAddress: address,
balance,
spendableBalance,
// merge operations from both token and account
operations: [
...operations,
...tokenAccounts.flatMap(t => sip010OpToParentOp(t.operations, accountId)),
].sort((a, b) => b.date.getTime() - a.date.getTime()),
blockHeight: blockHeight.chain_tip.block_height,
};
return result;
};
export const sync = makeSync({ getAccountShape });