Skip to content

fix(injected): reject with an Error, not a bare string - #133

Open
sktbrd wants to merge 1 commit into
keepkey:developfrom
sktbrd:fix/eip1193-reject-with-error
Open

fix(injected): reject with an Error, not a bare string#133
sktbrd wants to merge 1 commit into
keepkey:developfrom
sktbrd:fix/eip1193-reject-with-error

Conversation

@sktbrd

@sktbrd sktbrd commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

Closes #132.

The bug is bigger than the one error message

The reported symptom is that "Vault is not running" reaches the user as:

TypeError: Cannot use 'in' operator to search for 'data' in
KeepKey Vault is not running. Open the KeepKey Vault desktop app,
then try again. Get it at https://keepkey.com/launch

Tracing it end to end:

  1. background/index.ts:1240sendResponse({ error: formatUserError(error) }) sends the failure as a plain string
  2. content script relays it as data.error
  3. injected.ts:283callback.callback(data.error) passes the string through
  4. injected.ts:360reject(error) rejects the EIP-1193 promise with a raw string
  5. the dApp's library (wagmi/viem/ethers) inspects the rejection, does 'data' in err, and in throws on a primitive

So nothing about that message is special — every error from the extension rejects as a primitive. It's just long enough to make the TypeError read absurdly. And the message we most wanted the user to read is precisely the one we made unreadable.

EIP-1193 requires rejecting with an object carrying code and message. Rejecting with a primitive violates the spec, which is why consumer libraries choke.

Not one site — five

Every provider had the identical reject(error):

file line
injected.ts (EVM) 360
solana-provider.ts 338
tron-provider.ts 125
solana-wallet-standard.ts 377

The fix

New injected/provider-error.ts exporting toProviderError(), applied at all five. It:

  • keeps an existing Error by reference, so stacks and subclasses aren't discarded
  • preserves a code that's already set (e.g. 4901/4001) rather than overwriting it
  • rebuilds structured errors that lost their prototype crossing postMessage
  • defaults to -32603 (JSON-RPC internal error) when no code survived
  • never throws — a normalizer that can fail is worse than the bug it fixes

Follow-up, deliberately not in this PR

formatUserError() flattens errors to a string, so the 4900 "provider disconnected" code that createVaultRequiredError() sets is dropped before it reaches the dApp. That code is how a dApp shows a proper "wallet disconnected" state instead of a generic failure.

Sending { message, code } from the background would preserve it — and toProviderError already handles that shape, so the two are compatible — but it changes what every consumer of response.error receives and deserves a side-panel audit first. Happy to do it as a second PR.

Testing

  • New provider-error.test.ts — 6 cases, including the exact regression (asserting 'data' in err no longer throws), code preservation, prototype-loss recovery, and a fuzz-ish pass over 0 / false / Symbol / bigint / [] / fn to prove it can't throw
  • Full chrome-extension suite: 131 passed / 12 files
  • tsc --noEmit clean

Branched from origin/develop in a scratch worktree; no local checkout was disturbed.

Closes keepkey#132.

The background sends failures across postMessage as a plain string
(`sendResponse({ error: formatUserError(error) })`), and every injected
provider rejected with that string untouched. EIP-1193 requires rejecting
with an object carrying `code` and `message`, and dApp libraries rely on it:
wagmi/viem/ethers inspect the rejection value, `in` throws on a primitive,
and the user gets

  TypeError: Cannot use 'in' operator to search for 'data' in
  KeepKey Vault is not running. Open the KeepKey Vault desktop app...

instead of the instruction we wrote for exactly that situation. The message
we most want read is the one we made unreadable.

Nothing about that message is special — every error from the extension
rejected as a primitive. It is just long enough to make the TypeError absurd.

Add toProviderError() and apply it at all five reject sites: injected.ts
(EVM), solana-provider, tron-provider, solana-wallet-standard. It keeps an
existing Error by reference so stacks are not discarded, preserves a `code`
when one is already set, rebuilds structured errors that lost their prototype
crossing postMessage, and never throws — a normalizer that can fail is worse
than the bug it fixes.

Follow-up (deliberately not in this change): formatUserError() flattens
errors to a string, so the 4900 "provider disconnected" code from
createVaultRequiredError() is dropped before it reaches the dApp. Sending
`{ message, code }` would preserve it — toProviderError already handles that
shape — but it changes what every consumer of `response.error` receives and
wants a side-panel audit first.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@sktbrd
sktbrd requested a review from BitHighlander as a code owner July 31, 2026 20:32
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant