UNPKG

@ledgerhq/coin-canton

Version:
478 lines (400 loc) 16.2 kB
import { AmountRequired, FeeNotLoaded, FeeTooHigh, InvalidAddress, NotEnoughBalanceBecauseDestinationNotCreated, NotEnoughSpendableBalance, RecipientRequired, } from "@ledgerhq/ledger-wallet-framework/errors"; import BigNumber from "bignumber.js"; import coinConfig from "../config"; import * as gateway from "../network/gateway"; import { createMockCantonAccount } from "../test/fixtures"; import { CantonAccount, TooManyUtxosCritical, TooManyUtxosWarning, Transaction } from "../types"; import { TopologyChangeError } from "../types/errors"; import { getTransactionStatus, TO_MANY_UTXOS_CRITICAL_COUNT, TO_MANY_UTXOS_WARNING_COUNT, } from "./getTransactionStatus"; jest.mock("../config", () => ({ getCoinConfig: jest.fn() })); jest.mock("../network/gateway"); const mockCoinConfig = jest.mocked(coinConfig); const mockedGateway = gateway as jest.Mocked<typeof gateway>; describe("getTransactionStatus", () => { const mockAccount: CantonAccount = { ...createMockCantonAccount({ balance: new BigNumber(1000), spendableBalance: new BigNumber(1000), freshAddress: "test::33333333333333333333333333333333333333333333333333333333333333333333", xpub: "test-party-id", }), cantonResources: { isOnboarded: true, pendingTransferProposals: [], publicKey: "test-public-key", instrumentUtxoCounts: { Amulet: 5, }, }, }; beforeEach(() => { jest.clearAllMocks(); mockCoinConfig.getCoinConfig.mockReturnValue({ minReserve: 100, networkType: "mainnet", status: { type: "active" }, nativeInstrumentId: "Amulet", }); mockedGateway.isTopologyChangeRequiredCached.mockResolvedValue(false); }); describe("fee validation", () => { it("should return FeeNotLoaded error when fee is null", async () => { const transaction: Transaction = { family: "canton", amount: new BigNumber(100), recipient: "valid::11111111111111111111111111111111111111111111111111111111111111111111", fee: null, tokenId: "", }; const result = await getTransactionStatus(mockAccount, transaction); expect(result.errors.fee).toBeInstanceOf(FeeNotLoaded); expect(result.warnings).toEqual({}); }); it("should return FeeNotLoaded error when fee is undefined", async () => { const transaction: Transaction = { family: "canton", amount: new BigNumber(100), recipient: "valid::11111111111111111111111111111111111111111111111111111111111111111111", fee: undefined, tokenId: "", }; const result = await getTransactionStatus(mockAccount, transaction); expect(result.errors.fee).toBeInstanceOf(FeeNotLoaded); expect(result.warnings).toEqual({}); }); it("should add FeeTooHigh warning when fee is more than 10 times the amount", async () => { const transaction: Transaction = { family: "canton", amount: new BigNumber(100), recipient: "valid::11111111111111111111111111111111111111111111111111111111111111111111", fee: new BigNumber(1500), tokenId: "", }; const result = await getTransactionStatus(mockAccount, transaction); expect(result.warnings.feeTooHigh).toBeInstanceOf(FeeTooHigh); // Don't check for empty errors since there might be balance issues }); it("should not add FeeTooHigh warning when fee is reasonable", async () => { const transaction: Transaction = { family: "canton", amount: new BigNumber(100), recipient: "valid::11111111111111111111111111111111111111111111111111111111111111111111", fee: new BigNumber(10), tokenId: "", }; const result = await getTransactionStatus(mockAccount, transaction); expect(result.warnings).toEqual({}); expect(result.errors).toEqual({}); }); }); describe("balance validation", () => { it("should return NotEnoughSpendableBalance error when total spent exceeds balance minus reserve", async () => { const transaction: Transaction = { family: "canton", amount: mockAccount.balance .minus(new BigNumber(String(mockCoinConfig.getCoinConfig().minReserve ?? 0))) .plus(1), recipient: "valid::11111111111111111111111111111111111111111111111111111111111111111111", fee: new BigNumber(10), tokenId: "", }; const result = await getTransactionStatus(mockAccount, transaction); expect(result.errors.amount).toBeInstanceOf(NotEnoughSpendableBalance); }); it("should return NotEnoughBalanceBecauseDestinationNotCreated error when amount is below reserve", async () => { const transaction: Transaction = { family: "canton", amount: new BigNumber(String(mockCoinConfig.getCoinConfig().minReserve ?? 0)).minus(1), recipient: "valid::11111111111111111111111111111111111111111111111111111111111111111111", fee: new BigNumber(10), tokenId: "", }; const result = await getTransactionStatus(mockAccount, transaction); expect(result.errors.amount).toBeInstanceOf(NotEnoughBalanceBecauseDestinationNotCreated); }); it("should pass balance validation when transaction is within limits", async () => { const transaction: Transaction = { family: "canton", amount: mockAccount.balance.multipliedBy(0.5), recipient: "valid::11111111111111111111111111111111111111111111111111111111111111111111", fee: new BigNumber(10), tokenId: "", }; const result = await getTransactionStatus(mockAccount, transaction); expect(result.errors.amount).toBeUndefined(); }); }); describe("recipient validation", () => { it("should return RecipientRequired error when recipient is missing", async () => { const transaction: Transaction = { family: "canton", amount: new BigNumber(100), recipient: "", fee: new BigNumber(10), tokenId: "", }; const result = await getTransactionStatus(mockAccount, transaction); expect(result.errors.recipient).toBeInstanceOf(RecipientRequired); }); it("should not return error when sending to self", async () => { const transaction: Transaction = { family: "canton", amount: new BigNumber(100), recipient: mockAccount.freshAddress, fee: new BigNumber(10), tokenId: "", }; const result = await getTransactionStatus(mockAccount, transaction); expect(result.errors.recipient).toBeUndefined(); }); it("should return InvalidAddress error when recipient is invalid", async () => { const transaction: Transaction = { family: "canton", amount: new BigNumber(100), recipient: "invalid-address", fee: new BigNumber(10), tokenId: "", }; const result = await getTransactionStatus(mockAccount, transaction); expect(result.errors.recipient).toBeInstanceOf(InvalidAddress); }); it("should pass recipient validation when recipient is valid", async () => { const transaction: Transaction = { family: "canton", amount: new BigNumber(100), recipient: "valid::22222222222222222222222222222222222222222222222222222222222222222222", fee: new BigNumber(10), tokenId: "", }; const result = await getTransactionStatus(mockAccount, transaction); expect(result.errors.recipient).toBeUndefined(); }); }); describe("amount validation", () => { it("should return AmountRequired error when amount is zero", async () => { mockCoinConfig.getCoinConfig.mockReturnValue({ minReserve: 0, networkType: "mainnet", status: { type: "active" }, nativeInstrumentId: "Amulet", }); const transaction: Transaction = { family: "canton", amount: new BigNumber(0), recipient: "valid::11111111111111111111111111111111111111111111111111111111111111111111", fee: new BigNumber(10), tokenId: "", }; const result = await getTransactionStatus(mockAccount, transaction); expect(result.errors.amount).toBeInstanceOf(AmountRequired); }); it("should not return AmountRequired error when amount is positive", async () => { const transaction: Transaction = { family: "canton", amount: new BigNumber(100), recipient: "valid::11111111111111111111111111111111111111111111111111111111111111111111", fee: new BigNumber(10), tokenId: "", }; const result = await getTransactionStatus(mockAccount, transaction); expect(result.errors.amount).toBeUndefined(); }); }); describe("return values", () => { it("should return correct estimatedFees, amount, and totalSpent", async () => { const transaction: Transaction = { family: "canton", amount: new BigNumber(100), recipient: "valid::11111111111111111111111111111111111111111111111111111111111111111111", fee: new BigNumber(10), tokenId: "", }; const result = await getTransactionStatus(mockAccount, transaction); expect(result.estimatedFees).toEqual(new BigNumber(10)); expect(result.amount).toEqual(new BigNumber(100)); expect(result.totalSpent).toEqual(new BigNumber(110)); }); it("should return empty errors and warnings when transaction is valid", async () => { const transaction: Transaction = { family: "canton", amount: new BigNumber(100), recipient: "valid::11111111111111111111111111111111111111111111111111111111111111111111", fee: new BigNumber(10), tokenId: "", }; const result = await getTransactionStatus(mockAccount, transaction); expect(result.errors).toEqual({}); expect(result.warnings).toEqual({}); }); }); describe("UTXO count validation", () => { it("should show critical warning when UTXO count exceeds TO_MANY_UTXOS_CRITICAL_COUNT", async () => { const accountWithTooManyUtxos = { ...mockAccount, cantonResources: { ...mockAccount.cantonResources, instrumentUtxoCounts: { Amulet: TO_MANY_UTXOS_CRITICAL_COUNT + 1, }, }, }; const transaction: Transaction = { family: "canton", amount: new BigNumber(50), recipient: "valid::11111111111111111111111111111111111111111111111111111111111111111111", fee: new BigNumber(10), tokenId: "Amulet", }; const result = await getTransactionStatus(accountWithTooManyUtxos, transaction); expect(result.warnings).toMatchObject({ tooManyUtxos: new TooManyUtxosCritical(), }); }); it("should show warning when UTXO count exceeds TO_MANY_UTXOS_WARNING_COUNT", async () => { const accountWithManyUtxos = { ...mockAccount, cantonResources: { ...mockAccount.cantonResources, instrumentUtxoCounts: { Amulet: TO_MANY_UTXOS_WARNING_COUNT + 1, }, }, }; const transaction: Transaction = { family: "canton", amount: new BigNumber(50), recipient: "valid::11111111111111111111111111111111111111111111111111111111111111111111", fee: new BigNumber(10), tokenId: "Amulet", }; const result = await getTransactionStatus(accountWithManyUtxos, transaction); expect(result.warnings).toMatchObject({ tooManyUtxos: new TooManyUtxosWarning("families.canton.tooManyUtxos.warning"), }); }); it("should not show warning or error when UTXO count is less than TO_MANY_UTXOS_WARNING_COUNT", async () => { const accountWithFewUtxos = { ...mockAccount, cantonResources: { ...mockAccount.cantonResources, instrumentUtxoCounts: { Amulet: TO_MANY_UTXOS_WARNING_COUNT - 1, }, }, }; const transaction: Transaction = { family: "canton", amount: new BigNumber(50), recipient: "valid::11111111111111111111111111111111111111111111111111111111111111111111", fee: new BigNumber(10), tokenId: "Amulet", // Use the same tokenId as in cantonResources }; const result = await getTransactionStatus(accountWithFewUtxos, transaction); expect(result.warnings.tooManyUtxos).toBeUndefined(); }); it("should not show warning or error for abandon seed address transactions", async () => { const accountWithManyUtxos = { ...mockAccount, cantonResources: { ...mockAccount.cantonResources, instrumentUtxoCounts: { Amulet: 25, }, }, }; const transaction: Transaction = { family: "canton", amount: new BigNumber(50), recipient: "abandon::ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff", fee: new BigNumber(10), tokenId: "Amulet", }; const result = await getTransactionStatus(accountWithManyUtxos, transaction); expect(result.warnings.tooManyUtxos).toBeUndefined(); expect(result.errors.utxoCount).toBeUndefined(); }); }); describe("topology validation", () => { it("should return TopologyChangeError when isTopologyChangeRequiredCached returns true", async () => { // GIVEN const accountWithPartyId: CantonAccount = { ...mockAccount, xpub: "test-party-id", }; mockedGateway.isTopologyChangeRequiredCached.mockResolvedValue(true); const transaction: Transaction = { family: "canton", amount: new BigNumber(100), recipient: "valid::11111111111111111111111111111111111111111111111111111111111111111111", fee: new BigNumber(10), tokenId: "", }; // WHEN const result = await getTransactionStatus(accountWithPartyId, transaction); // THEN expect(result.errors.topologyChange).toBeInstanceOf(TopologyChangeError); expect(mockedGateway.isTopologyChangeRequiredCached).toHaveBeenCalledWith( accountWithPartyId.currency, "test-public-key", ); }); it("should not return topology error when isTopologyChangeRequiredCached returns false", async () => { // GIVEN const accountWithPartyId: CantonAccount = { ...mockAccount, xpub: "test-party-id", }; mockedGateway.isTopologyChangeRequiredCached.mockResolvedValue(false); const transaction: Transaction = { family: "canton", amount: new BigNumber(100), recipient: "valid::11111111111111111111111111111111111111111111111111111111111111111111", fee: new BigNumber(10), tokenId: "", }; // WHEN const result = await getTransactionStatus(accountWithPartyId, transaction); // THEN expect(result.errors.topologyChange).toBeUndefined(); expect(mockedGateway.isTopologyChangeRequiredCached).toHaveBeenCalledWith( accountWithPartyId.currency, "test-public-key", ); }); it("should not return topology error when isTopologyChangeRequiredCached throws an error", async () => { // GIVEN const accountWithPartyId: CantonAccount = { ...mockAccount, xpub: "test-party-id", }; mockedGateway.isTopologyChangeRequiredCached.mockRejectedValue(new Error("Network error")); const transaction: Transaction = { family: "canton", amount: new BigNumber(100), recipient: "valid::11111111111111111111111111111111111111111111111111111111111111111111", fee: new BigNumber(10), tokenId: "", }; // WHEN const result = await getTransactionStatus(accountWithPartyId, transaction); // THEN expect(result.errors.topologyChange).toBeUndefined(); expect(mockedGateway.isTopologyChangeRequiredCached).toHaveBeenCalledWith( accountWithPartyId.currency, "test-public-key", ); }); }); });