@ledgerhq/coin-hedera
Version:
Ledger Hedera Coin integration
551 lines (463 loc) • 20.5 kB
text/typescript
import {
InvalidAddress,
InvalidAddressBecauseDestinationIsAlsoSource,
AmountRequired,
NotEnoughBalance,
ClaimRewardsFeesWarning,
RecipientRequired,
} from "@ledgerhq/errors";
import * as accountHelpers from "@ledgerhq/ledger-wallet-framework/account";
import BigNumber from "bignumber.js";
import { HEDERA_TRANSACTION_MODES } from "../constants";
import {
HederaInsufficientFundsForAssociation,
HederaInvalidStakingNodeIdError,
HederaNoStakingRewardsError,
HederaRecipientEvmAddressVerificationRequired,
HederaRecipientInvalidChecksum,
HederaRecipientTokenAssociationRequired,
HederaRecipientTokenAssociationUnverified,
HederaRedundantStakingNodeIdError,
HederaMemoExceededSizeError,
} from "../errors";
import * as estimateFees from "../logic/estimateFees";
import * as logicUtils from "../logic/utils";
import { HEDERA_MAX_MEMO_SIZE } from "../logic/validateMemo";
import { rpcClient } from "../network/rpc";
import * as preloadData from "../preload-data";
import { getMockedAccount, getMockedTokenAccount } from "../test/fixtures/account.fixture";
import {
getMockedERC20TokenCurrency,
getMockedHTSTokenCurrency,
} from "../test/fixtures/currency.fixture";
import { getMockedTransaction } from "../test/fixtures/transaction.fixture";
import type { EstimateFeesResult, HederaPreloadData, Transaction } from "../types";
// Mock modules before importing
jest.mock("../logic/estimateFees", () => ({
...jest.requireActual("../logic/estimateFees"),
estimateFees: jest.fn(),
}));
jest.mock("../logic/utils", () => ({
...jest.requireActual("../logic/utils"),
getCurrencyToUSDRate: jest.fn(),
checkAccountTokenAssociationStatus: jest.fn(),
}));
jest.mock("@ledgerhq/ledger-wallet-framework/account", () => {
const actual = jest.requireActual("@ledgerhq/ledger-wallet-framework/account");
return {
...actual,
findSubAccountById: jest.fn(actual.findSubAccountById),
};
});
jest.mock("../preload-data", () => ({
...jest.requireActual("../preload-data"),
getCurrentHederaPreloadData: jest.fn(),
}));
import { getTransactionStatus } from "./getTransactionStatus";
const mockEstimateFees = estimateFees.estimateFees as jest.Mock;
const mockGetCurrencyToUSDRate = logicUtils.getCurrencyToUSDRate as unknown as jest.Mock;
const mockCheckAccountTokenAssociationStatus =
logicUtils.checkAccountTokenAssociationStatus as unknown as jest.Mock;
const mockGetCurrentHederaPreloadData = preloadData.getCurrentHederaPreloadData as jest.Mock;
const mockFindSubAccountById = accountHelpers.findSubAccountById as jest.Mock;
describe("getTransactionStatus", () => {
const mockedEstimatedFee: EstimateFeesResult = { tinybars: new BigNumber(1) };
const mockedUsdRate = new BigNumber(1);
const mockPreload = { validators: [{ nodeId: 1 }, { nodeId: 2 }] } as HederaPreloadData;
const validRecipientAddress = "0.0.1234567";
const validRecipientAddressWithChecksum = "0.0.1234567-ylkls";
beforeEach(() => {
jest.clearAllMocks();
// Use persistent mocks (better for test suites) instead of mockResolvedValueOnce
mockEstimateFees.mockResolvedValue(mockedEstimatedFee);
mockGetCurrencyToUSDRate.mockResolvedValue(mockedUsdRate);
mockGetCurrentHederaPreloadData.mockReturnValue(mockPreload);
// Default: association is verified
mockCheckAccountTokenAssociationStatus.mockResolvedValue(true);
// Reset findSubAccountById to use actual implementation
mockFindSubAccountById.mockImplementation(
jest.requireActual("@ledgerhq/ledger-wallet-framework/account").findSubAccountById,
);
});
afterAll(async () => {
await rpcClient._resetInstance();
});
it("coin transfer with valid recipient and sufficient balance completes successfully", async () => {
const mockedAccount = getMockedAccount({ balance: new BigNumber(1000) });
const mockedTransaction = getMockedTransaction({
recipient: validRecipientAddress,
amount: new BigNumber(100),
});
const result = await getTransactionStatus(mockedAccount, mockedTransaction);
expect(result.errors).toEqual({});
expect(result.warnings).toEqual({});
expect(result.amount).toEqual(new BigNumber(100));
expect(result.totalSpent.isGreaterThan(100)).toBe(true);
});
it("hts token transfer with valid recipient and sufficient balance completes successfully", async () => {
mockCheckAccountTokenAssociationStatus.mockResolvedValueOnce(true);
const tokenCurrency = getMockedHTSTokenCurrency();
const tokenAccount = getMockedTokenAccount(tokenCurrency, { balance: new BigNumber(500) });
const account = getMockedAccount({ balance: new BigNumber(1000), subAccounts: [tokenAccount] });
const transaction = getMockedTransaction({
subAccountId: tokenAccount.id,
recipient: validRecipientAddress,
amount: new BigNumber(200),
});
const result = await getTransactionStatus(account, transaction);
expect(result.errors).toEqual({});
expect(result.warnings).toEqual({});
expect(result.amount).toEqual(new BigNumber(200));
});
it("erc20 token transfer with valid recipient and sufficient balance completes successfully", async () => {
const tokenCurrency = getMockedERC20TokenCurrency();
const tokenAccount = getMockedTokenAccount(tokenCurrency, { balance: new BigNumber(500) });
const account = getMockedAccount({ balance: new BigNumber(1000), subAccounts: [tokenAccount] });
const transaction = getMockedTransaction({
subAccountId: tokenAccount.id,
recipient: validRecipientAddress,
amount: new BigNumber(200),
});
const result = await getTransactionStatus(account, transaction);
expect(result.errors).toEqual({});
expect(result.warnings).toMatchObject({
unverifiedEvmAddress: expect.any(Error),
});
expect(result.amount).toEqual(new BigNumber(200));
});
it("token associate transaction with sufficient USD worth completes successfully", async () => {
const mockedTokenCurrency = getMockedHTSTokenCurrency();
const mockedAccount = getMockedAccount();
const mockedTransaction = getMockedTransaction({
mode: HEDERA_TRANSACTION_MODES.TokenAssociate,
properties: {
token: mockedTokenCurrency,
},
});
const result = await getTransactionStatus(mockedAccount, mockedTransaction);
expect(result.amount).toEqual(new BigNumber(0));
expect(result.errors).toEqual({});
expect(result.warnings).toEqual({});
expect(result.totalSpent).toEqual(mockedEstimatedFee.tinybars);
expect(result.estimatedFees).toEqual(mockedEstimatedFee.tinybars);
});
it("recipient with checksum is supported", async () => {
const mockedAccount = getMockedAccount({ balance: new BigNumber(1000) });
const mockedTransaction = getMockedTransaction({
recipient: validRecipientAddressWithChecksum,
amount: new BigNumber(100),
});
const result = await getTransactionStatus(mockedAccount, mockedTransaction);
expect(result.errors).toEqual({});
expect(result.warnings).toEqual({});
});
it.each([
["undefined", undefined],
["empty", ""],
["short", "aaaaa"],
["exact limit", "a".repeat(HEDERA_MAX_MEMO_SIZE)],
])("allows %s memo", async (_description, memo) => {
const mockedAccount = getMockedAccount();
const mockedTransaction = getMockedTransaction({ memo });
const result = await getTransactionStatus(mockedAccount, mockedTransaction);
expect(result.errors.transaction).toBeUndefined();
});
it.each([
{
mode: HEDERA_TRANSACTION_MODES.Delegate,
description: "delegate",
},
{
mode: HEDERA_TRANSACTION_MODES.Undelegate,
description: "undelegate",
},
{
mode: HEDERA_TRANSACTION_MODES.Redelegate,
description: "redelegate",
},
{
mode: HEDERA_TRANSACTION_MODES.ClaimRewards,
description: "claim rewards",
},
{
mode: HEDERA_TRANSACTION_MODES.Send,
description: "send native",
},
{
mode: HEDERA_TRANSACTION_MODES.Send,
description: "send hts token",
subAccount: getMockedTokenAccount(getMockedHTSTokenCurrency(), { id: "hts-id" }),
},
{
mode: HEDERA_TRANSACTION_MODES.Send,
description: "send erc20 token",
subAccount: getMockedTokenAccount(getMockedERC20TokenCurrency(), { id: "erc20-id" }),
},
{
mode: HEDERA_TRANSACTION_MODES.TokenAssociate,
description: "token associate",
properties: { token: getMockedHTSTokenCurrency() },
},
])("adds error for too long memo - $description", async ({ mode, subAccount, properties }) => {
const tooLongMemo = "a".repeat(HEDERA_MAX_MEMO_SIZE + 1);
const mockedAccount = getMockedAccount();
const mockedTransaction = getMockedTransaction({
mode,
memo: tooLongMemo,
...(subAccount && { subAccountId: subAccount.id }),
...(properties && { properties }),
} as Transaction);
mockFindSubAccountById.mockImplementation(() => {
return subAccount;
});
const result = await getTransactionStatus(mockedAccount, mockedTransaction);
expect(result.errors.transaction).toBeInstanceOf(HederaMemoExceededSizeError);
});
it("adds error for invalid recipient address", async () => {
const mockedAccount = getMockedAccount();
const txWithInvalidAddress1 = getMockedTransaction({ recipient: "" });
const txWithInvalidAddress2 = getMockedTransaction({ recipient: "invalid_address" });
const txWithInvalidAddressChecksum = getMockedTransaction({ recipient: "0.0.9124531-invld" });
const [result1, result2, result3] = await Promise.all([
getTransactionStatus(mockedAccount, txWithInvalidAddress1),
getTransactionStatus(mockedAccount, txWithInvalidAddress2),
getTransactionStatus(mockedAccount, txWithInvalidAddressChecksum),
]);
expect(result1.errors.recipient).toBeInstanceOf(RecipientRequired);
expect(result2.errors.recipient).toBeInstanceOf(InvalidAddress);
expect(result3.errors.recipient).toBeInstanceOf(HederaRecipientInvalidChecksum);
});
it("adds error for self transfers", async () => {
const mockedAccount = getMockedAccount();
const mockedTransaction = getMockedTransaction({
recipient: mockedAccount.freshAddress,
});
const result = await getTransactionStatus(mockedAccount, mockedTransaction);
expect(result.errors.recipient).toBeInstanceOf(InvalidAddressBecauseDestinationIsAlsoSource);
});
it("adds error during coin transfer with insufficient balance", async () => {
const mockedAccount = getMockedAccount({ balance: new BigNumber(0) });
const mockedTransaction = getMockedTransaction({
amount: new BigNumber(100),
recipient: validRecipientAddress,
});
const result = await getTransactionStatus(mockedAccount, mockedTransaction);
expect(result.errors.amount).toBeInstanceOf(NotEnoughBalance);
});
it("adds error if USD balance is too low for token association", async () => {
const mockedTokenCurrency = getMockedHTSTokenCurrency();
const mockedAccount = getMockedAccount({ balance: new BigNumber(0) });
const mockedTransaction = getMockedTransaction({
mode: HEDERA_TRANSACTION_MODES.TokenAssociate,
properties: {
token: mockedTokenCurrency,
},
});
const result = await getTransactionStatus(mockedAccount, mockedTransaction);
expect(result.errors.insufficientAssociateBalance).toBeInstanceOf(
HederaInsufficientFundsForAssociation,
);
});
it("adds warning during token transfer if recipient has no token associated", async () => {
mockCheckAccountTokenAssociationStatus.mockResolvedValueOnce(false);
const mockedTokenCurrency = getMockedHTSTokenCurrency();
const mockedTokenAccount = getMockedTokenAccount(mockedTokenCurrency);
const mockedAccount = getMockedAccount({ subAccounts: [mockedTokenAccount] });
const mockedTransaction = getMockedTransaction({
subAccountId: mockedTokenAccount.id,
recipient: validRecipientAddress,
});
const result = await getTransactionStatus(mockedAccount, mockedTransaction);
expect(result.warnings.missingAssociation).toBeInstanceOf(
HederaRecipientTokenAssociationRequired,
);
});
it("adds evm address verification warning during ERC20 token transfer", async () => {
const mockedTokenCurrency = getMockedERC20TokenCurrency();
const mockedTokenAccount = getMockedTokenAccount(mockedTokenCurrency);
const mockedAccount = getMockedAccount({ subAccounts: [mockedTokenAccount] });
const mockedTransaction = getMockedTransaction({
subAccountId: mockedTokenAccount.id,
recipient: validRecipientAddress,
});
const result = await getTransactionStatus(mockedAccount, mockedTransaction);
expect(result.warnings.unverifiedEvmAddress).toBeInstanceOf(
HederaRecipientEvmAddressVerificationRequired,
);
});
it("adds warning if token association status can't be verified", async () => {
jest
.spyOn(logicUtils, "checkAccountTokenAssociationStatus")
.mockRejectedValueOnce(new HederaRecipientTokenAssociationUnverified());
const mockedTokenCurrency = getMockedHTSTokenCurrency();
const mockedTokenAccount = getMockedTokenAccount(mockedTokenCurrency);
const mockedAccount = getMockedAccount({ subAccounts: [mockedTokenAccount] });
const mockedTransaction = getMockedTransaction({
subAccountId: mockedTokenAccount.id,
recipient: validRecipientAddress,
});
const result = await getTransactionStatus(mockedAccount, mockedTransaction);
expect(result.warnings.unverifiedAssociation).toBeInstanceOf(
HederaRecipientTokenAssociationUnverified,
);
});
it("adds error during token transfer with insufficient balance", async () => {
const mockedTokenCurrency = getMockedHTSTokenCurrency();
const mockedTokenAccount = getMockedTokenAccount(mockedTokenCurrency, {
balance: new BigNumber(0),
});
const mockedAccount = getMockedAccount({ subAccounts: [mockedTokenAccount] });
const mockedTransaction = getMockedTransaction({
subAccountId: mockedTokenAccount.id,
recipient: validRecipientAddress,
amount: new BigNumber(100),
});
const result = await getTransactionStatus(mockedAccount, mockedTransaction);
expect(result.errors.amount).toBeInstanceOf(NotEnoughBalance);
});
it("adds error if amount is zero and useAllAmount is false", async () => {
const mockedAccount = getMockedAccount();
const mockedTransaction = getMockedTransaction({
recipient: validRecipientAddress,
amount: new BigNumber(0),
useAllAmount: false,
});
const result = await getTransactionStatus(mockedAccount, mockedTransaction);
expect(result.errors.amount).toBeInstanceOf(AmountRequired);
});
it("delegate transaction with valid node completes successfully", async () => {
const account = getMockedAccount();
const transaction = getMockedTransaction({
mode: HEDERA_TRANSACTION_MODES.Delegate,
properties: { stakingNodeId: 1 },
});
const result = await getTransactionStatus(account, transaction);
expect(result.errors).toEqual({});
expect(result.warnings).toEqual({});
expect(result.amount).toEqual(new BigNumber(0));
});
it("adds error for delegation without staking node id", async () => {
const account = getMockedAccount();
const transaction = getMockedTransaction({
mode: HEDERA_TRANSACTION_MODES.Delegate,
properties: {} as any,
});
const result = await getTransactionStatus(account, transaction);
expect(result.errors.missingStakingNodeId).toBeInstanceOf(HederaInvalidStakingNodeIdError);
});
it("adds error for delegation with invalid staking node id", async () => {
const account = getMockedAccount();
const transaction = getMockedTransaction({
mode: HEDERA_TRANSACTION_MODES.Delegate,
properties: { stakingNodeId: 999 },
});
const result = await getTransactionStatus(account, transaction);
expect(result.errors.stakingNodeId).toBeInstanceOf(HederaInvalidStakingNodeIdError);
});
it("adds error for delegation to already delegated node", async () => {
const account = getMockedAccount({
hederaResources: {
maxAutomaticTokenAssociations: 0,
isAutoTokenAssociationEnabled: false,
delegation: {
nodeId: 1,
pendingReward: new BigNumber(0),
delegated: new BigNumber(1000),
},
},
});
const transaction = getMockedTransaction({
mode: HEDERA_TRANSACTION_MODES.Delegate,
properties: { stakingNodeId: 1 },
});
const result = await getTransactionStatus(account, transaction);
expect(result.errors.stakingNodeId).toBeInstanceOf(HederaRedundantStakingNodeIdError);
});
it("adds error during staking transfer with insufficient balance", async () => {
const mockedAccount = getMockedAccount({ balance: new BigNumber(0) });
const mockedDelegateTransaction = getMockedTransaction({
mode: HEDERA_TRANSACTION_MODES.Delegate,
properties: { stakingNodeId: 1 },
});
const mockedUndelegateTransaction = getMockedTransaction({
mode: HEDERA_TRANSACTION_MODES.Undelegate,
properties: { stakingNodeId: null },
});
const mockedRedelegateTransaction = getMockedTransaction({
mode: HEDERA_TRANSACTION_MODES.Redelegate,
properties: { stakingNodeId: 2 },
});
const mockedClaimRewardsTransaction = getMockedTransaction({
mode: HEDERA_TRANSACTION_MODES.ClaimRewards,
});
const [resultDelegate, resultUndelegate, resultRedelegate, resultClaimRewards] =
await Promise.all([
getTransactionStatus(mockedAccount, mockedDelegateTransaction),
getTransactionStatus(mockedAccount, mockedUndelegateTransaction),
getTransactionStatus(mockedAccount, mockedRedelegateTransaction),
getTransactionStatus(mockedAccount, mockedClaimRewardsTransaction),
]);
expect(resultDelegate.errors.fee).toBeInstanceOf(NotEnoughBalance);
expect(resultUndelegate.errors.fee).toBeInstanceOf(NotEnoughBalance);
expect(resultRedelegate.errors.fee).toBeInstanceOf(NotEnoughBalance);
expect(resultClaimRewards.errors.fee).toBeInstanceOf(NotEnoughBalance);
});
it("adds error when claiming rewards with no rewards available", async () => {
const account = getMockedAccount({
hederaResources: {
maxAutomaticTokenAssociations: 0,
isAutoTokenAssociationEnabled: false,
delegation: {
nodeId: 1,
pendingReward: new BigNumber(0),
delegated: new BigNumber(1000),
},
},
});
const transaction = getMockedTransaction({
mode: HEDERA_TRANSACTION_MODES.ClaimRewards,
});
const result = await getTransactionStatus(account, transaction);
expect(result.errors.noRewardsToClaim).toBeInstanceOf(HederaNoStakingRewardsError);
});
it("adds warning when claiming rewards with fee higher than rewards", async () => {
const account = getMockedAccount({
hederaResources: {
maxAutomaticTokenAssociations: 0,
isAutoTokenAssociationEnabled: false,
delegation: {
nodeId: 1,
pendingReward: new BigNumber(10),
delegated: new BigNumber(1000),
},
},
});
const transaction = getMockedTransaction({
mode: HEDERA_TRANSACTION_MODES.ClaimRewards,
maxFee: new BigNumber(100),
});
const result = await getTransactionStatus(account, transaction);
expect(result.warnings.claimRewardsFee).toBeInstanceOf(ClaimRewardsFeesWarning);
});
it("claim rewards with sufficient rewards completes successfully", async () => {
const account = getMockedAccount({
hederaResources: {
maxAutomaticTokenAssociations: 0,
isAutoTokenAssociationEnabled: false,
delegation: {
nodeId: 1,
pendingReward: new BigNumber(100),
delegated: new BigNumber(1000),
},
},
});
const transaction = getMockedTransaction({
mode: HEDERA_TRANSACTION_MODES.ClaimRewards,
maxFee: new BigNumber(10),
});
const result = await getTransactionStatus(account, transaction);
expect(result.errors).toEqual({});
expect(result.warnings).toEqual({});
expect(result.amount).toEqual(new BigNumber(0));
});
});