// SPDX-FileCopyrightText: © 2026 LEDGER SAS // SPDX-License-Identifier: Apache-2.0 import type { StellarContext } from '../../config' import type { Operation } from '@ledgerhq/coin-module-framework/api/index' import type { Page } from '@ledgerhq/coin-module-framework/api/types' import { retry } from '@ledgerhq/coin-module-framework/promises' import { LedgerAPI4xx } from '@ledgerhq/live-network/errors' import { xdr } from '@stellar/stellar-sdk' import { createApi, envelopeFromAnyXDR } from '..' /** * Mainnet explorer: https://stellar.expert/explorer/public */ describe('Stellar Api', () => { let module: ReturnType let context: StellarContext const ADDRESS = 'GBAMU3EJX6KLW2JEIAIEAYNLPKHFPJR6OYQYX5HPYB3CVQ6QD4XUJ23J' beforeAll(() => { module = createApi() context = { config: async () => ({ name: 'Stellar', unit: { name: 'Lumen', code: 'XLM', magnitude: 7 }, explorer: { url: 'https://stellar.coin.ledger.com' }, status: { type: 'active' }, }), logger: () => undefined, } }) describe('estimateFees', () => { it('returns a default value', async () => { // Given const amount = BigInt(100_000) // When const result = await module.estimateFees(context, { intentType: 'transaction', asset: { type: 'native' }, type: 'send', sender: ADDRESS, recipient: 'address', amount: amount, memo: { type: 'NO_MEMO' }, }) expect(result.value).toBeGreaterThanOrEqual(100n) }) }) describe('listOperations', () => { let txs: Operation[] let nextCursor: string | undefined // To avoid rate limit, we make only one call for multiple tests beforeAll(async () => { const result = await module.listOperations(context, ADDRESS, { minHeight: 0, order: 'asc', limit: 2, // We put a small limit to ensure having multiple page during fetch }) txs = result.items nextCursor = result.next }) describe('testing the first page fetch', () => { it('returns a page of operations for the address', async () => { expect(txs.length).toBeGreaterThanOrEqual(1) txs.forEach((operation) => { const isSenderOrReceipt = operation.senders.includes(ADDRESS) || operation.recipients.includes(ADDRESS) expect(isSenderOrReceipt).toBe(true) expect(operation.value).toBeGreaterThanOrEqual(0) expect(operation.tx.hash).toMatch(/^[A-Fa-f0-9]{64}$/) // Block hash is left empty — resolved downstream by the blockInfo(height) endpoint (BACK-12184) expect(operation.tx.block.hash).toBe('') expect(operation.tx.block.height).toBeGreaterThanOrEqual(0) expect(operation.tx.fees).toBeGreaterThan(0) expect(operation.tx.date).toBeInstanceOf(Date) expect(operation.tx.feesPayer).toMatch(/^G[A-Z2-7]{55}$/) }) }) it('returns unique operations within the page', async () => { expect(txs.length).toBeGreaterThanOrEqual(1) const checkSet = new Set(txs.map((elt) => elt.id)) expect(checkSet.size).toEqual(txs.length) }) it('returns a cursor for pagination when more pages exist', async () => { expect(typeof nextCursor).toBe('string') }) }) describe('testing the second page fetch', () => { it('supports cursor-based pagination', async () => { if (!nextCursor) { fail('Next cursor for pagination must be present, check the address and the parameters') } const callback = () => module.listOperations(context, ADDRESS, { minHeight: 0, order: 'asc', cursor: nextCursor, }) const retryCondition = (error: unknown) => { return error instanceof LedgerAPI4xx && 'status' in error && error.status === 429 } const page = await retry>(callback, { retryCondition, }).catch((error: unknown) => { throw error }) expect(page.items.length).toBeGreaterThanOrEqual(1) // No overlap between pages const page1Ids = new Set(txs.map((op) => op.id)) page.items.forEach((op) => { expect(page1Ids.has(op.id)).toBe(false) }) }) }) }) describe('lastBlock', () => { it('returns last block info', async () => { // When const result = await module.lastBlock(context) // Then expect(result.hash).toMatch(/^[A-Fa-f0-9]{64}$/) expect(result.height).toBeGreaterThan(0) expect(result.time).toBeInstanceOf(Date) }) }) describe('getBalance', () => { it('returns a list regarding address parameter', async () => { // When const result = await module.getBalance(context, ADDRESS) // Then expect(result).toBeInstanceOf(Array) expect(result[0]).toMatchObject({ value: expect.any(BigInt), asset: { type: 'native' }, }) expect(result[0].value).toBeGreaterThan(0) result.slice(1).forEach((balance) => { expect(balance.asset.type).not.toEqual('native') expect(balance.value).toBeGreaterThanOrEqual(0) }) }) it('returns 0 when address is not found', async () => { // Valid mainnet address format, never funded — Horizon returns 404 (NotFoundError). const unfundedAddress = 'GDL4A6FKWO55CKF5HSFP2WN564TMRMJA4GZMAQIY2VPYBP2LBO22CTYG' const result = await module.getBalance(context, unfundedAddress) expect(result).toEqual([{ value: BigInt(0), asset: { type: 'native' }, locked: 0n }]) }) }) describe('craftTransaction', () => { const TYPE = 'send' const RECIPIENT = 'GD6QELUZPSKPRWVXOQ3F6GBF4OBRMCHO5PHREXH4ZRTPJAG7V5MD7JGX' const AMOUNT = BigInt(1_000_000) function readFees(transactionXdr: string) { const transactionEnvelope = envelopeFromAnyXDR(transactionXdr, 'base64') return transactionEnvelope.value().tx().fee() } function readMemo(transactionXdr: string) { const transactionEnvelope = envelopeFromAnyXDR(transactionXdr, 'base64') const tx = transactionEnvelope.value().tx() if (!('memo' in tx)) throw new Error('missing memo method') return tx.memo() } it('returns a raw transaction', async () => { const { transaction: result } = await module.craftTransaction(context, { intentType: 'transaction', asset: { type: 'native' }, type: TYPE, sender: ADDRESS, recipient: RECIPIENT, amount: AMOUNT, memo: { type: 'NO_MEMO' }, }) const envelope = envelopeFromAnyXDR(result, 'base64') expect(envelope.toXDR('base64').length).toEqual(188) }) it('should use estimated fees when user does not provide them for crafting a transaction', async () => { const { transaction: transactionXdr } = await module.craftTransaction(context, { intentType: 'transaction', asset: { type: 'native' }, type: TYPE, sender: ADDRESS, recipient: RECIPIENT, amount: AMOUNT, memo: { type: 'NO_MEMO' }, }) const fees = readFees(transactionXdr) expect(fees).toBeGreaterThan(0) }) it('should use custom user fees when user provides it for crafting a transaction', async () => { const customFees = 99n const { transaction: transactionXdr } = await module.craftTransaction( context, { intentType: 'transaction', asset: { type: 'native' }, type: TYPE, sender: ADDRESS, recipient: RECIPIENT, amount: AMOUNT, memo: { type: 'NO_MEMO' }, }, { customFees: { value: customFees } } ) const fees = readFees(transactionXdr) expect(fees).toEqual(Number(customFees)) }) it('should have no memo when not provided by user', async () => { const { transaction: transactionXdr } = await module.craftTransaction(context, { intentType: 'transaction', asset: { type: 'native' }, type: TYPE, sender: ADDRESS, recipient: RECIPIENT, amount: AMOUNT, memo: { type: 'NO_MEMO' }, }) expect(readMemo(transactionXdr)).toEqual(xdr.Memo.memoNone()) }) it('should have a memo when provided by user', async () => { const { transaction: transactionXdr } = await module.craftTransaction(context, { intentType: 'transaction', asset: { type: 'native' }, type: TYPE, sender: ADDRESS, recipient: RECIPIENT, amount: AMOUNT, memo: { type: 'MEMO_TEXT', value: 'test', }, }) expect(readMemo(transactionXdr)).toEqual(xdr.Memo.memoText(Buffer.from('test', 'ascii'))) }) }) /** * Signed transaction envelope from BACK-10960 / Jira (mainnet). * Live Horizon reports `tx_failed` with operation-level `op_underfunded`. */ describe('broadcast', () => { let broadcastModule: ReturnType let broadcastContext: StellarContext const tx = 'AAAAAgAAAABRUCgFba+DTbei2ifpyYt5w2Hh0VyZ+X9fayjIDne7YAAAAGQCkDOGAAAABQAAAAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEAAAAAAAAAAQAAAACEIuPfWXgM8WhyqjrpFdIcGV1SUVhMzPUm4YspNHF60QAAAAAAAAAAALkd2QAAAAAAAAABDne7YAAAAEAASzsT/yDIfCfEDstkfnznXjiN7rNd7PkKQEn+rRIFm9EHoirGfHipWoBdYMrc6ixQD/0y0of1piSid8TLiFAB' beforeAll(() => { broadcastModule = createApi() broadcastContext = { config: async () => ({ name: 'Stellar', unit: { name: 'Lumen', code: 'XLM', magnitude: 7 }, explorer: { url: 'https://horizon.stellar.org/' }, status: { type: 'active' }, }), logger: () => undefined, } }) it('maps Horizon transaction failure to StellarBroadcastFailedError with XDR context', async () => { await expect(broadcastModule.broadcast(broadcastContext, tx)).rejects.toMatchObject({ name: 'StellarBroadcastFailedError', documentationSummary: 'The source account does not have enough balance to send the payment amount while maintaining its minimum reserve.', horizonTransactionCode: 'tx_failed', horizonOperationCodes: ['op_underfunded'], stellarDocUrl: 'https://developers.stellar.org/docs/data/apis/horizon/api-reference/errors/result-codes/transactions', decodedResultXdr: { feeChargedStroops: expect.stringMatching(/^\d+$/), resultSwitch: 'txFailed', }, envelopeXdr: tx, cause: expect.objectContaining({ name: 'AxiosError' }), }) }) }) })