Skip to content
Open
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
3 changes: 2 additions & 1 deletion chrome-extension/src/injected/injected.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { registerSolanaWallet } from './solana-wallet-register';
import { KeepKeySolanaProvider } from './solana-provider';
import { KeepKeyTronProvider } from './tron-provider';
import { createHiveKeychainShim } from './hive-provider';
import { toProviderError } from './provider-error';
import { installConsoleCapture } from './consoleCapture';
import { installPageObserver } from './pageObserver';

Expand Down Expand Up @@ -357,7 +358,7 @@ import { installPageObserver } from './pageObserver';
`[HANDOFF] dApp ← KeepKey (${chain}/${method}) REJECT\n params=${JSON.stringify(params)}\n error=`,
error,
);
reject(error);
reject(toProviderError(error, `${method} failed`));
} else {
const resultType = typeof result;
const resultPreview =
Expand Down
56 changes: 56 additions & 0 deletions chrome-extension/src/injected/provider-error.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { describe, it, expect } from 'vitest';
import { toProviderError } from './provider-error';

const VAULT_MESSAGE =
'KeepKey Vault is not running. Open the KeepKey Vault desktop app, then try again. Get it at https://keepkey.com/launch';

describe('toProviderError', () => {
it('wraps a bare string so dApp libraries can inspect it', () => {
// The regression: the background sends failures as a plain string, and
// rejecting with a primitive made wagmi/viem/ethers throw
// "Cannot use 'in' operator to search for 'data' in <message>",
// hiding the actual instruction from the user.
const err = toProviderError(VAULT_MESSAGE);

expect(err).toBeInstanceOf(Error);
expect(err.message).toBe(VAULT_MESSAGE);
expect(err.code).toBe(-32603);
expect(() => 'data' in err).not.toThrow();
});

it('preserves an existing Error, adding a code when missing', () => {
const original = new Error('boom');
const err = toProviderError(original);

expect(err).toBe(original); // same reference — stack is not discarded
expect(err.code).toBe(-32603);
});

it('does not clobber a code the caller already set', () => {
const original = Object.assign(new Error('disconnected'), { code: 4900 });
expect(toProviderError(original).code).toBe(4900);
});

it('rebuilds a structured error that lost its prototype over postMessage', () => {
const err = toProviderError({ message: 'user rejected', code: 4001, data: { hint: 'x' } });

expect(err).toBeInstanceOf(Error);
expect(err.message).toBe('user rejected');
expect(err.code).toBe(4001);
expect(err.data).toEqual({ hint: 'x' });
});

it('falls back for null, undefined and empty values', () => {
expect(toProviderError(null, 'eth_call failed').message).toBe('eth_call failed');
expect(toProviderError(undefined, 'eth_call failed').message).toBe('eth_call failed');
expect(toProviderError('', 'eth_call failed').message).toBe('eth_call failed');
expect(toProviderError({}, 'eth_call failed').message).toBe('eth_call failed');
});

it('never throws, whatever it is handed', () => {
for (const input of [0, false, Symbol('s'), 123n, [], () => {}]) {
expect(() => toProviderError(input)).not.toThrow();
expect(toProviderError(input)).toBeInstanceOf(Error);
}
});
});
67 changes: 67 additions & 0 deletions chrome-extension/src/injected/provider-error.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
/**
* Error normalization for the injected providers.
*
* EIP-1193 requires a provider to reject with an object carrying `code` and
* `message`. Our background script sends failures across postMessage as a bare
* string (`sendResponse({ error: formatUserError(error) })`), and every
* provider used to `reject(error)` with that string untouched.
*
* Rejecting with a primitive breaks the dApp libraries downstream, because
* they inspect the rejection value before showing it. wagmi/viem/ethers all do
* some form of membership test, and `in` throws on a primitive:
*
* TypeError: Cannot use 'in' operator to search for 'data' in
* KeepKey Vault is not running. Open the KeepKey Vault desktop app...
*
* The user then sees a JavaScript error instead of the instruction we went to
* the trouble of writing. Normalizing here — at the boundary where values
* leave us for dApp code — fixes every provider at once and keeps us honest
* with the spec regardless of what the background sends.
*/

/** EIP-1193 provider error: an Error with a numeric `code`, optionally `data`. */
export interface ProviderRpcError extends Error {
code: number;
data?: unknown;
}

/** JSON-RPC internal error — the safe default when no code survived the trip. */
const INTERNAL_ERROR = -32603;

/**
* Coerce anything a provider might be handed into a proper ProviderRpcError.
*
* Preserves an existing `code`/`data` when present, so a meaningful code such
* as 4900 ("provider disconnected") still reaches the dApp. Never throws — a
* normalizer that can fail is worse than the bug it fixes.
*/
export function toProviderError(raw: unknown, fallbackMessage = 'Request failed'): ProviderRpcError {
// Already an Error: attach a code if it lacks one and pass it through, so we
// don't discard a stack or a subclass the caller cared about.
if (raw instanceof Error) {
const err = raw as ProviderRpcError;
if (typeof err.code !== 'number') err.code = INTERNAL_ERROR;
return err;
}

if (typeof raw === 'string') {
const err = new Error(raw || fallbackMessage) as ProviderRpcError;
err.code = INTERNAL_ERROR;
return err;
}

// Structured error that lost its prototype crossing postMessage, e.g.
// { message, code, data }. Keep whatever fields made it across.
if (raw && typeof raw === 'object') {
const src = raw as { message?: unknown; code?: unknown; data?: unknown };
const message = typeof src.message === 'string' && src.message ? src.message : fallbackMessage;
const err = new Error(message) as ProviderRpcError;
err.code = typeof src.code === 'number' ? src.code : INTERNAL_ERROR;
if (src.data !== undefined) err.data = src.data;
return err;
}

const err = new Error(fallbackMessage) as ProviderRpcError;
err.code = INTERNAL_ERROR;
return err;
}
3 changes: 2 additions & 1 deletion chrome-extension/src/injected/solana-provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
*/

import type { ChainType } from './types';
import { toProviderError } from './provider-error';

type WalletRequestFn = (
method: string,
Expand Down Expand Up @@ -335,7 +336,7 @@ export class KeepKeySolanaProvider {
#rpc(method: string, params: any[]): Promise<any> {
return new Promise((resolve, reject) => {
this.#walletRequest(method, params, 'solana' as ChainType, (error, result) => {
if (error) reject(error);
if (error) reject(toProviderError(error, `${method} failed`));
else resolve(result);
});
});
Expand Down
3 changes: 2 additions & 1 deletion chrome-extension/src/injected/solana-wallet-standard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
*/

import type { ChainType } from './types';
import { toProviderError } from './provider-error';

// ---------- Base58 (inline, no external dep) ----------

Expand Down Expand Up @@ -374,7 +375,7 @@ export class KeepKeySolanaWallet {
#rpc(method: string, params: any[]): Promise<any> {
return new Promise((resolve, reject) => {
this.#walletRequest(method, params, 'solana' as ChainType, (error, result) => {
if (error) reject(error);
if (error) reject(toProviderError(error, `${method} failed`));
else resolve(result);
});
});
Expand Down
3 changes: 2 additions & 1 deletion chrome-extension/src/injected/tron-provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
*/

import type { ChainType } from './types';
import { toProviderError } from './provider-error';

type WalletRequestFn = (
method: string,
Expand Down Expand Up @@ -122,7 +123,7 @@ class EventEmitter {
function promisifyRequest(walletRequest: WalletRequestFn, method: string, params: any[]): Promise<any> {
return new Promise((resolve, reject) => {
walletRequest(method, params, 'tron', (error, result) => {
if (error) reject(error);
if (error) reject(toProviderError(error, `${method} failed`));
else resolve(result);
});
});
Expand Down
Loading