diff --git a/packages/lnbits/src/__tests__/error-codes.test.ts b/packages/lnbits/src/__tests__/error-codes.test.ts new file mode 100644 index 0000000..7420810 --- /dev/null +++ b/packages/lnbits/src/__tests__/error-codes.test.ts @@ -0,0 +1,79 @@ +import { describe, it, expect } from 'vitest' +import { + LnbitsErrorCode, + LnbitsRpcError, + parseErrorCode, + retryPolicyFor, +} from '../error-codes.js' + +describe('parseErrorCode', () => { + it('recognizes every canonical code', () => { + for (const code of Object.values(LnbitsErrorCode)) { + expect(parseErrorCode(code)).toBe(code) + } + }) + + it('returns null for unknown / absent codes', () => { + expect(parseErrorCode('made_up_code')).toBeNull() + expect(parseErrorCode(undefined)).toBeNull() + expect(parseErrorCode(null)).toBeNull() + expect(parseErrorCode('')).toBeNull() + }) +}) + +describe('retryPolicyFor', () => { + it('classifies the signer + transport + app codes as agreed', () => { + expect(retryPolicyFor(LnbitsErrorCode.OperatorSignerUnavailable)).toBe('retry-backoff') + expect(retryPolicyFor(LnbitsErrorCode.OperatorSignerRejected)).toBe('terminal') + expect(retryPolicyFor(LnbitsErrorCode.RateLimited)).toBe('retry-long-backoff') + expect(retryPolicyFor(LnbitsErrorCode.InternalError)).toBe('retry-once') + expect(retryPolicyFor(LnbitsErrorCode.InvoiceAlreadyPaid)).toBe('terminal-idempotent') + expect(retryPolicyFor(LnbitsErrorCode.InsufficientBalance)).toBe('terminal') + }) + + it('has a policy for every code (exhaustive map)', () => { + for (const code of Object.values(LnbitsErrorCode)) { + expect(retryPolicyFor(code)).toBeTruthy() + } + }) +}) + +describe('LnbitsRpcError.fromResponse', () => { + it('maps a known error_code through', () => { + const err = LnbitsRpcError.fromResponse('pay_invoice', { + request_id: 'pay-1', + error_code: 'insufficient_balance', + error: 'not enough sats', + }) + expect(err).toBeInstanceOf(LnbitsRpcError) + expect(err.code).toBe(LnbitsErrorCode.InsufficientBalance) + expect(err.rpcName).toBe('pay_invoice') + expect(err.requestId).toBe('pay-1') + expect(err.message).toBe('not enough sats') + expect(err.retryPolicy).toBe('terminal') + expect(err.isRetryable).toBe(false) + }) + + it('treats an ABSENT error_code as internal_error (retry-once)', () => { + const err = LnbitsRpcError.fromResponse('get_wallet', { request_id: 'w-1' }) + expect(err.code).toBe(LnbitsErrorCode.InternalError) + expect(err.retryPolicy).toBe('retry-once') + expect(err.isRetryable).toBe(true) + }) + + it('treats an UNKNOWN error_code as internal_error', () => { + const err = LnbitsRpcError.fromResponse('get_wallet', { + request_id: 'w-2', + error_code: 'brand_new_code_we_dont_know', + }) + expect(err.code).toBe(LnbitsErrorCode.InternalError) + }) + + it('flags invoice_already_paid as idempotent-success', () => { + const err = LnbitsRpcError.fromResponse('pay_invoice', { + request_id: 'p-1', + error_code: 'invoice_already_paid', + }) + expect(err.isIdempotentSuccess).toBe(true) + }) +}) diff --git a/packages/lnbits/src/client.ts b/packages/lnbits/src/client.ts index 76b796e..946df5d 100644 --- a/packages/lnbits/src/client.ts +++ b/packages/lnbits/src/client.ts @@ -26,6 +26,7 @@ import { type Event as NostrEvent, } from '@bitSpire/nostr-client' import { verifyEvent } from 'nostr-tools' +import { LnbitsRpcError } from './error-codes.js' import type { LnbitsConfig, @@ -478,7 +479,7 @@ export class LnbitsClient { clearTimeout(timer) this.pending.delete(requestId) if (response.status === 'ERROR') { - reject(new Error(response.error ?? `${rpcName}: server returned ERROR`)) + reject(LnbitsRpcError.fromResponse(rpcName, response)) return } resolve(response.data as T) diff --git a/packages/lnbits/src/error-codes.ts b/packages/lnbits/src/error-codes.ts new file mode 100644 index 0000000..1944664 --- /dev/null +++ b/packages/lnbits/src/error-codes.ts @@ -0,0 +1,143 @@ +/** + * LNbits nostr-transport error taxonomy. + * + * Machine-readable discriminators for kind-21000 ERROR responses. Mirror of + * the lnbits canonical enum (`core/services/nostr_transport/error_codes.py`) + * and the vocabulary table in `docs/devs/nostr-transport.md`. Drift detection + * = diff this enum against that table. Agreed in the 2026-05-26 cross-session + * handshake on aiolabs/bitspire#52. + * + * Wire shape (additive to the existing envelope): + * { "status": "ERROR", "request_id": "...", "error_code": "...", "error": "..." } + * + * `error_code` is optional-additive for one lnbits release, then required. + * An ABSENT `error_code` is treated as `internal_error` (retry-once) — we do + * not string-match the human-readable `error`. So un-migrated handlers get a + * safe retry-then-surface default with no special parser paths. + */ + +export enum LnbitsErrorCode { + // signer class — the operator's signer (bunker) on the LNbits side + OperatorSignerUnavailable = 'operator_signer_unavailable', + OperatorSignerRejected = 'operator_signer_rejected', + OperatorSignerUnconfigured = 'operator_signer_unconfigured', + // transport class + Unauthorized = 'unauthorized', + RateLimited = 'rate_limited', + UnknownMethod = 'unknown_method', + InvalidParams = 'invalid_params', + InternalError = 'internal_error', + // app class + WalletNotFound = 'wallet_not_found', + InsufficientBalance = 'insufficient_balance', + InvoiceAlreadyPaid = 'invoice_already_paid', + InvoiceExpired = 'invoice_expired', + PaymentFailed = 'payment_failed', + AccountNotFound = 'account_not_found', +} + +/** + * Retry disposition for an error code: + * - `retry-backoff` — transient; retry with short exponential backoff. + * - `retry-long-backoff` — rate-limited; retry with a longer backoff. + * - `retry-once` — retry exactly once, then surface (the `internal_error` default). + * - `terminal` — do not retry; surface to the user. + * - `terminal-idempotent` — terminal, but the operation already took effect + * (e.g. `invoice_already_paid` — a cash-out watcher treats it as settled). + */ +export type RetryPolicy = + | 'retry-backoff' + | 'retry-long-backoff' + | 'retry-once' + | 'terminal' + | 'terminal-idempotent' + +const RETRY_POLICIES: Record = { + [LnbitsErrorCode.OperatorSignerUnavailable]: 'retry-backoff', + [LnbitsErrorCode.OperatorSignerRejected]: 'terminal', + [LnbitsErrorCode.OperatorSignerUnconfigured]: 'terminal', + [LnbitsErrorCode.Unauthorized]: 'terminal', + [LnbitsErrorCode.RateLimited]: 'retry-long-backoff', + [LnbitsErrorCode.UnknownMethod]: 'terminal', + [LnbitsErrorCode.InvalidParams]: 'terminal', + [LnbitsErrorCode.InternalError]: 'retry-once', + [LnbitsErrorCode.WalletNotFound]: 'terminal', + [LnbitsErrorCode.InsufficientBalance]: 'terminal', + [LnbitsErrorCode.InvoiceAlreadyPaid]: 'terminal-idempotent', + [LnbitsErrorCode.InvoiceExpired]: 'terminal', + // payment_failed is terminal-with-detail: the sub-reason rides in `error`. + [LnbitsErrorCode.PaymentFailed]: 'terminal', + [LnbitsErrorCode.AccountNotFound]: 'terminal', +} + +const RETRYABLE: ReadonlySet = new Set([ + 'retry-backoff', + 'retry-long-backoff', + 'retry-once', +]) + +/** Parse a wire string into a known code, or null if unrecognized. */ +export function parseErrorCode(raw: string | undefined | null): LnbitsErrorCode | null { + if (!raw) return null + return (Object.values(LnbitsErrorCode) as string[]).includes(raw) + ? (raw as LnbitsErrorCode) + : null +} + +/** Retry disposition for a code. */ +export function retryPolicyFor(code: LnbitsErrorCode): RetryPolicy { + return RETRY_POLICIES[code] +} + +/** + * Typed error thrown by `LnbitsClient` on an ERROR response. Carries the + * machine-readable `code` + its `retryPolicy` so callers (and the state + * machine) branch on disposition rather than string-matching `message`. + */ +export class LnbitsRpcError extends Error { + readonly code: LnbitsErrorCode + readonly rpcName: string + readonly requestId: string + readonly retryPolicy: RetryPolicy + + constructor(args: { + code: LnbitsErrorCode + rpcName: string + requestId: string + message?: string + }) { + super(args.message ?? `${args.rpcName}: ${args.code}`) + this.name = 'LnbitsRpcError' + this.code = args.code + this.rpcName = args.rpcName + this.requestId = args.requestId + this.retryPolicy = retryPolicyFor(args.code) + } + + /** + * Build from a wire ERROR response. An absent/unknown `error_code` maps to + * `internal_error` (retry-once) per the deprecation-window contract. + */ + static fromResponse( + rpcName: string, + response: { request_id: string; error_code?: string | null; error?: string } + ): LnbitsRpcError { + const code = parseErrorCode(response.error_code) ?? LnbitsErrorCode.InternalError + return new LnbitsRpcError({ + code, + rpcName, + requestId: response.request_id, + message: response.error ?? `${rpcName}: server returned ERROR (${code})`, + }) + } + + /** True when the disposition permits a retry (any backoff/once policy). */ + get isRetryable(): boolean { + return RETRYABLE.has(this.retryPolicy) + } + + /** True when the operation already took effect despite the error. */ + get isIdempotentSuccess(): boolean { + return this.retryPolicy === 'terminal-idempotent' + } +} diff --git a/packages/lnbits/src/index.ts b/packages/lnbits/src/index.ts index 34463dc..10e0f05 100644 --- a/packages/lnbits/src/index.ts +++ b/packages/lnbits/src/index.ts @@ -49,6 +49,13 @@ */ export { LnbitsClient } from './client.js' +export { + LnbitsErrorCode, + LnbitsRpcError, + parseErrorCode, + retryPolicyFor, +} from './error-codes.js' +export type { RetryPolicy } from './error-codes.js' export type { LnbitsConfig, LnbitsRpcRequest, diff --git a/packages/lnbits/src/types.ts b/packages/lnbits/src/types.ts index 6372182..c040b3c 100644 --- a/packages/lnbits/src/types.ts +++ b/packages/lnbits/src/types.ts @@ -39,7 +39,12 @@ export interface LnbitsRpcResponse { /** Non-null on subscription push events. Null on regular acks. */ subscription_id?: string | null data?: T + /** Human-readable error detail (ERROR status only). */ error?: string + /** Machine-readable error discriminator (ERROR status). Optional-additive + * for one lnbits release, then required; absent → internal_error. See + * error-codes.ts (aiolabs/bitspire#52). */ + error_code?: string } // ============================================================================