@ledgerhq/coin-stellar
Version:
Ledger Stellar Coin integration
119 lines (101 loc) • 4.39 kB
text/typescript
// SPDX-FileCopyrightText: © 2026 LEDGER SAS
// SPDX-License-Identifier: Apache-2.0
import { BadResponseError, Horizon } from '@stellar/stellar-sdk'
import coinConfig from '../config'
import { broadcastTransaction } from './horizon'
/** Parses on mainnet; used only so `submitTransaction` is reached (mocked). */
const tx =
'AAAAAgAAAABRUCgFba+DTbei2ifpyYt5w2Hh0VyZ+X9fayjIDne7YAAAAGQCkDOGAAAABQAAAAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEAAAAAAAAAAQAAAACEIuPfWXgM8WhyqjrpFdIcGV1SUVhMzPUm4YspNHF60QAAAAAAAAAAALkd2QAAAAAAAAABDne7YAAAAEAASzsT/yDIfCfEDstkfnznXjiN7rNd7PkKQEn+rRIFm9EHoirGfHipWoBdYMrc6ixQD/0y0of1piSid8TLiFAB'
function horizonTransactionFailedExtras() {
return {
type: 'https://stellar.org/horizon-errors/transaction_failed',
title: 'Transaction Failed',
status: 400,
detail: 'The transaction failed when submitted to the stellar network.',
extras: {
result_codes: {
transaction: 'tx_failed' as const,
operations: ['op_no_account'] as string[],
},
result_xdr: 'AAAAAAAAAGT////4AAAAAA==',
envelope_xdr: tx,
},
}
}
describe('broadcastTransaction', () => {
let submitTransactionSpy: jest.SpiedFunction<Horizon.Server['submitTransaction']>
beforeAll(() => {
coinConfig.setCoinConfig(() => ({
explorer: { url: 'https://horizon-broadcast-unit-test.invalid/' },
status: { type: 'active' },
}))
})
beforeEach(() => {
submitTransactionSpy = jest
.spyOn(Horizon.Server.prototype, 'submitTransaction')
.mockRejectedValue(new Error('mock not configured'))
})
afterEach(() => {
submitTransactionSpy.mockRestore()
})
it('maps BadResponseError with Horizon body on error.response to StellarBroadcastFailedError', async () => {
const body = horizonTransactionFailedExtras()
submitTransactionSpy.mockRejectedValue(
new BadResponseError('Transaction submission failed. Server responded: 400 Bad Request', body)
)
await expect(broadcastTransaction(tx)).rejects.toMatchObject({
name: 'StellarBroadcastFailedError',
horizonTransactionCode: 'tx_failed',
cause: expect.any(BadResponseError),
})
})
it('maps axios-like error with Horizon body on error.response.data to StellarBroadcastFailedError', async () => {
const body = horizonTransactionFailedExtras()
submitTransactionSpy.mockRejectedValue(
Object.assign(new Error('Request failed with status code 400'), {
name: 'AxiosError',
response: { status: 400, statusText: 'Bad Request', data: body },
})
)
await expect(broadcastTransaction(tx)).rejects.toMatchObject({
name: 'StellarBroadcastFailedError',
horizonTransactionCode: 'tx_failed',
cause: expect.objectContaining({ name: 'AxiosError' }),
})
})
it('maps malformed Horizon body without result_codes to StellarBroadcastFailedError', async () => {
const body = {
type: 'https://stellar.org/horizon-errors/transaction_malformed',
title: 'Transaction Malformed',
status: 400,
detail: 'Horizon could not decode the transaction envelope in this request.',
extras: { envelope_xdr: tx },
}
submitTransactionSpy.mockRejectedValue(
new BadResponseError('Transaction submission failed. Server responded: 400 Bad Request', body)
)
await expect(broadcastTransaction(tx)).rejects.toMatchObject({
name: 'StellarBroadcastFailedError',
documentationSummary: 'Horizon could not decode the transaction envelope in this request.',
horizonTransactionCode: '',
cause: expect.any(BadResponseError),
})
})
it('rethrows a plain error with no Horizon payload unchanged (same reference)', async () => {
const original = new Error('ECONNREFUSED')
submitTransactionSpy.mockRejectedValue(original)
await expect(broadcastTransaction(tx)).rejects.toBe(original)
})
it('rethrows an axios-like error when response.data is not a Horizon transaction failure body', async () => {
const original = Object.assign(new Error('Request failed with status code 500'), {
name: 'AxiosError',
response: {
status: 500,
statusText: 'Internal Server Error',
data: { title: 'Internal Server Error' },
},
})
submitTransactionSpy.mockRejectedValue(original)
await expect(broadcastTransaction(tx)).rejects.toBe(original)
})
})