@ledgerhq/coin-stellar
Version:
Ledger Stellar Coin integration
436 lines (380 loc) • 16.2 kB
text/typescript
// SPDX-FileCopyrightText: © 2026 LEDGER SAS
// SPDX-License-Identifier: Apache-2.0
import type { RawOperation } from '../types'
import { BadResponseError, Horizon, NotFoundError } from '@stellar/stellar-sdk'
import coinConfig, { type StellarCoinConfig } from '../config'
import { NetworkCongestionLevel } from '../types/model'
import {
broadcastTransaction,
fetchAllLedgerOperations,
fetchBaseFee,
fetchLedgerRecord,
fetchOperations,
getLastBlock,
} from './horizon'
import * as serialization from './serialization'
jest.mock('./serialization', () => {
const actual = jest.requireActual<typeof import('./serialization')>('./serialization')
return {
...actual,
rawOperationsToOperations: jest.fn(),
}
})
const rawOperationsToOperationsMock =
serialization.rawOperationsToOperations as jest.MockedFunction<
typeof 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> = {}): StellarCoinConfig {
return {
status: { type: 'active' },
explorer: { url: TEST_HORIZON_URL, fetchLimit: 100 },
useStaticFees: true,
enableNetworkLogs: false,
...overrides,
} as StellarCoinConfig
}
const HORIZON_SERVER_SPY_METHODS = [
'ledgers',
'operations',
'feeStats',
'submitTransaction',
] as const
function restoreHorizonServerPrototypeSpies(): void {
const proto = Horizon.Server.prototype as unknown as Record<string, unknown>
for (const key of HORIZON_SERVER_SPY_METHODS) {
const fn = proto[key]
if (jest.isMockFunction(fn)) {
fn.mockRestore()
}
}
}
function opBuilder(partial: Partial<RawOperation> & { paging_token: string }): RawOperation {
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,
} as RawOperation
}
describe('horizon.ts (unit, spies)', () => {
let originalGetCoinConfig: () => StellarCoinConfig
beforeAll(() => {
originalGetCoinConfig = coinConfig.getCoinConfig
coinConfig.setCoinConfig(() => baseCoinConfig())
})
afterAll(() => {
coinConfig.setCoinConfig(originalGetCoinConfig)
})
beforeEach(() => {
restoreHorizonServerPrototypeSpies()
rawOperationsToOperationsMock.mockReset()
rawOperationsToOperationsMock.mockImplementation(
jest.requireActual<typeof import('./serialization')>('./serialization')
.rawOperationsToOperations
)
})
describe('fetchLedgerRecord', () => {
it('maps NotFoundError to Stellar ledger not found message', async () => {
jest.spyOn(Horizon.Server.prototype, 'ledgers').mockReturnValue({
ledger: jest.fn().mockReturnValue({
call: jest.fn().mockRejectedValue(new NotFoundError('not found', {})),
}),
} as unknown as ReturnType<Horizon.Server['ledgers']>)
await expect(fetchLedgerRecord(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').mockReturnValue({
ledger: jest.fn().mockReturnValue({
call: jest.fn().mockResolvedValue(ledger),
}),
} as unknown as ReturnType<Horizon.Server['ledgers']>)
const rec = await fetchLedgerRecord(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').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', {})),
}),
} as unknown as ReturnType<Horizon.Server['operations']>)
await expect(fetchAllLedgerOperations(4242)).rejects.toThrow('Stellar ledger 4242 not found')
})
describe('with fetchLimit=2', () => {
beforeEach(() => {
coinConfig.setCoinConfig(() =>
baseCoinConfig({ explorer: { url: TEST_HORIZON_URL, fetchLimit: 2 } })
)
})
afterEach(() => {
coinConfig.setCoinConfig(() => 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 secondPage = { records: [] as RawOperation[] }
const firstPage = {
records: [op1, op2],
next: jest.fn().mockResolvedValue(secondPage),
}
jest.spyOn(Horizon.Server.prototype, 'operations').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),
}),
} as unknown as ReturnType<Horizon.Server['operations']>)
const out = await fetchAllLedgerOperations(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').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')),
}),
} as unknown as ReturnType<Horizon.Server['operations']>)
await expect(
fetchOperations({
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: 'mock-op' } as never])
jest.spyOn(Horizon.Server.prototype, 'operations').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] }),
}),
} as unknown as ReturnType<Horizon.Server['operations']>)
// fetchLimit defaults to 100, Horizon returned 2 records → incomplete page → no next cursor
const page = await fetchOperations({
accountId: 'aid',
addr: 'GADDRAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
minHeight: 100,
order: 'desc',
cursor: undefined,
})
expect(page.next).toBe('')
expect(page.items).toHaveLength(1)
})
describe('with fetchLimit=2', () => {
beforeEach(() => {
coinConfig.setCoinConfig(() =>
baseCoinConfig({ explorer: { url: TEST_HORIZON_URL, fetchLimit: 2 } })
)
})
afterEach(() => {
coinConfig.setCoinConfig(() => 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: 'mock-op' } as never])
jest.spyOn(Horizon.Server.prototype, 'operations').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] }),
}),
} as unknown as ReturnType<Horizon.Server['operations']>)
const page = await fetchOperations({
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: 'mock-op' } as never])
jest.spyOn(Horizon.Server.prototype, 'operations').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] }),
}),
} as unknown as ReturnType<Horizon.Server['operations']>)
const page = await fetchOperations({
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').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 }),
} as unknown as ReturnType<Horizon.Server['operations']>)
const page = await fetchOperations({
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(() => {
coinConfig.setCoinConfig(() => baseCoinConfig({ useStaticFees: false }))
})
afterEach(() => {
coinConfig.setCoinConfig(() => 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').mockResolvedValue({
ledger_capacity_usage: usage,
fee_charged: { mode: '200' },
} as Awaited<ReturnType<Horizon.Server['feeStats']>>)
const fees = await fetchBaseFee()
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()
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').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' }],
}),
}),
} as unknown as ReturnType<Horizon.Server['ledgers']>)
const block = await getLastBlock()
expect(block.height).toBe(42)
expect(block.hash).toBe('ab'.repeat(32))
expect(block.time).toEqual(new Date('2018-09-15T15:40:05Z'))
})
})
describe('broadcastTransaction decode / documentation edge cases', () => {
let submitTransactionSpy: jest.SpiedFunction<Horizon.Server['submitTransaction']>
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: [] as string[] },
result_xdr: 'AAAAAAAAAGT////4AAAAAA==',
envelope_xdr: signedTxFixture,
},
}
submitTransactionSpy.mockRejectedValue(
new BadResponseError(
'Transaction submission failed. Server responded: 400 Bad Request',
body
)
)
await expect(broadcastTransaction(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: [] as string[] },
result_xdr: 'not-valid-base64!!!',
envelope_xdr: signedTxFixture,
},
}
submitTransactionSpy.mockRejectedValue(
new BadResponseError(
'Transaction submission failed. Server responded: 400 Bad Request',
body
)
)
await expect(broadcastTransaction(signedTxFixture)).rejects.toMatchObject({
name: 'StellarBroadcastFailedError',
decodedResultXdr: { decodeFailed: true },
})
})
})
})