// SPDX-FileCopyrightText: © 2026 LEDGER SAS // SPDX-License-Identifier: Apache-2.0 import type { StellarCoinConfig, StellarContext } from '../../config' import type { RawOperation } from '../../types' import type { DeepPartialReturn } from '@ledgerhq/coin-module-framework/test/utils' import { BlockNotFoundError, UnknownError } from '@ledgerhq/coin-module-framework/errors' import { BadResponseError, Horizon, NotFoundError } from '@stellar/stellar-sdk' import BigNumber from 'bignumber.js' import { NetworkCongestionLevel } from '../../types/model' import { broadcastTransaction, fetchAccount, fetchAllLedgerOperations, fetchBaseFee, fetchLedgerRecord, fetchOperations, getLastBlock, } from '../horizon' import * as serialization from '../serialization' jest.mock('../serialization', () => { const actual = jest.requireActual('../serialization') return { ...actual, rawOperationsToOperations: jest.fn(), } }) const rawOperationsToOperationsMock = jest.mocked< DeepPartialReturn >(serialization.rawOperationsToOperations) const TEST_HORIZON_URL = 'https://horizon-stellar-unit-test.invalid/' const signedTxFixture = 'AAAAAgAAAABRUCgFba+DTbei2ifpyYt5w2Hh0VyZ+X9fayjIDne7YAAAAGQCkDOGAAAABQAAAAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEAAAAAAAAAAQAAAACEIuPfWXgM8WhyqjrpFdIcGV1SUVhMzPUm4YspNHF60QAAAAAAAAAAALkd2QAAAAAAAAABDne7YAAAAEAASzsT/yDIfCfEDstkfnznXjiN7rNd7PkKQEn+rRIFm9EHoirGfHipWoBdYMrc6ixQD/0y0of1piSid8TLiFAB' function baseCoinConfig(overrides: Partial = {}): StellarCoinConfig { return { status: { type: 'active' }, name: 'Stellar', unit: { name: 'Lumen', code: 'XLM', magnitude: 7 }, explorer: { url: TEST_HORIZON_URL, fetchLimit: 100 }, useStaticFees: true, enableNetworkLogs: false, ...overrides, } } let currentConfig: StellarCoinConfig = baseCoinConfig() const context: StellarContext = { config: async () => currentConfig, logger: jest.fn(), } const HORIZON_SERVER_SPY_METHODS = [ 'accounts', 'ledgers', 'operations', 'feeStats', 'submitTransaction', ] as const function restoreHorizonServerPrototypeSpies(): void { for (const key of HORIZON_SERVER_SPY_METHODS) { const fn: unknown = Reflect.get(Horizon.Server.prototype, key) if (jest.isMockFunction(fn)) { fn.mockRestore() } } } function opBuilder(partial: Partial & { paging_token: string }) { const { paging_token, ...rest } = partial return { id: '1', paging_token, source_account: 'GSOURCEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', transaction_successful: true, created_at: '2020-01-01T00:00:00Z', type: 'payment', transaction_hash: 'txh', ...rest, } } describe('horizon.ts (unit, spies)', () => { beforeAll(() => { currentConfig = baseCoinConfig() }) beforeEach(() => { restoreHorizonServerPrototypeSpies() rawOperationsToOperationsMock.mockReset() rawOperationsToOperationsMock.mockImplementation( jest.requireActual('../serialization').rawOperationsToOperations ) }) describe('fetchAccount', () => { it('returns zero balances when account is not found', async () => { jest.spyOn(Horizon.Server.prototype, 'accounts') const accountsSpy = jest.mocked>( Horizon.Server.prototype.accounts ) accountsSpy.mockReturnValue({ accountId: jest.fn().mockReturnValue({ call: jest.fn().mockRejectedValue(new NotFoundError('not found', {})), }), }) const result = await fetchAccount( currentConfig, 'GTEST000000000000000000000000000000000000000000000000000' ) expect(result).toMatchObject({ balance: new BigNumber(0), spendableBalance: new BigNumber(0), assets: [], blockHeight: 0, }) }) it('propagates non-404 account fetch failures', async () => { jest.spyOn(Horizon.Server.prototype, 'accounts') const accountsSpy = jest.mocked>( Horizon.Server.prototype.accounts ) accountsSpy.mockReturnValue({ accountId: jest.fn().mockReturnValue({ call: jest.fn().mockRejectedValue(new BadResponseError('server error', {})), }), }) await expect( fetchAccount(currentConfig, 'GTEST000000000000000000000000000000000000000000000000000') ).rejects.toThrow('server error') }) }) describe('fetchLedgerRecord', () => { it('maps NotFoundError to Stellar ledger not found message', async () => { jest.spyOn(Horizon.Server.prototype, 'ledgers') const ledgersSpy = jest.mocked>( Horizon.Server.prototype.ledgers ) ledgersSpy.mockReturnValue({ ledger: jest.fn().mockReturnValue({ call: jest.fn().mockRejectedValue(new NotFoundError('not found', {})), }), }) await expect(fetchLedgerRecord(currentConfig, 999)).rejects.toThrow( 'Stellar ledger 999 not found' ) }) it('returns ledger record on success', async () => { const ledger = { sequence: 7, hash: 'h'.repeat(64), closed_at: '2015-07-20T20:27:50Z' } jest.spyOn(Horizon.Server.prototype, 'ledgers') const ledgersSpy = jest.mocked>( Horizon.Server.prototype.ledgers ) ledgersSpy.mockReturnValue({ ledger: jest.fn().mockReturnValue({ call: jest.fn().mockResolvedValue(ledger), }), }) const rec = await fetchLedgerRecord(currentConfig, 7) expect(rec.sequence).toBe(7) expect(rec.hash).toBe(ledger.hash) }) }) describe('fetchAllLedgerOperations', () => { it('maps NotFoundError to Stellar ledger not found message', async () => { jest.spyOn(Horizon.Server.prototype, 'operations') const operationsSpy = jest.mocked< DeepPartialReturn >(Horizon.Server.prototype.operations) operationsSpy.mockReturnValue({ forLedger: jest.fn().mockReturnThis(), includeFailed: jest.fn().mockReturnThis(), join: jest.fn().mockReturnThis(), order: jest.fn().mockReturnThis(), limit: jest.fn().mockReturnValue({ call: jest.fn().mockRejectedValue(new NotFoundError('missing', {})), }), }) await expect(fetchAllLedgerOperations(currentConfig, 4242)).rejects.toThrow( 'Stellar ledger 4242 not found' ) }) describe('with fetchLimit=2', () => { beforeEach(() => { currentConfig = baseCoinConfig({ explorer: { url: TEST_HORIZON_URL, fetchLimit: 2 } }) }) afterEach(() => { currentConfig = baseCoinConfig() }) it('paginates when first page is full then next page is empty', async () => { const op1 = opBuilder({ paging_token: '1', id: 'a' }) const op2 = opBuilder({ paging_token: '2', id: 'b' }) const firstPage = { records: [op1, op2], next: jest.fn().mockResolvedValue({ records: [] }), } jest.spyOn(Horizon.Server.prototype, 'operations') const operationsSpy = jest.mocked< DeepPartialReturn >(Horizon.Server.prototype.operations) operationsSpy.mockReturnValue({ forLedger: jest.fn().mockReturnThis(), includeFailed: jest.fn().mockReturnThis(), join: jest.fn().mockReturnThis(), order: jest.fn().mockReturnThis(), limit: jest.fn().mockReturnValue({ call: jest.fn().mockResolvedValue(firstPage), }), }) const out = await fetchAllLedgerOperations(currentConfig, 10) expect(out).toHaveLength(2) expect(firstPage.next).toHaveBeenCalledTimes(1) }) }) }) describe('fetchOperations', () => { it('throws LedgerAPI4xx with status 429 when Horizon returns too many requests', async () => { jest.spyOn(Horizon.Server.prototype, 'operations') const operationsSpy = jest.mocked< DeepPartialReturn >(Horizon.Server.prototype.operations) operationsSpy.mockReturnValue({ forAccount: jest.fn().mockReturnThis(), limit: jest.fn().mockReturnThis(), order: jest.fn().mockReturnThis(), cursor: jest.fn().mockReturnThis(), includeFailed: jest.fn().mockReturnThis(), join: jest.fn().mockReturnValue({ call: jest.fn().mockRejectedValue(new Error('too many requests')), }), }) await expect( fetchOperations(currentConfig, { accountId: 'aid', addr: 'GADDRAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', minHeight: 0, order: 'asc', cursor: undefined, }) ).rejects.toEqual(expect.objectContaining({ name: 'LedgerAPI4xx', status: 429 })) }) it('sets next cursor to empty when Horizon returns fewer records than the fetch limit', async () => { const rawA = opBuilder({ paging_token: 'pt-a', id: '1' }) const rawB = opBuilder({ paging_token: 'pt-b', id: '2' }) rawOperationsToOperationsMock.mockResolvedValue([{ type: 'OUT' }]) jest.spyOn(Horizon.Server.prototype, 'operations') const operationsSpy = jest.mocked< DeepPartialReturn >(Horizon.Server.prototype.operations) operationsSpy.mockReturnValue({ forAccount: jest.fn().mockReturnThis(), limit: jest.fn().mockReturnThis(), order: jest.fn().mockReturnThis(), cursor: jest.fn().mockReturnThis(), includeFailed: jest.fn().mockReturnThis(), join: jest.fn().mockReturnValue({ call: jest.fn().mockResolvedValue({ records: [rawA, rawB] }), }), }) // fetchLimit defaults to 100, Horizon returned 2 records → incomplete page → no next cursor const page = await fetchOperations(currentConfig, { accountId: 'aid', addr: 'GADDRAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', minHeight: 100, order: 'desc', cursor: undefined, }) expect(page.next).toBe('') expect(page.items).toHaveLength(1) }) describe('with fetchLimit=2', () => { beforeEach(() => { currentConfig = baseCoinConfig({ explorer: { url: TEST_HORIZON_URL, fetchLimit: 2 } }) }) afterEach(() => { currentConfig = baseCoinConfig() }) it('continues pagination when unsupported ops are filtered from a full page', async () => { const rawA = opBuilder({ paging_token: 'pt-a', id: '1' }) const rawB = opBuilder({ paging_token: 'pt-b', id: '2' }) // Only 1 op survives filtering (e.g. the other was an unsupported type) rawOperationsToOperationsMock.mockResolvedValue([{ type: 'OUT' }]) jest.spyOn(Horizon.Server.prototype, 'operations') const operationsSpy = jest.mocked< DeepPartialReturn >(Horizon.Server.prototype.operations) operationsSpy.mockReturnValue({ forAccount: jest.fn().mockReturnThis(), limit: jest.fn().mockReturnThis(), order: jest.fn().mockReturnThis(), cursor: jest.fn().mockReturnThis(), includeFailed: jest.fn().mockReturnThis(), join: jest.fn().mockReturnValue({ call: jest.fn().mockResolvedValue({ records: [rawA, rawB] }), }), }) const page = await fetchOperations(currentConfig, { accountId: 'aid', addr: 'GADDRAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', minHeight: 0, order: 'desc', cursor: undefined, }) expect(page.next).toBe('pt-b') expect(page.items).toHaveLength(1) }) it('stops pagination when Horizon returns fewer records than fetchLimit', async () => { const rawA = opBuilder({ paging_token: 'pt-a', id: '1' }) // 1 record < fetchLimit of 2 → last page rawOperationsToOperationsMock.mockResolvedValue([{ type: 'OUT' }]) jest.spyOn(Horizon.Server.prototype, 'operations') const operationsSpy = jest.mocked< DeepPartialReturn >(Horizon.Server.prototype.operations) operationsSpy.mockReturnValue({ forAccount: jest.fn().mockReturnThis(), limit: jest.fn().mockReturnThis(), order: jest.fn().mockReturnThis(), cursor: jest.fn().mockReturnThis(), includeFailed: jest.fn().mockReturnThis(), join: jest.fn().mockReturnValue({ call: jest.fn().mockResolvedValue({ records: [rawA] }), }), }) const page = await fetchOperations(currentConfig, { accountId: 'aid', addr: 'GADDRAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', minHeight: 0, order: 'desc', cursor: undefined, }) expect(page.next).toBe('') expect(page.items).toHaveLength(1) }) it('returns empty items with cursor when all ops on a full page are filtered out', async () => { const rawA = opBuilder({ paging_token: 'pt-a', id: '1' }) const rawB = opBuilder({ paging_token: 'pt-b', id: '2' }) // All ops filtered out (e.g. unsupported types or below minHeight) rawOperationsToOperationsMock.mockResolvedValue([]) const callMock = jest.fn().mockResolvedValueOnce({ records: [rawA, rawB] }) jest.spyOn(Horizon.Server.prototype, 'operations') const operationsSpy = jest.mocked< DeepPartialReturn >(Horizon.Server.prototype.operations) operationsSpy.mockReturnValue({ forAccount: jest.fn().mockReturnThis(), limit: jest.fn().mockReturnThis(), order: jest.fn().mockReturnThis(), cursor: jest.fn().mockReturnThis(), includeFailed: jest.fn().mockReturnThis(), join: jest.fn().mockReturnValue({ call: callMock }), }) const page = await fetchOperations(currentConfig, { accountId: 'aid', addr: 'GADDRAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', minHeight: 999999, order: 'desc', cursor: undefined, }) // Returns empty items with cursor — caller handles pagination expect(page.items).toHaveLength(0) expect(page.next).toBe('pt-b') expect(callMock).toHaveBeenCalledTimes(1) }) }) }) describe('fetchBaseFee', () => { beforeEach(() => { currentConfig = baseCoinConfig({ useStaticFees: false }) }) afterEach(() => { currentConfig = baseCoinConfig() }) test.each([ ['0.4', 'low usage -> LOW', NetworkCongestionLevel.LOW], ['0.6', 'medium usage -> MEDIUM', NetworkCongestionLevel.MEDIUM], ['0.9', 'high usage -> HIGH', NetworkCongestionLevel.HIGH], ])('ledger_capacity_usage %s (%s)', async (usage, _label, expectedLevel) => { jest.spyOn(Horizon.Server.prototype, 'feeStats') const feeStatsSpy = jest.mocked>( Horizon.Server.prototype.feeStats ) feeStatsSpy.mockResolvedValue({ ledger_capacity_usage: usage, fee_charged: { mode: '200' }, }) const fees = await fetchBaseFee(currentConfig) expect(fees.networkCongestionLevel).toBe(expectedLevel) expect(fees.recommendedFee).toBe(200) }) it('falls back to defaults when feeStats throws', async () => { jest.spyOn(Horizon.Server.prototype, 'feeStats').mockRejectedValue(new Error('network error')) const fees = await fetchBaseFee(currentConfig) expect(fees.baseFee).toBeGreaterThan(0) expect(fees.networkCongestionLevel).toBe(NetworkCongestionLevel.MEDIUM) }) }) describe('getLastBlock', () => { it('returns height hash and time from latest ledger record', async () => { jest.spyOn(Horizon.Server.prototype, 'ledgers') const ledgersSpy = jest.mocked>( Horizon.Server.prototype.ledgers ) ledgersSpy.mockReturnValue({ order: jest.fn().mockReturnThis(), limit: jest.fn().mockReturnValue({ call: jest.fn().mockResolvedValue({ records: [{ sequence: 42, hash: 'ab'.repeat(32), closed_at: '2018-09-15T15:40:05Z' }], }), }), }) const block = await getLastBlock(currentConfig) expect(block.height).toBe(42) expect(block.hash).toBe('ab'.repeat(32)) expect(block.time).toEqual(new Date('2018-09-15T15:40:05Z')) }) it('should throw BlockNotFoundError when no ledger found', async () => { jest.spyOn(Horizon.Server.prototype, 'ledgers') const ledgersSpy = jest.mocked>( Horizon.Server.prototype.ledgers ) ledgersSpy.mockReturnValue({ order: jest.fn().mockReturnThis(), limit: jest.fn().mockReturnValue({ call: jest.fn().mockResolvedValue({ records: [], }), }), }) await expect(getLastBlock(currentConfig)).rejects.toEqual(new BlockNotFoundError()) }) it('should throw an UnknownError when horizon throw an error', async () => { jest.spyOn(Horizon.Server.prototype, 'ledgers') const ledgersSpy = jest.mocked>( Horizon.Server.prototype.ledgers ) const error = new Error('Error generated from unit test') ledgersSpy.mockReturnValue({ order: jest.fn().mockReturnThis(), limit: jest.fn().mockReturnValue({ call: jest.fn().mockRejectedValue(error), }), }) await expect(getLastBlock(currentConfig)).rejects.toEqual( new UnknownError('Error generated from unit test', error) ) }) }) describe('broadcastTransaction decode / documentation edge cases', () => { let submitTransactionSpy: jest.SpiedFunction beforeEach(() => { submitTransactionSpy = jest .spyOn(Horizon.Server.prototype, 'submitTransaction') .mockRejectedValue(new Error('mock not configured')) }) it('uses Horizon detail as documentationSummary when transaction result code is unknown', async () => { const body = { type: 'https://stellar.org/horizon-errors/transaction_failed', title: 'Transaction Failed', status: 400, detail: 'failed', extras: { result_codes: { transaction: 'tx_completely_unknown', operations: [] }, result_xdr: 'AAAAAAAAAGT////4AAAAAA==', envelope_xdr: signedTxFixture, }, } submitTransactionSpy.mockRejectedValue( new BadResponseError( 'Transaction submission failed. Server responded: 400 Bad Request', body ) ) await expect(broadcastTransaction(context, signedTxFixture)).rejects.toMatchObject({ name: 'StellarBroadcastFailedError', documentationSummary: 'failed', }) }) it('marks decodeFailed when result_xdr cannot be decoded', async () => { const body = { type: 'https://stellar.org/horizon-errors/transaction_failed', title: 'Transaction Failed', status: 400, detail: 'failed', extras: { result_codes: { transaction: 'tx_failed', operations: [] }, result_xdr: 'not-valid-base64!!!', envelope_xdr: signedTxFixture, }, } submitTransactionSpy.mockRejectedValue( new BadResponseError( 'Transaction submission failed. Server responded: 400 Bad Request', body ) ) await expect(broadcastTransaction(context, signedTxFixture)).rejects.toMatchObject({ name: 'StellarBroadcastFailedError', decodedResultXdr: { decodeFailed: true }, }) }) }) })