// SPDX-FileCopyrightText: © 2026 LEDGER SAS // SPDX-License-Identifier: Apache-2.0 import type { StellarContext } from '../../config' import type { StellarMemo } from '../../types' import type { FeeEstimation } from '@ledgerhq/coin-module-framework/api/types' import { createSendTransactionIntentMockData } from '@ledgerhq/coin-module-framework/test-utils' import BigNumber from 'bignumber.js' import * as utils from '../../logic-public/utils' import * as logicValidateMemo from '../../logic-public/validateMemo' import * as network from '../../network' import * as horizon from '../../network/horizon' import { StellarWrongMemoFormat } from '../../types' import { validateIntent } from '../validateIntent' jest.mock('../../network/horizon') jest.mock('../../logic-public/validateMemo') jest.mock('../../logic-public/utils') const context: StellarContext = { config: async () => ({ name: 'Stellar', unit: { name: 'Lumen', code: 'XLM', magnitude: 7 }, explorer: { url: 'https://horizon.example.com' }, status: { type: 'active' }, }), logger: jest.fn(), } describe('validateIntent', () => { const spiedValidateMemo = jest.mocked(logicValidateMemo.validateMemo) const spiedFetchAccount = jest.spyOn(horizon, 'fetchAccount') const spiedFetchAccountNetworkInfo = jest.spyOn(network, 'fetchAccountNetworkInfo') const spiedIsAccountMultiSign = jest.spyOn(utils, 'isAccountMultiSign') beforeEach(() => { spiedValidateMemo.mockClear() spiedFetchAccount.mockClear() spiedFetchAccountNetworkInfo.mockClear() spiedIsAccountMultiSign.mockClear() spiedFetchAccount.mockResolvedValue({ spendableBalance: BigNumber(0), balance: BigNumber(0), blockHeight: 0, assets: [], }) spiedFetchAccountNetworkInfo.mockResolvedValue({ family: 'stellar', baseFee: BigNumber(0), fees: BigNumber(0), baseReserve: BigNumber(0), }) spiedIsAccountMultiSign.mockResolvedValue(false) }) it.each(['MEMO_TEXT', 'MEMO_ID', 'MEMO_HASH', 'MEMO_RETURN'] as const)( 'should not set error on transaction when memo is validated for type %s', async (type) => { spiedValidateMemo.mockReturnValue(true) const intent = createSendTransactionIntentMockData({ type: 'payment', asset: { type: 'native' }, memo: { type, value: 'random memo for unit test' }, }) const fees: FeeEstimation = { value: 0n } const status = await validateIntent(context, intent, [], fees) expect(status.errors.transaction).not.toBeDefined() expect(spiedValidateMemo).toHaveBeenCalledWith('random memo for unit test', type) } ) it.each(['MEMO_TEXT', 'MEMO_ID', 'MEMO_HASH', 'MEMO_RETURN'] as const)( 'should set error on transaction when memo is invalidated for type %s', async (type) => { spiedValidateMemo.mockReturnValue(false) const intent = createSendTransactionIntentMockData({ type: 'payment', asset: { type: 'native' }, memo: { type, value: 'random memo for unit test' }, }) const fees: FeeEstimation = { value: 0n } const status = await validateIntent(context, intent, [], fees) expect(status.errors.transaction).toBeInstanceOf(StellarWrongMemoFormat) expect(spiedValidateMemo).toHaveBeenCalledWith('random memo for unit test', type) } ) it('should not validate memo when type is NO_MEMO', async () => { spiedValidateMemo.mockReturnValue(true) const intent = createSendTransactionIntentMockData({ type: 'payment', asset: { type: 'native' }, memo: { type: 'NO_MEMO' }, }) const fees: FeeEstimation = { value: 0n } const status = await validateIntent(context, intent, [], fees) expect(status.errors.transaction).not.toBeDefined() expect(spiedValidateMemo).not.toHaveBeenCalled() }) describe('native currency metadata in errors', () => { // Error payloads must describe the native currency from config, never the intent asset. const MISLEADING_ASSET = { type: 'native', name: 'Not Stellar', unit: { name: 'Fake', code: 'FAKE', magnitude: 2 }, } as const const spiedIsAddressValid = jest.mocked(utils.isAddressValid) const spiedGetRecipientAccount = jest.spyOn(network, 'getRecipientAccount') beforeEach(() => { spiedIsAddressValid.mockReset() spiedGetRecipientAccount.mockReset() }) it('uses the configured currency name for an invalid recipient', async () => { spiedIsAddressValid.mockReturnValueOnce(false) const intent = createSendTransactionIntentMockData({ type: 'payment', amount: 10_000_000n, recipient: 'not-an-address', asset: MISLEADING_ASSET, memo: { type: 'NO_MEMO' }, }) const status = await validateIntent(context, intent, [], { value: 100n }) expect(status.errors.recipient).toMatchObject({ name: 'InvalidAddress', currencyName: 'Stellar', }) }) it('formats the base reserve with the configured unit', async () => { spiedIsAddressValid.mockReturnValueOnce(true) spiedGetRecipientAccount.mockResolvedValueOnce({ id: 'recipient-address', isMuxedAccount: false, assetIds: [], }) spiedFetchAccount.mockResolvedValue({ spendableBalance: BigNumber(50_000_000), balance: BigNumber(50_000_000), blockHeight: 0, assets: [], }) spiedFetchAccountNetworkInfo.mockResolvedValue({ family: 'stellar', baseFee: BigNumber(0), fees: BigNumber(0), baseReserve: BigNumber(10_000_000), // 1 XLM }) const intent = createSendTransactionIntentMockData({ type: 'payment', amount: 45_000_000n, asset: MISLEADING_ASSET, memo: { type: 'NO_MEMO' }, }) const status = await validateIntent(context, intent, [], { value: 100n }) expect(status.errors.amount).toMatchObject({ name: 'NotEnoughSpendableBalance', minimumAmount: '1\u00A0XLM', }) }) }) })