diff --git a/src/onWalletReady.ts b/src/onWalletReady.ts index 829c89b..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 { @@ -40,6 +45,65 @@ 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 stable WERR identifier plus the + * structured result arrays needed by the SDK WERR_REVIEW_ACTIONS + * constructor. + */ +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 { + name?: unknown; + code?: unknown; + reviewActionResults?: unknown; + sendWithResults?: unknown; + txid?: unknown; + tx?: unknown; + noSendChange?: unknown; + }; + + return ( + (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, + ); +} + // 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,14 +199,8 @@ export const onWalletReady = async (wallet: WalletInterface): Promise<(() => voi body: JSON.stringify(result), }; } catch (error) { - if (typeof error === 'object' && error?.constructor.name === 'WERR_REVIEW_ACTIONS') { - 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'], - ); + if (isWerrReviewActions(error)) { + const e = toSdkWerrReviewActions(error); console.error('createAction WERR_REVIEW_ACTIONS:', e); response = { request_id: req.request_id, @@ -174,13 +232,8 @@ export const onWalletReady = async (wallet: WalletInterface): Promise<(() => voi body: JSON.stringify(result), }; } catch (error) { - if (typeof error === 'object' && error?.constructor.name === 'WERR_REVIEW_ACTIONS') { - const e = new WERR_REVIEW_ACTIONS( - (error as any)['reviewActionResults'], - (error as any)['sendWithResults'], - (error as any)['txid'], - (error as any)['tx'], - ); + if (isWerrReviewActions(error)) { + const e = toSdkWerrReviewActions(error); console.error('signAction WERR_REVIEW_ACTIONS:', e); response = { request_id: req.request_id, @@ -258,13 +311,8 @@ export const onWalletReady = async (wallet: WalletInterface): Promise<(() => voi body: JSON.stringify(result), }; } catch (error) { - if (typeof error === 'object' && error?.constructor.name === 'WERR_REVIEW_ACTIONS') { - const e = new WERR_REVIEW_ACTIONS( - (error as any)['reviewActionResults'], - (error as any)['sendWithResults'], - (error as any)['txid'], - (error as any)['tx'], - ); + if (isWerrReviewActions(error)) { + 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}` }) })