@ledgerhq/coin-algorand
Version:
Ledger Algorand Coin integration
609 lines (557 loc) • 20.3 kB
text/typescript
import { setCryptoAssetsStore } from "@ledgerhq/cryptoassets/state";
import BigNumber from "bignumber.js";
// Mock the network module
jest.mock("./network", () => ({
getAccount: jest.fn(),
getAllAccountTransactions: jest.fn(),
AlgoTransactionType: {
PAYMENT: "pay",
ASSET_TRANSFER: "axfer",
},
}));
import * as network from "./network";
import { AlgoTransactionType } from "./network";
import { getAccountShape, sync } from "./synchronization";
describe("Synchronization", () => {
beforeAll(() => {
setCryptoAssetsStore({
findTokenById: async (id: string) => {
switch (id) {
case "algorand/asa/1":
return { id, ticker: "USDC" };
case "algorand/asa/2":
return { id, ticker: "USDT" };
case "algorand/asa/12345":
return { id, ticker: "TEST" };
default:
return undefined;
}
},
} as never);
});
beforeEach(() => {
jest.clearAllMocks();
});
describe("getAccountShape", () => {
describe("basic account synchronization", () => {
it("should return correct account shape for new account", async () => {
jest.spyOn(network, "getAccount").mockResolvedValue({
round: 50000000,
balance: new BigNumber("10000000"),
pendingRewards: new BigNumber("5000"),
assets: [],
} as never);
jest.spyOn(network, "getAllAccountTransactions").mockResolvedValue([]);
const result = await getAccountShape(
{
address: "ALGO_ADDRESS",
initialAccount: null,
currency: { id: "algorand" },
derivationMode: "",
} as never,
{} as never,
);
expect(result.id).toBe("js:2:algorand:ALGO_ADDRESS:");
expect(result.xpub).toBe("ALGO_ADDRESS");
expect(result.blockHeight).toBe(50000000);
expect(result.balance.toString()).toBe("10000000");
expect(result.algorandResources?.rewards.toString()).toBe("5000");
expect(result.algorandResources?.nbAssets).toBe(0);
});
it("should calculate spendable balance correctly", async () => {
jest.spyOn(network, "getAccount").mockResolvedValue({
round: 50000000,
balance: new BigNumber("1000000"), // 1 ALGO
pendingRewards: new BigNumber("0"),
assets: [],
} as never);
jest.spyOn(network, "getAllAccountTransactions").mockResolvedValue([]);
const result = await getAccountShape(
{
address: "ALGO_ADDRESS",
initialAccount: null,
currency: { id: "algorand" },
derivationMode: "",
} as never,
{} as never,
);
// 1 ALGO - 0.1 ALGO min balance = 0.9 ALGO
expect(result.spendableBalance.toString()).toBe("900000");
});
it("should account for assets in spendable balance", async () => {
jest.spyOn(network, "getAccount").mockResolvedValue({
round: 50000000,
balance: new BigNumber("1000000"),
pendingRewards: new BigNumber("0"),
assets: [
{ assetId: "1", balance: new BigNumber("100") },
{ assetId: "2", balance: new BigNumber("200") },
],
} as never);
jest.spyOn(network, "getAllAccountTransactions").mockResolvedValue([]);
const result = await getAccountShape(
{
address: "ALGO_ADDRESS",
initialAccount: null,
currency: { id: "algorand" },
derivationMode: "",
} as never,
{} as never,
);
// 1 ALGO - 0.1 ALGO base - 0.2 ALGO for 2 assets = 0.7 ALGO
expect(result.spendableBalance.toString()).toBe("700000");
expect(result.algorandResources?.nbAssets).toBe(2);
});
});
describe("payment transactions", () => {
it("should map outgoing payment transaction", async () => {
const accountAddress = "SENDER_ADDRESS";
jest.spyOn(network, "getAccount").mockResolvedValue({
round: 50000000,
balance: new BigNumber("5000000"),
pendingRewards: new BigNumber("0"),
assets: [],
} as never);
jest.spyOn(network, "getAllAccountTransactions").mockResolvedValue([
{
id: "TX_123",
type: AlgoTransactionType.PAYMENT,
senderAddress: accountAddress,
timestamp: "1700000000",
round: 49999000,
fee: new BigNumber("1000"),
senderRewards: new BigNumber("100"),
recipientRewards: new BigNumber("0"),
note: "test payment",
details: {
amount: new BigNumber("1000000"),
recipientAddress: "RECIPIENT_ADDRESS",
},
},
]);
const result = await getAccountShape(
{
address: accountAddress,
initialAccount: null,
currency: { id: "algorand" },
derivationMode: "",
} as never,
{} as never,
);
expect(result.operations).toHaveLength(1);
expect(result.operations[0].type).toBe("OUT");
expect(result.operations[0].senders).toEqual([accountAddress]);
expect(result.operations[0].recipients).toEqual(["RECIPIENT_ADDRESS"]);
expect(result.operations[0].extra.memo).toBe("test payment");
expect(result.operations[0].extra.rewards?.toString()).toBe("100");
});
it("should map incoming payment transaction", async () => {
const accountAddress = "RECIPIENT_ADDRESS";
jest.spyOn(network, "getAccount").mockResolvedValue({
round: 50000000,
balance: new BigNumber("5000000"),
pendingRewards: new BigNumber("0"),
assets: [],
} as never);
jest.spyOn(network, "getAllAccountTransactions").mockResolvedValue([
{
id: "TX_456",
type: AlgoTransactionType.PAYMENT,
senderAddress: "SENDER_ADDRESS",
timestamp: "1700000000",
round: 49999000,
fee: new BigNumber("1000"),
senderRewards: new BigNumber("0"),
recipientRewards: new BigNumber("50"),
details: {
amount: new BigNumber("500000"),
recipientAddress: accountAddress,
},
},
]);
const result = await getAccountShape(
{
address: accountAddress,
initialAccount: null,
currency: { id: "algorand" },
derivationMode: "",
} as never,
{} as never,
);
expect(result.operations[0].type).toBe("IN");
expect(result.operations[0].extra.rewards?.toString()).toBe("50");
});
it("should handle payment with close-to address", async () => {
const accountAddress = "CLOSE_TO_ADDRESS";
jest.spyOn(network, "getAccount").mockResolvedValue({
round: 50000000,
balance: new BigNumber("5000000"),
pendingRewards: new BigNumber("0"),
assets: [],
} as never);
jest.spyOn(network, "getAllAccountTransactions").mockResolvedValue([
{
id: "TX_CLOSE",
type: AlgoTransactionType.PAYMENT,
senderAddress: "SENDER_ADDRESS",
timestamp: "1700000000",
round: 49999000,
fee: new BigNumber("1000"),
senderRewards: new BigNumber("0"),
recipientRewards: new BigNumber("0"),
closeRewards: new BigNumber("200"),
details: {
amount: new BigNumber("100000"),
recipientAddress: "RECIPIENT_ADDRESS",
closeAmount: new BigNumber("400000"),
closeToAddress: accountAddress,
},
},
]);
const result = await getAccountShape(
{
address: accountAddress,
initialAccount: null,
currency: { id: "algorand" },
derivationMode: "",
} as never,
{} as never,
);
expect(result.operations[0].type).toBe("IN");
expect(result.operations[0].recipients).toContain(accountAddress);
});
it("should handle self-payment (sender equals recipient)", async () => {
const accountAddress = "SELF_ADDRESS";
jest.spyOn(network, "getAccount").mockResolvedValue({
round: 50000000,
balance: new BigNumber("5000000"),
pendingRewards: new BigNumber("0"),
assets: [],
} as never);
jest.spyOn(network, "getAllAccountTransactions").mockResolvedValue([
{
id: "TX_SELF",
type: AlgoTransactionType.PAYMENT,
senderAddress: accountAddress,
timestamp: "1700000000",
round: 49999000,
fee: new BigNumber("1000"),
senderRewards: new BigNumber("100"),
recipientRewards: new BigNumber("0"),
details: {
amount: new BigNumber("100000"),
recipientAddress: accountAddress,
},
},
]);
const result = await getAccountShape(
{
address: accountAddress,
initialAccount: null,
currency: { id: "algorand" },
derivationMode: "",
} as never,
{} as never,
);
expect(result.operations[0].type).toBe("OUT");
});
});
describe("asset transfer transactions", () => {
it("should map OPT_IN transaction", async () => {
const accountAddress = "OPT_IN_ADDRESS";
jest.spyOn(network, "getAccount").mockResolvedValue({
round: 50000000,
balance: new BigNumber("5000000"),
pendingRewards: new BigNumber("0"),
assets: [{ assetId: "12345", balance: new BigNumber("0") }],
} as never);
jest.spyOn(network, "getAllAccountTransactions").mockResolvedValue([
{
id: "TX_OPTIN",
type: AlgoTransactionType.ASSET_TRANSFER,
senderAddress: accountAddress,
timestamp: "1700000000",
round: 49999000,
fee: new BigNumber("1000"),
senderRewards: new BigNumber("0"),
recipientRewards: new BigNumber("0"),
details: {
assetAmount: new BigNumber("0"),
assetId: "12345",
assetRecipientAddress: accountAddress,
assetSenderAddress: accountAddress,
},
},
]);
const result = await getAccountShape(
{
address: accountAddress,
initialAccount: null,
currency: { id: "algorand" },
derivationMode: "",
} as never,
{} as never,
);
expect(result.operations[0].type).toBe("OPT_IN");
expect(result.operations[0].extra.assetId).toBe("12345");
});
it("should map OPT_OUT transaction", async () => {
const accountAddress = "OPT_OUT_ADDRESS";
jest.spyOn(network, "getAccount").mockResolvedValue({
round: 50000000,
balance: new BigNumber("5000000"),
pendingRewards: new BigNumber("0"),
assets: [],
} as never);
jest.spyOn(network, "getAllAccountTransactions").mockResolvedValue([
{
id: "TX_OPTOUT",
type: AlgoTransactionType.ASSET_TRANSFER,
senderAddress: accountAddress,
timestamp: "1700000000",
round: 49999000,
fee: new BigNumber("1000"),
senderRewards: new BigNumber("0"),
recipientRewards: new BigNumber("0"),
details: {
assetAmount: new BigNumber("100"),
assetId: "12345",
assetRecipientAddress: "RECIPIENT_ADDRESS",
assetCloseToAddress: "CLOSE_TO_ADDRESS",
assetCloseAmount: new BigNumber("50"),
},
},
]);
const result = await getAccountShape(
{
address: accountAddress,
initialAccount: null,
currency: { id: "algorand" },
derivationMode: "",
} as never,
{} as never,
);
expect(result.operations[0].type).toBe("OPT_OUT");
});
it("should map FEES type for regular asset transfer from sender", async () => {
const accountAddress = "SENDER_ADDRESS";
jest.spyOn(network, "getAccount").mockResolvedValue({
round: 50000000,
balance: new BigNumber("5000000"),
pendingRewards: new BigNumber("0"),
assets: [{ assetId: "12345", balance: new BigNumber("500") }],
} as never);
jest.spyOn(network, "getAllAccountTransactions").mockResolvedValue([
{
id: "TX_ASA",
type: AlgoTransactionType.ASSET_TRANSFER,
senderAddress: accountAddress,
timestamp: "1700000000",
round: 49999000,
fee: new BigNumber("1000"),
senderRewards: new BigNumber("0"),
recipientRewards: new BigNumber("0"),
details: {
assetAmount: new BigNumber("100"),
assetId: "12345",
assetRecipientAddress: "RECIPIENT_ADDRESS",
},
},
]);
const result = await getAccountShape(
{
address: accountAddress,
initialAccount: null,
currency: { id: "algorand" },
derivationMode: "",
} as never,
{} as never,
);
expect(result.operations[0].type).toBe("FEES");
});
});
describe("token account ordering", () => {
it("preserves the order of existing token accounts", async () => {
jest.spyOn(network, "getAccount").mockResolvedValue({
round: 50000000,
balance: new BigNumber(10),
pendingRewards: new BigNumber(0),
assets: [
{ assetId: "1", balance: new BigNumber(4) },
{ assetId: "2", balance: new BigNumber(5) },
],
} as never);
jest.spyOn(network, "getAllAccountTransactions").mockResolvedValue([]);
const result = await getAccountShape(
{
address: "address",
initialAccount: {
subAccounts: [
{
id: "js:2:algorand:address:+2",
type: "TokenAccount",
token: { id: "algorand/asa/2", ticker: "USDT" },
},
{
id: "js:2:algorand:address:+1",
type: "TokenAccount",
token: { id: "algorand/asa/1", ticker: "USDC" },
},
],
},
currency: { id: "algorand" },
derivationMode: "",
} as never,
{} as never,
);
expect(result).toMatchObject({
id: "js:2:algorand:address:",
xpub: "address",
balance: new BigNumber(10),
subAccounts: [
expect.objectContaining({ id: "js:2:algorand:address:+2" }),
expect.objectContaining({ id: "js:2:algorand:address:+1" }),
],
});
});
it("uses the order of input assets if no token accounts exist yet", async () => {
jest.spyOn(network, "getAccount").mockResolvedValue({
round: 50000000,
balance: new BigNumber(10),
pendingRewards: new BigNumber(0),
assets: [
{ assetId: "1", balance: new BigNumber(4) },
{ assetId: "2", balance: new BigNumber(5) },
],
} as never);
jest.spyOn(network, "getAllAccountTransactions").mockResolvedValue([]);
const result = await getAccountShape(
{
address: "address",
initialAccount: {
subAccounts: [],
},
currency: { id: "algorand" },
derivationMode: "",
} as never,
{} as never,
);
expect(result).toMatchObject({
id: "js:2:algorand:address:",
xpub: "address",
balance: new BigNumber(10),
subAccounts: [
expect.objectContaining({ id: "js:2:algorand:address:+1" }),
expect.objectContaining({ id: "js:2:algorand:address:+2" }),
],
});
});
});
describe("incremental sync", () => {
it("should merge new operations with existing ones", async () => {
const accountAddress = "ALGO_ADDRESS";
jest.spyOn(network, "getAccount").mockResolvedValue({
round: 50000100,
balance: new BigNumber("6000000"),
pendingRewards: new BigNumber("0"),
assets: [],
} as never);
jest.spyOn(network, "getAllAccountTransactions").mockResolvedValue([
{
id: "TX_NEW",
type: AlgoTransactionType.PAYMENT,
senderAddress: "OTHER_ADDRESS",
timestamp: "1700000100",
round: 50000050,
fee: new BigNumber("1000"),
senderRewards: new BigNumber("0"),
recipientRewards: new BigNumber("0"),
details: {
amount: new BigNumber("1000000"),
recipientAddress: accountAddress,
},
},
]);
const existingOperation = {
id: "op-old",
hash: "TX_OLD",
type: "IN",
blockHeight: 49999000,
date: new Date("2023-11-01"),
};
const result = await getAccountShape(
{
address: accountAddress,
initialAccount: {
operations: [existingOperation],
subAccounts: [],
},
currency: { id: "algorand" },
derivationMode: "",
} as never,
{} as never,
);
expect(result.operations.length).toBeGreaterThanOrEqual(1);
expect(result.operationsCount).toBe(result.operations.length);
});
it("should start sync from last known block height", async () => {
const accountAddress = "ALGO_ADDRESS";
jest.spyOn(network, "getAccount").mockResolvedValue({
round: 50000100,
balance: new BigNumber("5000000"),
pendingRewards: new BigNumber("0"),
assets: [],
} as never);
const getAllAccountTransactionsSpy = jest
.spyOn(network, "getAllAccountTransactions")
.mockResolvedValue([]);
await getAccountShape(
{
address: accountAddress,
initialAccount: {
operations: [{ blockHeight: 50000000 }],
subAccounts: [],
},
currency: { id: "algorand" },
derivationMode: "",
} as never,
{} as never,
);
// Should start from blockHeight + 1
expect(getAllAccountTransactionsSpy).toHaveBeenCalledWith(accountAddress, 50000001);
});
});
describe("blacklisted tokens", () => {
it("should exclude blacklisted token accounts", async () => {
jest.spyOn(network, "getAccount").mockResolvedValue({
round: 50000000,
balance: new BigNumber("5000000"),
pendingRewards: new BigNumber("0"),
assets: [
{ assetId: "1", balance: new BigNumber("100") },
{ assetId: "2", balance: new BigNumber("200") },
],
} as never);
jest.spyOn(network, "getAllAccountTransactions").mockResolvedValue([]);
const result = await getAccountShape(
{
address: "ALGO_ADDRESS",
initialAccount: null,
currency: { id: "algorand" },
derivationMode: "",
} as never,
{
blacklistedTokenIds: ["algorand/asa/1"],
} as never,
);
expect(result.subAccounts).toHaveLength(1);
expect(result.subAccounts?.[0].token.ticker).toBe("USDT");
});
});
});
describe("sync", () => {
it("should be a function created by makeSync", () => {
expect(typeof sync).toBe("function");
});
});
});