UNPKG

@ledgerhq/coin-stellar

Version:
179 lines (157 loc) 6.71 kB
// SPDX-FileCopyrightText: © 2026 LEDGER SAS // SPDX-License-Identifier: Apache-2.0 import { BadResponseError } from '@stellar/stellar-sdk' /** Horizon RFC 7807 problem+json body (subset used by coin-stellar). */ export type HorizonErrorBody = { type?: string title?: string status?: number detail?: string extras?: { envelope_xdr?: string result_xdr?: string result_codes?: { transaction?: string operations?: string[] } invalid_field?: string reason?: string } } const GENERIC_TRANSACTION_FAILED_DETAIL = /The transaction failed when submitted to the Stellar network/i /** Horizon `extras.result_codes.transaction` → one-line description (Stellar docs). */ const HORIZON_TRANSACTION_RESULT_CODE_DOCUMENTATION: Record<string, string> = { tx_success: 'The transaction succeeded.', tx_failed: 'One of the operations failed (none were applied).', tx_too_early: 'The ledger closeTime was before the minTime.', tx_too_late: 'The ledger closeTime was after the maxTime.', tx_missing_operation: 'No operation was specified.', tx_bad_seq: 'Sequence number does not match source account.', tx_bad_auth: 'Too few valid signatures / wrong network.', tx_insufficient_balance: 'Fee would bring account below reserve.', tx_no_source_account: 'Source account not found.', tx_insufficient_fee: 'Fee is too small.', tx_bad_auth_extra: 'Unused signatures attached to transaction.', tx_internal_error: 'An unknown error occurred.', tx_fee_bump_inner_success: 'Fee bump inner transaction succeeded.', tx_fee_bump_inner_failed: 'Fee bump inner transaction failed.', tx_not_supported: 'Transaction type not supported.', tx_bad_sponsorship: 'Sponsorship is invalid.', tx_bad_min_seq_age_or_gap: 'Minimum sequence age or gap precondition failed.', tx_malformed: 'Transaction is malformed.', tx_soroban_invalid: 'Soroban transaction is invalid.', } /** Horizon `extras.result_codes.operations[]` → one-line description (Stellar docs). */ const HORIZON_OPERATION_RESULT_CODE_DOCUMENTATION: Record<string, string> = { op_inner: 'The inner object result is valid and the operation was a success.', op_bad_auth: 'There are too few valid signatures, or the transaction was submitted to the wrong network.', op_no_source_account: 'The source account was not found.', op_no_account: 'The source account was not found.', op_not_supported: 'The operation is not supported at this time.', op_too_many_subentries: 'Max number of subentries (1000) already reached.', op_exceeded_work_limit: 'Operation did too much work.', op_malformed: 'The operation input is invalid.', op_underfunded: 'The source account does not have enough balance to send the payment amount while maintaining its minimum reserve.', op_src_no_trust: 'The source account does not have a trustline for the asset it is trying to send.', op_src_not_authorized: 'The source account is not authorized to send this asset.', op_no_destination: 'The destination account does not exist.', op_no_trust: 'The destination account does not have a trustline for the asset being sent.', op_not_authorized: 'The destination account is not authorized to hold this asset.', op_line_full: 'The destination account does not have sufficient limits to receive the amount and still satisfy its buying liabilities.', op_no_issuer: 'The issuer of the asset does not exist.', } /** Operation result codes that mark success for a prior op in a multi-op failure list. */ const HORIZON_OPERATION_SUCCESS_RESULT_CODES = new Set<string>(['op_inner']) function failingOperationCode(operations: string[] | undefined): string | undefined { return operations?.find((code) => !HORIZON_OPERATION_SUCCESS_RESULT_CODES.has(code)) } export function isHorizonErrorBody(data: unknown): data is HorizonErrorBody { return !!( data && typeof data === 'object' && 'title' in data && typeof data.title === 'string' && 'status' in data && typeof data.status === 'number' ) } /** * Horizon failures are usually {@link BadResponseError} with `response` = problem+json body. * Axios rejects first; the SDK forwards it unchanged, so the body lives on `error.response.data`. */ export function getHorizonErrorBody(error: unknown): HorizonErrorBody | null { if (error instanceof BadResponseError && isHorizonErrorBody(error.response)) { return error.response } if (error && typeof error === 'object' && 'response' in error) { const data = (error as { response?: { data?: unknown } }).response?.data if (isHorizonErrorBody(data)) { return data } } return null } function lookupOperationDocumentation(operationCode: string | undefined): string | undefined { if (!operationCode) { return undefined } return HORIZON_OPERATION_RESULT_CODE_DOCUMENTATION[operationCode] } function lookupTransactionDocumentation(transactionCode: string | undefined): string | undefined { if (!transactionCode) { return undefined } return HORIZON_TRANSACTION_RESULT_CODE_DOCUMENTATION[transactionCode] } function detailDocumentation(body: HorizonErrorBody): string | undefined { const detail = body.detail?.trim() if (!detail || GENERIC_TRANSACTION_FAILED_DETAIL.test(detail)) { return undefined } return detail } function extrasReasonDocumentation(body: HorizonErrorBody): string | undefined { const reason = body.extras?.reason?.trim() if (!reason) { return undefined } const invalidField = body.extras?.invalid_field?.trim() return invalidField ? `${reason} (field: ${invalidField})` : reason } /** * Best-effort human-readable summary for logs (`errorExtras.documentationSummary`). * Prefers operation result codes, then transaction codes, then Horizon `detail` / `extras.reason`. */ export function documentationSummaryFromHorizonBody(body: HorizonErrorBody): string { const transactionCode = body.extras?.result_codes?.transaction const operationCode = failingOperationCode(body.extras?.result_codes?.operations) const operationSummary = lookupOperationDocumentation(operationCode) if (operationSummary) { return operationSummary } const transactionSummary = lookupTransactionDocumentation(transactionCode) if (transactionSummary && transactionCode !== 'tx_failed') { return transactionSummary } const detail = detailDocumentation(body) if (detail) { return detail } const extrasReason = extrasReasonDocumentation(body) if (extrasReason) { return extrasReason } if (transactionSummary) { return transactionSummary } const title = body.title?.trim() if (title) { return title } return 'Unknown Horizon error.' }