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
94 changes: 71 additions & 23 deletions src/onWalletReady.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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, string>): string | null {
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
83 changes: 83 additions & 0 deletions test/onWalletReady.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}` }) })
Expand Down
Loading