@ledgerhq/coin-algorand
Version:
Ledger Algorand Coin integration
395 lines (353 loc) • 12.2 kB
text/typescript
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 });