Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 9 additions & 2 deletions src/graphql/public/root/mutation/ln-invoice-payment-send.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import dedent from "dedent"
// FLASH FORK: import ibex dependencies
import { PaymentSendStatus } from "@domain/bitcoin/lightning"
import Ibex from "@services/ibex/client"
import { IbexError } from "@services/ibex/errors"
import { IbexError, InsufficientIbexBalance } from "@services/ibex/errors"
import { withPaymentIdempotency } from "@app/payments/idempotency"

const LnInvoicePaymentInput = GT.Input({
Expand Down Expand Up @@ -129,7 +129,14 @@ const LnInvoicePaymentSendMutation = GT.Field<
// }
// }

// Preserve the existing generic IBEX-failure message.
// Insufficient balance is actionable for the caller — surface the typed
// INSUFFICIENT_BALANCE error via the error map instead of the generic
// catch-all below (issue #93).
if (status instanceof InsufficientIbexBalance) {
return { status: "failed", errors: [mapAndParseErrorForGqlResponse(status)] }
}

// Preserve the existing generic IBEX-failure message for other IBEX errors.
if (status instanceof IbexError) {
return {
status: "failed",
Expand Down
18 changes: 16 additions & 2 deletions src/services/ibex/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,13 @@ import {
UsdWalletAmount,
} from "./types"

import { errorHandler, IbexError, ParseError, UnexpectedIbexResponse } from "./errors"
import {
errorHandler,
httpErrorHandler,
IbexError,
ParseError,
UnexpectedIbexResponse,
} from "./errors"
import { ibexWebhookEndpoints, ibexWebhookSecret } from "./webhook-config"

const Ibex = new IbexClient(
Expand Down Expand Up @@ -241,7 +247,15 @@ const payInvoice = async (
webhookSecret: ibexWebhookSecret,
} as PayInvoiceV2BodyParam
addAttributesToCurrentSpan({ "request.params": JSON.stringify(bodyWithHooks) })
return Ibex.payInvoiceV2(bodyWithHooks).then(errorHandler)
// Call the generated SDK through withAuth directly (instead of
// Ibex.payInvoiceV2) so a failed payment's FetchError — which carries the
// parsed IBEX error body on `.data` — reaches httpErrorHandler intact.
// ibex-client@3.2.0's own ApiError wrapper discards that body, which is what
// made "insufficient balance" 400s unclassifiable (lnflash/ibex-client#6).
return Ibex.authentication
.withAuth(() => Ibex.ibex.payInvoiceV2(bodyWithHooks))
.then(errorHandler)
.catch(httpErrorHandler)
}

// onchain transactions are typically high-value
Expand Down
110 changes: 103 additions & 7 deletions src/services/ibex/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,17 +27,113 @@ export class UnexpectedIbexResponse extends IbexError {

export class ParseError extends IbexError {}

export class InsufficientIbexBalance extends IbexError {}
export class InsufficientIbexBalance extends IbexError {
// Full vendor detail, preserved verbatim for logs/spans (pino's error
// serializer picks up own enumerable properties).
readonly detail?: string

constructor(err: Error, level: ErrorLevel = ErrorLevel.Info, detail?: string) {
super(err, level)
this.detail = detail
// error-map forwards `message` to the GraphQL client verbatim (the
// INSUFFICIENT_BALANCE case sets message = error.message). Keep it the
// human-readable IBEX detail — never the wrapped error's stack trace —
// but strip the trailing internal IBEX account UUID ("... account: <id>"):
// end users must never see vendor-internal identifiers on the failure
// screen. The unstripped detail stays on `this.detail`.
this.message =
detail?.replace(/[.,]?\s*account:\s*\S+\s*$/i, "") ?? "insufficient balance"
}
}
export class CompletedInvoice extends IbexError {}

/**
* Best-effort extraction of the IBEX error text from a failed call.
* Shapes handled:
* - ibex-client > 3.2.0 (lnflash/ibex-client#6): ApiError carries the parsed
* body on `ibexResponse` and the extracted text on `ibexMessage`
* - the generated api SDK's FetchError: parsed JSON body on `.data`,
* typically `{ "error": "..." }`, sometimes `{ "message": "..." }` or text
*/
export const ibexErrorDetail = (e: unknown): string | undefined => {
if (typeof e !== "object" || e === null) return undefined
const { ibexMessage, data } = e as { ibexMessage?: unknown; data?: unknown }
if (typeof ibexMessage === "string" && ibexMessage !== "") return ibexMessage
if (typeof data === "string" && data !== "") return data
if (typeof data === "object" && data !== null) {
const record = data as Record<string, unknown>
if (typeof record.error === "string" && record.error !== "") return record.error
if (typeof record.message === "string" && record.message !== "") return record.message
}
return undefined
}

// The single needle list mapping IBEX error prose to a typed error class.
// Matching is case-insensitive (the haystack is lowercased once here) so a
// vendor rewording like "Insufficient Balance" cannot silently revert
// classification to the generic path. Both errorHandler and httpErrorHandler
// classify through this helper — never add a needle anywhere else.
const classifyIbexErrorText = (
text: string,
): typeof InsufficientIbexBalance | typeof CompletedInvoice | undefined => {
const haystack = text.toLowerCase()
if (haystack.includes("insufficient balance")) return InsufficientIbexBalance
if (haystack.includes("payment already prepared")) return CompletedInvoice
return undefined
}

export const errorHandler = <T>(
e: T | IbexClientError | AuthenticationError | ApiError,
): T | IbexError => {
if (e instanceof AuthenticationError) return new IbexError(e, ErrorLevel.Critical)
else if (e instanceof ApiError && e.message.includes("insufficient balance"))
return new InsufficientIbexBalance(e, ErrorLevel.Info)
else if (e instanceof ApiError && e.message.includes("payment already prepared"))
return new CompletedInvoice(e, ErrorLevel.Info)
else if (e instanceof IbexClientError) return new IbexError(e, ErrorLevel.Warn)
else return e
if (e instanceof ApiError) {
// Classify against the structured body detail when the error carries one,
// and against `message` otherwise (flash's raw-fetch path embeds the body
// text in the message; ibex-client@3.2.0's ApiError message is only the
// wrapped stack, which is why body-carrying shapes are checked first).
const detail = ibexErrorDetail(e)
const classified = classifyIbexErrorText(detail ?? e.message)
if (classified === InsufficientIbexBalance)
return new InsufficientIbexBalance(e, ErrorLevel.Info, detail)
if (classified === CompletedInvoice) return new CompletedInvoice(e, ErrorLevel.Info)
// Unclassified path: mirror httpErrorHandler's carry — an unrecognized
// IBEX 400 whose ApiError has a stack-only message must still log what
// IBEX actually said. Build a new IbexError rather than mutating `e`,
// which the caller may still hold.
if (detail !== undefined) {
const generic = new IbexError(e, ErrorLevel.Warn)
generic.message = `${detail}\n${generic.message}`
return generic
}
}
if (e instanceof IbexClientError) return new IbexError(e, ErrorLevel.Warn)
return e
}

/**
* Classify a raw error thrown by the generated IBEX SDK (or fetch) before
* ibex-client's ApiError wrapper can discard the response body. With the
* pinned ibex-client@3.2.0, ApiError keeps only `httpCode` — the JSON error
* body that distinguishes e.g. "insufficient balance" from any other 400 only
* exists on the underlying FetchError's `.data` (lnflash/ibex-client#6).
* Call sites that need body-level classification invoke the SDK through
* `Ibex.authentication.withAuth` themselves and route the caught error here.
*/
export const httpErrorHandler = (e: unknown): IbexError => {
const raw = e instanceof Error ? e : new Error(String(e))
if (raw instanceof AuthenticationError) return new IbexError(raw, ErrorLevel.Critical)
const detail = ibexErrorDetail(raw)
// ApiError's constructor keeps `.status` as httpCode, which IbexError reads.
const wrapped = raw instanceof IbexClientError ? raw : new ApiError(raw)
const classified = classifyIbexErrorText(detail ?? raw.message)
if (classified === InsufficientIbexBalance)
return new InsufficientIbexBalance(wrapped, ErrorLevel.Info, detail)
if (classified === CompletedInvoice)
return new CompletedInvoice(wrapped, ErrorLevel.Info)
// Unclassified path: ApiError's message is only the wrapped stack, so carry
// the extracted body detail into it — an unrecognized IBEX 400 must still
// log what IBEX actually said, not just "FetchError: Bad Request" + stack.
if (detail !== undefined && !(raw instanceof IbexClientError))
wrapped.message = `${detail}\n${wrapped.message}`
return new IbexError(wrapped, ErrorLevel.Warn)
}
58 changes: 57 additions & 1 deletion test/flash/unit/graphql/error-map.spec.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import { mapError } from "@graphql/error-map"
import { ErrorLevel } from "@domain/shared"
import { mapAndParseErrorForGqlResponse, mapError } from "@graphql/error-map"
import { PhoneAccountAlreadyExistsCannotUpgradeError } from "@services/kratos"
import {
BridgeWithdrawalNotFoundError,
BridgeWithdrawalAlreadyInitiatedError,
BridgeDepositInstructionsMissingError,
} from "@services/bridge/errors"
import { IbexError, InsufficientIbexBalance } from "@services/ibex/errors"

describe("error-map", () => {
it("maps BridgeWithdrawalNotFoundError to BRIDGE_WITHDRAWAL_NOT_FOUND", () => {
Expand Down Expand Up @@ -36,4 +38,58 @@ describe("error-map", () => {
expect(result.message).toContain("already registered")
expect(result.extensions.code).toBe("PHONE_ALREADY_REGISTERED_TO_ANOTHER_USER")
})

describe("IBEX payment errors (issue #93)", () => {
const insufficientDetail =
"insufficient balance. Current Balance: 5.000000. Estimated Fee: 0.001109. invoice amount: 5.042164. account: 39c6e986-979b-40ab-9e7b-df18a9277a84"
// client-facing message strips IBEX's trailing internal account UUID
const insufficientDetailStripped =
"insufficient balance. Current Balance: 5.000000. Estimated Fee: 0.001109. invoice amount: 5.042164"

it("maps InsufficientIbexBalance to INSUFFICIENT_BALANCE with the IBEX detail", () => {
const input = new InsufficientIbexBalance(
new Error("Bad Request"),
ErrorLevel.Info,
insufficientDetail,
)
const result = mapError(input)

expect(result.extensions.code).toBe("INSUFFICIENT_BALANCE")
expect(result.message).toBe(insufficientDetailStripped)
// never the internal IBEX account UUID
expect(result.message).not.toContain("account:")
expect(result.message).not.toContain("39c6e986-979b-40ab-9e7b-df18a9277a84")
})

it("maps InsufficientIbexBalance without detail to a clean fallback message", () => {
const input = new InsufficientIbexBalance(new Error("Bad Request"))
const result = mapError(input)

expect(result.extensions.code).toBe("INSUFFICIENT_BALANCE")
expect(result.message).toBe("insufficient balance")
// never a stack trace
expect(result.message).not.toContain(" at ")
})

it("surfaces INSUFFICIENT_BALANCE through mapAndParseErrorForGqlResponse", () => {
const input = new InsufficientIbexBalance(
new Error("Bad Request"),
ErrorLevel.Info,
insufficientDetail,
)
const result = mapAndParseErrorForGqlResponse(input)

expect(result).toMatchObject({
code: "INSUFFICIENT_BALANCE",
message: insufficientDetailStripped,
})
})

it("keeps other IBEX errors mapped to the generic IBEX_ERROR", () => {
const result = mapError(new IbexError(new Error("some other 400")))

expect(result.extensions.code).toBe("IBEX_ERROR")
expect(result.message).toContain("An error occurred")
})
})
})
90 changes: 90 additions & 0 deletions test/flash/unit/graphql/ln-invoice-payment-send.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
const mockPayInvoice = jest.fn()

jest.mock("@services/ibex/client", () => ({
__esModule: true,
default: { payInvoice: (...args: unknown[]) => mockPayInvoice(...args) },
}))

// Run the resolver's execute() directly — idempotency plumbing is not under test
jest.mock("@app/payments/idempotency", () => ({
withPaymentIdempotency: async ({ execute }: { execute: () => Promise<unknown> }) =>
execute(),
}))

import { ErrorLevel } from "@domain/shared"
import LnInvoicePaymentSendMutation from "@graphql/public/root/mutation/ln-invoice-payment-send"
import { IbexError, InsufficientIbexBalance } from "@services/ibex/errors"

const insufficientDetail =
"insufficient balance. Current Balance: 5.000000. Estimated Fee: 0.001109. invoice amount: 5.042164. account: 39c6e986-979b-40ab-9e7b-df18a9277a84"
// client-facing message strips IBEX's trailing internal account UUID
const insufficientDetailStripped =
"insufficient balance. Current Balance: 5.000000. Estimated Fee: 0.001109. invoice amount: 5.042164"

type PaymentSendResult = {
status?: string
errors: { message: string; code?: string }[]
}

const resolvePayment = async (): Promise<PaymentSendResult> => {
const resolve = LnInvoicePaymentSendMutation.resolve as unknown as (
source: null,
args: { input: Record<string, unknown> },
ctx: { domainAccount: Record<string, unknown> },
) => Promise<PaymentSendResult>

return resolve(
null,
{ input: { walletId: "wallet-1", paymentRequest: "lnbc1" } },
{ domainAccount: { id: "account-1" } },
)
}

describe("lnInvoicePaymentSend IBEX error surfacing (issue #93)", () => {
beforeEach(() => {
jest.clearAllMocks()
})

it("returns a typed INSUFFICIENT_BALANCE error for insufficient-balance failures", async () => {
mockPayInvoice.mockResolvedValue(
new InsufficientIbexBalance(
new Error("Bad Request"),
ErrorLevel.Info,
insufficientDetail,
),
)

const result = await resolvePayment()

expect(result.status).toBe("failed")
expect(result.errors[0]).toMatchObject({
code: "INSUFFICIENT_BALANCE",
message: insufficientDetailStripped,
})
// never the internal IBEX account UUID
expect(result.errors[0].message).not.toContain("account:")
})

it("keeps the generic message for other IBEX failures", async () => {
mockPayInvoice.mockResolvedValue(new IbexError(new Error("some other 400")))

const result = await resolvePayment()

expect(result.status).toBe("failed")
expect(result.errors[0].message).toBe(
"An unexpected error occurred. Please try again later.",
)
expect(result.errors[0].code).toBeUndefined()
})

it("returns success for a settled payment", async () => {
mockPayInvoice.mockResolvedValue({
transaction: { payment: { status: { id: 2 } } },
})

const result = await resolvePayment()

expect(result.status).toBe("success")
expect(result.errors).toEqual([])
})
})
Loading
Loading