Skip to content

fix(ibex): surface typed INSUFFICIENT_BALANCE on payment failure - #476

Merged
islandbitcoin merged 3 commits into
mainfrom
fix/insufficient-balance-error
Aug 12, 2026
Merged

fix(ibex): surface typed INSUFFICIENT_BALANCE on payment failure#476
islandbitcoin merged 3 commits into
mainfrom
fix/insufficient-balance-error

Conversation

@islandbitcoin

Copy link
Copy Markdown
Contributor

Root cause

IBEX answers an underfunded payment with a 400 and a JSON body like {"error":"insufficient balance. Current Balance: 5.000000. Estimated Fee: 0.001109. invoice amount: 5.042164. account: ..."}, but the API returned only a generic error. Two stacked defects:

  1. Classification never fired. src/services/ibex/errors.ts matched e.message.includes("insufficient balance") — but ibex-client@3.2.0's ApiError message is just the wrapped FetchError stack (FetchError: Bad Request…). The JSON body lives on FetchError.data and is discarded at wrap time (lnflash/ibex-client#6), so on the SDK pay path the InsufficientIbexBalance branch was dead code. This also silently broke the fygaro credit-topup treasury-float detection, which matches on InsufficientIbexBalance by class.
  2. The resolver swallowed it anyway. lnInvoicePaymentSend collapsed every IbexError subclass into a hardcoded "An unexpected error occurred. Please try again later.", bypassing mapAndParseErrorForGqlResponse.

Design

Works with the currently pinned ibex-client@3.2.0 — no dependency bumps:

  • payInvoice now invokes the generated SDK through Ibex.authentication.withAuth directly (both fields are public API; getIbexToken already uses Ibex.authentication.storage the same way), so the raw FetchError — parsed IBEX body on .data — reaches the new httpErrorHandler before ibex-client's wrapper can discard it. httpErrorHandler classifies insufficient-balance / payment-already-prepared from the body and wraps results in the existing IbexError hierarchy (httpCode preserved via ApiError).
  • errorHandler additionally reads structured detail (duck-typed ibexMessage / .data), with the existing message-text matching kept as fallback. Once a release containing lnflash/ibex-client#6's fix (lnflash/ibex-client#12, which puts the body on ApiError.ibexResponse/ibexMessage) is adopted, every other call site picks up body-level classification with no further code change here.
  • InsufficientIbexBalance now carries a clean client-facing message — the full IBEX detail when available, "insufficient balance" otherwise. The error map forwards message verbatim for the INSUFFICIENT_BALANCE case, which previously would have shipped a raw stack trace to the client.
  • lnInvoicePaymentSend branches on InsufficientIbexBalancemapAndParseErrorForGqlResponse (code INSUFFICIENT_BALANCE); all other IBEX failures keep the exact existing generic message. error-map.ts needed no changes — the InsufficientIbexBalance → INSUFFICIENT_BALANCE case already existed.

Tests

  • test/flash/unit/services/ibex/errors.spec.tsibexErrorDetail extraction (JSON error/message fields, plain-text body, structured ibexMessage, no-body); errorHandler (raw-fetch-style message match, future structured shape, pinned-version stack-only ApiError stays generic, CompletedInvoice, AuthenticationError, success passthrough); httpErrorHandler (insufficient-balance 400 JSON and non-JSON → typed error with httpCode 400 + full detail message; other 400s → generic IbexError unchanged; network errors → no httpCode; non-Error throwables).
  • test/flash/unit/services/ibex/pay-invoice.spec.tspayInvoice wiring through withAuth: insufficient-balance 400 → InsufficientIbexBalance, other 400 → generic, network failure → generic, success passthrough.
  • test/flash/unit/graphql/error-map.spec.tsInsufficientIbexBalanceINSUFFICIENT_BALANCE (with detail and with fallback message, never a stack), mapAndParseErrorForGqlResponse shape, generic IbexErrorIBEX_ERROR unchanged.
  • test/flash/unit/graphql/ln-invoice-payment-send.spec.ts — resolver returns typed INSUFFICIENT_BALANCE for insufficient balance, keeps the generic message for other IBEX errors, success path intact.

Local checks: targeted unit suites (14 suites / 197 tests including adjacent client-usd-wallet and fygaro credit-topup consumers) pass; yarn tsc-check passes; eslint clean on all changed files. No changes to .env or yarn.lock.

Fixes #93

🤖 Generated with Claude Code

https://claude.ai/code/session_015fAxSGEsL2LsMx1uCjAzHH

bobodread876 and others added 3 commits August 11, 2026 23:24
When an IBEX payment failed for lack of funds (400 with body
{"error":"insufficient balance. Current Balance: ..."}), the GraphQL API
returned only a generic error. Two stacked defects:

1. errors.ts classified by e.message.includes("insufficient balance"), but
   ibex-client@3.2.0's ApiError message is only the wrapped FetchError stack
   ("FetchError: Bad Request...") — the JSON body is discarded at wrap time
   (lnflash/ibex-client#6), so the InsufficientIbexBalance branch never fired
   on the SDK pay path. This also silently broke the fygaro credit-topup
   float-exhaustion detection, which matches on InsufficientIbexBalance.
2. lnInvoicePaymentSend collapsed every IbexError subclass into a hardcoded
   "An unexpected error occurred." message, bypassing the error map.

Fix, working with the pinned ibex-client@3.2.0:

- payInvoice now calls the generated SDK through Ibex.authentication.withAuth
  directly, so the raw FetchError — which carries the parsed IBEX body on
  `.data` — reaches the new httpErrorHandler before the ApiError wrapper can
  discard it. httpErrorHandler classifies insufficient balance / payment
  already prepared from the body and wraps everything in the existing
  IbexError hierarchy (httpCode preserved).
- errorHandler now also reads structured detail (duck-typed `ibexMessage` /
  `.data`) so other call sites pick up classification automatically once an
  ibex-client release with lnflash/ibex-client#6's fix is adopted; the
  existing message-text fallback is preserved.
- InsufficientIbexBalance now carries a clean client-facing message (the IBEX
  detail when available, "insufficient balance" otherwise) — error-map
  forwards `message` verbatim, which previously would have exposed a stack
  trace.
- lnInvoicePaymentSend branches on InsufficientIbexBalance and returns the
  mapped INSUFFICIENT_BALANCE error; all other IBEX errors keep the existing
  generic message. error-map already mapped InsufficientIbexBalance →
  INSUFFICIENT_BALANCE; no change needed there.

Tests: services/ibex/errors.spec.ts (detail extraction, errorHandler old and
new shapes, httpErrorHandler JSON/non-JSON/other-400/network cases),
services/ibex/pay-invoice.spec.ts (payInvoice wiring through withAuth),
graphql/error-map.spec.ts (INSUFFICIENT_BALANCE mapping, generic IBEX_ERROR
unchanged), graphql/ln-invoice-payment-send.spec.ts (resolver branches).

Fixes #93

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015fAxSGEsL2LsMx1uCjAzHH
…hing

Review fixes for PR #476:

- httpErrorHandler no longer discards the extracted IBEX body detail for
  unclassified errors: the detail is prepended to the wrapped ApiError's
  message so an unrecognized IBEX 400 logs what IBEX actually said instead
  of only "FetchError: Bad Request" plus a stack.

- Classification needles now live in a single shared classifyIbexErrorText
  helper used by both errorHandler and httpErrorHandler, and matching is
  case-insensitive so a vendor rewording (e.g. "Insufficient Balance")
  cannot silently revert typed errors to the generic path.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015fAxSGEsL2LsMx1uCjAzHH
…y detail on errorHandler fall-through

Review fixes for PR #476:

- InsufficientIbexBalance no longer ships IBEX's internal account UUID
  ("... account: <uuid>") to end users — the client-facing message strips
  the trailing account tail; the unstripped vendor text is preserved on a
  new `detail` property for logs/spans.
- errorHandler's unclassified ApiError fall-through now mirrors
  httpErrorHandler's body-detail carry: when a body-carrying ApiError
  (future ibex-client shape, lnflash/ibex-client#12) matches no needle, the
  extracted detail is prepended to a fresh generic IbexError's message
  instead of being dropped, without mutating the caller's error.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015fAxSGEsL2LsMx1uCjAzHH
@islandbitcoin
islandbitcoin merged commit 7c6a96a into main Aug 12, 2026
15 checks passed
islandbitcoin added a commit that referenced this pull request Aug 12, 2026
…477)

* chore(deps): bump ibex-client to ^3.3.0 for structured error details

ibex-client 3.3.0 (lnflash/ibex-client#12) populates httpCode,
ibexResponse, and ibexMessage on ApiError at construction, so every
IBEX call site now gets the response detail #476 could only extract
at the payInvoice raw-fetch seam under pinned 3.2.0.

No code changes needed: errors.ts already duck-types ibexMessage.
New integration tests construct the real 3.3.0 ApiError (no simulated
fields) and pin extraction, insufficient-balance classification with
account-id stripping, and the unclassified fall-through detail.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015fAxSGEsL2LsMx1uCjAzHH

* fix(ibex): dedupe error detail carry, refresh stale 3.2.0 comments

Review fixes for #477:

- errorHandler/httpErrorHandler no longer prepend the IBEX body detail
  when the message already contains it — ibex-client@3.3.0's ApiError
  embeds "IBEX response (<code>): <detail>" itself, so the unclassified
  carries were logging every unrecognized IBEX error's detail twice
  (and the duplicate reached Discord alert embeds via #475). Tests
  extended to pin the detail appearing exactly once (verified failing
  against the pre-fix code).
- Rewrote the payInvoice raw-fetch seam comment: the 3.2.0
  body-discarding rationale no longer holds on 3.3.0; the seam is
  belt-and-braces only, follow-up to collapse it filed as #478.
- Rewrote httpErrorHandler's docblock and errorHandler's classification
  comment to describe 3.3.0 behavior (ApiError carries the body;
  httpErrorHandler stays as defense-in-depth for the raw-fetch seam).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015fAxSGEsL2LsMx1uCjAzHH

* fix(ibex): derive dedupe detail from the capped ApiError extraction

httpErrorHandler extracted the detail from the raw FetchError's .data
(uncapped) but compared it against the ApiError-wrapped message, which
embeds only ibex-client's MAX_IBEX_MESSAGE_LENGTH-truncated copy. For
any body over the cap — the Cloudflare-HTML-error-page outage the cap
exists for — the includes() guard always missed, prepending the full
multi-KB body onto every failing call's message with the first 500
chars duplicated (truncated + full), defeating the upstream cap in
logs and Discord alert embeds.

Construct the ApiError first and derive the detail from it: both sides
of the dedupe comparison now use the same bounded string. When the raw
error is already an IbexClientError, wrapped === raw and the extraction
is unchanged. The uncapped body remains available on ibexResponse.

Adds a regression test feeding a body 4x over MAX_IBEX_MESSAGE_LENGTH
through httpErrorHandler, asserting the message stays bounded and the
truncated detail appears exactly once (fails against the previous
implementation).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015fAxSGEsL2LsMx1uCjAzHH

* docs(ibex): correct httpErrorHandler comment — ibexResponse does not survive onto the returned error

Round-3 review fix for PR #477: the comment claimed the uncapped body
stays reachable on wrapped.ibexResponse, but wrapped is function-local
and IbexError's constructor copies only name/httpCode/message — during
a Cloudflare-outage scenario a maintainer would search for ibexResponse
and find nothing. State reality: the uncapped body is deliberately
dropped so pino never serializes a multi-KB blob.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015fAxSGEsL2LsMx1uCjAzHH

---------

Co-authored-by: Dread <bobodread@bobodread.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Return Insufficient balance message on payment failure

2 participants