@ledgerhq/coin-algorand
Version:
Ledger Algorand Coin integration
258 lines (249 loc) • 9.41 kB
text/typescript
import { isAccountEmpty } from "@ledgerhq/coin-framework/account";
import { botTest, genericTestDestination, pickSiblings } from "@ledgerhq/coin-framework/bot/specs";
import type { AppSpec } from "@ledgerhq/coin-framework/bot/types";
import { getCryptoCurrencyById, listTokensForCryptoCurrency } from "@ledgerhq/cryptoassets/index";
import { parseCurrencyUnit } from "@ledgerhq/coin-framework/currencies";
import { DeviceModelId } from "@ledgerhq/devices";
import { Account, AccountLike, TokenAccount } from "@ledgerhq/types-live";
import { BigNumber } from "bignumber.js";
import expect from "expect";
import invariant from "invariant";
import sample from "lodash/sample";
import { acceptTransaction } from "./speculos-deviceActions";
import { extractTokenId } from "./tokens";
import type { AlgorandAccount, AlgorandTransaction } from "./types";
const currency = getCryptoCurrencyById("algorand");
// Minimum balance required for a new non-ASA account
const minBalanceNewAccount = parseCurrencyUnit(currency.units[0], "0.1");
// Ensure that, when the recipient corresponds to an empty account,
// the amount to send is greater or equal to the required minimum
// balance for such a recipient
const checkSendableToEmptyAccount = (amount: BigNumber, recipient: AccountLike) => {
if (isAccountEmpty(recipient) && amount.lte(minBalanceNewAccount)) {
invariant(amount.gt(minBalanceNewAccount), "not enough funds to send to new account");
}
};
// Get list of ASAs associated with the account
const getAssetsWithBalance = (account: Account): TokenAccount[] => {
return account.subAccounts
? account.subAccounts.filter(a => a.type === "TokenAccount" && a.balance.gt(0))
: [];
};
const pickSiblingsOptedIn = (siblings: Account[], assetId: string): Account | undefined => {
return sample(
siblings.filter(a => {
return a.subAccounts?.some(sa => sa.type === "TokenAccount" && sa.token.id.endsWith(assetId));
}),
);
};
// TODO: rework to perform _difference_ between
// array of valid ASAs and array of ASAs currently
// being opted-in by an account
const getRandomAssetId = (account: Account): string | undefined => {
const optedInASA = account.subAccounts?.reduce((old: string[], current: TokenAccount) => {
if (current.type === "TokenAccount") {
return [...old, current.token.id];
}
return old;
}, []);
const ASAs = listTokensForCryptoCurrency(account.currency).map(asa => asa.id);
const diff = ASAs?.filter(asa => !optedInASA?.includes(asa));
invariant(diff && diff.length > 0, "already got all optin");
return sample(diff);
};
const algorand: AppSpec<AlgorandTransaction> = {
name: "Algorand",
currency,
appQuery: {
model: DeviceModelId.nanoS,
appName: "Algorand",
},
genericDeviceAction: acceptTransaction,
mutations: [
{
name: "move ~50%",
feature: "send",
maxRun: 1,
testDestination: genericTestDestination,
transaction: ({ account, siblings, bridge, maxSpendable }) => {
invariant(maxSpendable.gt(0), "Spendable balance is too low");
const sibling = pickSiblings(siblings, 4);
const recipient = sibling.freshAddress;
const transaction = bridge.createTransaction(account);
const amount = maxSpendable.div(1.9 + 0.2 * Math.random()).integerValue();
checkSendableToEmptyAccount(amount, sibling);
const updates = [
{
amount,
},
{
recipient,
},
];
return {
transaction,
updates,
};
},
test: ({ account, accountBeforeTransaction, operation }) => {
const rewards =
(accountBeforeTransaction as AlgorandAccount).algorandResources?.rewards || 0;
botTest("account balance moved with the operation value", () =>
expect(account.balance.plus(rewards).toString()).toBe(
accountBeforeTransaction.balance.minus(operation.value).toString(),
),
);
},
},
{
name: "send max",
feature: "sendMax",
maxRun: 1,
testDestination: genericTestDestination,
transaction: ({ account, siblings, bridge, maxSpendable }) => {
invariant(maxSpendable.gt(0), "Spendable balance is too low");
const sibling = pickSiblings(siblings, 4);
// Send the full spendable balance
const amount = maxSpendable;
checkSendableToEmptyAccount(amount, sibling);
return {
transaction: bridge.createTransaction(account),
updates: [
{
recipient: sibling.freshAddress,
},
{
useAllAmount: true,
},
],
};
},
test: ({ account }) => {
// Ensure that there is no more than 20 μALGOs (discretionary value)
// between the actual balance and the expected one to take into account
// the eventual pending rewards added _after_ the transaction
botTest("account spendable balance is very low", () =>
expect(account.spendableBalance.lt(20)).toBe(true),
);
},
},
{
name: "send ASA ~50%",
feature: "tokens",
maxRun: 1,
transaction: ({ account, siblings, bridge, maxSpendable }) => {
invariant(maxSpendable.gt(0), "Spendable balance is too low");
const subAccount = sample(getAssetsWithBalance(account));
invariant(subAccount && subAccount.type === "TokenAccount", "no subAccount with ASA");
const assetId = subAccount.token.id;
const sibling = pickSiblingsOptedIn(siblings, assetId);
const transaction = bridge.createTransaction(account);
const recipient = (sibling as Account).freshAddress;
const mode = "send";
const amount = subAccount.balance.div(1.9 + 0.2 * Math.random()).integerValue();
const updates: Array<Partial<AlgorandTransaction>> = [
{
mode,
subAccountId: subAccount.id,
},
{
recipient,
},
{
amount,
},
];
return {
transaction,
updates,
};
},
test: ({ account, accountBeforeTransaction, transaction, status }) => {
const subAccountId = transaction.subAccountId;
const subAccount = account.subAccounts?.find(sa => sa.id === subAccountId);
const subAccountBeforeTransaction = accountBeforeTransaction.subAccounts?.find(
sa => sa.id === subAccountId,
);
botTest("subAccount balance moved with the tx status amount", () =>
expect(subAccount?.balance.toString()).toBe(
subAccountBeforeTransaction?.balance.minus(status.amount).toString(),
),
);
},
},
{
name: "opt-In ASA available",
feature: "tokens",
maxRun: 1,
transaction: ({ account, bridge, maxSpendable }) => {
// maxSpendable is expected to be greater than 100,000 micro-Algos
// corresponding to the requirement that the main account will have
// one more ASA after the opt-in; its minimum balance is updated accordingly
invariant(maxSpendable.gt(new BigNumber(100000)), "Spendable balance is too low");
const transaction = bridge.createTransaction(account);
const mode = "optIn";
const assetId = getRandomAssetId(account);
const subAccount = account.subAccounts
? account.subAccounts.find(a => a.id.includes(assetId as string))
: null;
invariant(!subAccount, "already opt-in");
const updates: Array<Partial<AlgorandTransaction>> = [
{
mode,
},
{
assetId,
},
];
return {
transaction,
updates,
};
},
// eslint-disable-next-line no-unused-vars
test: ({ account, transaction }) => {
invariant(transaction.assetId, "should have an assetId");
const assetId = extractTokenId(transaction.assetId as string);
botTest("have sub account with asset id", () =>
expect(account.subAccounts && account.subAccounts.some(a => a.id.endsWith(assetId))).toBe(
true,
),
);
},
},
{
name: "claim rewards",
feature: "staking",
maxRun: 1,
transaction: ({ account, bridge, maxSpendable }) => {
const rewards = (account as AlgorandAccount).algorandResources?.rewards;
invariant(rewards && rewards.gt(0), "No pending rewards");
// Ensure that the rewards can effectively be claimed
// (fees have to be paid in order to claim the rewards)
invariant(maxSpendable.gt(0), "Spendable balance is too low");
const transaction = bridge.createTransaction(account);
const mode = "claimReward";
const updates: Array<Partial<AlgorandTransaction>> = [
{
mode,
},
];
return {
transaction,
updates,
};
},
test: ({ account }) => {
botTest("algoResources rewards is zero", () =>
expect(
(account as AlgorandAccount).algorandResources &&
(account as AlgorandAccount).algorandResources.rewards.eq(0),
).toBe(true),
);
},
},
],
};
export default {
algorand,
};