UNPKG

@ledgerhq/coin-algorand

Version:
395 lines (353 loc) 12.2 kB
import { emptyHistoryCache, encodeAccountId } from "@ledgerhq/coin-framework/account"; import { inferSubOperations } from "@ledgerhq/coin-framework/serialization"; import type { GetAccountShape } from "@ledgerhq/coin-framework/bridge/jsHelpers"; import { makeSync, mergeOps } from "@ledgerhq/coin-framework/bridge/jsHelpers"; import { findTokenById, listTokensForCryptoCurrency } from "@ledgerhq/cryptoassets/index"; import { encodeOperationId } from "@ledgerhq/coin-framework/operation"; import { promiseAllBatched } from "@ledgerhq/live-promise"; import { BigNumber } from "bignumber.js"; import algorandAPI, { type AlgoAsset, type AlgoAssetTransferInfo, type AlgoPaymentInfo, type AlgoTransaction, } from "./api"; import { AlgoTransactionType } from "./api"; import { CryptoCurrency, TokenCurrency } from "@ledgerhq/types-cryptoassets"; import type { SyncConfig, Account, TokenAccount, OperationType } from "@ledgerhq/types-live"; import { AlgorandAccount, AlgorandOperation } from "./types"; import { computeAlgoMaxSpendable } from "./logic"; import { addPrefixToken, extractTokenId } from "./tokens"; const getASAOperationAmount = (transaction: AlgoTransaction, accountAddress: string): BigNumber => { let assetAmount = new BigNumber(0); if (transaction.type === AlgoTransactionType.ASSET_TRANSFER) { const details = transaction.details as AlgoAssetTransferInfo; const assetSender = details.assetSenderAddress ? details.assetSenderAddress : transaction.senderAddress; // Account is either sender or recipient (if both the balance is unchanged) if ((assetSender === accountAddress) !== (details.assetRecipientAddress === accountAddress)) { assetAmount = assetAmount.plus(details.assetAmount); } // Account is either sender or close-to, but not both if ( (assetSender === accountAddress) !== (details.assetCloseToAddress && details.assetCloseToAddress === accountAddress) ) { if (details.assetCloseAmount) { assetAmount = assetAmount.plus(details.assetCloseAmount); } } } return assetAmount; }; const getOperationAmounts = ( transaction: AlgoTransaction, accountAddress: string, ): { amount: BigNumber; rewards: BigNumber } => { let amount = new BigNumber(0); let rewards = new BigNumber(0); if (transaction.senderAddress === accountAddress) { const senderRewards = transaction.senderRewards; amount = amount.minus(senderRewards).plus(transaction.fee); rewards = rewards.plus(senderRewards); } if (transaction.type === AlgoTransactionType.PAYMENT) { const details = transaction.details as AlgoPaymentInfo; if (transaction.senderAddress === details.recipientAddress) { return { amount, rewards, }; } if (transaction.senderAddress === accountAddress) { amount = amount.plus(details.amount); } if (details.recipientAddress === accountAddress) { const recipientRewards = transaction.recipientRewards; amount = amount.plus(details.amount).plus(recipientRewards); rewards = rewards.plus(recipientRewards); } if ( transaction.closeRewards && details.closeAmount && details.closeToAddress === accountAddress ) { const closeRewards = transaction.closeRewards; amount = amount.plus(details.closeAmount).plus(closeRewards); rewards = rewards.plus(closeRewards); } } return { amount, rewards, }; }; const getASAOperationType = ( transaction: AlgoTransaction, accountAddress: string, ): OperationType => { return transaction.senderAddress === accountAddress ? "OUT" : "IN"; }; const getOperationType = (transaction: AlgoTransaction, accountAddress: string): OperationType => { if (transaction.type === AlgoTransactionType.ASSET_TRANSFER) { const details = transaction.details as AlgoAssetTransferInfo; if ( details.assetAmount.isZero() && transaction.senderAddress === details.assetRecipientAddress ) { return "OPT_IN"; } else if (details.assetCloseToAddress && transaction.senderAddress === accountAddress) { return "OPT_OUT"; } else { return "FEES"; } } return transaction.senderAddress === accountAddress ? "OUT" : "IN"; }; const getOperationSenders = (transaction: AlgoTransaction): string[] => { return [transaction.senderAddress]; }; const getOperationRecipients = (transaction: AlgoTransaction): string[] => { const recipients: string[] = []; if (transaction.type === AlgoTransactionType.PAYMENT) { const details = transaction.details as AlgoPaymentInfo; recipients.push(details.recipientAddress); if (details.closeToAddress) { recipients.push(details.closeToAddress); } } else if (transaction.type === AlgoTransactionType.ASSET_TRANSFER) { const details = transaction.details as AlgoAssetTransferInfo; recipients.push(details.assetRecipientAddress); if (details.assetCloseToAddress) { recipients.push(details.assetCloseToAddress); } } return recipients; }; const getOperationAssetId = (transaction: AlgoTransaction): string | undefined => { if (transaction.type === AlgoTransactionType.ASSET_TRANSFER) { const details = transaction.details as AlgoAssetTransferInfo; return details.assetId; } }; const mapTransactionToOperation = ( tx: AlgoTransaction, accountId: string, accountAddress: string, subAccounts?: TokenAccount[], ): AlgorandOperation => { const hash = tx.id; const blockHeight = tx.round; const date = new Date(parseInt(tx.timestamp) * 1000); const fee = tx.fee; const memo = tx.note; const senders: string[] = getOperationSenders(tx); const recipients: string[] = getOperationRecipients(tx); const { amount, rewards } = getOperationAmounts(tx, accountAddress); const type = getOperationType(tx, accountAddress); const assetId = getOperationAssetId(tx); const subOperations = subAccounts ? inferSubOperations(tx.id, subAccounts) : []; return { id: encodeOperationId(accountId, hash, type), hash, date, type, value: amount, fee, senders, recipients, blockHeight, blockHash: null, accountId, subOperations, extra: { rewards, memo, assetId, }, }; }; const mapTransactionToASAOperation = ( tx: AlgoTransaction, accountId: string, accountAddress: string, ): AlgorandOperation => { const hash = tx.id; const blockHeight = tx.round; const date = new Date(parseInt(tx.timestamp) * 1000); const fee = tx.fee; const senders: string[] = getOperationSenders(tx); const recipients: string[] = getOperationRecipients(tx); const type = getASAOperationType(tx, accountAddress); const amount = getASAOperationAmount(tx, accountAddress); return { id: encodeOperationId(accountId, hash, type), hash, date, type, value: amount, fee, senders, recipients, blockHeight, blockHash: null, accountId, extra: {}, }; }; export const getAccountShape: GetAccountShape<AlgorandAccount> = async (info, syncConfig) => { const { address, initialAccount, currency, derivationMode } = info; const oldOperations = initialAccount?.operations || []; const startAt = oldOperations.length ? (oldOperations[0].blockHeight || 0) + 1 : 0; const accountId = encodeAccountId({ type: "js", version: "2", currencyId: currency.id, xpubOrAddress: address, derivationMode, }); const { round, balance, pendingRewards, assets } = await algorandAPI.getAccount(address); const nbAssets = assets.length; // NOTE Actual spendable amount depends on the transaction const spendableBalance = computeAlgoMaxSpendable({ accountBalance: balance, nbAccountAssets: nbAssets, mode: "send", }); const newTransactions: AlgoTransaction[] = await algorandAPI.getAccountTransactions( address, startAt, ); const subAccounts = await buildSubAccounts({ currency, accountId, initialAccount, initialAccountAddress: address, assets, newTransactions, syncConfig, }); const newOperations = newTransactions.map(tx => mapTransactionToOperation(tx, accountId, address, subAccounts), ); const operations = mergeOps(oldOperations, newOperations); return { id: accountId, xpub: address, blockHeight: round, balance, spendableBalance, operations, operationsCount: operations.length, subAccounts: subAccounts || [], algorandResources: { rewards: pendingRewards, nbAssets, }, }; }; async function buildSubAccount({ parentAccountId, parentAccountAddress, token, initialTokenAccount, newTransactions, balance, }: { parentAccountId: string; parentAccountAddress: string; token: TokenCurrency; initialTokenAccount: TokenAccount; newTransactions: AlgoTransaction[]; balance: BigNumber; }) { const extractedId = extractTokenId(token.id); const tokenAccountId = parentAccountId + "+" + extractedId; const oldOperations = initialTokenAccount?.operations || []; const newOperations = newTransactions .filter(tx => tx.type === AlgoTransactionType.ASSET_TRANSFER) .filter(tx => { const details = tx.details as AlgoAssetTransferInfo; return Number(details.assetId) === Number(extractedId); }) .filter(tx => getOperationType(tx, parentAccountAddress) !== "OPT_IN") .map(tx => mapTransactionToASAOperation(tx, tokenAccountId, parentAccountAddress)); const operations = mergeOps(oldOperations, newOperations); const tokenAccount: TokenAccount = { type: "TokenAccount", id: tokenAccountId, parentId: parentAccountId, token, operationsCount: operations.length, operations, pendingOperations: [], balance, spendableBalance: balance, swapHistory: [], creationDate: operations.length > 0 ? operations[operations.length - 1].date : new Date(), balanceHistoryCache: emptyHistoryCache, }; return tokenAccount; } async function buildSubAccounts({ currency, accountId, initialAccount, initialAccountAddress, assets, newTransactions, syncConfig, }: { currency: CryptoCurrency; accountId: string; initialAccount: Account | null | undefined; initialAccountAddress: string; assets: AlgoAsset[]; newTransactions: AlgoTransaction[]; syncConfig: SyncConfig; }): Promise<TokenAccount[] | undefined> { const { blacklistedTokenIds = [] } = syncConfig; if (listTokensForCryptoCurrency(currency).length === 0) return undefined; const tokenAccounts: TokenAccount[] = []; const existingAccountByTicker: { [ticker: string]: TokenAccount } = {}; // used for fast lookup const existingAccountTickers: string[] = []; // used to keep track of ordering if (initialAccount && initialAccount.subAccounts) { for (const existingSubAccount of initialAccount.subAccounts) { if (existingSubAccount.type === "TokenAccount") { const { ticker, id } = existingSubAccount.token; if (!blacklistedTokenIds.includes(id)) { existingAccountTickers.push(ticker); existingAccountByTicker[ticker] = existingSubAccount; } } } } // filter by token existence await promiseAllBatched(3, assets, async asset => { const token = findTokenById(addPrefixToken(asset.assetId)); if (token && !blacklistedTokenIds.includes(token.id)) { const initialTokenAccount = existingAccountByTicker[token.ticker]; const tokenAccount = await buildSubAccount({ parentAccountId: accountId, parentAccountAddress: initialAccountAddress, initialTokenAccount, token, newTransactions, balance: asset.balance, }); if (tokenAccount) tokenAccounts.push(tokenAccount); } }); // Preserve order of tokenAccounts from the existing token accounts tokenAccounts.sort((a, b) => { const i = existingAccountTickers.indexOf(a.token.ticker); const j = existingAccountTickers.indexOf(b.token.ticker); if (i === j) return 0; if (i < 0) return 1; if (j < 0) return -1; return i - j; }); return tokenAccounts; } export const sync = makeSync({ getAccountShape });