From c9b009975856a1f4c9b502fa0215123ead30343c Mon Sep 17 00:00:00 2001 From: BSVanon Date: Tue, 5 May 2026 13:26:36 -0500 Subject: [PATCH 1/2] fix: detect WERR_REVIEW_ACTIONS by duck-type, not constructor.name MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Vite production build (esbuild minification) renames class names, so `error?.constructor.name === 'WERR_REVIEW_ACTIONS'` returns mangled identifiers and the check fails at runtime. Errors then fall through to the generic `{message: ...}` wrapper which strips `code: 5`, `reviewActionResults`, `sendWithResults`, `txid`, `tx`, and `noSendChange` from the response body. The calling app's `@bsv/sdk` HTTPWalletJSON dispatch checks `data.code === 5` to reconstruct WERR_REVIEW_ACTIONS with the signed transaction and review reasons. Without `code: 5` it falls through to its own generic Error wrapper, leaving the app unable to recover the signed tx or surface review reasons to the user — so flows that use `acceptDelayedBroadcast: false` and hit any review-actions condition (serviceError, doubleSpend, invalidTx) become un-debuggable. Replace the constructor.name check with a duck-type predicate that matches on the canonical message + presence of the `reviewActionResults` array. This is robust to bundler minification and to the cross-package class identity issue (errors thrown by `@bsv/wallet-toolbox`'s WERR_REVIEW_ACTIONS class are not `instanceof @bsv/sdk`'s WERR_REVIEW_ACTIONS class — they are separate class declarations). Applies to all three handlers: createAction, signAction, internalizeAction. Tests: full build clean (renderer + electron), all existing tests pass (translations + onWalletReady — 25 total). Co-Authored-By: Claude Opus 4.7 (1M context) --- src/onWalletReady.ts | 63 +++++++++++++++++++++++++++++++++----------- 1 file changed, 47 insertions(+), 16 deletions(-) diff --git a/src/onWalletReady.ts b/src/onWalletReady.ts index 829c89b..572fe90 100644 --- a/src/onWalletReady.ts +++ b/src/onWalletReady.ts @@ -40,6 +40,34 @@ interface HttpResponseEvent { body: string; } +/** + * Duck-type check for WERR_REVIEW_ACTIONS-shaped errors. We don't use + * `error?.constructor.name === 'WERR_REVIEW_ACTIONS'` because Vite's + * default production build (esbuild minification) renames class names + * — `constructor.name` then returns mangled identifiers like `'a'`, + * the check fails, and the error falls through to the generic + * `{message: ...}` wrapper which strips `code`, `tx`, `txid`, + * `reviewActionResults`, and `noSendChange`. The calling app then + * can't recover the signed transaction or surface review reasons, + * making `acceptDelayedBroadcast: false` flows un-debuggable. + * + * `instanceof WERR_REVIEW_ACTIONS` doesn't work either because the + * error is thrown by `@bsv/wallet-toolbox`'s WERR_REVIEW_ACTIONS class + * (a different class identity from `@bsv/sdk`'s class imported here). + * + * The duck-type check matches on the canonical message + presence of + * the `reviewActionResults` array, which is unique to this error type + * across both wallet-toolbox and SDK class hierarchies. + */ +function isWerrReviewActions(error: unknown): error is { reviewActionResults: unknown[] } { + if (typeof error !== 'object' || error === null) return false; + const e = error as { message?: unknown; reviewActionResults?: unknown }; + return ( + e.message === 'Undelayed createAction or signAction results require review.' && + Array.isArray(e.reviewActionResults) + ); +} + // Parse the origin header and turn it into a fqdn (e.g. projectbabbage.com:8080) // Handles both origin and legacy originator headers function parseOrigin(headers: Record): string | null { @@ -135,13 +163,14 @@ export const onWalletReady = async (wallet: WalletInterface): Promise<(() => voi body: JSON.stringify(result), }; } catch (error) { - if (typeof error === 'object' && error?.constructor.name === 'WERR_REVIEW_ACTIONS') { + if (isWerrReviewActions(error)) { + const errAny = error as any; const e = new WERR_REVIEW_ACTIONS( - (error as any)['reviewActionResults'], - (error as any)['sendWithResults'], - (error as any)['txid'], - (error as any)['tx'], - (error as any)['noSendChange'], + errAny['reviewActionResults'], + errAny['sendWithResults'], + errAny['txid'], + errAny['tx'], + errAny['noSendChange'], ); console.error('createAction WERR_REVIEW_ACTIONS:', e); response = { @@ -174,12 +203,13 @@ export const onWalletReady = async (wallet: WalletInterface): Promise<(() => voi body: JSON.stringify(result), }; } catch (error) { - if (typeof error === 'object' && error?.constructor.name === 'WERR_REVIEW_ACTIONS') { + if (isWerrReviewActions(error)) { + const errAny = error as any; const e = new WERR_REVIEW_ACTIONS( - (error as any)['reviewActionResults'], - (error as any)['sendWithResults'], - (error as any)['txid'], - (error as any)['tx'], + errAny['reviewActionResults'], + errAny['sendWithResults'], + errAny['txid'], + errAny['tx'], ); console.error('signAction WERR_REVIEW_ACTIONS:', e); response = { @@ -258,12 +288,13 @@ export const onWalletReady = async (wallet: WalletInterface): Promise<(() => voi body: JSON.stringify(result), }; } catch (error) { - if (typeof error === 'object' && error?.constructor.name === 'WERR_REVIEW_ACTIONS') { + if (isWerrReviewActions(error)) { + const errAny = error as any; const e = new WERR_REVIEW_ACTIONS( - (error as any)['reviewActionResults'], - (error as any)['sendWithResults'], - (error as any)['txid'], - (error as any)['tx'], + errAny['reviewActionResults'], + errAny['sendWithResults'], + errAny['txid'], + errAny['tx'], ); console.error('internalizeAction WERR_REVIEW_ACTIONS:', e); response = { From 3f5d4605bc830874acc7c35c8df137a691f98779 Mon Sep 17 00:00:00 2001 From: Deggen Date: Wed, 6 May 2026 20:51:54 -0500 Subject: [PATCH 2/2] Address WERR_REVIEW_ACTIONS review feedback --- src/onWalletReady.ts | 77 +++++++++++++++++++++-------------- test/onWalletReady.test.ts | 83 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 130 insertions(+), 30 deletions(-) diff --git a/src/onWalletReady.ts b/src/onWalletReady.ts index 572fe90..ac98b46 100644 --- a/src/onWalletReady.ts +++ b/src/onWalletReady.ts @@ -23,7 +23,12 @@ import { DiscoverByIdentityKeyArgs, DiscoverByAttributesArgs, GetHeaderArgs, - WERR_REVIEW_ACTIONS + WERR_REVIEW_ACTIONS, + type AtomicBEEF, + type OutpointString, + type ReviewActionResult, + type SendWithResult, + type TXIDHexString } from '@bsv/sdk'; interface HttpRequestEvent { @@ -55,16 +60,47 @@ interface HttpResponseEvent { * error is thrown by `@bsv/wallet-toolbox`'s WERR_REVIEW_ACTIONS class * (a different class identity from `@bsv/sdk`'s class imported here). * - * The duck-type check matches on the canonical message + presence of - * the `reviewActionResults` array, which is unique to this error type - * across both wallet-toolbox and SDK class hierarchies. + * The duck-type check matches on the stable WERR identifier plus the + * structured result arrays needed by the SDK WERR_REVIEW_ACTIONS + * constructor. */ -function isWerrReviewActions(error: unknown): error is { reviewActionResults: unknown[] } { +interface WerrReviewActionsLike { + reviewActionResults: ReviewActionResult[]; + sendWithResults: SendWithResult[]; + txid?: TXIDHexString; + tx?: AtomicBEEF; + noSendChange?: OutpointString[]; +} + +function isWerrReviewActions(error: unknown): error is WerrReviewActionsLike { if (typeof error !== 'object' || error === null) return false; - const e = error as { message?: unknown; reviewActionResults?: unknown }; + const e = error as { + name?: unknown; + code?: unknown; + reviewActionResults?: unknown; + sendWithResults?: unknown; + txid?: unknown; + tx?: unknown; + noSendChange?: unknown; + }; + return ( - e.message === 'Undelayed createAction or signAction results require review.' && - Array.isArray(e.reviewActionResults) + (e.name === 'WERR_REVIEW_ACTIONS' || e.code === 5) && + Array.isArray(e.reviewActionResults) && + Array.isArray(e.sendWithResults) && + (e.txid === undefined || typeof e.txid === 'string') && + (e.tx === undefined || Array.isArray(e.tx) || e.tx instanceof Uint8Array) && + (e.noSendChange === undefined || Array.isArray(e.noSendChange)) + ); +} + +function toSdkWerrReviewActions(error: WerrReviewActionsLike): WERR_REVIEW_ACTIONS { + return new WERR_REVIEW_ACTIONS( + error.reviewActionResults, + error.sendWithResults, + error.txid, + error.tx, + error.noSendChange, ); } @@ -164,14 +200,7 @@ export const onWalletReady = async (wallet: WalletInterface): Promise<(() => voi }; } catch (error) { if (isWerrReviewActions(error)) { - const errAny = error as any; - const e = new WERR_REVIEW_ACTIONS( - errAny['reviewActionResults'], - errAny['sendWithResults'], - errAny['txid'], - errAny['tx'], - errAny['noSendChange'], - ); + const e = toSdkWerrReviewActions(error); console.error('createAction WERR_REVIEW_ACTIONS:', e); response = { request_id: req.request_id, @@ -204,13 +233,7 @@ export const onWalletReady = async (wallet: WalletInterface): Promise<(() => voi }; } catch (error) { if (isWerrReviewActions(error)) { - const errAny = error as any; - const e = new WERR_REVIEW_ACTIONS( - errAny['reviewActionResults'], - errAny['sendWithResults'], - errAny['txid'], - errAny['tx'], - ); + const e = toSdkWerrReviewActions(error); console.error('signAction WERR_REVIEW_ACTIONS:', e); response = { request_id: req.request_id, @@ -289,13 +312,7 @@ export const onWalletReady = async (wallet: WalletInterface): Promise<(() => voi }; } catch (error) { if (isWerrReviewActions(error)) { - const errAny = error as any; - const e = new WERR_REVIEW_ACTIONS( - errAny['reviewActionResults'], - errAny['sendWithResults'], - errAny['txid'], - errAny['tx'], - ); + const e = toSdkWerrReviewActions(error); console.error('internalizeAction WERR_REVIEW_ACTIONS:', e); response = { request_id: req.request_id, diff --git a/test/onWalletReady.test.ts b/test/onWalletReady.test.ts index e1a8fb8..39cec57 100644 --- a/test/onWalletReady.test.ts +++ b/test/onWalletReady.test.ts @@ -276,6 +276,89 @@ describe('onWalletReady', () => { ) }) + it('preserves WERR_REVIEW_ACTIONS fields when the class name is minified', async () => { + const reviewActionResults = [{ txid: '00'.repeat(32), status: 'serviceError' }] + const sendWithResults = [{ txid: '00'.repeat(32), status: 'failed' }] + const noSendChange = [`${'00'.repeat(32)}.0`] + const wallet = makeMockWallet({ + createAction: vi.fn().mockRejectedValue({ + name: 'a', + code: 5, + message: 'Review is required before returning this result.', + reviewActionResults, + sendWithResults, + txid: '00'.repeat(32), + tx: [1, 2, 3], + noSendChange, + }), + }) + + await onWalletReady(wallet) + const handler = mockOnHttpRequest.mock.calls[0][0] + + await handler({ + request_id: 6, + path: '/createAction', + headers: { origin: 'https://example.com' }, + body: '{}', + method: 'POST', + }) + + const response = mockSendHttpResponse.mock.calls[0][0] + expect(response).toEqual( + expect.objectContaining({ + request_id: 6, + status: 400, + }) + ) + expect(JSON.parse(response.body)).toEqual( + expect.objectContaining({ + code: 5, + isError: true, + reviewActionResults, + sendWithResults, + txid: '00'.repeat(32), + tx: [1, 2, 3], + noSendChange, + }) + ) + }) + + it('detects WERR_REVIEW_ACTIONS by stable name without matching message text', async () => { + const reviewActionResults = [{ txid: '11'.repeat(32), status: 'invalidTx' }] + const sendWithResults = [] + const wallet = makeMockWallet({ + signAction: vi.fn().mockRejectedValue({ + name: 'WERR_REVIEW_ACTIONS', + message: 'Upstream wording changed.', + reviewActionResults, + sendWithResults, + }), + }) + + await onWalletReady(wallet) + const handler = mockOnHttpRequest.mock.calls[0][0] + + await handler({ + request_id: 7, + path: '/signAction', + headers: { origin: 'https://example.com' }, + body: '{}', + method: 'POST', + }) + + const response = mockSendHttpResponse.mock.calls[0][0] + expect(response.status).toBe(400) + expect(JSON.parse(response.body)).toEqual( + expect.objectContaining({ + code: 5, + isError: true, + reviewActionResults, + sendWithResults, + }) + ) + }) + it('survives 10 rapid wallet swaps without losing listener', async () => { const wallets = Array.from({ length: 10 }, (_, i) => makeMockWallet({ getVersion: vi.fn().mockResolvedValue({ version: `${i}` }) })