From b0ac34ee01a70fc10311c3d21db3c2d2f5e62b49 Mon Sep 17 00:00:00 2001 From: Padreug Date: Fri, 19 Jun 2026 23:29:50 +0200 Subject: [PATCH 1/2] feat(lnbits): typed nostr-transport error codes + retry policy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements the error-handling layer agreed in the 2026-05-26 cross-session handshake (aiolabs/bitspire#52). LnbitsClient now rejects ERROR responses with a typed LnbitsRpcError carrying the machine-readable code + its retry disposition, so callers (and the state machine, Phase D.3) branch on disposition rather than string-matching the human-readable message. - error-codes.ts: LnbitsErrorCode (14 codes, signer/transport/app classes) mirroring the lnbits canonical enum; retryPolicyFor() classifier; LnbitsRpcError.fromResponse(). - error_code is optional-additive on the wire: an absent or unknown code maps to internal_error (retry-once), so this is safe to land before lnbits emits codes — no string-matching, no special parser paths. - invoice_already_paid is flagged terminal-idempotent (isIdempotentSuccess) for the cash-out resume-after-reboot case. Part of Phase D, aiolabs/bitspire#52. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../lnbits/src/__tests__/error-codes.test.ts | 79 ++++++++++ packages/lnbits/src/client.ts | 3 +- packages/lnbits/src/error-codes.ts | 143 ++++++++++++++++++ packages/lnbits/src/index.ts | 7 + packages/lnbits/src/types.ts | 5 + 5 files changed, 236 insertions(+), 1 deletion(-) create mode 100644 packages/lnbits/src/__tests__/error-codes.test.ts create mode 100644 packages/lnbits/src/error-codes.ts 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 } // ============================================================================ From 78d54cdc94a7539eadafc831c7af365d408c0bfe Mon Sep 17 00:00:00 2001 From: Padreug Date: Fri, 19 Jun 2026 23:34:07 +0200 Subject: [PATCH 2/2] feat(machine): re-pair UX on bunker deauth at boot A revoked / TTL-expired / off-policy bunker binding now surfaces a dedicated "Pairing Required" screen instead of a raw error, and a signer/relay timeout shows "Signer Unreachable" (transient). Shared classifyInitError() maps the typed BunkerRejectedError / BunkerTimeoutError (by name, so it survives bundle boundaries) to maintenance-screen sentinels, used at every store init catch + the App.vue fallback. App.vue's nested-ternary screen copy refactored to a keyed map (cleaner, and the new screens drop in). Scope: boot-time detection (covers the dominant restart-after-revoke case). Mid-session re-pair detection (flipping the screen when a sign fails during a live flow) is a deliberate follow-up. Part of Phase D, aiolabs/bitspire#52. Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/machine/src/App.vue | 65 +++++++++++++------ .../src/services/__tests__/init-error.test.ts | 30 +++++++++ apps/machine/src/services/init-error.ts | 17 +++++ apps/machine/src/stores/atm.ts | 7 +- 4 files changed, 96 insertions(+), 23 deletions(-) create mode 100644 apps/machine/src/services/__tests__/init-error.test.ts create mode 100644 apps/machine/src/services/init-error.ts diff --git a/apps/machine/src/App.vue b/apps/machine/src/App.vue index 652f708..48cfc8a 100644 --- a/apps/machine/src/App.vue +++ b/apps/machine/src/App.vue @@ -4,6 +4,7 @@ import { useRoute } from 'vue-router' import { useAtmStore } from '@/stores/atm' import { useTheme } from '@/composables/useTheme' import { setBranding } from '@/composables/useBranding' +import { classifyInitError } from '@/services/init-error' import { Badge } from '@/components/ui/badge' import { Button } from '@/components/ui/button' import { Sun, Moon } from 'lucide-vue-next' @@ -20,6 +21,46 @@ function formatSats(sats: number): string { return sats.toLocaleString() } +/** + * Maintenance-screen copy keyed by the `initError` sentinel. Falls back to a + * generic out-of-service message (the raw error text shows under debug only). + */ +const MAINTENANCE_SCREENS: Record = { + maintenance: { + title: 'Under Service', + message: 'This machine is currently being serviced. We will be back shortly.', + }, + 'awaiting-fees': { + title: 'Awaiting Configuration', + message: + 'Awaiting fee configuration from operator. Contact operator to publish initial fee config.', + }, + unpaired: { + title: 'Pairing Required', + message: + 'This machine needs to be re-paired by the operator before it can accept transactions.', + }, + 'signer-unreachable': { + title: 'Signer Unreachable', + message: 'Cannot reach the signing service right now. This usually resolves shortly.', + }, +} + +const GENERIC_SCREEN = { + title: 'ATM Unavailable', + message: + 'This machine is temporarily out of service. Please try again later or use another machine.', +} + +const maintenanceScreen = computed(() => + atmStore.initError ? (MAINTENANCE_SCREENS[atmStore.initError] ?? GENERIC_SCREEN) : GENERIC_SCREEN +) + +/** True when the screen is a known sentinel (hide the raw debug error line). */ +const isKnownMaintenanceScreen = computed( + () => !!atmStore.initError && atmStore.initError in MAINTENANCE_SCREENS +) + const formattedBtcPrice = computed(() => { if (atmStore.btcPrice === null) return null const local = `${atmStore.fiatCode}/BTC: ${atmStore.fiatSymbol}${Math.round(atmStore.btcPrice).toLocaleString()}` @@ -96,7 +137,7 @@ onMounted(async () => { atmStore.startPricePolling() } catch (error) { console.error('[App] Initialization failed:', error) - atmStore.initError = error instanceof Error ? error.message : 'Initialization failed' + atmStore.initError = classifyInitError(error) } }) @@ -141,29 +182,13 @@ function toggleLiveServices() {

- {{ - atmStore.initError === 'maintenance' - ? 'Under Service' - : atmStore.initError === 'awaiting-fees' - ? 'Awaiting Configuration' - : 'ATM Unavailable' - }} + {{ maintenanceScreen.title }}

- {{ - atmStore.initError === 'maintenance' - ? 'This machine is currently being serviced. We will be back shortly.' - : atmStore.initError === 'awaiting-fees' - ? 'Awaiting fee configuration from operator. Contact operator to publish initial fee config.' - : 'This machine is temporarily out of service. Please try again later or use another machine.' - }} + {{ maintenanceScreen.message }}

{{ atmStore.initError }} diff --git a/apps/machine/src/services/__tests__/init-error.test.ts b/apps/machine/src/services/__tests__/init-error.test.ts new file mode 100644 index 0000000..fc4215b --- /dev/null +++ b/apps/machine/src/services/__tests__/init-error.test.ts @@ -0,0 +1,30 @@ +import { describe, it, expect } from 'vitest' +import { BunkerRejectedError, BunkerTimeoutError } from '@bitSpire/nostr-client' +import { classifyInitError } from '../init-error.js' + +describe('classifyInitError', () => { + it('maps a bunker rejection (revoke / TTL / off-policy) to "unpaired"', () => { + expect(classifyInitError(new BunkerRejectedError('revoked'))).toBe('unpaired') + }) + + it('maps a bunker timeout to "signer-unreachable"', () => { + expect(classifyInitError(new BunkerTimeoutError('no response'))).toBe('signer-unreachable') + }) + + it('classifies by error name across bundle boundaries (no instanceof)', () => { + // A structurally-equivalent error from a different module copy still maps. + const lookalike = Object.assign(new Error('x'), { name: 'BunkerRejectedError' }) + expect(classifyInitError(lookalike)).toBe('unpaired') + }) + + it('surfaces a generic error message unchanged', () => { + expect(classifyInitError(new Error('relay down'))).toBe('relay down') + }) + + it('uses the fallback for non-Error throws', () => { + expect(classifyInitError('boom', 'Lightning initialization failed')).toBe( + 'Lightning initialization failed' + ) + expect(classifyInitError(undefined)).toBe('Initialization failed') + }) +}) diff --git a/apps/machine/src/services/init-error.ts b/apps/machine/src/services/init-error.ts new file mode 100644 index 0000000..f9b0c84 --- /dev/null +++ b/apps/machine/src/services/init-error.ts @@ -0,0 +1,17 @@ +/** + * Classify an initialization failure into a maintenance-screen sentinel + * (see App.vue's MAINTENANCE_SCREENS). + * + * Bunker failures (aiolabs/bitspire#52) get dedicated screens: + * - `BunkerRejectedError` (revoked / TTL-expired / off-policy binding) → + * `unpaired` — the operator must re-pair the machine. + * - `BunkerTimeoutError` (signer/relay unreachable) → `signer-unreachable`, + * a transient condition. + * Everything else surfaces its raw message (or the caller's fallback). + */ +export function classifyInitError(error: unknown, fallback = 'Initialization failed'): string { + const name = (error as { name?: string } | null)?.name + if (name === 'BunkerRejectedError') return 'unpaired' + if (name === 'BunkerTimeoutError') return 'signer-unreachable' + return error instanceof Error ? error.message : fallback +} diff --git a/apps/machine/src/stores/atm.ts b/apps/machine/src/stores/atm.ts index efc80cf..ca22847 100644 --- a/apps/machine/src/stores/atm.ts +++ b/apps/machine/src/stores/atm.ts @@ -10,6 +10,7 @@ import { type ATMMachine, } from '@bitSpire/state-machine' import { initializeLightningServices, fetchBtcPrice } from '@/services/lightning' +import { classifyInitError } from '@/services/init-error' import { startOperatorConfigService, type OperatorConfigService, @@ -706,7 +707,7 @@ export const useAtmStore = defineStore('atm', () => { useLiveServices.value = false initialize(mockServices) } else { - initError.value = error instanceof Error ? error.message : 'Lightning initialization failed' + initError.value = classifyInitError(error, 'Lightning initialization failed') } } } @@ -1019,7 +1020,7 @@ export const useAtmStore = defineStore('atm', () => { useLiveServices.value = false initialize(mockServices) } else { - initError.value = error instanceof Error ? error.message : 'HAL initialization failed' + initError.value = classifyInitError(error, 'HAL initialization failed') } } } @@ -1330,7 +1331,7 @@ export const useAtmStore = defineStore('atm', () => { initialize(mockServices) } } else { - initError.value = error instanceof Error ? error.message : 'Hardware initialization failed' + initError.value = classifyInitError(error, 'Hardware initialization failed') } } }