@ledgerhq/coin-algorand
Version:
Ledger Algorand Coin integration
398 lines (330 loc) • 12.1 kB
text/typescript
import { DeviceModelId } from "@ledgerhq/devices";
import type { Account } from "@ledgerhq/types-live";
import { BigNumber } from "bignumber.js";
import specs from "./specs";
import type { AlgorandAccount } from "./types";
// Mock dependencies
jest.mock("@ledgerhq/ledger-wallet-framework/account", () => ({
isAccountEmpty: jest.fn((account: Account) => account.balance?.isZero() ?? true),
}));
jest.mock("@ledgerhq/ledger-wallet-framework/bot/specs", () => ({
botTest: jest.fn((name, fn) => fn()),
genericTestDestination: jest.fn(),
pickSiblings: jest.fn(siblings => siblings[0]),
}));
jest.mock("@ledgerhq/cryptoassets/index", () => ({
getCryptoCurrencyById: jest.fn(() => ({
id: "algorand",
ticker: "ALGO",
units: [{ code: "ALGO", magnitude: 6 }],
})),
}));
jest.mock("@ledgerhq/coin-module-framework/currencies", () => ({
parseCurrencyUnit: jest.fn(() => new BigNumber("100000")),
}));
jest.mock("./speculos-deviceActions", () => ({
acceptTransaction: jest.fn(),
}));
describe("specs", () => {
const { algorand } = specs;
describe("algorand AppSpec", () => {
it("should have correct name", () => {
expect(algorand.name).toBe("Algorand");
});
it("should have currency defined", () => {
expect(algorand.currency).not.toBeUndefined();
expect(algorand.currency.id).toBe("algorand");
});
it("should have appQuery for nanoS", () => {
expect(algorand.appQuery).toEqual({
model: DeviceModelId.nanoS,
appName: "Algorand",
});
});
it("should have genericDeviceAction", () => {
expect(algorand.genericDeviceAction).not.toBeUndefined();
});
it("should have mutations array", () => {
expect(algorand.mutations).not.toBeUndefined();
expect(Array.isArray(algorand.mutations)).toBe(true);
});
});
describe("mutations", () => {
const findMutation = (name: string) => algorand.mutations.find(m => m.name === name);
describe("move ~50%", () => {
const mutation = findMutation("move ~50%");
it("should exist", () => {
expect(mutation).not.toBeUndefined();
});
it("should have feature 'send'", () => {
expect(mutation?.feature).toBe("send");
});
it("should have testDestination", () => {
expect(mutation?.testDestination).not.toBeUndefined();
});
it("should create transaction with amount ~50% of maxSpendable", () => {
const account = { freshAddress: "SENDER" } as Account;
const sibling = { freshAddress: "RECIPIENT", balance: new BigNumber("1000000") } as Account;
const bridge = {
createTransaction: jest.fn(() => ({ family: "algorand" })),
};
const maxSpendable = new BigNumber("2000000");
const result = mutation?.transaction?.({
account,
siblings: [sibling],
bridge,
maxSpendable,
} as never);
expect(result?.transaction).not.toBeUndefined();
expect(result?.updates).toHaveLength(2);
expect(result?.updates[0].amount).not.toBeUndefined();
expect(result?.updates[1].recipient).toBe("RECIPIENT");
});
it("should have test function", () => {
expect(mutation?.test).not.toBeUndefined();
});
});
describe("send max", () => {
const mutation = findMutation("send max");
it("should exist", () => {
expect(mutation).not.toBeUndefined();
});
it("should have feature 'sendMax'", () => {
expect(mutation?.feature).toBe("sendMax");
});
it("should create transaction with useAllAmount true", () => {
const account = { freshAddress: "SENDER" } as Account;
const sibling = { freshAddress: "RECIPIENT", balance: new BigNumber("1000000") } as Account;
const bridge = {
createTransaction: jest.fn(() => ({ family: "algorand" })),
};
const maxSpendable = new BigNumber("2000000");
const result = mutation?.transaction?.({
account,
siblings: [sibling],
bridge,
maxSpendable,
} as never);
expect(result?.updates).toContainEqual({ useAllAmount: true });
});
it("should have test function that checks spendable balance is low", () => {
expect(mutation?.test).not.toBeUndefined();
});
});
describe("send ASA ~50%", () => {
const mutation = findMutation("send ASA ~50%");
it("should exist", () => {
expect(mutation).not.toBeUndefined();
});
it("should have feature 'tokens'", () => {
expect(mutation?.feature).toBe("tokens");
});
it("should have test function", () => {
expect(mutation?.test).not.toBeUndefined();
});
});
describe("opt-In ASA available", () => {
const mutation = findMutation("opt-In ASA available");
it("should exist", () => {
expect(mutation).not.toBeUndefined();
});
it("should have feature 'tokens'", () => {
expect(mutation?.feature).toBe("tokens");
});
it("should have test function", () => {
expect(mutation?.test).not.toBeUndefined();
});
});
describe("claim rewards", () => {
const mutation = findMutation("claim rewards");
it("should exist", () => {
expect(mutation).not.toBeUndefined();
});
it("should have feature 'staking'", () => {
expect(mutation?.feature).toBe("staking");
});
it("should create transaction with claimReward mode", () => {
const account = {
freshAddress: "SENDER",
algorandResources: {
rewards: new BigNumber("1000"),
nbAssets: 0,
},
} as AlgorandAccount;
const bridge = {
createTransaction: jest.fn(() => ({ family: "algorand" })),
};
const maxSpendable = new BigNumber("1000000");
const result = mutation?.transaction?.({
account,
bridge,
maxSpendable,
} as never);
expect(result?.updates).toContainEqual({ mode: "claimReward" });
});
it("should have test function that checks rewards are zero", () => {
expect(mutation?.test).not.toBeUndefined();
});
});
});
describe("mutation count", () => {
it("should have 5 mutations", () => {
expect(algorand.mutations).toHaveLength(5);
});
it("should have all expected mutations", () => {
const mutationNames = algorand.mutations.map(m => m.name);
expect(mutationNames).toContain("move ~50%");
expect(mutationNames).toContain("send max");
expect(mutationNames).toContain("send ASA ~50%");
expect(mutationNames).toContain("opt-In ASA available");
expect(mutationNames).toContain("claim rewards");
});
});
describe("helper functions behavior", () => {
describe("move ~50% mutation transaction", () => {
const mutation = algorand.mutations.find(m => m.name === "move ~50%");
it("should throw when maxSpendable is 0", () => {
const account = { freshAddress: "SENDER" } as Account;
const sibling = { freshAddress: "RECIPIENT", balance: new BigNumber("1000000") } as Account;
const bridge = {
createTransaction: jest.fn(() => ({ family: "algorand" })),
};
const maxSpendable = new BigNumber("0");
expect(() =>
mutation?.transaction?.({
account,
siblings: [sibling],
bridge,
maxSpendable,
} as never),
).toThrow("Spendable balance is too low");
});
});
describe("send max mutation transaction", () => {
const mutation = algorand.mutations.find(m => m.name === "send max");
it("should throw when maxSpendable is 0", () => {
const account = { freshAddress: "SENDER" } as Account;
const sibling = { freshAddress: "RECIPIENT", balance: new BigNumber("1000000") } as Account;
const bridge = {
createTransaction: jest.fn(() => ({ family: "algorand" })),
};
const maxSpendable = new BigNumber("0");
expect(() =>
mutation?.transaction?.({
account,
siblings: [sibling],
bridge,
maxSpendable,
} as never),
).toThrow("Spendable balance is too low");
});
});
describe("claim rewards mutation transaction", () => {
const mutation = algorand.mutations.find(m => m.name === "claim rewards");
it("should throw when no pending rewards", () => {
const account = {
freshAddress: "SENDER",
algorandResources: {
rewards: new BigNumber("0"),
nbAssets: 0,
},
} as AlgorandAccount;
const bridge = {
createTransaction: jest.fn(() => ({ family: "algorand" })),
};
const maxSpendable = new BigNumber("1000000");
expect(() =>
mutation?.transaction?.({
account,
bridge,
maxSpendable,
} as never),
).toThrow("No pending rewards");
});
it("should throw when maxSpendable is 0", () => {
const account = {
freshAddress: "SENDER",
algorandResources: {
rewards: new BigNumber("1000"),
nbAssets: 0,
},
} as AlgorandAccount;
const bridge = {
createTransaction: jest.fn(() => ({ family: "algorand" })),
};
const maxSpendable = new BigNumber("0");
expect(() =>
mutation?.transaction?.({
account,
bridge,
maxSpendable,
} as never),
).toThrow("Spendable balance is too low");
});
});
describe("opt-In ASA mutation transaction", () => {
const mutation = algorand.mutations.find(m => m.name === "opt-In ASA available");
it("should throw when maxSpendable is too low for opt-in", () => {
const account = {
freshAddress: "SENDER",
subAccounts: [],
} as unknown as Account;
const bridge = {
createTransaction: jest.fn(() => ({ family: "algorand" })),
};
const maxSpendable = new BigNumber("50000"); // Less than 100000 required
expect(() =>
mutation?.transaction?.({
account,
bridge,
maxSpendable,
} as never),
).toThrow("Spendable balance is too low");
});
});
});
describe("test functions", () => {
describe("move ~50% test", () => {
const mutation = algorand.mutations.find(m => m.name === "move ~50%");
it("should validate balance change", () => {
const account = { balance: new BigNumber("4000000") } as Account;
const accountBeforeTransaction = {
balance: new BigNumber("5000000"),
algorandResources: { rewards: new BigNumber("0") },
} as AlgorandAccount;
const operation = { value: new BigNumber("1000000") };
expect(() =>
mutation?.test?.({
account,
accountBeforeTransaction,
operation,
} as never),
).not.toThrow();
});
});
describe("send max test", () => {
const mutation = algorand.mutations.find(m => m.name === "send max");
it("should validate spendable balance is low", () => {
const account = { spendableBalance: new BigNumber("10") } as Account;
expect(() =>
mutation?.test?.({
account,
} as never),
).not.toThrow();
});
});
describe("claim rewards test", () => {
const mutation = algorand.mutations.find(m => m.name === "claim rewards");
it("should validate rewards are zero after claim", () => {
const account = {
algorandResources: { rewards: new BigNumber("0") },
} as AlgorandAccount;
expect(() =>
mutation?.test?.({
account,
} as never),
).not.toThrow();
});
});
});
});