@ledgerhq/coin-stellar
Version:
Ledger Stellar Coin integration
156 lines (132 loc) • 4.87 kB
text/typescript
// SPDX-FileCopyrightText: © 2026 LEDGER SAS
// SPDX-License-Identifier: Apache-2.0
import type { CoinConfig } from '@ledgerhq/coin-module-framework/config'
import { Horizon, NotFoundError } from '@stellar/stellar-sdk'
import coinConfig, { type StellarCoinConfig } from '../config'
import { fetchAllLedgerOperations, fetchLedgerRecord } from './horizon'
const HORIZON = 'https://horizon-testnet.stellar.org'
let originalGetCoinConfig: CoinConfig<StellarCoinConfig>
/** Minimal Horizon payment op shape (embedded.records). */
const MINIMAL_HORIZON_PAYMENT_OP = {
_links: {
self: { href: '' },
transaction: { href: '' },
effects: { href: '' },
succeeds: { href: '' },
precedes: { href: '' },
},
id: '1',
paging_token: '1',
transaction_successful: true,
source_account: 'GB7FW6GRFEH63Q3BVRT65VMB26OKHQRG74QRFUMTL6D553UKDZCDM7U',
type: 'payment',
type_i: 1,
created_at: '2015-07-20T20:27:50Z',
transaction_hash: 'txh',
asset_type: 'native',
from: 'GB7FW6GRFEH63Q3BVRT65VMB26OKHQRG74QRFUMTL6D553UKDZCDM7U',
to: 'GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN',
amount: '1.0000000',
}
/**
* Minimal chainable stub of a Horizon call builder: every builder method returns
* the builder itself, and `call()` resolves (or rejects) with the configured value.
* Lets us drive `getServer().ledgers()...call()` / `.operations()...call()` without HTTP.
*/
function callBuilder(call: () => Promise<unknown>): unknown {
const builder: unknown = new Proxy(
{},
{
get(_target, prop) {
if (prop === 'call') return call
return () => builder
},
}
)
return builder
}
function setExplorer(fetchLimit = 100): void {
coinConfig.setCoinConfig(
(): StellarCoinConfig =>
({
status: { type: 'active' },
explorer: { url: `${HORIZON}/`, fetchLimit },
}) as StellarCoinConfig
)
}
function mockLedgers(call: () => Promise<unknown>): void {
jest.spyOn(Horizon.Server.prototype, 'ledgers').mockReturnValue(callBuilder(call) as never)
}
function mockOperations(call: () => Promise<unknown>): void {
jest.spyOn(Horizon.Server.prototype, 'operations').mockReturnValue(callBuilder(call) as never)
}
describe('fetchLedgerRecord / fetchAllLedgerOperations', () => {
beforeAll(() => {
originalGetCoinConfig = coinConfig.getCoinConfig
})
afterAll(() => {
if (originalGetCoinConfig) {
coinConfig.setCoinConfig(originalGetCoinConfig)
}
})
beforeEach(() => {
setExplorer(100)
})
afterEach(() => {
jest.restoreAllMocks()
})
it('fetchLedgerRecord maps 404 to not found error', async () => {
mockLedgers(() => Promise.reject(new NotFoundError('Not Found', {} as never)))
await expect(fetchLedgerRecord(424242)).rejects.toThrow('Stellar ledger 424242 not found')
})
it('fetchAllLedgerOperations maps 404 to not found error', async () => {
mockOperations(() => Promise.reject(new NotFoundError('Not Found', {} as never)))
await expect(fetchAllLedgerOperations(424243)).rejects.toThrow(
'Stellar ledger 424243 not found'
)
})
it('fetchLedgerRecord returns ledger on 200', async () => {
const ledgerBody = {
_links: { self: { href: '' }, transactions: { href: '' }, operations: { href: '' } },
id: 'abc',
paging_token: 'abc',
hash: '0000000000000000000000000000000000000000000000000000000000000000',
prev_hash: '',
sequence: 3,
successful_transaction_count: 0,
failed_transaction_count: 0,
operation_count: 0,
tx_set_operation_count: 0,
closed_at: '2015-07-20T20:27:50Z',
total_coins: '100000000000.0000000',
fee_pool: '0.0000000',
base_fee_in_stroops: 100,
base_reserve_in_stroops: 100000000,
max_tx_set_size: 50,
protocol_version: 1,
header_xdr: '',
}
mockLedgers(() => Promise.resolve(ledgerBody))
const rec = await fetchLedgerRecord(3)
expect(rec.sequence).toBe(3)
expect(rec.hash).toBe(ledgerBody.hash)
})
it('fetchAllLedgerOperations returns embedded records on 200', async () => {
const page = {
records: [MINIMAL_HORIZON_PAYMENT_OP],
next: () => Promise.resolve({ records: [] }),
}
mockOperations(() => Promise.resolve(page))
const records = await fetchAllLedgerOperations(10)
expect(records).toHaveLength(1)
expect(records[0].type).toBe('payment')
})
it('fetchAllLedgerOperations paginates when page is full then stops on empty page', async () => {
setExplorer(1)
const page2 = { records: [], next: () => Promise.resolve({ records: [] }) }
const page1 = { records: [MINIMAL_HORIZON_PAYMENT_OP], next: () => Promise.resolve(page2) }
mockOperations(() => Promise.resolve(page1))
const records = await fetchAllLedgerOperations(11)
expect(records.length).toBe(1)
})
})