@ledgerhq/coin-stellar 6.26.1 → 6.26.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,178 @@
1
+ // SPDX-FileCopyrightText: © 2026 LEDGER SAS
2
+ // SPDX-License-Identifier: Apache-2.0
3
+
4
+ import { BadResponseError } from '@stellar/stellar-sdk'
5
+
6
+ /** Horizon RFC 7807 problem+json body (subset used by coin-stellar). */
7
+ export type HorizonErrorBody = {
8
+ type?: string
9
+ title?: string
10
+ status?: number
11
+ detail?: string
12
+ extras?: {
13
+ envelope_xdr?: string
14
+ result_xdr?: string
15
+ result_codes?: {
16
+ transaction?: string
17
+ operations?: string[]
18
+ }
19
+ invalid_field?: string
20
+ reason?: string
21
+ }
22
+ }
23
+
24
+ const GENERIC_TRANSACTION_FAILED_DETAIL =
25
+ /The transaction failed when submitted to the Stellar network/i
26
+
27
+ /** Horizon `extras.result_codes.transaction` → one-line description (Stellar docs). */
28
+ const HORIZON_TRANSACTION_RESULT_CODE_DOCUMENTATION: Record<string, string> = {
29
+ tx_success: 'The transaction succeeded.',
30
+ tx_failed: 'One of the operations failed (none were applied).',
31
+ tx_too_early: 'The ledger closeTime was before the minTime.',
32
+ tx_too_late: 'The ledger closeTime was after the maxTime.',
33
+ tx_missing_operation: 'No operation was specified.',
34
+ tx_bad_seq: 'Sequence number does not match source account.',
35
+ tx_bad_auth: 'Too few valid signatures / wrong network.',
36
+ tx_insufficient_balance: 'Fee would bring account below reserve.',
37
+ tx_no_source_account: 'Source account not found.',
38
+ tx_insufficient_fee: 'Fee is too small.',
39
+ tx_bad_auth_extra: 'Unused signatures attached to transaction.',
40
+ tx_internal_error: 'An unknown error occurred.',
41
+ tx_fee_bump_inner_success: 'Fee bump inner transaction succeeded.',
42
+ tx_fee_bump_inner_failed: 'Fee bump inner transaction failed.',
43
+ tx_not_supported: 'Transaction type not supported.',
44
+ tx_bad_sponsorship: 'Sponsorship is invalid.',
45
+ tx_bad_min_seq_age_or_gap: 'Minimum sequence age or gap precondition failed.',
46
+ tx_malformed: 'Transaction is malformed.',
47
+ tx_soroban_invalid: 'Soroban transaction is invalid.',
48
+ }
49
+
50
+ /** Horizon `extras.result_codes.operations[]` → one-line description (Stellar docs). */
51
+ const HORIZON_OPERATION_RESULT_CODE_DOCUMENTATION: Record<string, string> = {
52
+ op_inner: 'The inner object result is valid and the operation was a success.',
53
+ op_bad_auth:
54
+ 'There are too few valid signatures, or the transaction was submitted to the wrong network.',
55
+ op_no_source_account: 'The source account was not found.',
56
+ op_no_account: 'The source account was not found.',
57
+ op_not_supported: 'The operation is not supported at this time.',
58
+ op_too_many_subentries: 'Max number of subentries (1000) already reached.',
59
+ op_exceeded_work_limit: 'Operation did too much work.',
60
+ op_malformed: 'The operation input is invalid.',
61
+ op_underfunded:
62
+ 'The source account does not have enough balance to send the payment amount while maintaining its minimum reserve.',
63
+ op_src_no_trust:
64
+ 'The source account does not have a trustline for the asset it is trying to send.',
65
+ op_src_not_authorized: 'The source account is not authorized to send this asset.',
66
+ op_no_destination: 'The destination account does not exist.',
67
+ op_no_trust: 'The destination account does not have a trustline for the asset being sent.',
68
+ op_not_authorized: 'The destination account is not authorized to hold this asset.',
69
+ op_line_full:
70
+ 'The destination account does not have sufficient limits to receive the amount and still satisfy its buying liabilities.',
71
+ op_no_issuer: 'The issuer of the asset does not exist.',
72
+ }
73
+
74
+ /** Operation result codes that mark success for a prior op in a multi-op failure list. */
75
+ const HORIZON_OPERATION_SUCCESS_RESULT_CODES = new Set<string>(['op_inner'])
76
+
77
+ function failingOperationCode(operations: string[] | undefined): string | undefined {
78
+ return operations?.find((code) => !HORIZON_OPERATION_SUCCESS_RESULT_CODES.has(code))
79
+ }
80
+
81
+ export function isHorizonErrorBody(data: unknown): data is HorizonErrorBody {
82
+ return !!(
83
+ data &&
84
+ typeof data === 'object' &&
85
+ 'title' in data &&
86
+ typeof data.title === 'string' &&
87
+ 'status' in data &&
88
+ typeof data.status === 'number'
89
+ )
90
+ }
91
+
92
+ /**
93
+ * Horizon failures are usually {@link BadResponseError} with `response` = problem+json body.
94
+ * Axios rejects first; the SDK forwards it unchanged, so the body lives on `error.response.data`.
95
+ */
96
+ export function getHorizonErrorBody(error: unknown): HorizonErrorBody | null {
97
+ if (error instanceof BadResponseError && isHorizonErrorBody(error.response)) {
98
+ return error.response
99
+ }
100
+ if (error && typeof error === 'object' && 'response' in error) {
101
+ const data = (error as { response?: { data?: unknown } }).response?.data
102
+ if (isHorizonErrorBody(data)) {
103
+ return data
104
+ }
105
+ }
106
+ return null
107
+ }
108
+
109
+ function lookupOperationDocumentation(operationCode: string | undefined): string | undefined {
110
+ if (!operationCode) {
111
+ return undefined
112
+ }
113
+ return HORIZON_OPERATION_RESULT_CODE_DOCUMENTATION[operationCode]
114
+ }
115
+
116
+ function lookupTransactionDocumentation(transactionCode: string | undefined): string | undefined {
117
+ if (!transactionCode) {
118
+ return undefined
119
+ }
120
+ return HORIZON_TRANSACTION_RESULT_CODE_DOCUMENTATION[transactionCode]
121
+ }
122
+
123
+ function detailDocumentation(body: HorizonErrorBody): string | undefined {
124
+ const detail = body.detail?.trim()
125
+ if (!detail || GENERIC_TRANSACTION_FAILED_DETAIL.test(detail)) {
126
+ return undefined
127
+ }
128
+ return detail
129
+ }
130
+
131
+ function extrasReasonDocumentation(body: HorizonErrorBody): string | undefined {
132
+ const reason = body.extras?.reason?.trim()
133
+ if (!reason) {
134
+ return undefined
135
+ }
136
+ const invalidField = body.extras?.invalid_field?.trim()
137
+ return invalidField ? `${reason} (field: ${invalidField})` : reason
138
+ }
139
+
140
+ /**
141
+ * Best-effort human-readable summary for logs (`errorExtras.documentationSummary`).
142
+ * Prefers operation result codes, then transaction codes, then Horizon `detail` / `extras.reason`.
143
+ */
144
+ export function documentationSummaryFromHorizonBody(body: HorizonErrorBody): string {
145
+ const transactionCode = body.extras?.result_codes?.transaction
146
+ const operationCode = failingOperationCode(body.extras?.result_codes?.operations)
147
+
148
+ const operationSummary = lookupOperationDocumentation(operationCode)
149
+ if (operationSummary) {
150
+ return operationSummary
151
+ }
152
+
153
+ const transactionSummary = lookupTransactionDocumentation(transactionCode)
154
+ if (transactionSummary && transactionCode !== 'tx_failed') {
155
+ return transactionSummary
156
+ }
157
+
158
+ const detail = detailDocumentation(body)
159
+ if (detail) {
160
+ return detail
161
+ }
162
+
163
+ const extrasReason = extrasReasonDocumentation(body)
164
+ if (extrasReason) {
165
+ return extrasReason
166
+ }
167
+
168
+ if (transactionSummary) {
169
+ return transactionSummary
170
+ }
171
+
172
+ const title = body.title?.trim()
173
+ if (title) {
174
+ return title
175
+ }
176
+
177
+ return 'Unknown Horizon error.'
178
+ }
@@ -9,6 +9,21 @@ import {
9
9
  } from './horizonLedgerErrors'
10
10
 
11
11
  describe('messageFromHorizonUnknown', () => {
12
+ it('returns Horizon documentationSummary when response body is present', () => {
13
+ const error = Object.assign(new Error('Request failed with status code 400'), {
14
+ response: {
15
+ data: {
16
+ title: 'Transaction Malformed',
17
+ status: 400,
18
+ detail: 'Horizon could not decode the transaction envelope in this request.',
19
+ },
20
+ },
21
+ })
22
+ expect(messageFromHorizonUnknown(error)).toBe(
23
+ 'Horizon could not decode the transaction envelope in this request.'
24
+ )
25
+ })
26
+
12
27
  it('returns message for Error', () => {
13
28
  expect(messageFromHorizonUnknown(new Error('hello'))).toBe('hello')
14
29
  })
@@ -39,18 +54,59 @@ describe('throwHorizonLedgerOrOperationsError', () => {
39
54
  ).toThrow(notFound)
40
55
  })
41
56
 
57
+ it('throws notFoundMessage when Horizon problem body has status 404', () => {
58
+ const error = Object.assign(new Error('Request failed with status code 404'), {
59
+ response: {
60
+ data: {
61
+ title: 'Resource Missing',
62
+ status: 404,
63
+ detail: 'The resource at the url requested was not found.',
64
+ },
65
+ },
66
+ })
67
+ expect(() => throwHorizonLedgerOrOperationsError(error, notFound)).toThrow(notFound)
68
+ })
69
+
42
70
  it('throws LedgerAPI4xx 429 for too many requests in message', () => {
43
71
  expect(() =>
44
72
  throwHorizonLedgerOrOperationsError(new Error('Too Many Requests'), notFound)
45
73
  ).toThrow(LedgerAPI4xx)
46
74
  })
47
75
 
76
+ it('throws LedgerAPI4xx 429 when Horizon problem body has status 429', () => {
77
+ const error = Object.assign(new Error('Request failed with status code 429'), {
78
+ response: {
79
+ data: {
80
+ title: 'Rate Limited',
81
+ status: 429,
82
+ detail: 'The request was rate limited.',
83
+ },
84
+ },
85
+ })
86
+ expect(() => throwHorizonLedgerOrOperationsError(error, notFound)).toThrow(
87
+ expect.objectContaining({ name: 'LedgerAPI4xx', status: 429 })
88
+ )
89
+ })
90
+
48
91
  it('throws LedgerAPI4xx for other 4xx status in message', () => {
49
92
  expect(() =>
50
93
  throwHorizonLedgerOrOperationsError(new Error('status code 400'), notFound)
51
94
  ).toThrow(LedgerAPI4xx)
52
95
  })
53
96
 
97
+ it('throws LedgerAPI4xx when Horizon problem body has status 400', () => {
98
+ const error = Object.assign(new Error('Request failed with status code 400'), {
99
+ response: {
100
+ data: {
101
+ title: 'Bad Request',
102
+ status: 400,
103
+ detail: 'The request you sent was invalid in some way.',
104
+ },
105
+ },
106
+ })
107
+ expect(() => throwHorizonLedgerOrOperationsError(error, notFound)).toThrow(LedgerAPI4xx)
108
+ })
109
+
54
110
  test.each([['401'], ['403']])(
55
111
  'throws LedgerAPI4xx when message contains status code %s',
56
112
  (code) => {
@@ -69,6 +125,19 @@ describe('throwHorizonLedgerOrOperationsError', () => {
69
125
  ).toThrow(LedgerAPI5xx)
70
126
  })
71
127
 
128
+ it('throws LedgerAPI5xx when Horizon problem body has status 503', () => {
129
+ const error = Object.assign(new Error('Request failed with status code 503'), {
130
+ response: {
131
+ data: {
132
+ title: 'Internal Server Error',
133
+ status: 503,
134
+ detail: 'An error occurred while processing this request.',
135
+ },
136
+ },
137
+ })
138
+ expect(() => throwHorizonLedgerOrOperationsError(error, notFound)).toThrow(LedgerAPI5xx)
139
+ })
140
+
72
141
  test.each([['502'], ['500']])(
73
142
  'throws LedgerAPI5xx when message contains status code %s',
74
143
  (code) => {
@@ -3,8 +3,13 @@
3
3
 
4
4
  import { LedgerAPI4xx, LedgerAPI5xx, NetworkDown } from '@ledgerhq/errors'
5
5
  import { NetworkError, NotFoundError } from '@stellar/stellar-sdk'
6
+ import { documentationSummaryFromHorizonBody, getHorizonErrorBody } from './horizonErrorBody'
6
7
 
7
8
  export function messageFromHorizonUnknown(e: unknown): string {
9
+ const body = getHorizonErrorBody(e)
10
+ if (body) {
11
+ return documentationSummaryFromHorizonBody(body)
12
+ }
8
13
  if (e instanceof Error) {
9
14
  return e.message
10
15
  }
@@ -22,9 +27,28 @@ const HORIZON_NETWORK_DOWN = /ECONNRESET|ECONNREFUSED|ENOTFOUND|EPIPE|ETIMEDOUT/
22
27
  const HORIZON_UNDEFINED_OBJECT = /undefined is not an object/
23
28
 
24
29
  export function throwHorizonLedgerOrOperationsError(e: unknown, notFoundMessage: string): never {
30
+ if (e instanceof NotFoundError) {
31
+ throw new Error(notFoundMessage)
32
+ }
33
+
34
+ const body = getHorizonErrorBody(e)
35
+ const status = body?.status
36
+ if (status === 404) {
37
+ throw new Error(notFoundMessage)
38
+ }
39
+ if (status === 429) {
40
+ throw new LedgerAPI4xx('status code 4xx', { status: 429, url: undefined, method: 'GET' })
41
+ }
42
+ if (status !== undefined && status >= 400 && status < 500) {
43
+ throw new LedgerAPI4xx()
44
+ }
45
+ if (status !== undefined && status >= 500 && status < 600) {
46
+ throw new LedgerAPI5xx()
47
+ }
48
+
25
49
  const errorMsg = messageFromHorizonUnknown(e)
26
50
 
27
- if (e instanceof NotFoundError || HORIZON_STATUS_404.exec(errorMsg)) {
51
+ if (HORIZON_STATUS_404.exec(errorMsg)) {
28
52
  throw new Error(notFoundMessage)
29
53
  }
30
54
  if (HORIZON_TOO_MANY_REQUESTS.exec(errorMsg)) {