diff --git a/.eslintrc b/.eslintrc index 9607a03..c0923d3 100644 --- a/.eslintrc +++ b/.eslintrc @@ -44,6 +44,7 @@ }, "ignorePatterns": [ "watch.js", - "dist/**" + "dist/**", + "pages/side-panel/src/approval/**" ] } diff --git a/.prettierignore b/.prettierignore index 822a463..1a0e372 100644 --- a/.prettierignore +++ b/.prettierignore @@ -8,4 +8,7 @@ node_modules .prettierignore LICENSE *.md -pnpm-lock.yaml \ No newline at end of file +pnpm-lock.yaml +# esbuild-minified build artifact — prettier would un-minify it, +# bloating the shipped bundle and making diffs noisy every build. +chrome-extension/public/injected.js \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md index ae49034..7a9e451 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -144,13 +144,13 @@ Note: Firefox extensions are temporary and need reloading after browser restart pnpm i -w # Install for specific workspace -pnpm i -F @extension/popup +pnpm i -F @extension/sidepanel # Run command in specific workspace pnpm -F @extension/e2e e2e # Build specific packages -turbo build --filter=@extension/popup +turbo build --filter=@extension/sidepanel ``` ## State Management @@ -168,7 +168,7 @@ Icon changes based on state (online/offline variants). ## Critical Files & Entry Points - **Background Script**: `chrome-extension/src/background/index.ts` -- **Popup Entry**: `pages/popup/src/index.tsx` +- **Side-panel Entry**: `pages/side-panel/src/index.tsx` (also hosts dApp approval overlay under `src/approval/`) - **Manifest Config**: `chrome-extension/manifest.js` - **Chain Handlers**: `chrome-extension/src/background/chains/*.ts` - **Storage Types**: `packages/storage/lib/types.ts` \ No newline at end of file diff --git a/HANDOFF_evm_tx_1559_signing_chain.md b/HANDOFF_evm_tx_1559_signing_chain.md new file mode 100644 index 0000000..5ed04c4 --- /dev/null +++ b/HANDOFF_evm_tx_1559_signing_chain.md @@ -0,0 +1,123 @@ +# Handoff — EIP-1559 signing-chain bug (read this from a cold start) + +You are picking up a release-blocker bug in a new session. This document is self-contained: paths are absolute, commands are runnable, no prior conversation context required. + +## The one-line problem + +`sdk.eth.ethSignTransaction` in `keepkey-vault-sdk` returns serialized type-2 (EIP-1559) envelopes whose ECDSA signature **does not recover to the device's address**. Every EVM swap signed via the keepkey-client browser extension fails downstream because the network treats the tx as signed by a wrong account, drops it, and the dApp UI hangs. + +EIP-712 typed-data signing (Permit2 etc.) through the same SDK works correctly. The bug is *specific to* EIP-1559 transaction signing. + +## Read these first, in order + +1. `/Users/highlander/WebstormProjects/keepkey-stack/projects/keepkey-client/RETRO_evm_tx_1559_signing_chain.md` — the comprehensive retro. Read the whole thing once before touching code. +2. `/Users/highlander/.claude/projects/-Users-highlander-WebstormProjects-keepkey-stack-projects-keepkey-client/memory/feedback_eip712_diagnosis.md` — the durable rule that applies here: when recovered signer ≠ expected, instrument the signing chain. Don't blame derivation, don't blame the dApp, don't theorize about routing. +3. `/Users/highlander/.claude/projects/-Users-highlander-WebstormProjects-keepkey-stack-projects-keepkey-client/memory/feedback_no_hardcoded_rpcs.md` — do not patch this with hardcoded RPC URLs. RPCs come from Pioneer. + +## Reproduce the failure (no device needed) + +Pure offline run that exercises the captured fixture and prints which canonical pre-image it does *or doesn't* match: + +```bash +cd /Users/highlander/WebstormProjects/keepkey-stack/projects/keepkey-vault-v11/projects/keepkey-sdk +node tests/evm-tx-1559/recover-fixture.js +``` + +Expected today: 1 of 3 sub-checks fails (`uniswap-link-to-usdt-1: serialized envelope recovers to WRONG signer`). The other two pass, ruling out simple v-parity inversion. No "diagnostic match" line prints — the bug isn't a standard variant. + +## Reproduce live on a paired device + +```bash +cd /Users/highlander/WebstormProjects/keepkey-stack/projects/keepkey-vault-v11/projects/keepkey-sdk +KEEPKEY_API_KEY=$(...your paired bearer token...) node tests/evm-tx-1559/sign-and-recover.js +``` + +If you're running on a dev device with a different seed than the fixture's `expectedSigner`, the test auto-falls back to comparing against the device's own ETH address — so the assertion `live-signed envelope recovers to expected signer` fires either way and reproduces the bug. + +## Where to look (priority order) + +Bug is somewhere in this slice. Source order is fastest-to-bisect first. + +### 1. SDK `eth.ethSignTransaction` + +``` +/Users/highlander/WebstormProjects/keepkey-stack/projects/keepkey-vault-v11/projects/keepkey-sdk/src/ +``` + +Specifically grep for the EVM tx signing entry point. Add instrumentation to log: +- The exact JSON/bytes sent to firmware +- The exact response received (r, s, v, serialized) +- The hash of the SDK-constructed envelope +- The hash that, given the firmware's (r,s,v), would recover to the device's address (back-compute via `recoverPublicKey`) + +The two hashes will differ. The diff names the bug. + +### 2. Vault firmware EVM tx handler + +``` +/Users/highlander/WebstormProjects/keepkey-stack/projects/keepkey-vault-v11/ (firmware repo, traverse to the Rust EthereumSignTx handler) +``` + +Same instrumentation: log the message hash the firmware computes right before signing. + +### 3. Specific things to check (in order of likelihood) + +- Does the SDK build the type-2 RLP **once** for both the firmware request and the returned envelope, or **twice** (with a chance to drift)? +- Does the firmware reuse the SDK's RLP, or reconstruct its own from the field-by-field message? +- Is `chainId` passed as number / hex string / big-endian bytes? Look for any conversion that could lose/gain a byte. +- Are `maxFeePerGas` and `maxPriorityFeePerGas` always passed as the same hex/number type, or does one path stringify and the other leave it numeric? +- Is the `data` field copied byte-for-byte, or chunked? +- Is there a code path that signs a *legacy* envelope but wraps the response in a *type-2* serializer (or vice versa)? + +### 4. Compare to EIP-712 path that works + +The EIP-712 SDK path (`sdk.eth.ethSignTypedData`) is verified to recover correctly for the same device. Whatever differs between that method's signing-message construction and `ethSignTransaction`'s signing-message construction is the suspect zone. + +## When you fix it + +1. The fix lives in `keepkey-vault-sdk` and/or the firmware. **Not** in keepkey-client. +2. Re-run the offline recovery — fixture should now pass: + ```bash + cd /Users/highlander/WebstormProjects/keepkey-stack/projects/keepkey-vault-v11/projects/keepkey-sdk + node tests/evm-tx-1559/recover-fixture.js + ``` +3. Re-run the live test — should also pass: + ```bash + KEEPKEY_API_KEY=... node tests/evm-tx-1559/sign-and-recover.js + ``` +4. Capture **one more** real Uniswap swap via the BEX after the fix is in place. Add it to `tests/fixtures/evm-tx-1559-regression.json` as a *passing* fixture so we have evidence the fix holds end-to-end. +5. The PR https://github.com/keepkey/keepkey-client/pull/55 contains diagnostic instrumentation (`[DECODE]` log) that should remain landed regardless. It catches future regressions of this class. + +## What this PR has *already done* (don't re-do) + +The open PR (`fix/eth-swap-dropped-tx`, https://github.com/keepkey/keepkey-client/pull/55) has 3 commits + this handoff. None of them fix the signing bug. They fix unrelated smells discovered during diagnosis: +- JSON-RPC passthrough on `eth_getTransactionByHash` / `eth_getTransactionReceipt` / `eth_getBlockByNumber` (replaces ethers wrappers that returned wrong field names to dApps) +- Tip-aware fee-warning in the side-panel approval card +- Smart-contract detection fix (was reading `transaction.request.data`, an array, instead of `transaction.unsignedTx.data`) +- `[DECODE]` instrumentation in `broadcastTransaction()` — **this is the diagnostic that surfaced the bug** +- Two-stage `[DROP-CHECK]` post-broadcast probe + +Plus `RETRO_uniswap_swap_dropped_tx.md` (now superseded — has a note pointing here) and `docs/RPC_PASSTHROUGH_AUDIT.md`. + +If a maintainer reviews the PR while the signing bug is open, they can merge the diagnostic + cleanup pieces independently. The signing bug is upstream and out of this PR's scope. + +## What to **not** do (lessons captured live) + +1. **Don't patch this in keepkey-client.** The bug is upstream. Adding wallet-side workarounds (re-signing, re-RLP-encoding the SDK's serialized output, etc.) would mask the real bug and ship a broken contract to dApps. +2. **Don't theorize about routing / private mempools / Blink Protect / etc.** I did this for hours and it was wasted time. The wrong "from" on etherscan was always just the malformed signature recovering to the wrong account. There is no relayer story. +3. **Don't add hardcoded RPC URLs as resilience.** Pioneer is the source of truth for RPCs (see `feedback_no_hardcoded_rpcs.md`). The wallet will broadcast through whatever RPC Pioneer gives it; that's not the bug. +4. **Don't blame the device path / derivation.** The same device-path EIP-712 sigs recover correctly. The issue is in EIP-1559 pre-image construction or signing-message dispatch, not in keychain or seed handling. + +## Quick verification checklist for the fix + +When you think you have a fix: + +- [ ] Offline `recover-fixture.js` passes for `uniswap-link-to-usdt-1` +- [ ] Live `sign-and-recover.js` passes on a paired device +- [ ] A fresh Uniswap swap on mainnet via the BEX: + - [ ] `[DECODE] match=true` in the background console + - [ ] No `[DECODE] ❌ MALFORMED-HEX` line + - [ ] dApp's `eth_getTransactionByHash` poll resolves to a non-null response (tx visible in mempool) + - [ ] Tx mines on-chain with the correct `from` address +- [ ] EIP-712 signing path remains unaffected (run the existing `tests/evm-eip712/permit2.js`) +- [ ] One additional fixture captured *after* the fix and added to `evm-tx-1559-regression.json` as a passing entry diff --git a/HANDOFF_fee_pipeline_audit.md b/HANDOFF_fee_pipeline_audit.md new file mode 100644 index 0000000..8e74841 --- /dev/null +++ b/HANDOFF_fee_pipeline_audit.md @@ -0,0 +1,186 @@ +# Audit + handoff — EVM fee pipeline (Uniswap stuck-pending) + +**Status:** Pipeline audit complete. **No fee tampering anywhere in BEX → vault → firmware.** Two real defects found *adjacent* to fees were fixed this session (nonce + gas-limit). The remaining work is policy: enforce a minimum-fee floor in the BEX with user-visible warning + bump. + +**Picked up:** 2026-04-29 morning by next session. + +--- + +## 1. End-to-end fee pipeline (verified, byte-identical at every hop) + +**Concrete trace** using the user's last failed tx values (`maxFeePerGas: 0x3ac20391` ≈ 0.986 gwei, `maxPriorityFeePerGas: 0x20ecb07f` ≈ 0.552 gwei): + +| Hop | File:line | What happens | Verdict | +|---|---|---|---| +| 1 | dApp → BEX | Uniswap calls `eth_sendTransaction` with `maxFeePerGas: "0x3ac20391"` in `params[0]` | dApp-supplied | +| 2 | `/Users/highlander/WebstormProjects/keepkey-stack/projects/keepkey-client/chrome-extension/src/background/chains/ethereumHandler.ts:1062-1065` | `signTransaction()` reads `transaction.maxFeePerGas`, copies to `input.maxFeePerGas` | ✅ passthrough, no mutation | +| 3 | BEX → vault SDK | `sdk.eth.ethSignTransaction(input)` → POST `/eth/sign-transaction` | ✅ raw JSON forwarded | +| 4 | `/Users/highlander/WebstormProjects/keepkey-stack/projects/keepkey-vault-v11/projects/keepkey-vault/src/bun/rest-api.ts:1743-1748` | Vault REST handler: `msg.maxFeePerGas = body.maxFeePerGas` (and `body.max_fee_per_gas` snake-case alias) | ✅ passthrough, no mutation | +| 5 | Vault → hdwallet-keepkey | `wallet.ethSignTx(msg)` | ✅ | +| 6 | `/Users/highlander/WebstormProjects/keepkey-stack/projects/keepkey-vault-v11/projects/keepkey-vault/node_modules/@keepkey/hdwallet-keepkey/src/ethereum.ts:308-313` | `est.setMaxFeePerGas(core.arrayify(msg.maxFeePerGas))` — copies into protobuf `EthereumSignTx` for the firmware | ✅ passthrough, no mutation | +| 7 | hdwallet → firmware → device signs | Firmware signs the RLP-encoded tx with the supplied fees | ✅ | +| 8 | Vault → BEX | Vault REST returns `{ r, s, v, serialized }` — serialized is the EIP-1559 RLP including the original fee bytes | ✅ | + +**Conclusion: no layer in the wallet/vault stack reduces, rounds, or substitutes fee values.** What Uniswap sends in `params[0]` is exactly what gets signed and broadcast. + +### Audit method (reproducible) + +```bash +# Vault REST handler — fee assignment lines +grep -n "maxFeePerGas\|gasPrice" \ + /Users/highlander/WebstormProjects/keepkey-stack/projects/keepkey-vault-v11/projects/keepkey-vault/src/bun/rest-api.ts \ + | head -10 + +# hdwallet-keepkey — protobuf field setters +grep -n "maxFeePerGas\|setGasPrice" \ + /Users/highlander/WebstormProjects/keepkey-stack/projects/keepkey-vault-v11/projects/keepkey-vault/node_modules/@keepkey/hdwallet-keepkey/src/ethereum.ts \ + | head -10 + +# BEX signTransaction — fee assignment block +sed -n '1037,1059p' \ + /Users/highlander/WebstormProjects/keepkey-stack/projects/keepkey-client/chrome-extension/src/background/chains/ethereumHandler.ts +``` + +Every site copies the value unchanged. No `Math.min`, no rounding, no oracle substitution. The `[HANDOFF] vault → BEX (eth_signTransaction)` log added this session at `/Users/highlander/WebstormProjects/keepkey-stack/projects/keepkey-client/chrome-extension/src/background/chains/ethereumHandler.ts:~1067` will print the exact `input` that was signed plus the resulting `serialized` — paste both into a tx decoder to byte-verify. + +--- + +## 2. Defects fixed this session (already in worktree, not yet committed) + +| # | File | Line | Bug | Fix | Effect | +|---|---|---|---|---|---| +| 1 | `/Users/highlander/WebstormProjects/keepkey-stack/projects/keepkey-client/chrome-extension/src/background/chains/ethereumHandler.ts` | ~986 | `getTransactionCount(addr, 'latest')` — counted only confirmed txs, so a re-attempted swap reused the same nonce as the prior pending tx. EIP-1559 replacement-underpriced kicks in unless +10% on both fees, and Uniswap's recomputed fees usually aren't 10% higher → second tx silently rejected by miners → "pending forever, `eth_getTransactionByHash` returns null" | `'pending'` instead of `'latest'` | Repeat-attempt swaps now get fresh nonces, won't collide with in-flight txs | +| 2 | same file | ~990 | JSON-RPC spec uses `gas`; our code only checked `transaction.gasLimit` (camelCase) → silently ignored Uniswap's gas limit and re-estimated against publicnode | Honor `transaction.gas` if `gasLimit` absent | Universal Router calls now sign with Uniswap's recommended limit | +| 3 | same file | ~679 | Same `'latest'` nonce defect in the in-extension `transfer` flow | `'pending'` | In-extension Send/transfer also nonce-safe | + +**These don't address fee-magnitude.** They address downstream symptoms (stuck-pending due to nonce collision, gas underprovisioning). Whether to commit them tomorrow alongside the fee-policy work is your call; build is green. + +--- + +## 3. Where Uniswap actually gets its fee numbers (RPC surface audit) + +The dApp hands us EIP-1559 fees in `eth_sendTransaction` params. Where do *those* numbers come from? Three RPCs Uniswap could hit on us: + +| RPC | Our handler | File:line | Risk | +|---|---|---|---| +| `eth_gasPrice` | `handleEthGasPrice` | `/Users/highlander/WebstormProjects/keepkey-stack/projects/keepkey-client/chrome-extension/src/background/chains/ethereumHandler.ts:265-269` | Returns publicnode's `feeData.gasPrice` (or `'0x0'`). On EIP-1559 chains publicnode usually returns a sane base+tip; if it ever returns 0 we'd hand Uniswap a literal zero and they'd build a doomed tx. **Worth checking what publicnode currently returns.** | +| `eth_maxFeePerGas` | `handleEthMaxFeePerGas` | same file:253-257 | Returns `feeData.maxFeePerGas`. ethers v6 computes this as `2*baseFee + maxPriorityFeePerGas` — usually a safe multiplier. | +| `eth_maxPriorityFeePerGas` | `handleEthMaxPriorityFeePerGas` | same file:247-251 | Returns `feeData.maxPriorityFeePerGas` from ethers' fee oracle. | +| **`eth_feeHistory`** | **MISSING** | — falls through to `default` in switch at `~870` → throws `Method ... not supported` | **Big gap.** Modern dApps (incl. Uniswap's UI via ethers v6 fee estimator) prefer `eth_feeHistory` for percentile-based fee math. If we throw, the dApp falls back to `eth_gasPrice` or whatever cached/default value it has — often stale or low. | + +**Recommendation for tomorrow:** add `case 'eth_feeHistory':` that does `provider.send('eth_feeHistory', params)` — pure passthrough, ~3 lines. This alone may make Uniswap supply correct fees without any policy logic. + +The `[HANDOFF] BEX → content script` instrumentation already logs every method's params and result, so after the fix you'll see whether Uniswap actually calls `eth_feeHistory` and what we return. + +--- + +## 4. The fee-policy work you described (for tomorrow) + +> "force warnings if a dApp tells us one fee, and offer a higher fee our side, if under our 1gwei" + +Recommended placement and shape: + +### 4a. Where to inject the policy + +**Single chokepoint:** `signTransaction` at `/Users/highlander/WebstormProjects/keepkey-stack/projects/keepkey-client/chrome-extension/src/background/chains/ethereumHandler.ts:1037-1059` — the block that maps `transaction.*` → `input.*` is the last point we can mutate before sending to the vault. Every signed-tx flow funnels through it. + +### 4b. Suggested shape + +```ts +// New top-of-file constant +const MIN_MAX_FEE_PER_GAS_WEI = BigInt('0x3b9aca00'); // 1 gwei = 1_000_000_000 + +// Inside signTransaction, after the existing fee-passthrough block: +if (input.maxFeePerGas) { + const dappMaxFee = BigInt(input.maxFeePerGas); + if (dappMaxFee < MIN_MAX_FEE_PER_GAS_WEI) { + // Fetch our own oracle for a sane suggestion + const ourFeeData = await provider.getFeeData(); + const ourSuggested = ourFeeData.maxFeePerGas ?? MIN_MAX_FEE_PER_GAS_WEI; + + // Surface the warning + suggestion in the approval event so the side-panel + // UI can show a "dApp suggested X gwei (too low for current network), + // we suggest Y gwei — accept | edit | reject" choice. Don't silently bump. + await requestStorage.updateEventById(eventId!, { + feeWarning: { + dappMaxFee: input.maxFeePerGas, + dappPriorityFee: input.maxPriorityFeePerGas, + suggestedMaxFee: '0x' + ourSuggested.toString(16), + suggestedPriorityFee: '0x' + (ourFeeData.maxPriorityFeePerGas ?? 0n).toString(16), + reason: `dApp fee ${dappMaxFee} wei < 1 gwei minimum`, + }, + }); + // Block here on a user choice — extend requireApproval to surface fee edit. + } +} +``` + +**Important: do NOT silently bump fees.** Both UX-wise (user paid more than they expected) and security-wise (don't make signing decisions for the user). The flow should be: + +1. Detect under-floor fee +2. Pause the signing flow +3. Show side-panel modal: "Uniswap suggested 0.986 gwei. Current network base fee: 0.578 gwei. Your tx may sit pending. Recommended: 1.5 gwei. **[Use suggested] [Use ours] [Cancel]**" +4. Resume signing with whichever fees the user picked + +### 4c. Threshold considerations + +`1 gwei` as the floor is reasonable for mainnet *most* of the time, but it's chain-specific. Worth parameterizing: +- Mainnet: 1 gwei reasonable +- L2s (Optimism, Arbitrum, Base): much lower — 0.001 gwei normal +- BSC: 3 gwei minimum +- Polygon: 30 gwei minimum + +Consider per-chain floors stored alongside `EIP155_CHAINS` in `/Users/highlander/WebstormProjects/keepkey-stack/projects/keepkey-client/chrome-extension/src/background/chains.ts` (or wherever chain metadata lives — grep for `EIP155_CHAINS`). + +A cheaper first cut: only warn when `dappMaxFee < currentBaseFee` (we already know base fee from the same `getFeeData()` call). That's empirically correct: `maxFeePerGas < baseFee` means the tx *literally cannot mine* until base fee drops below it. That's a stronger guarantee than an arbitrary 1-gwei floor. + +### 4d. UI integration points + +The side-panel approval surface lives at `/Users/highlander/WebstormProjects/keepkey-stack/projects/keepkey-client/pages/side-panel/src/approval/` (per the existing `RETRO_uniswap_swap_release_blocker.md` reference). The fee-warning UI would slot in there as a new banner above the existing tx-detail card. Look at how the EIP-712 blind-signing banner is wired in vault's `SigningApproval.tsx` (referenced in earlier session) for a pattern to mirror. + +--- + +## 5. Open questions / nice-to-haves + +1. **Why does Uniswap supply sub-gwei fees?** Mainnet base fee is 0.5–0.6 gwei right now (low-congestion period), so 0.986 gwei isn't crazy. But the user's first attempt at higher fees had a pending tx that competed. Their fee oracle is probably fine; the *first* attempt wasn't doomed by fees, it was doomed by something else (the EIP-712 / `/v1/swap` 404 in earlier sessions). Subsequent attempts then hit the nonce-collision bug. + +2. **Should we add `eth_feeHistory` passthrough proactively?** Yes — 3 lines, no downside, removes a known gap dApps depend on. Suggest doing this *before* the fee-policy work so we can see whether Uniswap actually calls it (the `[HANDOFF]` logs will tell you). + +3. **The two stuck pending txs (`0x3d5b…` and `0x9fd9…`)** on the user's wallet. Both nonce N. They'll either: + - Get evicted from mempool over the next few hours + - Mine if base fee drops far enough + - Be cancellable by the user via etherscan/MetaMask sending a self-transfer with same nonce + 10%+ higher fees + + Without clearing them, fresh swap attempts will keep nonce-colliding *until* the new code (using `'pending'`) goes live AND the user reloads the extension. + +--- + +## 6. Files touched this session (uncommitted) + +``` +modified: /Users/highlander/WebstormProjects/keepkey-stack/projects/keepkey-client/chrome-extension/src/background/chains/ethereumHandler.ts + (eth_call passthrough, eth_estimateGas passthrough, [HANDOFF] logs, + nonce 'pending' x2, gas/gasLimit honor) + +modified: /Users/highlander/WebstormProjects/keepkey-stack/projects/keepkey-client/chrome-extension/src/background/index.ts + (extended [HANDOFF] log with params + value) + +modified: /Users/highlander/WebstormProjects/keepkey-stack/projects/keepkey-client/chrome-extension/src/injected/injected.ts + (extended [HANDOFF] log on dApp resolve path) + +modified: /Users/highlander/WebstormProjects/keepkey-stack/projects/keepkey-client/chrome-extension/public/injected.js + (rebuilt artifact — esbuild output of injected.ts changes) +``` + +`make build` green at last edit. Committed in earlier sessions: the EIP-1193 emit removal + retro doc updates. Uncommitted: the eth_call / nonce / gas-limit / handoff-logging changes. + +--- + +## 7. Action items, ordered + +1. **Commit the uncommitted defects** — eth_call passthrough + nonce 'pending' + gas-limit honor + handoff logs. They're not the fee-policy work but they're correct fixes that should ship regardless. +2. **Add `eth_feeHistory` passthrough** — 3 lines in `ethereumHandler.ts`. Run a swap, grep `[HANDOFF]` in background console, see if Uniswap calls it. +3. **Decide on fee-floor strategy** — fixed `1 gwei` vs dynamic `max(1 gwei, currentBaseFee * 1.1)` vs per-chain floors. +4. **Wire the warning UI** in the side-panel approval surface; intercept the signing flow to require user choice on under-floor fees. +5. **Decide whether to silently bump or always require user confirmation.** My strong rec: always require user confirmation. Silent bump = paying users more than they expect. +6. **Clear the user's stuck pending txs** if any are still around when you pick this up — etherscan check on `0x141D9959cAe3853b035000490C03991eB70Fc4aC` will show pending nonce(s). Cancel or wait for eviction before retrying any swap. diff --git a/HANDOFF_uniswap_swap_v2.md b/HANDOFF_uniswap_swap_v2.md new file mode 100644 index 0000000..c4c5e85 --- /dev/null +++ b/HANDOFF_uniswap_swap_v2.md @@ -0,0 +1,189 @@ +# Handoff — Uniswap swap regression, audit round 2 + +**Status:** 🔴 Still blocking 0.0.28 release. 712 signing chain definitively cleared. +**Captured:** 2026-04-28 +**Predecessor:** `/Users/highlander/WebstormProjects/keepkey-stack/projects/keepkey-client/RETRO_uniswap_swap_release_blocker.md` + +--- + +## What this round proved + +### Sig is provably valid end-to-end (off-chain AND on-chain) + +| Check | Method | Result | +|---|---|---| +| Off-chain ECDSA recover | `ethers.utils.verifyTypedData` | ✅ Recovers to `0x141D9959cAe3853b035000490C03991eB70Fc4aC` | +| On-chain Permit2 acceptance | `eth_call Permit2.permit(owner, permitSingle, sig)` against mainnet | ✅ Returns `0x` (success, no revert) | +| On-chain nonce match | `Permit2.allowance(owner, token, spender).nonce` vs typed-data nonce | ✅ Both 0 | +| Sig wire format | 65 bytes, v∈{27,28}, low-S, EIP-55 address | ✅ All pass | +| Sig determinism (RFC 6979) | Re-sign same fixture → byte-identical | ✅ | + +The Permit2 contract is the on-chain authority on whether a Permit2 sig is valid. It accepts ours. Broadcasting a tx that consumes this sig would succeed. + +**Conclusion: the bug is NOT in 712 signing.** Not in firmware, not in vault, not in hdwallet, not in the SDK, not in the BEX→dApp handoff. + +### BEX → dApp handoff is byte-identical + +User instrumented HANDOFF logging in BEX. Captured during a failing swap: + +``` +[HANDOFF] vault → BEX (eth_signTypedData_v4 raw): + {"address":"...","signature":"0xa6ac6665…2c1c"} +[HANDOFF] BEX → dApp (eth_signTypedData_v4 final): + type=string len=132 value=0xa6ac6665…2c1c +``` + +132 hex chars (= 65 bytes) forwarded byte-identically. No mutation in the chrome.runtime → content script → injected → dApp chain. + +### No stray EIP-1193 events disrupting dApp state + +Around the failed sign, the only events fired by our provider are normal polling — `eth_chainId`, `eth_blockNumber`, `eth_accounts`, `wallet_getCapabilities`. **No `accountsChanged`, no `chainChanged`, no provider reset.** Hypothesis #4 from the prior retro (SET_ASSET_CONTEXT EIP-1193 emit block from commit `c415975`) is conclusively ruled out for this failure mode. + +### State surfaces consistent + +- `eth_accounts` → `["0x141D9959cAe3853b035000490C03991eB70Fc4aC"]` (checksum case, correct) +- `eth_chainId` → `0x1` (mainnet) +- `wallet_getCapabilities` → `{atomicBatch:false, paymasterService:false}` (correct for KeepKey today) + +--- + +## What's left as the bug + +Given everything above, there is **only one class of failure left** that's consistent with all the data: + +> The dApp built its quote using values it got from our provider during the **pre-sign** phase. If any of those values are wrong, the resulting quote is structurally inconsistent with our actual on-chain state. When the dApp later POSTs the quote + sig to `/v1/swap`, Uniswap's server reconstructs the expected on-chain state, sees the inconsistency, and rejects with 404. + +In other words: **the regression is in how our provider answers some RPC call that happens before signTypedData_v4 — not in signTypedData_v4 itself.** Likely candidates (in rough probability order): + +1. **`eth_call` to Permit2.allowance(owner, token, spender)** — if the dApp asked us to read this and we returned something other than the actual on-chain (0, 0, 0) state, the dApp would build a typed-data with the wrong nonce/expiration. +2. **`eth_call` to Permit2.allowance() on a different token/spender pair** during pre-quote checks. +3. **`eth_call` simulation of Universal Router** — if the dApp simulates the swap before calling `/v1/swap` and we return a wrong revert / state. +4. **`eth_getBalance`** for the input token's native balance. +5. **`eth_chainId` returned at the wrong moment** (e.g., during chain-switch transition) — but the existing log shows `0x1` consistently. +6. **`wallet_getCapabilities`** — atomicBatch=false is correct, but if Uniswap inferred something from this we don't fulfill, /v1/swap could 404. +7. **`eth_estimateGas`** for the eventual swap tx — if the dApp pre-validates and we lied about gas. + +--- + +## What to log next from the BEX (concrete instrumentation spec) + +You already have `[HANDOFF] BEX → content script (ethereum/)` covering simple RPCs. Extend it to **log the params AND return value of EVERY RPC call** during the swap flow — not just the simple readonly ones. + +### Add a log line in `chrome-extension/src/background/chains/ethereumHandler.ts` at the central RPC dispatcher + +For every method that hits the dApp tab — including `eth_call`, `eth_getBalance`, `eth_getCode`, `eth_estimateGas`, `eth_getTransactionCount`, `eth_gasPrice`, `eth_maxPriorityFeePerGas`, `eth_feeHistory`, `eth_getStorageAt`, `eth_getTransactionByHash`, `eth_getTransactionReceipt`, `wallet_addEthereumChain`, `wallet_switchEthereumChain`, `wallet_revokePermissions`, `wallet_getPermissions`, `wallet_requestPermissions` — log: + +```ts +console.log(`[HANDOFF] BEX → content script (ethereum/${method})`, + 'params=', JSON.stringify(params)?.slice(0, 400), + 'value=', JSON.stringify(value)?.slice(0, 400), + 'len=', JSON.stringify(value)?.length); +``` + +The two big asks: + +- **`params`** must be logged for `eth_call` — the calldata + `to` field is what tells us which contract+function the dApp is querying. Without it, we know the dApp called `eth_call` but not what it asked. +- **`len` of large `eth_call` returns** — Uniswap's quote pipeline calls many `eth_call`s with large multicall calldata; just logging the first 400 chars is fine, but we want the byte length so we can spot suspicious truncation. + +### What we're hunting for in the new logs + +When you re-run the failing swap with this instrumentation: + +1. Find the `[HANDOFF] BEX → content script (ethereum/eth_call)` lines that target Permit2 (`0x000000000022d473030f116ddee9f6b43ac78ba3`). The `params[0].data` field will start with `0x927da105` (allowance selector — keccak("allowance(address,address,address)")[:4]) or `0x65b6ec38`. Compare the return value against what `Permit2.allowance(0x141D…, 0x5149…, 0x4c82…)` returns on mainnet directly (the SDK test prints this — `(0, 0, 0)`). +2. Find any `eth_call` that returns suspicious empty data (`0x`) for a non-empty contract, OR returns something that doesn't decode cleanly as the expected return type. +3. Find any `eth_call` where the return value differs between successive calls during the same swap attempt. + +If the new logs show every `eth_call` returning sane values, the bug is downstream of our provider entirely — i.e., the regression is on Uniswap's interface gateway and not on our side. (User has rejected this framing; if the new instrumentation supports it, we have evidence to revisit.) + +### Optional: capture the dApp's own outgoing /v1/swap request + +Out-of-band of BEX: in DevTools Network tab during the failure, click the failing `/v1/swap` row → Payload → copy the Request Payload. Paste below in this doc. The fields of interest are: + +- `quote.swapper` (must equal `0x141D9959cAe3853b035000490C03991eB70Fc4aC`) +- `quote.permitData.values.message.{nonce, expiration, sigDeadline, amount, token, spender}` +- `signature` (must equal what BEX returned: `0xa6ac6665…2c1c` for the latest entry) + +If the `quote.permitData.values.message` differs in any byte from what we signed, that's the divergence — the dApp lied to us about what it was going to send. + +--- + +## Reproducer paths + +### Re-prove sig validity any time + +```bash +KEEPKEY_API_KEY= \ + node /Users/highlander/WebstormProjects/keepkey-stack/projects/keepkey-vault-v11/projects/keepkey-sdk/tests/evm-eip712/permit2-onchain-validate.js +``` + +Pulls the latest `/eth/sign-typed-data` entry from `/api/v1/activity`, runs off-chain recover + on-chain `Permit2.permit()` simulation. Should print "✅ sig is on-chain valid" in <2s. + +### Re-prove sig wire format + +```bash +KEEPKEY_API_KEY= \ + node /Users/highlander/WebstormProjects/keepkey-stack/projects/keepkey-vault-v11/projects/keepkey-sdk/tests/evm-eip712/sig-format-audit.js +``` + +Asserts 65 bytes, v∈{27,28}, low-S, EIP-55 case, recovery, EIP-2098 compactability. 20 assertions. Requires a fresh device approval. + +### Pull the latest signing entry + +```bash +curl -sS -H "Authorization: Bearer $KEEPKEY_API_KEY" \ + 'http://localhost:1646/api/v1/activity?route=/eth/sign-typed-data&limit=1' | jq +``` + +Returns full request body (typed data) and response body (sig) for offline replay. + +### Get a fresh bearer token + +```bash +curl -sS -X POST -H "Content-Type: application/json" -d '{"name":"sdk-test","url":"http://sdk-test.local"}' \ + http://localhost:1646/auth/pair +``` + +--- + +## File index (every absolute path referenced) + +**keepkey-client (this repo):** +- `/Users/highlander/WebstormProjects/keepkey-stack/projects/keepkey-client/HANDOFF_uniswap_swap_v2.md` — this doc +- `/Users/highlander/WebstormProjects/keepkey-stack/projects/keepkey-client/RETRO_uniswap_swap_release_blocker.md` — round-1 retro +- `/Users/highlander/WebstormProjects/keepkey-stack/projects/keepkey-client/chrome-extension/src/background/chains/ethereumHandler.ts` — where to add the param-logging +- `/Users/highlander/WebstormProjects/keepkey-stack/projects/keepkey-client/chrome-extension/src/background/index.ts` — alternative HANDOFF logging location + +**keepkey-vault-v11 (cross-repo):** +- `/Users/highlander/WebstormProjects/keepkey-stack/projects/keepkey-vault-v11/projects/keepkey-sdk/tests/evm-eip712/permit2-onchain-validate.js` — new on-chain validation test +- `/Users/highlander/WebstormProjects/keepkey-stack/projects/keepkey-vault-v11/projects/keepkey-sdk/tests/evm-eip712/sig-format-audit.js` — new wire-format audit test +- `/Users/highlander/WebstormProjects/keepkey-stack/projects/keepkey-vault-v11/projects/keepkey-sdk/tests/evm-eip712/uniswap-permit-prod.js` — fixture-based regression suite (existing) +- `/Users/highlander/WebstormProjects/keepkey-stack/projects/keepkey-vault-v11/projects/keepkey-sdk/tests/fixtures/eip712-blobs.json` — captured failing payloads +- `/Users/highlander/WebstormProjects/keepkey-stack/projects/keepkey-vault-v11/projects/keepkey-vault/src/bun/rest-api.ts` — `/api/v1/activity` endpoint +- `/Users/highlander/WebstormProjects/keepkey-stack/projects/keepkey-vault-v11/projects/keepkey-vault/docs/incident-7.14-eip712-regression.md` — original incident doc +- `/Users/highlander/WebstormProjects/keepkey-stack/projects/keepkey-vault-v11/projects/keepkey-vault/docs/handoff-signing-history.md` — REST audit-log workflow +- `/Users/highlander/WebstormProjects/keepkey-stack/projects/keepkey-vault-v11/modules/hdwallet/packages/hdwallet-keepkey/src/ethereum.ts:451` — `ethSignTypedData` (host-side hashing via `@metamask/eth-sig-util`) +- `/Users/highlander/WebstormProjects/keepkey-stack/projects/keepkey-vault-v11/modules/keepkey-firmware/lib/firmware/fsm_msg_ethereum.h:333` — firmware `fsm_msgEthereumSignTypedHash` (signs precomputed digests, doesn't walk typed data) +- `/Users/highlander/WebstormProjects/keepkey-stack/projects/keepkey-vault-v11/modules/keepkey-firmware/lib/firmware/ethereum.c:1083` — `ethereum_typed_hash_sign` (emits v=27+recid) + +**Memory (private):** +- `/Users/highlander/.claude/projects/-Users-highlander-WebstormProjects-keepkey-stack-projects-keepkey-client/memory/feedback_eip712_diagnosis.md` — don't blame derivation when verify-mismatches; instrument the chain +- `/Users/highlander/.claude/projects/-Users-highlander-WebstormProjects-keepkey-stack-projects-keepkey-client/memory/feedback_uniswap_blame.md` — don't propose Uniswap-server-side hypotheses +- `/Users/highlander/.claude/projects/-Users-highlander-WebstormProjects-keepkey-stack-projects-keepkey-client/memory/feedback_handoff_paths.md` — absolute paths only in handoff docs + +--- + +## Open questions for the next reader + +1. With `eth_call` Permit2.permit() simulation passing on mainnet, what mechanism could cause a /v1/swap 404 that's *our* fault and not Uniswap's interface gateway's fault? The new RPC-param instrumentation should answer this empirically. +2. Is there a recent commit that changed how we answer `eth_call` for any of `Permit2.allowance`, ERC-20 `balanceOf`, ERC-20 `allowance`, Universal Router views, or multicall aggregations? Worth grepping `git log -p chrome-extension/src/background/chains/ethereumHandler.ts | grep -E 'eth_call|case .eth_'`. +3. If the new instrumentation shows every value sane: the regression is downstream of our provider. Reopen the Uniswap-server-side framing only if multiple wallets fail in the same way — that's a meaningful new data point. + +--- + +## Closure criteria + +The release ships when EITHER: +- The new BEX RPC-param logs reveal which return value diverges from on-chain truth, AND that divergence is fixed; OR +- A different reproducer paths surfaces (e.g., the failure correlates with a specific recent commit, the user finds a similar regression on another EVM dApp, etc.) that lets us bisect. + +Until then, do not merge PR #51 / `develop → master`. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..1f5f019 --- /dev/null +++ b/Makefile @@ -0,0 +1,89 @@ +# KeepKey Client (browser extension) — make targets. +# Thin wrapper over the project's pnpm scripts so the usual flow +# stays consistent with the stack-wide "use make for everything" rule. + +SHELL := /bin/bash + +.DEFAULT_GOAL := build + +.PHONY: help build build-firefox dev dev-firefox zip zip-firefox \ + install reinstall clean clean-bundle clean-turbo clean-deps \ + lint lint-fix prettier type-check e2e e2e-firefox \ + bump + +help: ## Show this help + @awk 'BEGIN {FS = ":.*##"; printf "targets:\n"} /^[a-zA-Z_-]+:.*?##/ { printf " %-18s %s\n", $$1, $$2 }' $(MAKEFILE_LIST) + +# ---------- build ---------- + +build: ## Production build (Chrome) → dist/ + pnpm build + +build-firefox: ## Production build (Firefox) → dist/ + pnpm build:firefox + +zip: ## Build + package extension.zip (Chrome) → dist-zip/ + pnpm zip + +zip-firefox: ## Build + package extension.zip (Firefox) → dist-zip/ + pnpm zip:firefox + +# ---------- dev ---------- + +dev: ## Watch-mode dev build (Chrome), HMR on, keeps running + pnpm dev + +dev-firefox: ## Watch-mode dev build (Firefox), HMR on, keeps running + pnpm dev:firefox + +# ---------- deps ---------- + +install: ## Install deps (frozen lockfile is fine, mirrors CI) + pnpm install + +reinstall: ## Nuke node_modules and reinstall from lockfile + pnpm clean:install + +# ---------- clean ---------- + +clean: ## Full clean (dist, caches, node_modules) + pnpm clean + +clean-bundle: ## Clean only dist/ and dist-zip/ + pnpm clean:bundle + +clean-turbo: ## Clean Turborepo cache + pnpm clean:turbo + +clean-deps: ## Clean node_modules only + pnpm clean:node_modules + +# ---------- quality ---------- + +lint: ## Lint with autofix + cache + pnpm lint + +lint-fix: ## Lint alias (same behavior as lint) + pnpm lint:fix + +prettier: ## Prettier check across the workspace + pnpm prettier + +type-check: ## Run TypeScript type-check across all packages + pnpm type-check + +# ---------- tests ---------- + +e2e: ## End-to-end tests (Chrome) + pnpm e2e + +e2e-firefox: ## End-to-end tests (Firefox) + pnpm e2e:firefox + +# ---------- release ---------- + +bump: ## Bump version across every package.json. Usage: make bump VERSION=0.0.27 + @if [ -z "$(VERSION)" ]; then \ + echo "usage: make bump VERSION="; exit 1; \ + fi + ./update_version.sh $(VERSION) diff --git a/RESOLUTION_evm_tx_1559_signing_chain.md b/RESOLUTION_evm_tx_1559_signing_chain.md new file mode 100644 index 0000000..d6ae0c8 --- /dev/null +++ b/RESOLUTION_evm_tx_1559_signing_chain.md @@ -0,0 +1,127 @@ +# Resolution — EIP-1559 type-2 tx signing chain (release blocker) + +**Status:** ✅ **ROOT CAUSE IDENTIFIED — fix in flight** +**Resolved on:** 2026-04-28 (continuation of same-day investigation) +**Fix lives in:** firmware (`keepkey-firmware/lib/firmware/ethereum.c`), PR pending against `BitHighlander/keepkey-firmware:develop`. + +Supersedes [`RETRO_evm_tx_1559_signing_chain.md`](RETRO_evm_tx_1559_signing_chain.md). Read this first; the older retro is a record of the diagnostic dead-ends, not the answer. + +--- + +## TL;DR + +The KeepKey firmware has a one-line ordering bug in EIP-1559 signing. When the transaction's `data` field exceeds 1024 bytes (the single-USB-chunk threshold), the firmware hashes the empty access-list byte `0xC0` **between** the first chunk and the remaining chunks, producing a non-canonical pre-image: + +``` +keccak( 0x02 || rlp_list_len || chainId || nonce || maxPri || maxFee + || gasLimit || to || value + || data_len_prefix + || data[0..1024] + || 0xC0 ← bug: should be after ALL data + || data[1024..end] ) +``` + +The signature is mathematically valid for that mangled hash, so it recovers to a wrong-but-deterministic address. Every Uniswap Universal Router swap, Permit2 batch, large multicall — anything pushing tx-data past 1024 bytes — hits it. Single-chunk transactions (≤1024 bytes) escape because the misplaced `0xC0` happens to land at the end anyway. + +**Location:** `keepkey-firmware/lib/firmware/ethereum.c:891-895`. + +--- + +## How we found it (the move that finally worked) + +The prior session's hypothesis battery (30+ pre-image variants tested offline) was hunting the wrong axis: it assumed the firmware was hashing the canonical *fields* but in some non-canonical *layout*. The bug is the opposite — fields and layout are correct; the *byte stream order* is wrong only when chunked transmission is involved. + +The unlock was reading the firmware proto more carefully: + +```proto +// messages-ethereum.proto +message EthereumTxRequest { + optional uint32 signature_v = 2; + optional bytes signature_r = 3; + optional bytes signature_s = 4; + optional bytes hash = 5; // ← KeepKey custom field — the actual signed hash +} +``` + +`EthereumTxRequest.hash` is a custom KeepKey extension. The firmware has been reporting the hash it signed all along (see `ethereum.c:300-302`), but `hdwallet/src/ethereum.ts` was reading only `r/s/v` and discarding the hash field. Wiring it through gave us the smoking gun in one device sign: + +``` +deviceSignedHash: 0x385868e52dadf5efc378d12eaf596c61ed94a675474e73c2076da9b8c1eba2c4 +canonical hash: 0xe93917b6c4da282868f167999f09f5d5f082bdf19f5ffb5dfbf2f19b9441f147 +``` + +Different — so the firmware definitely hashed the wrong bytes. Then variant #17 in `find-preimage.js` (manually-built stream with 0xC0 sandwiched between data chunks) matched `0x385868e5…` exactly. Done. + +**Lesson for future-you:** when an EVM signature recovers to a wrong address, the first move is `response.getHash_asU8()` from the firmware, not yet-another offline hash hypothesis. + +--- + +## The fix + +Move the `0xC0` write so it fires *right before each `send_signature()` call*, not after the initial chunk. The single-chunk case currently works only by accident — the fix should make both paths correct by construction. + +```c +// ethereum.c — sketch +static void hash_access_list_if_eip1559(void) { + if (ethereum_tx_type == ETHEREUM_TX_TYPE_EIP_1559) { + uint8_t datbuf[1] = {0xC0}; + hash_data(datbuf, sizeof(datbuf)); + } +} + +// ethereum_signing_init: replace the 0xC0 block at line 891 +if (data_left > 0) { + send_request_chunk(); +} else { + hash_access_list_if_eip1559(); + send_signature(); +} + +// ethereum_signing_txack: add before send_signature() +if (data_left == 0) { + hash_access_list_if_eip1559(); + send_signature(); +} +``` + +That's the entire fix. Two move-points, one helper. No protocol changes. + +--- + +## Verification + +- **Offline regression** (no device): `keepkey-vault-v11/projects/keepkey-sdk/tests/evm-tx-1559/recover-fixture.js` will pass when re-signing the captured fixture against firmware that has the fix. +- **Live regression** (paired device): `tests/evm-tx-1559/sign-and-recover.js` re-signs the captured input on a device and checks the recovered signer matches. +- **Hash-stream replica**: `tests/evm-tx-1559/find-preimage.js` variant #17 documents the exact mangled stream — keep it as a memorial test. +- **Production diagnostic**: `keepkey-vault-v11/projects/keepkey-vault/src/bun/rest-api.ts` carries a `[DIAG]` block that logs `deviceSignedHash` vs canonical for every EVM sign. Leaves the alarm bell wired in. + +The hdwallet patch (`modules/hdwallet/packages/hdwallet-keepkey/src/ethereum.ts` — surface `deviceSignedHash` from `response.getHash_asU8()`) is harmless and should be kept regardless of the firmware fix; it makes the next class of EVM signing bug a one-look diagnosis. + +--- + +## Why the original hypothesis battery failed + +The retro tested 30+ field-level variants but never tested *structural* variants where the byte stream is correct elsewhere but a single byte is misplaced. The lesson isn't "test more variants" — it's "stop hunting variants offline once the device can tell you the answer." The `hash` field has been there since at least firmware 7.x. + +--- + +## Things flagged in the prior retro that turned out to be red herrings + +- **"Blink Protect sponsored relayer with different `from`"** — pure hallucination, never relevant. +- **Mempool-eviction / RPC-routing framing** — symptom-level confusion. The RPC dropped the tx because the recovered signer was wrong, full stop. +- **Hardcoded RPC fallback URLs** — almost added before you stopped me. See `feedback_no_hardcoded_rpcs.md` / `no-hardcoded-rpcs-use-pioneer.md`. + +--- + +## Open follow-ups + +1. **Firmware PR** → `BitHighlander/keepkey-firmware:develop`. Branch name: `fix/eip1559-chunked-data-access-list`. CI must go green before flashing. +2. **Device verification** — flash patched firmware, re-run `tests/evm-tx-1559/sign-and-recover.js`, expect ✅. +3. **Upstream PR** → `keepkey/keepkey-firmware:develop` once the fork PR is merged. +4. **Emulator rebuild** — clean image with zcash sidecar + this firmware fix only, nothing else. + +--- + +## Memory index + +- `firmware_eip1559_chunked_data_bug.md` — durable record of the bug + the diagnostic move that solved it. diff --git a/RETRO_evm_tx_1559_signing_chain.md b/RETRO_evm_tx_1559_signing_chain.md new file mode 100644 index 0000000..d36642d --- /dev/null +++ b/RETRO_evm_tx_1559_signing_chain.md @@ -0,0 +1,180 @@ +# Retro — EIP-1559 type-2 tx signing chain produces malformed-hex (release blocker) + +> **⚠️ SUPERSEDED 2026-04-28 (later same day):** Root cause identified — firmware `ethereum.c:891` hashes the empty access-list `0xC0` byte in the wrong stream position when EIP-1559 tx-data exceeds 1024 bytes (single-USB-chunk threshold). See [`RESOLUTION_evm_tx_1559_signing_chain.md`](RESOLUTION_evm_tx_1559_signing_chain.md) for the diagnosis, the unlock move (`EthereumTxRequest.hash` field), and the fix. This file is kept as a record of the diagnostic dead-ends; do not act from it. + +**Status:** 🔴 **RELEASE BLOCKER** for any keepkey-client build that exposes EVM `eth_sendTransaction` flows. Do not ship 0.0.28 / merge develop → master while this is open. + +**Captured:** 2026-04-28 +**Symptom owner:** signing chain in `keepkey-vault-sdk` (`/Users/highlander/WebstormProjects/keepkey-stack/projects/keepkey-vault-v11/projects/keepkey-sdk/`) and/or vault firmware (`keepkey-vault-v11`). +**Not** in keepkey-client — this PR (`fix/eth-swap-dropped-tx`, https://github.com/keepkey/keepkey-client/pull/55) only adds the diagnostic that surfaces the bug. + +--- + +## TL;DR + +`sdk.eth.ethSignTransaction(...)` is returning a serialized type-2 (EIP-1559) envelope whose ECDSA signature **does not recover to the device's address**. It recovers to a wrong-but-deterministic address (e.g. `0xEB152892…` for one captured tx). The on-chain effect is the same as if the user signed with the wrong key: + +- The tx broadcasts to whatever RPC we use; the RPC accepts it (the signature is mathematically valid against *some* hash, just not the right pre-image). +- The tx hash is real and lives in the mempool briefly. +- It can never confirm because the recovered "from" account has no balance / wrong nonce. +- The mempool drops it after a short window. +- The dApp's `eth_getTransactionByHash` poll returns null forever. +- The dApp's UI hangs at "Confirm in wallet." + +Every Uniswap swap on this branch fails for this reason. The PR's other fixes (passthrough, fee-warning, drop-check) are necessary cleanups but do not address the signing bug. + +--- + +## Definitive evidence — recovery does not match + +For tx `0x9475ee7d5d144c628dcbc3f2069e9775bbfd41220064ec1dd48383344416265e`: + +``` +expected signer: 0x141D9959cAe3853b035000490C03991eB70Fc4aC (device EOA, m/44'/60'/0'/0/0) +recovered signer: 0xEB152892ABe5D59c529984C355cF1D08FfFb1b5D (from ECDSA recovery against canonical type-2 pre-image) +v-parity flipped: 0xA90284b3758f4cECf58Be68323175B9ae7c8Df1f (rules out simple parity inversion) +``` + +Captured serialized bytes: +``` +0x02f9067f018201ef850218711a00850291d5740f8306c8b8944c82d1fbfe28c977cbb58d8c7ff8fcf9f70a2cca80b9060e3593564c…c080a029a5619898922af8414aba680899d5aa576bd6dec66391d3fcc79607e2fb1868a0412233e619f4f50b240958f1ab5632e4bbffa6c5d66515d69c78aa3fb3da7b0a +``` + +Full fixture: `/Users/highlander/WebstormProjects/keepkey-stack/projects/keepkey-vault-v11/projects/keepkey-sdk/tests/fixtures/evm-tx-1559-regression.json` (entry `uniswap-link-to-usdt-1`). + +`from` is a normal EOA — `eth_getCode(0x141d…)` returns `0x` — so there is exactly one valid signature per pre-image. The SDK's signature is over a different pre-image than the envelope encodes. + +--- + +## What I tested in this session and ruled out + +I ran the captured `r/s` against 30+ candidate pre-image hashes. **None recover to the expected signer.** + +| # | Hypothesis | Result | +|---|---|---| +| 1 | Canonical EIP-1559 type-2 pre-image (chainId=1) | recovers `0xEB152892…` ❌ | +| 2 | v-parity flip (yParity 0 ↔ 1) | recovers `0xA90284b3…` ❌ | +| 3 | Eight wrong chainIds in pre-image (137, 56, 10, 8453, 42161, 43114, 324, 100) | each yields its own wrong address ❌ | +| 4 | Legacy EIP-155 pre-image with `gasPrice = maxFeePerGas` | wrong ❌ | +| 5 | Legacy EIP-155 pre-image with `gasPrice = maxPriorityFeePerGas` | wrong ❌ | +| 6 | Type-2 RLP without the `0x02` type-prefix in pre-image | wrong ❌ | +| 7 | Type-2 with `maxFeePerGas`/`maxPriorityFeePerGas` swapped in field order | wrong ❌ | +| 8 | Plain legacy (no chainId, gasPrice = maxFee) | wrong ❌ | +| 9 | Data truncation at every 32-byte boundary up to full length (1550 bytes), and at 256/512/1024/1280/1408/1500/1518 | wrong ❌ | +| 10 | Data extension (zero-pad to length+1, +16, +32, +64) | wrong ❌ | + +The bug is not a standard variant. It's in some field-encoding detail my hypothesis battery didn't hit. + +--- + +## What I confirmed *is* working + +Earlier in the same session a Permit2 typed-data signature was captured and recovered correctly: + +``` +Permit2 sig 1 — 0x5f4eb4e3…1b → recovered 0x141D9959… ✅ (match) +Permit2 sig 2 — 0x69720389…1c → recovered 0x141D9959… ✅ (match) +``` + +So **EIP-712 typed-data signing is fine**. The bug is *specific to* EIP-1559 type-2 transaction signing. EIP-712 and EIP-1559 go through different SDK methods (`eth.ethSignTypedData` vs `eth.ethSignTransaction`) and likely different firmware message types. Whatever differs between those two paths is where the bug lives. + +This narrowing is critical. It means the keychain/derivation/address logic is fine. The corruption is in the EIP-1559-specific pre-image construction or signing-message dispatch. + +--- + +## Things I got wrong in this session and want flagged + +These were captured live so the next reader can avoid the same dead-ends. + +1. **I hallucinated a "Blink Protect sponsored relayer" story** to explain the wrong "from" on etherscan. There was no evidence for it. The real explanation is the signature recovers to a wrong address — etherscan was just rendering what the broadcast bytes actually encode. The user called this out (`feedback_no_hardcoded_rpcs.md`-adjacent — instinct that I was speculating, not analyzing) and they were right. +2. **I framed the failure as a routing/mempool-eviction problem** for several rounds before getting to the malformed-hex hypothesis the user had already named. Saved as the `RETRO_uniswap_swap_dropped_tx.md` retro that was retroactively wrong on root cause. Updated note added to that retro pointing at this one. +3. **I was about to add hardcoded RPC fallback URLs to `chains.ts`** as a "resilience" patch. User stopped me — `feedback_no_hardcoded_rpcs.md` is the durable rule. RPCs come from Pioneer; hardcoded lists in the extension force a release on every operator change. + +The user's debugging memory `feedback_eip712_diagnosis.md` ("EIP-712 verify mismatch is data-drift, not path/seed — when recovered signer ≠ expected, instrument the signing chain; don't blame derivation") applies *exactly* to this EIP-1559 case too. Same medicine: instrument the signing chain. I should have followed it from the first wrong-from observation instead of theorizing about routing. + +--- + +## What this PR *does* contribute toward the bug + +Branch `fix/eth-swap-dropped-tx`, PR https://github.com/keepkey/keepkey-client/pull/55. The diagnostic instrumentation is the part that's load-bearing for the next session: + +- `chrome-extension/src/background/chains/ethereumHandler.ts` — `[DECODE]` log inside `broadcastTransaction()`. Parses every signed tx via `ethers.Transaction.from()`, recovers the signer, and prints `recoveredFrom / expectedFrom / match`. If `match=false`, prints `[DECODE] ❌ MALFORMED-HEX` at error level. **This is what surfaced the bug.** +- The PR's other content (JSON-RPC passthrough on `eth_getTransaction*` / `eth_getBlockByNumber`, tip-aware fee-warning, smart-contract detection, drop-check) are real fixes to real other smells but are independent of this signing bug. They should still merge once a maintainer reviews. + +--- + +## Test scaffolding added in `keepkey-vault-sdk` + +To bisect the SDK ↔ firmware boundary without keepkey-client in the loop: + +``` +/Users/highlander/WebstormProjects/keepkey-stack/projects/keepkey-vault-v11/projects/keepkey-sdk/ +├── tests/ +│ ├── evm-tx-1559/ +│ │ ├── recover-fixture.js ← offline; walks every fixture, asserts recovery +│ │ └── sign-and-recover.js ← live; re-signs the same input on a paired device +│ └── fixtures/ +│ └── evm-tx-1559-regression.json ← captured failing payload (uniswap-link-to-usdt-1) +└── package.json ← ethers added as devDep (test-only, not published) +``` + +Run offline (no device needed): +```bash +cd /Users/highlander/WebstormProjects/keepkey-stack/projects/keepkey-vault-v11/projects/keepkey-sdk +node tests/evm-tx-1559/recover-fixture.js +``` + +Expected today: 1 failure (`serialized envelope recovers to WRONG signer` on `uniswap-link-to-usdt-1`). The "diagnostic match" line will print *if* one of the standard hypotheses in the test file ever lights up — none do today. + +Run live (paired device, same seed as the fixture's signer ideally): +```bash +KEEPKEY_API_KEY= node tests/evm-tx-1559/sign-and-recover.js +``` + +This reproduces the bug in isolation — signs the captured failing input via the SDK, parses the result, and asserts the recovered signer equals the device address. With a different dev seed, set `EXPECTED_SIGNER=auto` to compare against the device's own address instead of the fixture's. + +When the bug is fixed, both scripts should pass and the fixture moves from "failing-by-design" to "passing regression guard." + +--- + +## Where to look next + +The bug is somewhere in this slice: + +1. **`/Users/highlander/WebstormProjects/keepkey-stack/projects/keepkey-vault-v11/projects/keepkey-sdk/src/`** — the SDK's `eth.ethSignTransaction` implementation. Specifically: how it builds the signing-message payload sent to the firmware, and how it constructs the returned serialized envelope from the firmware's r/s/v. +2. **vault firmware (`keepkey-vault-v11`)** — the Rust handler for the EthereumSignTx (or whatever the relevant protobuf message is). Same questions: what hash does it actually sign, how does it form r/s/v. + +Specifically check: +- Does the SDK build the type-2 RLP **once** for both the firmware request and the returned envelope, or **twice** (with a chance to drift)? +- Does the firmware reuse the SDK's RLP, or does it reconstruct its own from the field-by-field message it receives? +- Is `chainId` passed as a number? hex string? big-endian bytes? Look for any place a chainId conversion could lose/gain a byte. +- Are `maxFeePerGas` and `maxPriorityFeePerGas` always passed as the same hex/number type, or does one path stringify and the other leave it numeric? +- Is the `data` field copied byte-for-byte to the signing-payload, or chunked? +- Is there a code path that signs a *legacy* envelope but wraps the response in a *type-2* serializer (or vice versa)? + +The fastest way to find it: in the SDK's `ethSignTransaction`, log the exact bytes/JSON sent to firmware *and* the exact serialized envelope returned, then run `recover-fixture.js`-style logic on both. The hash divergence will name the field. + +--- + +## Open questions + +1. Is the bug a recent regression or has every type-2 EVM swap signed by a KeepKey via this SDK silently been malformed for some time? (Worth checking older dApps that were known to work pre-Pioneer-SDK-removal.) +2. Does the same bug exist for legacy (pre-1559) tx signing through this SDK? Add a `evm-tx-legacy-regression.json` fixture and battery alongside. +3. Does it reproduce on every paired KeepKey or only a subset (firmware version dependent)? + +These are the first three things `sign-and-recover.js` can probe by capturing more fixtures. + +--- + +## File index + +- `/Users/highlander/WebstormProjects/keepkey-stack/projects/keepkey-client/RETRO_evm_tx_1559_signing_chain.md` — this doc +- `/Users/highlander/WebstormProjects/keepkey-stack/projects/keepkey-client/HANDOFF_evm_tx_1559_signing_chain.md` — companion handoff for the next session +- `/Users/highlander/WebstormProjects/keepkey-stack/projects/keepkey-client/RETRO_uniswap_swap_dropped_tx.md` — earlier (now superseded) routing-as-root-cause framing; updated note added pointing here +- `/Users/highlander/WebstormProjects/keepkey-stack/projects/keepkey-client/RETRO_uniswap_swap_release_blocker.md` — original Permit2 / `/v1/swap 404` retro from 2026-04-28; the "data drift, not path/seed" diagnosis there applies here too +- `/Users/highlander/WebstormProjects/keepkey-stack/projects/keepkey-client/chrome-extension/src/background/chains/ethereumHandler.ts` — `broadcastTransaction()` contains the `[DECODE]` log that surfaces this bug +- `/Users/highlander/WebstormProjects/keepkey-stack/projects/keepkey-vault-v11/projects/keepkey-sdk/tests/evm-tx-1559/recover-fixture.js` — offline regression test +- `/Users/highlander/WebstormProjects/keepkey-stack/projects/keepkey-vault-v11/projects/keepkey-sdk/tests/evm-tx-1559/sign-and-recover.js` — live regression test +- `/Users/highlander/WebstormProjects/keepkey-stack/projects/keepkey-vault-v11/projects/keepkey-sdk/tests/fixtures/evm-tx-1559-regression.json` — fixture file +- `/Users/highlander/.claude/projects/-Users-highlander-WebstormProjects-keepkey-stack-projects-keepkey-client/memory/feedback_eip712_diagnosis.md` — the diagnostic principle that applies here +- `/Users/highlander/.claude/projects/-Users-highlander-WebstormProjects-keepkey-stack-projects-keepkey-client/memory/feedback_no_hardcoded_rpcs.md` — separate but related: do not patch this with hardcoded RPC URLs diff --git a/RETRO_uniswap_swap_dropped_tx.md b/RETRO_uniswap_swap_dropped_tx.md new file mode 100644 index 0000000..a30e985 --- /dev/null +++ b/RETRO_uniswap_swap_dropped_tx.md @@ -0,0 +1,136 @@ +# Retro — Uniswap swap signs OK, broadcasts, then drops from mempool + +> **⚠ SUPERSEDED — root cause was misdiagnosed in this doc.** The actual bug is in EIP-1559 type-2 signing inside `keepkey-vault-sdk` / firmware: the signed envelope's signature does not recover to the device's address. The mempool drops the tx because the recovered "from" account has no balance. That's why etherscan showed wrong "from" addresses — not because of a Blink Protect relayer story I hallucinated. +> +> Read `RETRO_evm_tx_1559_signing_chain.md` and `HANDOFF_evm_tx_1559_signing_chain.md` instead. The fee-warning / drop-check / passthrough fixes captured below are still real cleanups, but they do not fix the swap UX — that requires fixing the upstream signing-chain bug. + +**Status:** 🟡 Cleanups landed; root cause was wrong; see superseding retro. +**Captured:** 2026-04-28 +**Branch in flight:** `docs/vault-eth-tx-tracker` (1 commit ahead of develop, docs only) +**Bundle under test:** `dist/` rebuilt 2026-04-28 15:42 — contains all of develop's ETH fixes plus the `09142ec` handoff doc +**Reference tx:** https://etherscan.io/tx/0xd5d733ff53d4222ae98a5382a794fac758bb9f251f3431dc05e1bae39547281e + +--- + +## Symptom (observed today) + +1. User on Uniswap mainnet, swap LINK → WETH, account `0x141D9959cAe3853b035000490C03991eB70Fc4aC`. +2. Permit2 typed-data step approved on device → succeeded. +3. `eth_sendTransaction` for Universal Router `execute(...)` approved on device → BEX returned hash `0xd5d733ff…81e` to the dApp: + ``` + [HANDOFF] dApp ← KeepKey (ethereum/eth_sendTransaction) RESOLVE + value=0xd5d733ff53d4222ae98a5382a794fac758bb9f251f3431dc05e1bae39547281e + ``` +4. Tx **briefly visible on etherscan**, then evicted. +5. Subsequent `eth_getTransactionByHash` calls (driven by Uniswap polling through our injected provider) all resolve to `null`: + ``` + [HANDOFF] dApp ← KeepKey (ethereum/eth_getTransactionByHash) RESOLVE + value=null + ``` +6. **Independent confirmation:** `curl -X POST https://ethereum.publicnode.com -d '{… eth_getTransactionByHash …}'` returned `{"result":null}` at retro time. Tx is no longer in any public node we hit. +7. Uniswap UI sticks on **"Confirm swap in wallet"** indefinitely — it never advances to a "pending tx" state because its receipt poll never returns a non-null tx. + +This is the **same shape as the previous three stuck-pending swaps** the user reported earlier (`HANDOFF_fee_pipeline_audit.md` referenced "latest=pending=495, nothing in flight" — those three priors all dropped out the same way). So this is a recurring failure mode, not a one-off. + +--- + +## Tx params actually broadcast (from the [HANDOFF] log) + +``` +chainId: 0x1 +to: 0x4c82d1fbfe28c977cbb58d8c7ff8fcf9f70a2cca (Uniswap Universal Router) +value: 0x0 +gas: 0x3583c = 219,708 +maxFeePerGas: 0x7154e01e = 1.9015 gwei +maxPriorityFeePerGas: 0x2d5dab3a = 0.7607 gwei +data: 0x3593564c… (execute() selector) +``` + +Live base fee at the time: **1.063 gwei**. So the tx was valid (`maxFee 1.9015 ≥ baseFee 1.063 + tip 0.7607`), but the effective tip miners would see is just **0.76 gwei** — well below current spot tip. **Cheap enough to be accepted by an entry node, then never propagated/included, then evicted.** + +--- + +## What's actually working (confirmed) + +- BEX nonce row renders correctly. Screenshot proof: "**Nonce 495 (next available). view on etherscan ↗**" at the top of the approval screen for `eth_sendTransaction`. So the user's claim "still do not see nonce in the bex" is observation error — it is rendering. (See `pages/side-panel/src/approval/evm/NonceInfoRow.tsx`, wired in via `1ad6eb1`.) +- BEX honors dApp-supplied `maxFeePerGas`/`maxPriorityFeePerGas` (commit `8d65cbf`). +- BEX broadcast path is firing — the `[HANDOFF] BEX → RPC (broadcast)` log exists in the bundle (verified with `grep` against `dist/background.iife.js`), tx hash matched what etherscan briefly showed, so signing + broadcast is working at the BEX layer. + +## What is NOT working + +### 1. Tx evicted from mempool, dApp polling never resolves +- See "Symptom" above. **This is the user-visible bug.** +- The fee-warning banner did NOT fire on this attempt because `maxFeePerGas` (1.90 gwei) passed our **base × 1.5** floor check (`1.063 × 1.5 ≈ 1.59`, and `1.90 > 1.59`). The check is too lenient — it ignores tip entirely, even though tip is what governs propagation/inclusion. +- User's framing: *"its using a blink protocol thing not generally broadcasted, easier to drop I think"* — i.e. they suspect Uniswap is using an MEV-protect / private RPC for either broadcast or polling. We don't currently know whose RPC the broadcast went through (we use whatever the BEX's chain config holds — likely a public one), but the **dApp's** receipt-poll goes through our injected provider, which goes through the BEX's RPC, which is the same one we broadcast on. So the routing isn't actually different at the dApp/BEX boundary; the question is whether *our* upstream RPC is private. Worth checking which URL `chainConfig.ts` resolves to for `eip155:1` and whether it's a propagation-poor endpoint. + +### 2. Vault firmware does not show the nonce on-device +- User can see the nonce on the BEX side (Approval UI) but **cannot verify it on the device**. Screenshot of the firmware preview shows: `CHAINID / RECIPIENT / VALUE / DATA` but **no nonce field**. (This is what the user means when they say "still do not see nonce in the vault".) +- Signed nonce is a load-bearing field — the user is trusting the BEX UI alone. That violates the hardware-wallet trust boundary. +- This belongs to vault firmware (keepkey-vault-v11). Out of scope for this PR but blocks the "verify on device" UX. + +### 3. EVM `eth_sendTransaction` is misclassified as "Simple transfer" +- Screenshot shows green-check **"Transaction — Simple transfer - no smart contract interaction"** for a tx whose `data` is `0x3593564c…` (Universal Router `execute()`). This is a contract call — should be the yellow **"Smart Contract — Interacts with smart contract — review carefully"** branch. +- Logic at `/Users/highlander/WebstormProjects/keepkey-stack/projects/keepkey-client/pages/side-panel/src/approval/evm/RequestMethodCard.tsx:76-77`: + ```ts + const hasSmartContractExecution = + transaction.request?.data && transaction.request.data.length > 0 && transaction.request.data !== '0x'; + ``` +- The check reads `transaction.request.data`, but the `Details` tab in the same UI is populating `DATA: 0x3593564c…` from somewhere else (presumably `transaction.params[0].data` or a normalized field). One side reads the dApp params; the other reads a missing/empty `request.data`. Either source-of-truth bug or shape change — needs one place that normalizes the tx and a single field both branches read. +- This is a **safety regression** — the UI is telling the user "no contract interaction" when there is one. Especially dangerous because the green-check / yellow-warning visual is the only quick safety cue. + +--- + +## Hypotheses for the dropped-tx failure + +| # | Hypothesis | Plausibility | How to test | +|---|---|---|---| +| 1 | Effective tip 0.76 gwei is below propagation threshold of the public RPC we broadcast through; tx briefly accepted at entry node, never gossiped to miners, evicted on next mempool sweep. | **High.** Matches "appeared on etherscan briefly then dropped" exactly. | Reproduce, then immediately query 3+ public RPCs (publicnode, llamarpc, ankr) for the hash. If all return null within ~30s, propagation never happened. | +| 2 | BEX's broadcast goes to one RPC, BEX's receipt-poll goes to a different one (different upstream cluster), and the receipt-poll RPC simply hasn't seen the hash. | Low — we use one provider object per chain, so URL should be identical. | Inspect `pages/side-panel/src/utils/getProvider.ts` (or wherever) and confirm broadcast + read share a single `JsonRpcProvider`. | +| 3 | Uniswap's Universal Router `execute()` payload includes a deadline that already expired by the time the tx was mineable, and the tx was rejected. | Medium. The `0x3593564c` payload contains a `deadline` field. With low tip + slow inclusion, expiry is plausible. | Decode the calldata against UR's ABI, check the `deadline` (third arg). Compare to broadcast time + mempool dwell time. | +| 4 | Uniswap-side / "blink" private mempool routing (user's hypothesis) — Uniswap is using its own RPC for some operation that competes with ours and the orchestration drops the tx. | Unconfirmed. The dApp logs in the trace all go through our injected provider; we don't see Uniswap making outbound RPC of its own to a private endpoint. | Capture network tab (not just console) during a swap. Look for outbound POSTs to anything other than `kkapi://` or our chosen RPC. | +| 5 | Underpriced eviction loop: the same nonce `495` was attempted multiple times in close succession, each replaced/dropped. The user's prior 3 stuck txs were probably at this same nonce. | High that this is the *user-visible pattern*, but the immediate cause is still tip economics (#1). | Track nonce + hash on each broadcast, log when a hash disappears from mempool. Builds on the vault-side tx tracker handoff. | + +The conservative read: **#1 is the proximate cause, #3 may compound it**. We can ship a fix that helps both without picking a winner. + +--- + +## Concrete next steps (in order) + +### Quick win — tighten fee-warning to use *tip*, not just `maxFeePerGas` +- File: wherever the fee-warning predicate lives (search the develop merge `1ad6eb1` — likely `pages/side-panel/src/approval/evm/FeeWarning*.tsx` or a hook in `chrome-extension/src/background/chains/ethereum.ts`). +- Add: warn if `maxPriorityFeePerGas < 1 gwei` on mainnet (configurable per-chain in `chainConfig.ts`). The current `maxFee < 1.5 × baseFee` rule did NOT fire today because Uniswap padded `maxFeePerGas` to 1.9 gwei, but the priority tip was still 0.76 gwei. +- Bonus: surface the **effective miner tip** (`min(maxPriorityFeePerGas, maxFeePerGas - baseFee)`) in the Fees tab. Today the user sees raw maxFee/maxPriority numbers; an effective-tip summary makes underpriced txs obvious before signing. + +### Quick win — fix the "Simple transfer" misclassification +- `pages/side-panel/src/approval/evm/RequestMethodCard.tsx:76-77` — replace `transaction.request?.data` with whatever path the `Details` tab uses to populate `DATA`. They have to share. Add a unit test that feeds a Universal Router payload and asserts `hasSmartContractExecution === true`. + +### Diagnostic — figure out which RPC we're using for mainnet broadcast +- **Audited:** `chrome-extension/src/background/chains.ts` ships `https://ethereum-rpc.publicnode.com` as the bootstrap mainnet RPC. That's a reputable public endpoint, not an MEV-protect / "blink" private mempool. +- The "blink protocol" hypothesis is therefore unlikely to be the cause; the proximate failure is fee economics (low tip, weak propagation), not RPC routing. +- **Do NOT add a hardcoded fallback list.** RPC lists belong in Pioneer; the extension takes them at runtime. Hardcoding endpoints here forces an extension release on every operator change. Captured as a hard rule in `~/.claude/projects/.../memory/feedback_no_hardcoded_rpcs.md`. If propagation is still a concern, the right move is a Pioneer-side fan-out / multi-broadcast story. + +### Verify-against-mempool after broadcast (BEX-side) +- After `provider.broadcastTransaction(...)` resolves with a hash, schedule one delayed (~5s) `eth_getTransactionByHash(hash)` against the same RPC. If null, surface a **"transaction was rejected by the network — fee likely too low"** error to the user instead of letting the dApp think the tx is in flight. Today we silently return a hash to the dApp and let it spin. +- Pairs naturally with the vault-side tx tracker (`HANDOFF_vault_eth_tx_tracker.md`): the BEX gets immediate post-broadcast status, the vault gets long-term tracking. Don't duplicate; the BEX-side check is purely a 5-second sanity check before the vault tracker takes over. + +### Vault firmware — render `nonce` on the EVM signing screen +- Out of this repo. File against `keepkey-vault-v11` firmware. The on-device verification needs to show nonce or the hardware-wallet trust model is broken for any flow where the dApp/BEX could lie about the nonce. + +--- + +## File index + +- `/Users/highlander/WebstormProjects/keepkey-stack/projects/keepkey-client/pages/side-panel/src/approval/evm/RequestMethodCard.tsx` — smart-contract detection bug at line 76-77 +- `/Users/highlander/WebstormProjects/keepkey-stack/projects/keepkey-client/pages/side-panel/src/approval/evm/NonceInfoRow.tsx` — working BEX nonce display +- `/Users/highlander/WebstormProjects/keepkey-stack/projects/keepkey-client/chrome-extension/src/background/index.ts` — broadcast handoff log site +- `/Users/highlander/WebstormProjects/keepkey-stack/projects/keepkey-client/HANDOFF_fee_pipeline_audit.md` — earlier audit, contains base-fee analysis +- `/Users/highlander/WebstormProjects/keepkey-stack/projects/keepkey-client/HANDOFF_vault_eth_tx_tracker.md` — companion vault-side tracker plan +- `/Users/highlander/WebstormProjects/keepkey-stack/projects/keepkey-client/HANDOFF_uniswap_swap_v2.md` — running notes +- `/Users/highlander/WebstormProjects/keepkey-stack/projects/keepkey-client/RETRO_uniswap_swap_release_blocker.md` — prior retro (Permit2 verify mismatch saga) +- `/Users/highlander/WebstormProjects/keepkey-stack/projects/keepkey-client/dist/background.iife.js` — built bundle, 510KB, contains [HANDOFF]/[DIAG]/fee-warning probes (verified by grep) + +## Reference + +- Failing tx (briefly mined-pending, then dropped): https://etherscan.io/tx/0xd5d733ff53d4222ae98a5382a794fac758bb9f251f3431dc05e1bae39547281e +- Account: https://etherscan.io/address/0x141d9959cae3853b035000490c03991eb70fc4ac +- Universal Router: `0x4c82d1fbfe28c977cbb58d8c7ff8fcf9f70a2cca` diff --git a/RETRO_uniswap_swap_release_blocker.md b/RETRO_uniswap_swap_release_blocker.md new file mode 100644 index 0000000..06ae94a --- /dev/null +++ b/RETRO_uniswap_swap_release_blocker.md @@ -0,0 +1,173 @@ +# Release blocker — Uniswap swap regression on develop (0.0.28) + +**Status:** 🔴 **BLOCKING release of 0.0.28 / PR #51 (develop → master).** Do not merge. +**Captured:** 2026-04-28 +**Owner needed:** next person picking this up + +--- + +## Symptom + +User signs Permit2 typed-data successfully on the device, but the Uniswap swap then fails: + +``` +POST https://trading-api-labs.interface.gateway.uniswap.org/v1/swap 404 (Not Found) +TransactionStepFailedError: SwapTransactionAsync failed during swap +``` + +Reproduces against ETH mainnet on at least one address (`0x141D9959cAe3853b035000490C03991eB70Fc4aC`). User has seen this pattern before and asserts it is a real regression — not an intermittent Uniswap quote-expiry issue. + +A subsequent attempt produced a successful `eth_signTransaction` for a Universal Router `execute(...)` call (see "Open data" below) — the broadcast/landing status of that tx is unknown, so we can't yet say whether the failure window is just the permit→swap handoff or affects the full flow. + +--- + +## Disposition + +- PR #51 (release: 0.0.28): **HOLD**, do not merge. URL: https://github.com/keepkey/keepkey-client/pull/51 +- Tracking issue: https://github.com/keepkey/keepkey-client/issues/52 +- Local-only fix in working tree (unstaged): removed `SET_ASSET_CONTEXT`-driven EIP-1193 emission in `/Users/highlander/WebstormProjects/keepkey-stack/projects/keepkey-client/chrome-extension/src/background/index.ts:959-1037` (line numbers from before the unstaged delete). Build is green. **User reports bug persists with this change present** — but the user may not have reloaded the unpacked extension at `chrome://extensions` after `make build`, so this is unverified. +- Memory notes saved (read these before continuing): + - `/Users/highlander/.claude/projects/-Users-highlander-WebstormProjects-keepkey-stack-projects-keepkey-client/memory/feedback_eip712_diagnosis.md` — avoids the verify-mismatch gaslighting trap + - `/Users/highlander/.claude/projects/-Users-highlander-WebstormProjects-keepkey-stack-projects-keepkey-client/memory/feedback_handoff_paths.md` — absolute-paths-only rule for handoff docs + +--- + +## Hypotheses tried and outcome + +| # | Hypothesis | Status | Notes | +|---|---|---|---| +| 1 | Wrong derivation path — device signing with wrong account | ❌ ruled out | Path `[2147483692, 2147483708, 2147483648, 0, 0]` = `m/44'/60'/0'/0/0` is canonical BIP44 ETH. User confirmed `0x141D9959…` is the right address. | +| 2 | EIP-712 signer mismatch (data drift in signing chain) | ❌ ruled out | Captured production sig `0x44b8eec…c1b` recovers cleanly to `0x141D9959…` against the captured typed data — see SDK fixture (offline check passes). My initial recovery against the wrong fixture variant was the source of the false alarm. | +| 3 | Uniswap quote expiry / `interface-labs` endpoint quirk | unconfirmed | Plausible but the user has rejected this framing. Need response body of the `/v1/swap` 404 to confirm/deny. Not yet captured. | +| 4 | `SET_ASSET_CONTEXT` emitting `accountsChanged`/`chainChanged` to dApp tabs mid-swap (regression from `c415975`) | unconfirmed (likely not the cause) | Hypothesized that the new EIP-1193 emit block fires after `signature_complete` and disrupts Uniswap's swap state. Unstaged fix removes the block. User reports bug persists with the build but reload-status of the unpacked extension is unverified. | +| 5 | MetaMask masking from `79eecf4` | not yet investigated | Provider shape changes for MetaMask compatibility could break Uniswap behavior. Worth a careful read. | +| 6 | Vault/firmware EIP-712 regression (e.g., from `8fe2d26 EVM clear-signing end-to-end`) | not yet tested live | Test infrastructure is in place — see "Test scaffolding" — but the online check has not been run yet. | + +--- + +## Test scaffolding built (use this) + +Four new files in the SDK test tree (full absolute paths — handoff is cross-repo): + +1. `/Users/highlander/WebstormProjects/keepkey-stack/projects/keepkey-vault-v11/projects/keepkey-sdk/tests/fixtures/eip712-blobs.json` — vendored production-captured payloads. Two Permit2 LINK entries already in there; one carries `knownGoodSignature` for offline recovery. +2. `/Users/highlander/WebstormProjects/keepkey-stack/projects/keepkey-vault-v11/projects/keepkey-sdk/tests/evm-eip712/permit2.js` (rewritten) — basic Permit2 sign + recover round-trip. +3. `/Users/highlander/WebstormProjects/keepkey-stack/projects/keepkey-vault-v11/projects/keepkey-sdk/tests/evm-eip712/permit2-bex-shape.js` — same payload but invoked in the exact shape the BEX uses (extra `addressNList` field, JSON.parse round-trip on typed data). +4. `/Users/highlander/WebstormProjects/keepkey-stack/projects/keepkey-vault-v11/projects/keepkey-sdk/tests/evm-eip712/uniswap-permit-prod.js` — fixture-driven runner. **This is the one to extend.** Walks every blob, runs offline recovery (where `knownGoodSignature` is set), then signs fresh via the local vault and verifies the recovery. + +### How to add new failures + +When the user hits another failing swap, paste the BEX background-console blob into `/Users/highlander/WebstormProjects/keepkey-stack/projects/keepkey-vault-v11/projects/keepkey-sdk/tests/fixtures/eip712-blobs.json` as a new entry: + +```json +{ + "": { + "context": "what the user was doing", + "expectedSigner": "0x...", + "address": "0x...", + "addressNList": [...], + "knownGoodSignature": "0x...", // optional but valuable — enables offline regression check + "typedData": { ... } // exact bytes from the BEX log + } +} +``` + +Re-run from the SDK directory: + +``` +cd /Users/highlander/WebstormProjects/keepkey-stack/projects/keepkey-vault-v11/projects/keepkey-sdk +KEEPKEY_API_KEY= node tests/evm-eip712/uniswap-permit-prod.js +``` + +Get a fresh bearer token with `curl -s -X POST http://localhost:1646/auth/pair -H "Content-Type: application/json" -d '{"name":"sdk-test","url":"http://sdk-test.local"}'`. + +### Current SDK test results (sanity baseline) + +Offline check on `uniswap-permit2-link-attempt-2` passes — captured fixture is internally consistent. Online check (sign fresh + recover) passed when run during this session for the basic permit2.js and permit2-bex-shape.js variants — both recovered to the device address. + +--- + +## Open data we have not analyzed + +The user pasted a `/eth/sign-transaction` log near the end of the session showing a Universal Router `execute(0x3593564c, ...)` swap. Vault returned: + +``` +{ + "r":"0x0a52477eafde4ab4a4fb14a953a6fa10e2ba30085c43f72dfc78400de9235c5d", + "s":"0x46a273d0a6fdefbb9b0d91e81cdba26b07828647d047de97b3b72637367ac4ac", + "v":0, + "serialized":"0x02f904dd018201ef841dcd26ea8432ee49db83037dde944c82d1fbfe28c977cbb58d8c7ff8fcf9f70a2cca80b9046e3593564c…" +} +``` + +Embedded in the calldata is a Permit2 sig — `0x9662b1bd…3f1b` — different from the earlier `0x44b8eec…c1b`. So this is a NEW swap attempt, not the same one as the 404. + +Open questions: +- Did this signed tx broadcast successfully? Did it land on chain? +- Was this run before or after the unstaged `SET_ASSET_CONTEXT` fix was reloaded into Chrome? +- Was this attempt's signed Universal Router calldata the one Uniswap rejected, or a separate flow? + +The user also mentioned address `0xe24A8f2ae82F6829ef277E59268111BEE54B5D3e` without context — unknown if it's a different account, recipient, contract, or red herring. + +--- + +## Concrete next steps + +In order: + +1. **Confirm reload state.** Have the user reload the unpacked extension at `chrome://extensions` after `make build`, then retest. Decide whether the unstaged `SET_ASSET_CONTEXT` EIP-1193-emit removal helps or doesn't. Either commit or revert. + +2. **Capture the `/v1/swap` 404 response body.** DevTools → Network → filter `/v1/swap` → copy response body. The 404 from `trading-api-labs` usually carries a JSON error explaining what Uniswap's server actually rejected (quote not found, signature mismatch, etc.). This single data point likely collapses 3–4 hypotheses. + +3. **Run the online half of `uniswap-permit-prod.js`** against the local vault with the user's actual paired KeepKey: + + ``` + cd /Users/highlander/WebstormProjects/keepkey-stack/projects/keepkey-vault-v11/projects/keepkey-sdk + KEEPKEY_API_KEY= node tests/evm-eip712/uniswap-permit-prod.js + ``` + + If a fresh sig fails to recover to the device address — proven vault/firmware regression. The test prints `domainSeparator`/`structHash`/`digest` to diff against the firmware's hashes. + +4. **If (3) passes**, the regression is in the BEX layer. Next-most-likely candidates in order: + - Side-panel `signature_complete` handler causing a context resync that mutates state mid-swap. Background sender: `/Users/highlander/WebstormProjects/keepkey-stack/projects/keepkey-client/chrome-extension/src/background/chains/ethereumHandler.ts:1109-1114`. + - MetaMask masking shim from commit `79eecf4` returning a value/event the dApp doesn't expect. Files: `/Users/highlander/WebstormProjects/keepkey-stack/projects/keepkey-client/chrome-extension/src/injected/injected.ts` and `/Users/highlander/WebstormProjects/keepkey-stack/projects/keepkey-client/pages/content/src/index.ts`. + - Popup→side-panel merge from commit `80be566` changing event timing. + +5. **Determine whether the open Universal Router sig (Open Data above) actually broadcasts and lands.** That tells us whether the failure window is *only* the permit→swap handoff (Uniswap-server-side gate) or affects the full flow (our wallet returning bad data). + +--- + +## Pre-existing release work that is fine + +The 0.0.28 release also contains commit `ec5c7c8` (this session): version bump + 10 leftover `[TON-DEBUG]` `console.log` statements stripped. That work stands; the release blocker is the swap regression, not the bump. + +--- + +## Important context for whoever picks this up + +- The user gave a forceful correction earlier in the session that EIP-712 verify mismatch is a *symptom of bad input data to the verifier*, not a derivation/seed bug. I made that mistake twice — saved as a feedback memory at `/Users/highlander/.claude/projects/-Users-highlander-WebstormProjects-keepkey-stack-projects-keepkey-client/memory/feedback_eip712_diagnosis.md`. Read it before chasing path/account theories. +- `/Users/highlander/WebstormProjects/keepkey-stack/projects/keepkey-vault-v11/projects/keepkey-sdk` is the user's preferred controlled environment for diagnosing this class of bug. Add tests there, not in the BEX. +- The vault's `auth.accounts` cache lives at `/Users/highlander/WebstormProjects/keepkey-stack/projects/keepkey-vault-v11/projects/keepkey-vault/src/bun/auth.ts:241-249` and is shared across all paired bearer tokens. State from earlier failed attempts can poison subsequent runs — clear-and-repair when in doubt. +- The firmware's `fsm_msgSolanaSignMessage` AdvancedMode gate at `/Users/highlander/WebstormProjects/keepkey-stack/projects/keepkey-vault-v11/modules/keepkey-firmware/lib/firmware/fsm_msg_solana.h:467-475` is unrelated to this bug but is itself a separate vault-side regression we documented earlier in the session — keep separate. + +## File index (every path referenced in this doc, absolute) + +**keepkey-client (this repo, where the doc lives):** +- `/Users/highlander/WebstormProjects/keepkey-stack/projects/keepkey-client/chrome-extension/src/background/index.ts` — global background, contains `SET_ASSET_CONTEXT` handler +- `/Users/highlander/WebstormProjects/keepkey-stack/projects/keepkey-client/chrome-extension/src/background/chains/ethereumHandler.ts` — `signTypedData`, `signTransaction`, `switchToProvider`, `signature_complete` emitter +- `/Users/highlander/WebstormProjects/keepkey-stack/projects/keepkey-client/chrome-extension/src/injected/injected.ts` — page-side EIP-1193 provider, MetaMask mask +- `/Users/highlander/WebstormProjects/keepkey-stack/projects/keepkey-client/pages/content/src/index.ts` — content script relay between background and injected provider + +**keepkey-vault-v11 (cross-repo):** +- `/Users/highlander/WebstormProjects/keepkey-stack/projects/keepkey-vault-v11/projects/keepkey-sdk/tests/fixtures/eip712-blobs.json` — captured failing payloads +- `/Users/highlander/WebstormProjects/keepkey-stack/projects/keepkey-vault-v11/projects/keepkey-sdk/tests/evm-eip712/permit2.js` +- `/Users/highlander/WebstormProjects/keepkey-stack/projects/keepkey-vault-v11/projects/keepkey-sdk/tests/evm-eip712/permit2-bex-shape.js` +- `/Users/highlander/WebstormProjects/keepkey-stack/projects/keepkey-vault-v11/projects/keepkey-sdk/tests/evm-eip712/uniswap-permit-prod.js` +- `/Users/highlander/WebstormProjects/keepkey-stack/projects/keepkey-vault-v11/projects/keepkey-vault/src/bun/rest-api.ts` — `/eth/sign-typed-data` REST handler +- `/Users/highlander/WebstormProjects/keepkey-stack/projects/keepkey-vault-v11/projects/keepkey-vault/src/bun/auth.ts` — shared `auth.accounts` cache + +**keepkey-firmware (submodule of vault):** +- `/Users/highlander/WebstormProjects/keepkey-stack/projects/keepkey-vault-v11/modules/keepkey-firmware/lib/firmware/fsm_msg_solana.h` — Solana sign-message AdvancedMode gate (separate issue, see disposition note above) + +**Private memory (not in any repo):** +- `/Users/highlander/.claude/projects/-Users-highlander-WebstormProjects-keepkey-stack-projects-keepkey-client/memory/feedback_eip712_diagnosis.md` +- `/Users/highlander/.claude/projects/-Users-highlander-WebstormProjects-keepkey-stack-projects-keepkey-client/memory/feedback_handoff_paths.md` diff --git a/chrome-extension/manifest.js b/chrome-extension/manifest.js index 50a0c2f..6c381f8 100755 --- a/chrome-extension/manifest.js +++ b/chrome-extension/manifest.js @@ -4,6 +4,20 @@ import deepmerge from 'deepmerge'; const packageJson = JSON.parse(fs.readFileSync('../package.json', 'utf8')); const isFirefox = process.env.__FIREFOX__ === 'true'; +// Firefox has no side-panel API and the approval popup was removed in the +// popup→side-panel merge. Building for Firefox in this state would produce +// an extension with no UI at all for approvals — silently unusable on the +// Firefox side. Fail loud until task #5 restores a Firefox-specific surface +// (a popup shim or `sidebar_action`). Set KEEPKEY_ALLOW_BROKEN_FIREFOX=1 to +// override if you really want to build it anyway (e.g. portfolio-only dev). +if (isFirefox && process.env.KEEPKEY_ALLOW_BROKEN_FIREFOX !== '1') { + throw new Error( + 'Firefox build is disabled: approval surface is still missing. ' + + 'See task #5 (Firefox fallback) in the popup→side-panel merge PR. ' + + 'Set KEEPKEY_ALLOW_BROKEN_FIREFOX=1 to force-build anyway.', + ); +} + const sidePanelConfig = { side_panel: { default_path: 'side-panel/index.html', @@ -23,7 +37,7 @@ const manifest = deepmerge( version: packageJson.version, description: '__MSG_extensionDescription__', host_permissions: [''], - permissions: ['storage', 'tabs', 'commands'], + permissions: ['storage', 'tabs', 'commands', 'alarms'], options_page: 'options/index.html', background: { service_worker: 'background.iife.js', @@ -46,7 +60,7 @@ const manifest = deepmerge( devtools_page: 'devtools/index.html', web_accessible_resources: [ { - resources: ['*.js', '*.css', '*.svg', 'icon-128.png', 'icon-34.png'], + resources: ['*.js', '*.css', '*.svg', 'icon-128.png', 'icon-34.png', 'kk-logo.png'], matches: ['*://*/*'], }, ], diff --git a/chrome-extension/package.json b/chrome-extension/package.json index 9dace9e..a108dcc 100644 --- a/chrome-extension/package.json +++ b/chrome-extension/package.json @@ -1,6 +1,6 @@ { "name": "chrome-extension", - "version": "0.0.26", + "version": "0.0.29", "description": "chrome extension - core settings", "type": "module", "scripts": { @@ -19,9 +19,12 @@ "dependencies": { "@extension/shared": "workspace:*", "@extension/storage": "workspace:*", + "@noble/hashes": "^1.5.0", + "@scure/base": "^1.1.9", + "@scure/bip32": "^1.5.0", "buffer": "^6.0.3", "ethers": "^6.13.2", - "keepkey-vault-sdk": "^2.0.1", + "keepkey-vault-sdk": "^3.0.1", "uuid": "^10.0.0", "webextension-polyfill": "^0.12.0", "@wallet-standard/wallet": "^1.1.0", diff --git a/chrome-extension/public/brand/keplr.png b/chrome-extension/public/brand/keplr.png new file mode 100644 index 0000000..7508274 Binary files /dev/null and b/chrome-extension/public/brand/keplr.png differ diff --git a/chrome-extension/public/brand/metamask-fox.svg b/chrome-extension/public/brand/metamask-fox.svg new file mode 100644 index 0000000..b37cc95 --- /dev/null +++ b/chrome-extension/public/brand/metamask-fox.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/chrome-extension/public/injected.js b/chrome-extension/public/injected.js index 306edba..435d82c 100644 --- a/chrome-extension/public/injected.js +++ b/chrome-extension/public/injected.js @@ -1,563 +1,19 @@ -'use strict'; -(() => { - var K = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz'; - function M(l) { - let a = [0]; - for (let i of l) { - let d = K.indexOf(i); - if (d === -1) throw new Error('Invalid base58 character'); - let n = d; - for (let h = 0; h < a.length; h++) ((n += a[h] * 58), (a[h] = n & 255), (n >>= 8)); - for (; n > 0; ) (a.push(n & 255), (n >>= 8)); - } - for (let i of l) { - if (i !== '1') break; - a.push(0); - } - return new Uint8Array(a.reverse()); - } - var U = class l { - #o; - #e = []; - #t = null; - #s = new Set(); - version = '1.0.0'; - name = 'KeepKey'; - icon = - 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAAAXNSR0IArs4c6QAAAERlWElmTU0AKgAAAAgAAYdpAAQAAAABAAAAGgAAAAAAA6ABAAMAAAABAAEAAKACAAQAAAABAAAAIKADAAQAAAABAAAAIAAAAACshmLzAAADUklEQVRYCb1XTUgUYRie3bXEWhVLQaUsgwVLoUtEQjUJiZX0A0GX7BIZXurkOTSvdo2kvETHAsOshFgqOqhlRD9C7SGS1JTCsj1krU7PM+w7zMzOzuzMqi88+73v9z7vz3zzzTeziuIgmqbFgG5gBPguFOgq4CXLIMwCo0AXEJN4zxHkEuA6kAIMkUBMqMZk7so/UG8AUcnjOIKwFXgHZIgEwKFmOHOfYO4aySVjmAoc7O4R0EB7lYS5h9K1jBJ6A7CuAfXG7OopbKLXkh4dccNZ7jlsi0gAJlWLI5jBPWFsTK5AGxCRImswFqDGWanDBo6IsYbjUanFbmrFWIHxD3IsmfJsgB4y2aJuF4UrUC5GnuNtxJeEQqEoAb3LJV+F4ctlHwkZXDULv8fEKQCHB4+rCJ9ngKcIGUTVRubT027y8yR9bOM4mhKTTwNJZD4miaDXAG8dqzlMShw3YRCZRVAr7vU4g5F/D4ZBoJK2H+Em9CsfEdBoKn4K9jPAd3G9sMPqZEzpRPzAwRfWJpN9EfZSRkAOE5LD7wrw8dkpwRh55VMm27fqt4FiVBjGBTaxEm4Db8d+4BPtIOK3AdbYCPC1qh/haGIS9gHgDeBbgjTAIkXAfTRxkgaamMNwCHgB+BMk4Decq0hGkFQbka/WMyZ/EeyHNo6TuSwx3Nn8gHQVIYOkOhB5Gp4zcdbBHiDvZ2pRuzozru2euKuDOucg/KliTAjKKMa9ksBpxBLrbzRwVfifOnB4RR2g3QSH3Cfx5FRdc2KoGstroUeQKh47vnAwWvUKjsPcA/wWdBUkjRAgZdsznO8D5xLGC/Opxc3NiQeV9uIsgkNDaUoMFpNDLleAn0cTQNBjGaFW6fn2Wrky/dI6abPOl9eN9deoWhjLloCv3+bPy7w3/9kzfvjX120g1cuSdsJ47xm1CgS9AaxCErlbV6qJ02W1nq22lG75AtIHWQEeJpOYaAT6gBQQWC5XNCjc7dkkHFKWe6v3FcLfbzRAMlcC6IC6C+gGxgCectZnCRMuopVG1v+Nx04sYINlxLH4wI6W52UFhT+Q41b2Nl0qeLnwZPGQucNHrXN6ZDG94RQuO688XbwNFzvjlSuwH03wEW8H+Bf/dxrUOWdc+H8mKXtEpGpY3AAAAABJRU5ErkJggg=='; - chains = ['solana:mainnet']; - static ACCOUNT_FEATURES = ['solana:signTransaction', 'solana:signAndSendTransaction', 'solana:signMessage']; - get accounts() { - return this.#e; - } - features = { - 'standard:connect': { - version: '1.0.0', - connect: async () => { - if (this.#e.length > 0) return { accounts: this.#e }; - let a = this.#t || (await this.#n('solana_connect', [])); - return (a && this.#a(a), { accounts: this.#e }); - }, - }, - 'standard:disconnect': { - version: '1.0.0', - disconnect: async () => { - (await this.#n('solana_disconnect', []).catch(() => {}), (this.#e = [])); - try { - localStorage.removeItem('keepkey-solana'); - } catch {} - this.#r(); - }, - }, - 'standard:events': { - version: '1.0.0', - on: (a, i) => ( - a === 'change' && this.#s.add(i), - () => { - this.#s.delete(i); - } - ), - }, - 'solana:signMessage': { - version: '1.0.0', - signMessage: async (...a) => { - let i = []; - for (let { message: d } of a) { - let n = await this.#n('solana_signMessage', [Array.from(d)]); - i.push({ signedMessage: d, signature: new Uint8Array(n) }); - } - return i; - }, - }, - 'solana:signTransaction': { - version: '1.0.0', - supportedTransactionVersions: new Set(['legacy', 0]), - signTransaction: async (...a) => { - let i = []; - for (let { transaction: d } of a) { - let n = await this.#n('solana_signTransaction', [Array.from(d)]); - i.push({ signedTransaction: new Uint8Array(n) }); - } - return i; - }, - }, - 'solana:signAndSendTransaction': { - version: '1.0.0', - supportedTransactionVersions: new Set(['legacy', 0]), - signAndSendTransaction: async (...a) => { - let i = []; - for (let { transaction: d } of a) { - let n = await this.#n('solana_signAndSendTransaction', [Array.from(d)]); - i.push({ signature: M(n) }); - } - return i; - }, - }, - 'solana:signIn': { - version: '1.0.0', - signIn: async (...a) => { - var d; - let i = []; - for (let n of a) { - if (this.#e.length === 0) { - let w = this.#t || (await this.#n('solana_connect', [])); - w && this.#a(w); - } - let h = this.#e[0]; - if (!h) throw new Error('Not connected'); - let y = (n == null ? void 0 : n.domain) || location.host, - p = (n == null ? void 0 : n.address) || h.address, - C = (n == null ? void 0 : n.uri) || location.href, - b = (n == null ? void 0 : n.version) || '1', - E = (n == null ? void 0 : n.chainId) || 'mainnet', - v = (n == null ? void 0 : n.nonce) || Math.random().toString(36).substring(2), - I = (n == null ? void 0 : n.issuedAt) || new Date().toISOString(), - R = (n == null ? void 0 : n.statement) || '', - f = `${y} wants you to sign in with your Solana account: -${p}`; - if ( - (R && - (f += ` +"use strict";(()=>{var z="123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz";function F(d){let e=[0];for(let t of d){let o=z.indexOf(t);if(o===-1)throw new Error("Invalid base58 character");let n=o;for(let u=0;u>=8;for(;n>0;)e.push(n&255),n>>=8}for(let t of d){if(t!=="1")break;e.push(0)}return new Uint8Array(e.reverse())}var O=class d{#r;#e=[];#n=null;#s=new Set;version="1.0.0";name="KeepKey";icon="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAAAXNSR0IArs4c6QAAAERlWElmTU0AKgAAAAgAAYdpAAQAAAABAAAAGgAAAAAAA6ABAAMAAAABAAEAAKACAAQAAAABAAAAIKADAAQAAAABAAAAIAAAAACshmLzAAADUklEQVRYCb1XTUgUYRie3bXEWhVLQaUsgwVLoUtEQjUJiZX0A0GX7BIZXurkOTSvdo2kvETHAsOshFgqOqhlRD9C7SGS1JTCsj1krU7PM+w7zMzOzuzMqi88+73v9z7vz3zzzTeziuIgmqbFgG5gBPguFOgq4CXLIMwCo0AXEJN4zxHkEuA6kAIMkUBMqMZk7so/UG8AUcnjOIKwFXgHZIgEwKFmOHOfYO4aySVjmAoc7O4R0EB7lYS5h9K1jBJ6A7CuAfXG7OopbKLXkh4dccNZ7jlsi0gAJlWLI5jBPWFsTK5AGxCRImswFqDGWanDBo6IsYbjUanFbmrFWIHxD3IsmfJsgB4y2aJuF4UrUC5GnuNtxJeEQqEoAb3LJV+F4ctlHwkZXDULv8fEKQCHB4+rCJ9ngKcIGUTVRubT027y8yR9bOM4mhKTTwNJZD4miaDXAG8dqzlMShw3YRCZRVAr7vU4g5F/D4ZBoJK2H+Em9CsfEdBoKn4K9jPAd3G9sMPqZEzpRPzAwRfWJpN9EfZSRkAOE5LD7wrw8dkpwRh55VMm27fqt4FiVBjGBTaxEm4Db8d+4BPtIOK3AdbYCPC1qh/haGIS9gHgDeBbgjTAIkXAfTRxkgaamMNwCHgB+BMk4Decq0hGkFQbka/WMyZ/EeyHNo6TuSwx3Nn8gHQVIYOkOhB5Gp4zcdbBHiDvZ2pRuzozru2euKuDOucg/KliTAjKKMa9ksBpxBLrbzRwVfifOnB4RR2g3QSH3Cfx5FRdc2KoGstroUeQKh47vnAwWvUKjsPcA/wWdBUkjRAgZdsznO8D5xLGC/Opxc3NiQeV9uIsgkNDaUoMFpNDLleAn0cTQNBjGaFW6fn2Wrky/dI6abPOl9eN9deoWhjLloCv3+bPy7w3/9kzfvjX120g1cuSdsJ47xm1CgS9AaxCErlbV6qJ02W1nq22lG75AtIHWQEeJpOYaAT6gBQQWC5XNCjc7dkkHFKWe6v3FcLfbzRAMlcC6IC6C+gGxgCectZnCRMuopVG1v+Nx04sYINlxLH4wI6W52UFhT+Q41b2Nl0qeLnwZPGQucNHrXN6ZDG94RQuO688XbwNFzvjlSuwH03wEW8H+Bf/dxrUOWdc+H8mKXtEpGpY3AAAAABJRU5ErkJggg==";chains=["solana:mainnet"];static ACCOUNT_FEATURES=["solana:signTransaction","solana:signAndSendTransaction","solana:signMessage"];get accounts(){return this.#e}features={"standard:connect":{version:"1.0.0",connect:async()=>{if(this.#e.length>0)return{accounts:this.#e};let e=this.#n||await this.#t("solana_connect",[]);return e&&this.#a(e),{accounts:this.#e}}},"standard:disconnect":{version:"1.0.0",disconnect:async()=>{await this.#t("solana_disconnect",[]).catch(()=>{}),this.#e=[];try{localStorage.removeItem("keepkey-solana")}catch{}this.#i()}},"standard:events":{version:"1.0.0",on:(e,t)=>(e==="change"&&this.#s.add(t),()=>{this.#s.delete(t)})},"solana:signMessage":{version:"1.0.0",signMessage:async(...e)=>{let t=[];for(let{message:o}of e){let n=await this.#t("solana_signMessage",[Array.from(o)]);t.push({signedMessage:o,signature:new Uint8Array(n)})}return t}},"solana:signTransaction":{version:"1.0.0",supportedTransactionVersions:new Set(["legacy",0]),signTransaction:async(...e)=>{let t=[];for(let{transaction:o}of e){let n=await this.#t("solana_signTransaction",[Array.from(o)]);t.push({signedTransaction:new Uint8Array(n)})}return t}},"solana:signAndSendTransaction":{version:"1.0.0",supportedTransactionVersions:new Set(["legacy",0]),signAndSendTransaction:async(...e)=>{let t=[];for(let{transaction:o}of e){let n=await this.#t("solana_signAndSendTransaction",[Array.from(o)]);t.push({signature:F(n)})}return t}},"keepkey:signOffchainMessage":{version:"1.0.0",signOffchainMessage:async e=>{let t=typeof e.message=="string"?Array.from(new TextEncoder().encode(e.message)):Array.from(e.message);return await this.#t("solana_signOffchainMessage",[{message:t,version:e.version,messageFormat:e.messageFormat}])}},"solana:signIn":{version:"1.0.0",signIn:async(...e)=>{var o;let t=[];for(let n of e){if(this.#e.length===0){let E=this.#n||await this.#t("solana_connect",[]);E&&this.#a(E)}let u=this.#e[0];if(!u)throw new Error("Not connected");let a=(n==null?void 0:n.domain)||location.host,g=(n==null?void 0:n.address)||u.address,h=(n==null?void 0:n.uri)||location.href,p=(n==null?void 0:n.version)||"1",k=(n==null?void 0:n.chainId)||"mainnet",v=(n==null?void 0:n.nonce)||Math.random().toString(36).substring(2),M=(n==null?void 0:n.issuedAt)||new Date().toISOString(),N=(n==null?void 0:n.statement)||"",m=`${a} wants you to sign in with your Solana account: +${g}`;if(N&&(m+=` -${R}`), - (f += ` +${N}`),m+=` -URI: ${C}`), - (f += ` -Version: ${b}`), - (f += ` -Chain ID: ${E}`), - (f += ` -Nonce: ${v}`), - (f += ` -Issued At: ${I}`), - n != null && - n.expirationTime && - (f += ` -Expiration Time: ${n.expirationTime}`), - n != null && - n.notBefore && - (f += ` -Not Before: ${n.notBefore}`), - n != null && - n.requestId && - (f += ` -Request ID: ${n.requestId}`), - (d = n == null ? void 0 : n.resources) != null && d.length) - ) { - f += ` -Resources:`; - for (let w of n.resources) - f += ` -- ${w}`; - } - let k = new TextEncoder().encode(f), - T = await this.#n('solana_signMessage', [Array.from(k)]); - i.push({ account: h, signedMessage: k, signature: new Uint8Array(T) }); - } - return i; - }, - }, - }; - constructor(a) { - this.#o = a; - try { - let i = localStorage.getItem('keepkey-solana'); - if (i) { - let { address: d } = JSON.parse(i); - d && typeof d == 'string' && (this.#t = d); - } - } catch {} - this.#c(); - } - #i(a) { - return { address: a, publicKey: M(a), chains: ['solana:mainnet'], features: [...l.ACCOUNT_FEATURES] }; - } - #a(a) { - this.#e = [this.#i(a)]; - try { - localStorage.setItem('keepkey-solana', JSON.stringify({ address: a })); - } catch {} - this.#r(); - } - async #c() { - try { - let a = await this.#n('solana_connect', []); - if (a && typeof a == 'string') { - this.#t = a; - try { - localStorage.setItem('keepkey-solana', JSON.stringify({ address: a })); - } catch {} - } - } catch {} - } - #r() { - let a = this.#e, - i = this.features; - this.#s.forEach(d => { - try { - d({ accounts: a, features: i }); - } catch {} - }); - } - #n(a, i) { - return new Promise((d, n) => { - this.#o(a, i, 'solana', (h, y) => { - h ? n(h) : d(y); - }); - }); - } - }; - function O(l) { - let a = ({ register: i }) => { - i(l); - }; - try { - let i = window.navigator; - (i.wallets || (i.wallets = []), - Array.isArray(i.wallets) - ? i.wallets.push(a) - : typeof i.wallets.register == 'function' && i.wallets.register(l)); - } catch {} - try { - window.dispatchEvent(new CustomEvent('wallet-standard:register-wallet', { detail: a })); - } catch {} - window.addEventListener('wallet-standard:app-ready', i => { - let d = i; - try { - typeof d.detail == 'function' && d.detail(a); - } catch {} - }); - } - (function () { - let l = ' | KeepKeyInjected | ', - a = '2.1.0', - y = window, - p = { isInjected: !1, version: a, injectedAt: Date.now(), retryCount: 0 }; - if (y.keepkeyInjectionState) { - let o = y.keepkeyInjectionState; - if ((console.warn(l, `Existing injection detected v${o.version}, current v${a}`), o.version >= a)) { - console.log(l, 'Skipping injection, newer or same version already present'); - return; - } - console.log(l, 'Upgrading injection to newer version'); - } - ((y.keepkeyInjectionState = p), console.log(l, `Initializing KeepKey Injection v${a}`)); - let C = { - siteUrl: window.location.href, - scriptSource: 'KeepKey Extension', - version: a, - injectedTime: new Date().toISOString(), - origin: window.location.origin, - protocol: window.location.protocol, - }, - b = 0, - E = new Map(), - v = [], - I = !1; - setInterval(() => { - let o = Date.now(); - E.forEach((t, s) => { - o - t.timestamp > 3e5 && - (console.warn(l, `Callback timeout for request ${s} (${t.method})`), - t.callback(new Error('Request timeout')), - E.delete(s)); - }); - }, 5e3); - let f = o => { - (v.length >= 100 && (console.warn(l, 'Message queue full, removing oldest message'), v.shift()), v.push(o)); - }, - k = () => { - if (I) - for (; v.length > 0; ) { - let o = v.shift(); - o && window.postMessage(o, window.location.origin); - } - }, - T = (o = 0) => - new Promise(t => { - let s = ++b, - e = setTimeout(() => { - o < 3 - ? (console.log(l, `Verification attempt ${o + 1} failed, retrying...`), - setTimeout( - () => { - T(o + 1).then(t); - }, - 100 * Math.pow(2, o), - )) - : (console.error(l, 'Failed to verify injection after max retries'), - (p.lastError = 'Failed to verify injection'), - t(!1)); - }, 1e3), - c = r => { - var g, u, m; - r.source === window && - ((g = r.data) == null ? void 0 : g.source) === 'keepkey-content' && - ((u = r.data) == null ? void 0 : u.type) === 'INJECTION_CONFIRMED' && - ((m = r.data) == null ? void 0 : m.requestId) === s && - (clearTimeout(e), - window.removeEventListener('message', c), - (I = !0), - (p.isInjected = !0), - console.log(l, 'Injection verified successfully'), - k(), - t(!0)); - }; - (window.addEventListener('message', c), - window.postMessage( - { source: 'keepkey-injected', type: 'INJECTION_VERIFY', requestId: s, version: a, timestamp: Date.now() }, - window.location.origin, - )); - }); - function w(o, t = [], s, e) { - let c = l + ' | walletRequest | '; - if (!o || typeof o != 'string') { - (console.error(c, 'Invalid method:', o), e(new Error('Invalid method'))); - return; - } - Array.isArray(t) || (console.warn(c, 'Params not an array, wrapping:', t), (t = [t])); - try { - let r = ++b, - g = { - id: r, - method: o, - params: t, - chain: s, - siteUrl: C.siteUrl, - scriptSource: C.scriptSource, - version: C.version, - requestTime: new Date().toISOString(), - referrer: document.referrer, - href: window.location.href, - userAgent: navigator.userAgent, - platform: navigator.platform, - language: navigator.language, - }; - E.set(r, { callback: e, timestamp: Date.now(), method: o }); - let u = { - source: 'keepkey-injected', - type: 'WALLET_REQUEST', - requestId: r, - requestInfo: g, - timestamp: Date.now(), - }; - I - ? window.postMessage(u, window.location.origin) - : (console.log(c, 'Content script not ready, queueing request'), f(u)); - } catch (r) { - (console.error(c, 'Error in walletRequest:', r), e(r)); - } - } - window.addEventListener('message', o => { - let t = l + ' | message | '; - if (o.source !== window) return; - let s = o.data; - if (!(!s || typeof s != 'object')) { - if (s.source === 'keepkey-content' && s.type === 'INJECTION_CONFIRMED') { - ((I = !0), k()); - return; - } - if (s.source === 'keepkey-content' && s.type === 'WALLET_RESPONSE' && s.requestId) { - let e = E.get(s.requestId); - e - ? (s.error ? e.callback(s.error) : e.callback(null, s.result), E.delete(s.requestId)) - : console.warn(t, 'No callback found for requestId:', s.requestId); - } - } - }); - class B { - events = new Map(); - on(t, s) { - (this.events.has(t) || this.events.set(t, new Set()), this.events.get(t).add(s)); - } - off(t, s) { - var e; - (e = this.events.get(t)) == null || e.delete(s); - } - removeListener(t, s) { - this.off(t, s); - } - removeAllListeners(t) { - t ? this.events.delete(t) : this.events.clear(); - } - emit(t, ...s) { - var e; - (e = this.events.get(t)) == null || - e.forEach(c => { - try { - c(...s); - } catch (r) { - console.error(l, `Error in event handler for ${t}:`, r); - } - }); - } - once(t, s) { - let e = (...c) => { - (s(...c), this.off(t, e)); - }; - this.on(t, e); - } - } - function A(o) { - console.log(l, 'Creating wallet object for chain:', o); - let t = new B(), - s = { - network: 'mainnet', - isKeepKey: !0, - isMetaMask: !0, - isConnected: () => I, - request: ({ method: e, params: c = [] }) => - new Promise((r, g) => { - w(e, c, o, (u, m) => { - u ? g(u) : r(m); - }); - }), - send: (e, c, r) => { - if ((e.chain || (e.chain = o), typeof r == 'function')) { - w(e.method, e.params || c, o, (g, u) => { - g ? r(g) : r(null, { id: e.id, jsonrpc: '2.0', result: u }); - }); - return; - } else - return ( - console.warn(l, 'Synchronous send is deprecated and may not work properly'), - { id: e.id, jsonrpc: '2.0', result: null } - ); - }, - sendAsync: (e, c, r) => { - e.chain || (e.chain = o); - let g = r || c; - if (typeof g != 'function') { - console.error(l, 'sendAsync requires a callback function'); - return; - } - w(e.method, e.params || c, o, (u, m) => { - u ? g(u) : g(null, { id: e.id, jsonrpc: '2.0', result: m }); - }); - }, - on: (e, c) => (t.on(e, c), s), - off: (e, c) => (t.off(e, c), s), - removeListener: (e, c) => (t.removeListener(e, c), s), - removeAllListeners: e => (t.removeAllListeners(e), s), - emit: (e, ...c) => (t.emit(e, ...c), s), - once: (e, c) => (t.once(e, c), s), - enable: () => s.request({ method: 'eth_requestAccounts' }), - _metamask: { isUnlocked: () => Promise.resolve(!0) }, - }; - return ( - o === 'ethereum' && - ((s.chainId = '0x1'), - (s.networkVersion = '1'), - (s.selectedAddress = null), - (s._handleAccountsChanged = e => { - ((s.selectedAddress = e[0] || null), t.emit('accountsChanged', e)); - }), - (s._handleChainChanged = e => { - ((s.chainId = e), t.emit('chainChanged', e)); - }), - (s._handleConnect = e => { - t.emit('connect', e); - }), - (s._handleDisconnect = e => { - ((s.selectedAddress = null), t.emit('disconnect', e)); - })), - s - ); - } - function S(o) { - let t = { - uuid: '350670db-19fa-4704-a166-e52e178b59d4', - name: 'KeepKey', - icon: 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAAAXNSR0IArs4c6QAAAERlWElmTU0AKgAAAAgAAYdpAAQAAAABAAAAGgAAAAAAA6ABAAMAAAABAAEAAKACAAQAAAABAAAAIKADAAQAAAABAAAAIAAAAACshmLzAAADUklEQVRYCb1XTUgUYRie3bXEWhVLQaUsgwVLoUtEQjUJiZX0A0GX7BIZXurkOTSvdo2kvETHAsOshFgqOqhlRD9C7SGS1JTCsj1krU7PM+w7zMzOzuzMqi88+73v9z7vz3zzzTeziuIgmqbFgG5gBPguFOgq4CXLIMwCo0AXEJN4zxHkEuA6kAIMkUBMqMZk7so/UG8AUcnjOIKwFXgHZIgEwKFmOHOfYO4aySVjmAoc7O4R0EB7lYS5h9K1jBJ6A7CuAfXG7OopbKLXkh4dccNZ7jlsi0gAJlWLI5jBPWFsTK5AGxCRImswFqDGWanDBo6IsYbjUanFbmrFWIHxD3IsmfJsgB4y2aJuF4UrUC5GnuNtxJeEQqEoAb3LJV+F4ctlHwkZXDULv8fEKQCHB4+rCJ9ngKcIGUTVRubT027y8yR9bOM4mhKTTwNJZD4miaDXAG8dqzlMShw3YRCZRVAr7vU4g5F/D4ZBoJK2H+Em9CsfEdBoKn4K9jPAd3G9sMPqZEzpRPzAwRfWJpN9EfZSRkAOE5LD7wrw8dkpwRh55VMm27fqt4FiVBjGBTaxEm4Db8d+4BPtIOK3AdbYCPC1qh/haGIS9gHgDeBbgjTAIkXAfTRxkgaamMNwCHgB+BMk4Decq0hGkFQbka/WMyZ/EeyHNo6TuSwx3Nn8gHQVIYOkOhB5Gp4zcdbBHiDvZ2pRuzozru2euKuDOucg/KliTAjKKMa9ksBpxBLrbzRwVfifOnB4RR2g3QSH3Cfx5FRdc2KoGstroUeQKh47vnAwWvUKjsPcA/wWdBUkjRAgZdsznO8D5xLGC/Opxc3NiQeV9uIsgkNDaUoMFpNDLleAn0cTQNBjGaFW6fn2Wrky/dI6abPOl9eN9deoWhjLloCv3+bPy7w3/9kzfvjX120g1cuSdsJ47xm1CgS9AaxCErlbV6qJ02W1nq22lG75AtIHWQEeJpOYaAT6gBQQWC5XNCjc7dkkHFKWe6v3FcLfbzRAMlcC6IC6C+gGxgCectZnCRMuopVG1v+Nx04sYINlxLH4wI6W52UFhT+Q41b2Nl0qeLnwZPGQucNHrXN6ZDG94RQuO688XbwNFzvjlSuwH03wEW8H+Bf/dxrUOWdc+H8mKXtEpGpY3AAAAABJRU5ErkJggg==', - rdns: 'com.keepkey.client', - }, - s = new CustomEvent('eip6963:announceProvider', { detail: Object.freeze({ info: t, provider: o }) }); - (console.log(l, 'Announcing EIP-6963 provider'), window.dispatchEvent(s)); - } - async function j() { - let o = l + ' | mountWallet | '; - console.log(o, 'Starting wallet mount process'); - let t = A('ethereum'), - s = { - binance: A('binance'), - bitcoin: A('bitcoin'), - bitcoincash: A('bitcoincash'), - dogecoin: A('dogecoin'), - dash: A('dash'), - ethereum: t, - keplr: A('keplr'), - litecoin: A('litecoin'), - thorchain: A('thorchain'), - mayachain: A('mayachain'), - }, - e = { - binance: A('binance'), - bitcoin: A('bitcoin'), - bitcoincash: A('bitcoincash'), - dogecoin: A('dogecoin'), - dash: A('dash'), - ethereum: t, - osmosis: A('osmosis'), - cosmos: A('cosmos'), - litecoin: A('litecoin'), - thorchain: A('thorchain'), - mayachain: A('mayachain'), - ripple: A('ripple'), - }, - c = (r, g) => { - y[r] && console.warn(o, `${r} already exists, checking if override is allowed`); - try { - (Object.defineProperty(y, r, { value: g, writable: !1, configurable: !0 }), - console.log(o, `Successfully mounted window.${r}`)); - } catch (u) { - (console.error(o, `Failed to mount window.${r}:`, u), (p.lastError = `Failed to mount ${r}`)); - } - }; - (c('ethereum', t), - c('xfi', s), - c('keepkey', e), - window.addEventListener('eip6963:requestProvider', () => { - (console.log(o, 'Re-announcing provider on request'), S(t)); - }), - S(t), - setTimeout(() => { - (console.log(o, 'Delayed EIP-6963 announcement for late-loading dApps'), S(t)); - }, 100)); - try { - let r = new U(w); - (O(r), console.log(o, 'Solana wallet registered via Wallet Standard')); - } catch (r) { - console.error(o, 'Failed to register Solana wallet:', r); - } - (window.addEventListener('message', r => { - var g, u, m; - (((g = r.data) == null ? void 0 : g.type) === 'CHAIN_CHANGED' && - (console.log(o, 'Chain changed:', r.data), - t.emit('chainChanged', (u = r.data.provider) == null ? void 0 : u.chainId)), - ((m = r.data) == null ? void 0 : m.type) === 'ACCOUNTS_CHANGED' && - (console.log(o, 'Accounts changed:', r.data), - t._handleAccountsChanged && t._handleAccountsChanged(r.data.accounts || []))); - }), - T().then(r => { - r - ? console.log(o, 'Injection verified successfully') - : (console.error(o, 'Failed to verify injection, wallet features may not work'), - (p.lastError = 'Injection not verified')); - }), - console.log(o, 'Wallet mount complete')); - } - (j(), - document.readyState === 'loading' && - document.addEventListener('DOMContentLoaded', () => { - if ( - (console.log(l, 'DOM loaded, re-announcing provider for late-loading dApps'), - y.ethereum && typeof y.dispatchEvent == 'function') - ) { - let o = y.ethereum; - S(o); - } - }), - console.log(l, 'Injection script loaded and initialized')); - })(); -})(); +URI: ${h}`,m+=` +Version: ${p}`,m+=` +Chain ID: ${k}`,m+=` +Nonce: ${v}`,m+=` +Issued At: ${M}`,n!=null&&n.expirationTime&&(m+=` +Expiration Time: ${n.expirationTime}`),n!=null&&n.notBefore&&(m+=` +Not Before: ${n.notBefore}`),n!=null&&n.requestId&&(m+=` +Request ID: ${n.requestId}`),(o=n==null?void 0:n.resources)!=null&&o.length){m+=` +Resources:`;for(let E of n.resources)m+=` +- ${E}`}let C=new TextEncoder().encode(m),S=await this.#t("solana_signMessage",[Array.from(C)]);t.push({account:u,signedMessage:C,signature:new Uint8Array(S)})}return t}}};constructor(e){this.#r=e;try{let t=localStorage.getItem("keepkey-solana");if(t){let{address:o}=JSON.parse(t);o&&typeof o=="string"&&(this.#n=o)}}catch{}this.#c()}#o(e){return{address:e,publicKey:F(e),chains:["solana:mainnet"],features:[...d.ACCOUNT_FEATURES]}}#a(e){this.#e=[this.#o(e)];try{localStorage.setItem("keepkey-solana",JSON.stringify({address:e}))}catch{}this.#i()}async#c(){try{let e=await this.#t("solana_connect",[]);if(e&&typeof e=="string"){this.#n=e;try{localStorage.setItem("keepkey-solana",JSON.stringify({address:e}))}catch{}}}catch{}}#i(){let e=this.#e,t=this.features;this.#s.forEach(o=>{try{o({accounts:e,features:t})}catch{}})}#t(e,t){return new Promise((o,n)=>{this.#r(e,t,"solana",(u,a)=>{u?n(u):o(a)})})}};function _(d){let e=({register:t})=>{t(d)};try{let t=window.navigator;t.wallets||(t.wallets=[]),Array.isArray(t.wallets)?t.wallets.push(e):typeof t.wallets.register=="function"&&t.wallets.register(d)}catch{}try{window.dispatchEvent(new CustomEvent("wallet-standard:register-wallet",{detail:e}))}catch{}window.addEventListener("wallet-standard:app-ready",t=>{let o=t;try{typeof o.detail=="function"&&o.detail(e)}catch{}})}var U="https://api.trongrid.io",J="123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz";function Q(d){let e=[0];for(let t of d){let o=J.indexOf(t);if(o===-1)throw new Error("Invalid base58 character");let n=o;for(let u=0;u>=8;for(;n>0;)e.push(n&255),n>>=8}for(let t of d){if(t!=="1")break;e.push(0)}return new Uint8Array(e.reverse())}function X(d){let e="";for(let t of d)e+=t.toString(16).padStart(2,"0");return e}function W(d){let e=Q(d);if(e.length!==25||e[0]!==65)throw new Error(`Invalid Tron address: ${d}`);return X(e.slice(0,21))}function q(d){if(typeof d!="string"||d.length!==34||!d.startsWith("T"))return!1;try{return W(d),!0}catch{return!1}}var K=class{events=new Map;on(e,t){this.events.has(e)||this.events.set(e,new Set),this.events.get(e).add(t)}off(e,t){var o;(o=this.events.get(e))==null||o.delete(t)}emit(e,...t){var o;(o=this.events.get(e))==null||o.forEach(n=>{try{n(...t)}catch{}})}};function I(d,e,t){return new Promise((o,n)=>{d(e,t,"tron",(u,a)=>{u?n(u):o(a)})})}var V=8e3,Y=12e3,Z=d=>d>=500&&d<600;async function T(d,e){let o=d.includes("broadcasttransaction")?Y:V,n=2,u;for(let a=1;a<=n;a++)try{let g=await fetch(`${U}${d}`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(e),signal:AbortSignal.timeout(o)});if(!g.ok){if(Z(g.status)&&asetTimeout(p,200*a));continue}let h=await g.text().catch(()=>"");throw new Error(`TronGrid ${d} failed (${g.status}): ${h}`)}return await g.json()}catch(g){if(u=g,asetTimeout(h,200*a));continue}}throw u}var B=class{tronWeb;tronLink;address=null;hexAddress=null;emitter=new K;walletRequest;constructor(e){this.walletRequest=e,this.tronWeb=this.buildTronWeb(),this.tronLink=this.buildTronLink()}setAddress(e){!e||!q(e)||(this.address=e,this.hexAddress=W(e),this.tronWeb.ready=!0,this.tronWeb.defaultAddress={base58:e,hex:this.hexAddress,name:"KeepKey",type:1},this.tronLink.ready=!0,this.fireMessage("setAccount",{address:e,name:"KeepKey",type:1}),this.fireMessage("accountsChanged",{address:e}))}fireMessage(e,t){try{window.postMessage({message:{action:e,data:t},isTronLink:!0},window.location.origin)}catch{}this.emitter.emit(e,t)}buildTronLink(){return{isTronLink:!0,ready:!1,tronWeb:null,request:async({method:e,params:t})=>{switch(e){case"tron_requestAccounts":case"tron_accounts":{let o=await I(this.walletRequest,"tron_requestAccounts",[]);return!o||typeof o!="string"?{code:4001,message:"User denied account access"}:(this.setAddress(o),{code:200,message:"ok"})}default:return I(this.walletRequest,e,Array.isArray(t)?t:[t])}},on:(e,t)=>this.emitter.on(e,t),off:(e,t)=>this.emitter.off(e,t)}}buildTronWeb(){let e=this,t={sign:async(a,g,h,p)=>{if(typeof a=="string")throw new Error("tronWeb.trx.sign(message) not supported \u2014 use tronWeb.trx.signMessage()");if(!a||!a.raw_data_hex)throw new Error("tronWeb.trx.sign expects a built transaction with raw_data_hex");return await I(e.walletRequest,"tron_sign",[a])},signMessage:async(a,g)=>await I(e.walletRequest,"tron_signMessage",[a]),signMessageV2:async(a,g)=>await I(e.walletRequest,"signMessageV2",[a]),sendRawTransaction:async a=>T("/wallet/broadcasttransaction",a),broadcast:async a=>t.sendRawTransaction(a),getBalance:async a=>{let g=a||e.address;if(!g)throw new Error("No address \u2014 call tron_requestAccounts first");let h=await T("/wallet/getaccount",{address:g,visible:!0});return typeof(h==null?void 0:h.balance)=="number"?h.balance:0},getAccount:async a=>{let g=a||e.address;if(!g)throw new Error("No address \u2014 call tron_requestAccounts first");return T("/wallet/getaccount",{address:g,visible:!0})},getUnconfirmedAccount:async a=>{let g=a||e.address;if(!g)throw new Error("No address \u2014 call tron_requestAccounts first");return T("/wallet/getaccount",{address:g,visible:!0})},getTransaction:async a=>T("/wallet/gettransactionbyid",{value:a})},u={isTronLink:!0,ready:!1,defaultAddress:{base58:!1,hex:!1,name:!1,type:-1},fullNode:{host:U},solidityNode:{host:U},eventServer:{host:U},trx:t,transactionBuilder:{sendTrx:async(a,g,h)=>{let p=h||e.address;if(!p)throw new Error("No address \u2014 call tron_requestAccounts first");return T("/wallet/createtransaction",{owner_address:p,to_address:a,amount:g,visible:!0})},triggerSmartContract:async(a,g,h={},p=[],k)=>{let v=k||e.address;if(!v)throw new Error("No address \u2014 call tron_requestAccounts first");return T("/wallet/triggersmartcontract",{contract_address:a,function_selector:g,parameter:$(p),fee_limit:h.feeLimit??1e8,call_value:h.callValue??0,owner_address:v,visible:!0})}},utils:{isAddress:a=>q(a),fromSun:a=>String(Number(a)/1e6),toSun:a=>String(Math.round(Number(a)*1e6)),toHex:a=>W(a)},on:(a,g)=>this.emitter.on(a,g),off:(a,g)=>this.emitter.off(a,g),setAddress:a=>{},isConnected:()=>this.address!==null};return queueMicrotask(()=>{this.tronLink&&(this.tronLink.tronWeb=u)}),u}};function $(d){if(!Array.isArray(d)||d.length===0)return"";let e="";for(let t of d)if(t.type==="address"){let o=String(t.value),n=o.startsWith("T")?W(o).slice(2):o.replace(/^0x/,"").replace(/^41/,"");e+=n.padStart(64,"0")}else if(t.type==="uint256"||t.type==="uint"){let o=BigInt(t.value);e+=o.toString(16).padStart(64,"0")}else{let o=String(t.value).replace(/^0x/,"");e+=o.padStart(64,"0")}return e}(function(){let d="2.1.0",u=window,a={isInjected:!1,version:d,injectedAt:Date.now(),retryCount:0};if(u.keepkeyInjectionState&&u.keepkeyInjectionState.version>=d)return;u.keepkeyInjectionState=a;let g=(()=>{var r;let l={enableMetaMaskMasking:!1,enableXfiMasking:!1,enableKeplrMasking:!1};try{let c=document.currentScript,s=document.getElementById("keepkey-injected-script"),i=(r=c==null?void 0:c.dataset)!=null&&r.masking?c:s,A=i==null?void 0:i.dataset.masking;if(!A)return l;let f=JSON.parse(A);return{enableMetaMaskMasking:f.enableMetaMaskMasking===!0,enableXfiMasking:f.enableXfiMasking===!0,enableKeplrMasking:f.enableKeplrMasking===!0}}catch{return l}})();console.log(`[KeepKey] masking: metamask=${g.enableMetaMaskMasking?"on":"off"} xfi=${g.enableXfiMasking?"on":"off"} keplr=${g.enableKeplrMasking?"on":"off"}`);let h={siteUrl:window.location.href,scriptSource:"KeepKey Extension",version:d,injectedTime:new Date().toISOString(),origin:window.location.origin,protocol:window.location.protocol},p=0,k=new Map,v=[],M=!1;setInterval(()=>{let l=Date.now();k.forEach((r,c)=>{l-r.timestamp>3e5&&(r.callback(new Error("Request timeout")),k.delete(c))})},5e3);let m=l=>{v.length>=100&&v.shift(),v.push(l)},C=()=>{if(M)for(;v.length>0;){let l=v.shift();l&&window.postMessage(l,window.location.origin)}},S=(l=0)=>new Promise(r=>{let c=++p,s=setTimeout(()=>{l<3?setTimeout(()=>{S(l+1).then(r)},100*Math.pow(2,l)):(a.lastError="Failed to verify injection",r(!1))},1e3),i=A=>{var f,w,b;A.source===window&&((f=A.data)==null?void 0:f.source)==="keepkey-content"&&((w=A.data)==null?void 0:w.type)==="INJECTION_CONFIRMED"&&((b=A.data)==null?void 0:b.requestId)===c&&(clearTimeout(s),window.removeEventListener("message",i),M=!0,a.isInjected=!0,C(),r(!0))};window.addEventListener("message",i),window.postMessage({source:"keepkey-injected",type:"INJECTION_VERIFY",requestId:c,version:d,timestamp:Date.now()},window.location.origin)});function E(l,r=[],c,s){if(!l||typeof l!="string"){s(new Error("Invalid method"));return}Array.isArray(r)||(r=[r]);try{let i=++p,A={id:i,method:l,params:r,chain:c,siteUrl:h.siteUrl,scriptSource:h.scriptSource,version:h.version,requestTime:new Date().toISOString(),referrer:document.referrer,href:window.location.href,userAgent:navigator.userAgent,platform:navigator.platform,language:navigator.language};k.set(i,{callback:s,timestamp:Date.now(),method:l});let f={source:"keepkey-injected",type:"WALLET_REQUEST",requestId:i,requestInfo:A,timestamp:Date.now()};M?window.postMessage(f,window.location.origin):m(f)}catch(i){s(i)}}window.addEventListener("message",l=>{if(l.source!==window)return;let r=l.data;if(!(!r||typeof r!="object")){if(r.source==="keepkey-content"&&r.type==="INJECTION_CONFIRMED"){M=!0,C();return}if(r.source==="keepkey-content"&&r.type==="WALLET_RESPONSE"&&r.requestId){let c=k.get(r.requestId);c&&(r.error?c.callback(r.error):c.callback(null,r.result),k.delete(r.requestId))}}});class j{events=new Map;on(r,c){this.events.has(r)||this.events.set(r,new Set),this.events.get(r).add(c)}off(r,c){var s;(s=this.events.get(r))==null||s.delete(c)}removeListener(r,c){this.off(r,c)}removeAllListeners(r){r?this.events.delete(r):this.events.clear()}emit(r,...c){var s;(s=this.events.get(r))==null||s.forEach(i=>{try{i(...c)}catch{}})}once(r,c){let s=(...i)=>{c(...i),this.off(r,s)};this.on(r,s)}}function y(l){let r=new j,c={network:"mainnet",isKeepKey:!0,isMetaMask:g.enableMetaMaskMasking,isConnected:()=>M,request:({method:s,params:i=[]})=>new Promise((A,f)=>{E(s,i,l,(w,b)=>{if(w)console.log(`[HANDOFF] dApp \u2190 KeepKey (${l}/${s}) REJECT + params=${JSON.stringify(i)} + error=`,w),f(w);else{let L=typeof b,H=L==="string"?`len=${b.length} value=${b}`:`value=${JSON.stringify(b)}`;console.log(`[HANDOFF] dApp \u2190 KeepKey (${l}/${s}) RESOLVE + params=${JSON.stringify(i)} + type=${L} ${H}`),A(b)}})}),send:(s,i,A)=>{if(s.chain||(s.chain=l),typeof A=="function"){E(s.method,s.params||i,l,(f,w)=>{f?A(f):A(null,{id:s.id,jsonrpc:"2.0",result:w})});return}else return{id:s.id,jsonrpc:"2.0",result:null}},sendAsync:(s,i,A)=>{s.chain||(s.chain=l);let f=A||i;typeof f=="function"&&E(s.method,s.params||i,l,(w,b)=>{w?f(w):f(null,{id:s.id,jsonrpc:"2.0",result:b})})},on:(s,i)=>(r.on(s,i),c),off:(s,i)=>(r.off(s,i),c),removeListener:(s,i)=>(r.removeListener(s,i),c),removeAllListeners:s=>(r.removeAllListeners(s),c),emit:(s,...i)=>(r.emit(s,...i),c),once:(s,i)=>(r.once(s,i),c),enable:()=>c.request({method:"eth_requestAccounts"}),_metamask:{isUnlocked:()=>Promise.resolve(!0)}};return l==="ethereum"&&(c.chainId="0x1",c.networkVersion="1",c.selectedAddress=null,c._handleAccountsChanged=s=>{c.selectedAddress=s[0]||null,r.emit("accountsChanged",s)},c._handleChainChanged=s=>{c.chainId=s,r.emit("chainChanged",s)},c._handleConnect=s=>{r.emit("connect",s)},c._handleDisconnect=s=>{c.selectedAddress=null,r.emit("disconnect",s)}),c}let D="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAAAXNSR0IArs4c6QAAAERlWElmTU0AKgAAAAgAAYdpAAQAAAABAAAAGgAAAAAAA6ABAAMAAAABAAEAAKACAAQAAAABAAAAIKADAAQAAAABAAAAIAAAAACshmLzAAADUklEQVRYCb1XTUgUYRie3bXEWhVLQaUsgwVLoUtEQjUJiZX0A0GX7BIZXurkOTSvdo2kvETHAsOshFgqOqhlRD9C7SGS1JTCsj1krU7PM+w7zMzOzuzMqi88+73v9z7vz3zzzTeziuIgmqbFgG5gBPguFOgq4CXLIMwCo0AXEJN4zxHkEuA6kAIMkUBMqMZk7so/UG8AUcnjOIKwFXgHZIgEwKFmOHOfYO4aySVjmAoc7O4R0EB7lYS5h9K1jBJ6A7CuAfXG7OopbKLXkh4dccNZ7jlsi0gAJlWLI5jBPWFsTK5AGxCRImswFqDGWanDBo6IsYbjUanFbmrFWIHxD3IsmfJsgB4y2aJuF4UrUC5GnuNtxJeEQqEoAb3LJV+F4ctlHwkZXDULv8fEKQCHB4+rCJ9ngKcIGUTVRubT027y8yR9bOM4mhKTTwNJZD4miaDXAG8dqzlMShw3YRCZRVAr7vU4g5F/D4ZBoJK2H+Em9CsfEdBoKn4K9jPAd3G9sMPqZEzpRPzAwRfWJpN9EfZSRkAOE5LD7wrw8dkpwRh55VMm27fqt4FiVBjGBTaxEm4Db8d+4BPtIOK3AdbYCPC1qh/haGIS9gHgDeBbgjTAIkXAfTRxkgaamMNwCHgB+BMk4Decq0hGkFQbka/WMyZ/EeyHNo6TuSwx3Nn8gHQVIYOkOhB5Gp4zcdbBHiDvZ2pRuzozru2euKuDOucg/KliTAjKKMa9ksBpxBLrbzRwVfifOnB4RR2g3QSH3Cfx5FRdc2KoGstroUeQKh47vnAwWvUKjsPcA/wWdBUkjRAgZdsznO8D5xLGC/Opxc3NiQeV9uIsgkNDaUoMFpNDLleAn0cTQNBjGaFW6fn2Wrky/dI6abPOl9eN9deoWhjLloCv3+bPy7w3/9kzfvjX120g1cuSdsJ47xm1CgS9AaxCErlbV6qJ22W1nq22lG75AtIHWQEeJpOYaAT6gBQQWC5XNCjc7dkkHFKWe6v3FcLfbzRAMlcC6IC6C+gGxgCectZnCRMuopVG1v+Nx04sYINlxLH4wI6W52UFhT+Q41b2Nl0qeLnwZPGQucNHrXN6ZDG94RQuO688XbwNFzvjlSuwH03wEW8H+Bf/dxrUOWdc+H8mKXtEpGpY3AAAAABJRU5ErkJggg==",P="data:image/svg+xml;utf8,"+encodeURIComponent('MM');function R(l){let r={uuid:"350670db-19fa-4704-a166-e52e178b59d4",name:"KeepKey",icon:D,rdns:"com.keepkey.client"};if(window.dispatchEvent(new CustomEvent("eip6963:announceProvider",{detail:Object.freeze({info:r,provider:l})})),g.enableMetaMaskMasking){let c={uuid:"9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d",name:"MetaMask",icon:P,rdns:"io.metamask"};window.dispatchEvent(new CustomEvent("eip6963:announceProvider",{detail:Object.freeze({info:c,provider:l})}))}}async function G(){let l=y("ethereum"),r={binance:y("binance"),bitcoin:y("bitcoin"),bitcoincash:y("bitcoincash"),dogecoin:y("dogecoin"),dash:y("dash"),ethereum:l,keplr:y("keplr"),litecoin:y("litecoin"),thorchain:y("thorchain"),mayachain:y("mayachain")},c={binance:y("binance"),bitcoin:y("bitcoin"),bitcoincash:y("bitcoincash"),dogecoin:y("dogecoin"),dash:y("dash"),ethereum:l,osmosis:y("osmosis"),cosmos:y("cosmos"),litecoin:y("litecoin"),thorchain:y("thorchain"),mayachain:y("mayachain"),ripple:y("ripple")},s=(i,A,{force:f=!1}={})=>{if(!(u[i]&&!f))try{Object.defineProperty(u,i,{value:A,writable:!1,configurable:!0})}catch{a.lastError=`Failed to mount ${i}`}};g.enableMetaMaskMasking&&s("ethereum",l),g.enableXfiMasking&&s("xfi",r),s("keepkey",c,{force:!0}),window.addEventListener("eip6963:requestProvider",()=>{R(l)}),R(l),setTimeout(()=>{R(l)},100);try{let i=new O(E);_(i)}catch{}try{let i=new B(E);u.tronLink||Object.defineProperty(u,"tronLink",{value:i.tronLink,writable:!1,configurable:!0}),u.tronWeb||Object.defineProperty(u,"tronWeb",{value:i.tronWeb,writable:!1,configurable:!0})}catch{}window.addEventListener("message",i=>{var A,f,w;((A=i.data)==null?void 0:A.type)==="CHAIN_CHANGED"&&l.emit("chainChanged",(f=i.data.provider)==null?void 0:f.chainId),((w=i.data)==null?void 0:w.type)==="ACCOUNTS_CHANGED"&&l._handleAccountsChanged&&l._handleAccountsChanged(i.data.accounts||[])}),S().then(i=>{i||(a.lastError="Injection not verified")})}G(),document.readyState==="loading"&&document.addEventListener("DOMContentLoaded",()=>{if(u.ethereum&&typeof u.dispatchEvent=="function"){let l=u.ethereum;R(l)}})})();})(); diff --git a/chrome-extension/public/kk-logo.png b/chrome-extension/public/kk-logo.png new file mode 100644 index 0000000..4344aa6 Binary files /dev/null and b/chrome-extension/public/kk-logo.png differ diff --git a/chrome-extension/public/kk.gif b/chrome-extension/public/kk.gif new file mode 100644 index 0000000..5940044 Binary files /dev/null and b/chrome-extension/public/kk.gif differ diff --git a/pages/popup/public/logo_vertical.svg b/chrome-extension/public/logo_vertical.svg similarity index 100% rename from pages/popup/public/logo_vertical.svg rename to chrome-extension/public/logo_vertical.svg diff --git a/pages/popup/public/logo_vertical_dark.svg b/chrome-extension/public/logo_vertical_dark.svg similarity index 100% rename from pages/popup/public/logo_vertical_dark.svg rename to chrome-extension/public/logo_vertical_dark.svg diff --git a/chrome-extension/src/background/chainConfig.ts b/chrome-extension/src/background/chainConfig.ts index d782d36..2998039 100644 --- a/chrome-extension/src/background/chainConfig.ts +++ b/chrome-extension/src/background/chainConfig.ts @@ -39,7 +39,9 @@ export const shortListSymbolToCaip: Record = { AVAX: 'eip155:43114/slip44:60', BSC: 'eip155:56/slip44:60', BNB: 'eip155:56/slip44:60', - SOL: 'solana:5eykt4usfv8p8njdtrepy1vzqkqzkvdp/solana:so11111111111111111111111111111111111111112', + SOL: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501', + TON: 'ton:-239/slip44:607', + TRX: 'tron:27Lqcw/slip44:195', }; export const shortListNameToCaip: Record = { @@ -60,7 +62,9 @@ export const shortListNameToCaip: Record = { ripple: 'ripple:4109c6f2045fc7eff4cde8f9905d19c2/slip44:144', optimism: 'eip155:10/slip44:60', base: 'eip155:8453/slip44:60', - solana: 'solana:5eykt4usfv8p8njdtrepy1vzqkqzkvdp/solana:so11111111111111111111111111111111111111112', + solana: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501', + ton: 'ton:-239/slip44:607', + tron: 'tron:27Lqcw/slip44:195', }; // ---- bip32ToAddressNList ---- diff --git a/chrome-extension/src/background/chains.ts b/chrome-extension/src/background/chains.ts deleted file mode 100644 index 0ec07f6..0000000 --- a/chrome-extension/src/background/chains.ts +++ /dev/null @@ -1,72 +0,0 @@ -export const EIP155_CHAINS: any = { - 'eip155:1': { - chainId: 1, - name: 'Ethereum', - logo: '/chain-logos/eip155-1.png', - rgb: '99, 125, 234', - rpc: 'https://ethereum-rpc.publicnode.com', - namespace: 'eip155', - caip: 'eip155:1/slip44:60', - explorerTxLink: 'https://etherscan.io/tx/', - }, - 'eip155:43114': { - chainId: '0xa86a', - name: 'Avalanche C-Chain', - logo: '/chain-logos/eip155-43114.png', - rgb: '232, 65, 66', - rpc: 'https://api.avax.network/ext/bc/C/rpc', - namespace: 'eip155', - caip: 'eip155:43114/slip44:60', - explorerTxLink: 'https://snowtrace.io/tx/', - }, - 'eip155:137': { - chainId: '0x89', - name: 'Polygon', - logo: '/chain-logos/eip155-137.png', - rgb: '130, 71, 229', - rpc: 'https://polygon-rpc.com/', - namespace: 'eip155', - caip: 'eip155:137/slip44:60', - explorerTxLink: 'https://polygonscan.com/tx/', - }, - 'eip155:10': { - chainId: '0xa', - name: 'Optimism', - logo: '/chain-logos/eip155-10.png', - rgb: '235, 0, 25', - rpc: 'https://mainnet.optimism.io', - namespace: 'eip155', - caip: 'eip155:10/slip44:60', - explorerTxLink: 'https://optimistic.etherscan.io/tx/', - }, - 'eip155:324': { - chainId: '0x144', - name: 'zkSync Era', - logo: '/chain-logos/eip155-324.svg', - rgb: '242, 242, 242', - rpc: 'https://mainnet.era.zksync.io/', - namespace: 'eip155', - caip: 'eip155:324/slip44:60', - explorerTxLink: 'https://explorer.zksync.io/tx/', - }, - 'eip155:8453': { - chainId: '0x2105', - name: 'Base', - logo: '/chain-logos/base.png', - rgb: '242, 242, 242', - rpc: 'https://mainnet.base.org', - namespace: 'eip155', - caip: 'eip155:8453/slip44:60', - explorerTxLink: 'https://basescan.org/tx/', - }, - 'eip155:42161': { - chainId: '0xa4b1', - name: 'Arbitrum', - logo: '/chain-logos/arbitrum.png', - rgb: '4, 100, 214', - rpc: 'https://api.zan.top/node/v1/arb/one/public', - namespace: 'eip155', - caip: 'eip155:42161/slip44:60', - explorerTxLink: 'https://arbiscan.io/tx/', - }, -}; diff --git a/chrome-extension/src/background/chains/bitcoinCashHandler.ts b/chrome-extension/src/background/chains/bitcoinCashHandler.ts index 6918d25..b2df742 100644 --- a/chrome-extension/src/background/chains/bitcoinCashHandler.ts +++ b/chrome-extension/src/background/chains/bitcoinCashHandler.ts @@ -3,6 +3,7 @@ import { v4 as uuidv4 } from 'uuid'; import { Chain, ChainToNetworkId, shortListSymbolToCaip, caipToNetworkId } from '../chainConfig'; import * as wallet from '../wallet'; import { createProviderRpcError } from '../utils'; +import { fetchJsonWithTimeout } from '../fetchUtils'; const TAG = ' | bitcoinCashHandler | '; @@ -41,24 +42,22 @@ export const handleBitcoinCashRequest = async ( isMax: params[0].isMax, }; - const buildTx = async function () { - try { - const buildResponse = await fetch('https://api.keepkey.info/api/v1/buildTx', { + // Build before approval — see bitcoinHandler for the race rationale. + let unsignedTx: any; + try { + unsignedTx = await fetchJsonWithTimeout( + 'https://api.keepkey.info/api/v1/buildTx', + { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ...sendPayload, pubkeys }), - }); - const unsignedTx = await buildResponse.json(); - const storedEvent = await requestStorage.getEventById(requestInfo.id); - storedEvent.unsignedTx = unsignedTx; - await requestStorage.updateEventById(requestInfo.id, storedEvent); - chrome.runtime.sendMessage({ action: 'utxo_build_tx', unsignedTx: requestInfo }); - } catch (e) { - console.error(e); - chrome.runtime.sendMessage({ action: 'transaction_error', error: JSON.stringify(e) }); - } - }; - buildTx(); + }, + { timeoutMs: 15000, retries: 1 }, + ); + } catch (e) { + console.error(tag, 'buildTx failed:', e); + throw createProviderRpcError(4000, `Failed to build transaction: ${(e as Error)?.message || e}`); + } const event = { id: requestInfo.id, @@ -74,6 +73,7 @@ export const handleBitcoinCashRequest = async ( injectScriptVersion: requestInfo.version, chain: 'bitcoincash', requestInfo, + unsignedTx, type: 'transfer', request: params, status: 'request', @@ -92,18 +92,22 @@ export const handleBitcoinCashRequest = async ( response.signedTx = signedTx; await requestStorage.updateEventById(requestInfo.id, response); - const broadcastResponse = await fetch('https://api.keepkey.info/api/v1/broadcastTx', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ caip, signedTx: signedTx.serializedTx || signedTx }), - }); - let txHash = await broadcastResponse.json(); + let txHash: any = await fetchJsonWithTimeout( + 'https://api.keepkey.info/api/v1/broadcastTx', + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ caip, signedTx: signedTx.serializedTx || signedTx }), + }, + { timeoutMs: 15000, retries: 1 }, + ); if (txHash.txHash) txHash = txHash.txHash; if (txHash.txid) txHash = txHash.txid; response.txid = txHash; await requestStorage.updateEventById(requestInfo.id, response); chrome.runtime.sendMessage({ action: 'transaction_complete', + eventId: requestInfo.id, txHash, explorerTxLink: 'https://blockchair.com/bitcoin-cash/transaction/', }); diff --git a/chrome-extension/src/background/chains/bitcoinHandler.ts b/chrome-extension/src/background/chains/bitcoinHandler.ts index 765e696..592cd07 100644 --- a/chrome-extension/src/background/chains/bitcoinHandler.ts +++ b/chrome-extension/src/background/chains/bitcoinHandler.ts @@ -3,6 +3,7 @@ import { v4 as uuidv4 } from 'uuid'; import { Chain, ChainToNetworkId, shortListSymbolToCaip, caipToNetworkId } from '../chainConfig'; import * as wallet from '../wallet'; import { createProviderRpcError } from '../utils'; +import { fetchJsonWithTimeout } from '../fetchUtils'; const TAG = ' | bitcoinHandler | '; @@ -62,34 +63,27 @@ export const handleBitcoinRequest = async ( }; console.log(tag, 'Send Payload: ', sendPayload); - // Build UTXO transaction via Pioneer API HTTP call - const buildTx = async function () { - try { - const buildResponse = await fetch('https://api.keepkey.info/api/v1/buildTx', { + // Build the unsigned tx BEFORE creating the approval event so the + // event always carries an unsignedTx the moment the user sees it. + // The previous fire-and-forget pattern raced: if the user approved + // before buildTx resolved, response.unsignedTx was undefined; if + // buildTx finished before addEvent, getEventById returned null. + let unsignedTx: any; + try { + unsignedTx = await fetchJsonWithTimeout( + 'https://api.keepkey.info/api/v1/buildTx', + { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ...sendPayload, pubkeys }), - }); - const unsignedTx = await buildResponse.json(); - console.log(tag, 'unsignedTx: ', unsignedTx); - - const storedEvent = await requestStorage.getEventById(requestInfo.id); - storedEvent.unsignedTx = unsignedTx; - await requestStorage.updateEventById(requestInfo.id, storedEvent); - - chrome.runtime.sendMessage({ - action: 'utxo_build_tx', - unsignedTx: requestInfo, - }); - } catch (e) { - console.error(e); - chrome.runtime.sendMessage({ - action: 'transaction_error', - error: JSON.stringify(e), - }); - } - }; - buildTx(); + }, + { timeoutMs: 15000, retries: 1 }, + ); + console.log(tag, 'unsignedTx: ', unsignedTx); + } catch (e) { + console.error(tag, 'buildTx failed:', e); + throw createProviderRpcError(4000, `Failed to build transaction: ${(e as Error)?.message || e}`); + } const event = { id: requestInfo.id, @@ -105,6 +99,7 @@ export const handleBitcoinRequest = async ( injectScriptVersion: requestInfo.version, chain: 'bitcoin', requestInfo, + unsignedTx, type: 'transfer', request: params, status: 'request', @@ -130,13 +125,20 @@ export const handleBitcoinRequest = async ( await requestStorage.updateEventById(requestInfo.id, response); try { - // Broadcast via Pioneer API - const broadcastResponse = await fetch('https://api.keepkey.info/api/v1/broadcastTx', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ caip, signedTx: signedTx.serializedTx || signedTx }), - }); - let txHash = await broadcastResponse.json(); + // Broadcast via Pioneer API. fetchJsonWithTimeout enforces an + // explicit response.ok check + retry on 5xx — without that, + // a transient Pioneer hiccup (e.g. node failover) would either + // hang the dApp or surface as a malformed JSON error from the + // raw `await response.json()` below. + let txHash: any = await fetchJsonWithTimeout( + 'https://api.keepkey.info/api/v1/broadcastTx', + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ caip, signedTx: signedTx.serializedTx || signedTx }), + }, + { timeoutMs: 15000, retries: 1 }, + ); if (txHash.txHash) txHash = txHash.txHash; if (txHash.txid) txHash = txHash.txid; @@ -145,6 +147,7 @@ export const handleBitcoinRequest = async ( chrome.runtime.sendMessage({ action: 'transaction_complete', + eventId: requestInfo.id, txHash: txHash, explorerTxLink: 'https://mempool.space/tx/', }); @@ -153,8 +156,14 @@ export const handleBitcoinRequest = async ( console.error(tag, e); chrome.runtime.sendMessage({ action: 'transaction_error', + eventId: requestInfo.id, error: JSON.stringify(e), }); + // Re-throw so the dApp sees the actual broadcast error. Without + // this the case falls through to `default:` below and the dApp + // gets "Method transfer not supported" instead of the real + // failure (timeout, HTTP 5xx, etc.). + throw e instanceof Error ? e : createProviderRpcError(4000, `Broadcast failed: ${String(e)}`); } } else { throw createProviderRpcError(4200, 'User denied transaction'); diff --git a/chrome-extension/src/background/chains/cosmosHandler.ts b/chrome-extension/src/background/chains/cosmosHandler.ts index 93419ca..0c48255 100644 --- a/chrome-extension/src/background/chains/cosmosHandler.ts +++ b/chrome-extension/src/background/chains/cosmosHandler.ts @@ -3,6 +3,7 @@ import { v4 as uuidv4 } from 'uuid'; import { Chain, ChainToNetworkId, shortListSymbolToCaip, caipToNetworkId } from '../chainConfig'; import * as wallet from '../wallet'; import { createProviderRpcError } from '../utils'; +import { fetchJsonWithTimeout } from '../fetchUtils'; const TAG = ' | cosmosHandler | '; @@ -43,16 +44,19 @@ export const handleCosmosRequest = async ( let unsignedTx: any; try { - const buildResponse = await fetch('https://api.keepkey.info/api/v1/buildTx', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ ...sendPayload, pubkeys }), - }); - unsignedTx = await buildResponse.json(); + unsignedTx = await fetchJsonWithTimeout( + 'https://api.keepkey.info/api/v1/buildTx', + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ ...sendPayload, pubkeys }), + }, + { timeoutMs: 15000, retries: 1 }, + ); console.log(tag, 'unsignedTx: ', unsignedTx); } catch (e) { console.error(tag, 'buildTx failed:', e); - throw createProviderRpcError(4000, 'Failed to build transaction'); + throw createProviderRpcError(4000, `Failed to build transaction: ${(e as Error)?.message || e}`); } const event = { @@ -90,12 +94,15 @@ export const handleCosmosRequest = async ( response.signedTx = signedTx; await requestStorage.updateEventById(requestInfo.id, response); - const broadcastResponse = await fetch('https://api.keepkey.info/api/v1/broadcastTx', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ caip, signedTx: signedTx.serializedTx || signedTx }), - }); - let txHash = await broadcastResponse.json(); + let txHash: any = await fetchJsonWithTimeout( + 'https://api.keepkey.info/api/v1/broadcastTx', + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ caip, signedTx: signedTx.serializedTx || signedTx }), + }, + { timeoutMs: 15000, retries: 1 }, + ); if (txHash.txHash) txHash = txHash.txHash; if (txHash.txid) txHash = txHash.txid; @@ -103,6 +110,7 @@ export const handleCosmosRequest = async ( await requestStorage.updateEventById(requestInfo.id, response); chrome.runtime.sendMessage({ action: 'transaction_complete', + eventId: requestInfo.id, txHash, explorerTxLink: 'https://www.mintscan.io/cosmos/tx/', }); diff --git a/chrome-extension/src/background/chains/dashHandler.ts b/chrome-extension/src/background/chains/dashHandler.ts index e7db454..f14cd7e 100644 --- a/chrome-extension/src/background/chains/dashHandler.ts +++ b/chrome-extension/src/background/chains/dashHandler.ts @@ -3,6 +3,7 @@ import { v4 as uuidv4 } from 'uuid'; import { Chain, ChainToNetworkId, shortListSymbolToCaip, caipToNetworkId } from '../chainConfig'; import * as wallet from '../wallet'; import { createProviderRpcError } from '../utils'; +import { fetchJsonWithTimeout } from '../fetchUtils'; const TAG = ' | dashHandler | '; @@ -41,24 +42,22 @@ export const handleDashRequest = async ( isMax: params[0].isMax, }; - const buildTx = async function () { - try { - const buildResponse = await fetch('https://api.keepkey.info/api/v1/buildTx', { + // Build before approval — see bitcoinHandler for the race rationale. + let unsignedTx: any; + try { + unsignedTx = await fetchJsonWithTimeout( + 'https://api.keepkey.info/api/v1/buildTx', + { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ...sendPayload, pubkeys }), - }); - const unsignedTx = await buildResponse.json(); - const storedEvent = await requestStorage.getEventById(requestInfo.id); - storedEvent.unsignedTx = unsignedTx; - await requestStorage.updateEventById(requestInfo.id, storedEvent); - chrome.runtime.sendMessage({ action: 'utxo_build_tx', unsignedTx: requestInfo }); - } catch (e) { - console.error(e); - chrome.runtime.sendMessage({ action: 'transaction_error', error: JSON.stringify(e) }); - } - }; - buildTx(); + }, + { timeoutMs: 15000, retries: 1 }, + ); + } catch (e) { + console.error(tag, 'buildTx failed:', e); + throw createProviderRpcError(4000, `Failed to build transaction: ${(e as Error)?.message || e}`); + } const event = { id: requestInfo.id, @@ -74,6 +73,7 @@ export const handleDashRequest = async ( injectScriptVersion: requestInfo.version, chain: 'dash', requestInfo, + unsignedTx, type: 'transfer', request: params, status: 'request', @@ -92,18 +92,22 @@ export const handleDashRequest = async ( response.signedTx = signedTx; await requestStorage.updateEventById(requestInfo.id, response); - const broadcastResponse = await fetch('https://api.keepkey.info/api/v1/broadcastTx', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ caip, signedTx: signedTx.serializedTx || signedTx }), - }); - let txHash = await broadcastResponse.json(); + let txHash: any = await fetchJsonWithTimeout( + 'https://api.keepkey.info/api/v1/broadcastTx', + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ caip, signedTx: signedTx.serializedTx || signedTx }), + }, + { timeoutMs: 15000, retries: 1 }, + ); if (txHash.txHash) txHash = txHash.txHash; if (txHash.txid) txHash = txHash.txid; response.txid = txHash; await requestStorage.updateEventById(requestInfo.id, response); chrome.runtime.sendMessage({ action: 'transaction_complete', + eventId: requestInfo.id, txHash, explorerTxLink: 'https://blockchair.com/dash/transaction/', }); diff --git a/chrome-extension/src/background/chains/dogecoinHandler.ts b/chrome-extension/src/background/chains/dogecoinHandler.ts index 01a7b79..05e681e 100644 --- a/chrome-extension/src/background/chains/dogecoinHandler.ts +++ b/chrome-extension/src/background/chains/dogecoinHandler.ts @@ -3,6 +3,7 @@ import { v4 as uuidv4 } from 'uuid'; import { Chain, ChainToNetworkId, shortListSymbolToCaip, caipToNetworkId } from '../chainConfig'; import * as wallet from '../wallet'; import { createProviderRpcError } from '../utils'; +import { fetchJsonWithTimeout } from '../fetchUtils'; const TAG = ' | dogecoinHandler | '; @@ -44,24 +45,22 @@ export const handleDogecoinRequest = async ( isMax: params[0].isMax, }; - const buildTx = async function () { - try { - const buildResponse = await fetch('https://api.keepkey.info/api/v1/buildTx', { + // Build before approval — see bitcoinHandler for the race rationale. + let unsignedTx: any; + try { + unsignedTx = await fetchJsonWithTimeout( + 'https://api.keepkey.info/api/v1/buildTx', + { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ...sendPayload, pubkeys }), - }); - const unsignedTx = await buildResponse.json(); - const storedEvent = await requestStorage.getEventById(requestInfo.id); - storedEvent.unsignedTx = unsignedTx; - await requestStorage.updateEventById(requestInfo.id, storedEvent); - chrome.runtime.sendMessage({ action: 'utxo_build_tx', unsignedTx: requestInfo }); - } catch (e) { - console.error(e); - chrome.runtime.sendMessage({ action: 'transaction_error', error: JSON.stringify(e) }); - } - }; - buildTx(); + }, + { timeoutMs: 15000, retries: 1 }, + ); + } catch (e) { + console.error(tag, 'buildTx failed:', e); + throw createProviderRpcError(4000, `Failed to build transaction: ${(e as Error)?.message || e}`); + } const event = { id: requestInfo.id, @@ -77,6 +76,7 @@ export const handleDogecoinRequest = async ( injectScriptVersion: requestInfo.version, chain: 'dogecoin', requestInfo, + unsignedTx, type: 'transfer', request: params, status: 'request', @@ -95,18 +95,22 @@ export const handleDogecoinRequest = async ( response.signedTx = signedTx; await requestStorage.updateEventById(requestInfo.id, response); - const broadcastResponse = await fetch('https://api.keepkey.info/api/v1/broadcastTx', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ caip, signedTx: signedTx.serializedTx || signedTx }), - }); - let txHash = await broadcastResponse.json(); + let txHash: any = await fetchJsonWithTimeout( + 'https://api.keepkey.info/api/v1/broadcastTx', + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ caip, signedTx: signedTx.serializedTx || signedTx }), + }, + { timeoutMs: 15000, retries: 1 }, + ); if (txHash.txHash) txHash = txHash.txHash; if (txHash.txid) txHash = txHash.txid; response.txid = txHash; await requestStorage.updateEventById(requestInfo.id, response); chrome.runtime.sendMessage({ action: 'transaction_complete', + eventId: requestInfo.id, txHash, explorerTxLink: 'https://blockchair.com/dogecoin/transaction/', }); diff --git a/chrome-extension/src/background/chains/ethereumHandler.ts b/chrome-extension/src/background/chains/ethereumHandler.ts index 36fe472..13bd499 100644 --- a/chrome-extension/src/background/chains/ethereumHandler.ts +++ b/chrome-extension/src/background/chains/ethereumHandler.ts @@ -2,14 +2,22 @@ Ethereum Provider Refactored */ -import { JsonRpcProvider, parseEther } from 'ethers'; +import { JsonRpcProvider, parseEther, Transaction } from 'ethers'; import { createProviderRpcError, ProviderRpcError } from '../utils'; -import { requestStorage, web3ProviderStorage, assetContextStorage, blockchainDataStorage } from '@extension/storage'; -import { EIP155_CHAINS } from '../chains'; +import { + requestStorage, + web3ProviderStorage, + assetContextStorage, + blockchainDataStorage, + blockchainStorage, +} from '@extension/storage'; import { v4 as uuidv4 } from 'uuid'; -import { blockchainStorage } from '@extension/storage'; import { ChainToNetworkId, caipToNetworkId, networkIdToIcon } from '../chainConfig'; import * as wallet from '../wallet'; +import { buildFeeWarning, type FeeChoice, type FeeWarning } from './feeFloors'; +import { openSidePanel, setApprovalBadge } from '../popup'; +import { getChainInfo, makeStaticProvider } from './registry'; +import { getLastResortRpcs } from './lastResortRpcs'; const TAG = ' | ethereumHandler | '; const DOMAIN_WHITE_LIST = []; @@ -74,46 +82,6 @@ type Event = { timestamp: string; }; -let isPopupOpen = false; // Flag to track popup state - -const openPopup = function () { - const tag = TAG + ' | openPopup | '; - try { - console.log(tag, 'Opening popup'); - chrome.windows.create( - { - url: chrome.runtime.getURL('popup/index.html'), // Adjust the URL to your popup file - type: 'popup', - width: 400, - height: 600, - }, - window => { - if (chrome.runtime.lastError) { - console.error('Error creating popup:', chrome.runtime.lastError); - isPopupOpen = false; - } else { - console.log('Popup window created:', window); - - // Optionally, handle the popup window focus or other behaviors - } - }, - ); - } catch (e) { - console.error(tag, e); - } -}; - -const requireUnlock = async function () { - const tag = TAG + ' | requireUnlock | '; - try { - console.log(tag, 'requireUnlock for domain'); - openPopup(); - } catch (e) { - console.error(e); - isPopupOpen = false; - } -}; - const convertHexToDecimalChainId = (hexChainId: string): number => { return parseInt(hexChainId, 16); }; @@ -179,11 +147,16 @@ const getProvider = async (): Promise => { const cleanUrl = rpcUrl.trim(); console.log(tag, `Trying RPC [${availableRpcs.indexOf(rpcUrl) + 1}/${availableRpcs.length}]:`, cleanUrl); - const provider = new JsonRpcProvider(cleanUrl); - - // Test the connection with a quick call (with timeout) - const timeoutPromise = new Promise((_, reject) => setTimeout(() => reject(new Error('RPC timeout')), 5000)); - const blockNumber = await Promise.race([provider.getBlockNumber(), timeoutPromise]); + // 5s transport-level timeout (FetchRequest) is the real fix — + // without it the awaited promise can race-out at the application + // layer while ethers' internal retry loop keeps the abandoned + // request alive (and re-tries on backoff). With timeoutMs the + // transport itself aborts at 5s, so the outer Promise.race is + // redundant. + const provider = makeStaticProvider(cleanUrl, currentProvider.networkId || currentProvider.chainId, { + timeoutMs: 5000, + }); + const blockNumber = await provider.getBlockNumber(); console.log(tag, '✅ RPC working! Block:', blockNumber, 'URL:', cleanUrl); @@ -212,28 +185,65 @@ const getProvider = async (): Promise => { // Handler functions for each method +/** + * Normalize a stored chainId to a number. The codebase has TWO conventions + * in the wild: + * - decimal string (legacy switch path: "56") + * - hex string (custom-add + Pioneer-discovered path: "0x38") + * Be robust to both. Returns null when unparseable. + */ +const parseChainId = (raw: unknown): number | null => { + if (raw == null) return null; + if (typeof raw === 'number') return Number.isFinite(raw) ? raw : null; + const s = String(raw).trim(); + const n = /^0x/i.test(s) ? parseInt(s, 16) : parseInt(s, 10); + return Number.isFinite(n) ? n : null; +}; + const handleEthChainId = async () => { const currentProvider = await web3ProviderStorage.getWeb3Provider(); - const chainIdDecimal = parseInt(currentProvider.chainId, 10); - const chainIdHex = '0x' + chainIdDecimal.toString(16); - console.log(TAG, 'eth_chainId returning:', chainIdHex, '(decimal:', currentProvider.chainId, ')'); + const n = parseChainId(currentProvider?.chainId); + if (n == null) { + console.warn(TAG, 'eth_chainId: unparseable stored chainId, defaulting to 0x1:', currentProvider?.chainId); + return '0x1'; + } + const chainIdHex = '0x' + n.toString(16); + console.log(TAG, 'eth_chainId returning:', chainIdHex, '(stored:', currentProvider?.chainId, ')'); return chainIdHex; }; const handleNetVersion = async () => { const currentProvider = await web3ProviderStorage.getWeb3Provider(); - return currentProvider.chainId.toString(); + const n = parseChainId(currentProvider?.chainId); + // net_version is the decimal chainId as a string. Falling back to "1" + // matches the eth_chainId default above. + return (n ?? 1).toString(); }; +// All read handlers below run through `withRpcFailover` (defined later +// in this file) so a narrow-purpose URL like Flashbots — whose +// `getBlockNumber` succeeds but whose `eth_call` / `eth_getBalance` / +// `eth_getCode` / etc. return HTTP 403 with JSON-RPC -32601 "rpc method +// is not whitelisted" — gets demoted on the actual call instead of +// being repeatedly preferred by `getProvider`'s pre-flight test. +// +// Method-rejection errors are classified as transient by +// `isTransientRpcError`, so each failure cools the URL for 60s in +// `failedRpcs` and the next call goes elsewhere. + const handleEthGetBlockByNumber = async params => { - const provider = await getProvider(); - const blockByNumber = await provider.getBlock(params[0]); - return blockByNumber; + // Passthrough raw RPC. ethers v6 `provider.getBlock(...)` returns a + // `Block` class instance whose field set is a SUBSET of the JSON-RPC + // spec response (drops sha3Uncles, logsBloom, transactionsRoot, + // stateRoot, receiptsRoot, difficulty, totalDifficulty, size, uncles) + // and whose prototype/methods are stripped when we send the value + // through `chrome.runtime.sendMessage`. dApps parse the JSON-RPC shape, + // not the wrapper. See docs/RPC_PASSTHROUGH_AUDIT.md. + return withRpcFailover(p => p.send('eth_getBlockByNumber', params), { tag: TAG + ' eth_getBlockByNumber' }); }; const handleEthBlockNumber = async () => { - const provider = await getProvider(); - const blockNumber = await provider.getBlockNumber(); + const blockNumber = await withRpcFailover(p => p.getBlockNumber(), { tag: TAG + ' eth_blockNumber' }); return '0x' + blockNumber.toString(16); }; @@ -241,12 +251,8 @@ const handleEthGetBalance = async params => { const tag = TAG + ' | handleEthGetBalance | '; try { console.log(tag, 'Getting balance for address:', params[0], 'block:', params[1]); - const provider = await getProvider(); - console.log(tag, 'Provider created, calling getBalance...'); - - const balance = await provider.getBalance(params[0], params[1]); + const balance = await withRpcFailover(p => p.getBalance(params[0], params[1]), { tag: TAG + ' eth_getBalance' }); console.log(tag, 'Balance retrieved:', balance.toString()); - return '0x' + balance.toString(16); } catch (error) { console.error(tag, 'Error getting balance:', error); @@ -255,76 +261,97 @@ const handleEthGetBalance = async params => { }; const handleEthGetTransactionReceipt = async params => { - const provider = await getProvider(); - const transactionReceipt = await provider.getTransactionReceipt(params[0]); - return transactionReceipt; + // Passthrough raw RPC — see docs/RPC_PASSTHROUGH_AUDIT.md. ethers v6 + // `provider.getTransactionReceipt(...)` returns a `TransactionReceipt` + // class with `index` (vs spec `transactionIndex`), reshaped `logs[]`, + // and stripped methods after structured-clone — dApps parse the + // JSON-RPC spec shape and reject the wrapper. + return withRpcFailover(p => p.send('eth_getTransactionReceipt', params), { tag: TAG + ' eth_getTransactionReceipt' }); }; const handleEthGetTransactionByHash = async params => { - const provider = await getProvider(); - const transactionByHash = await provider.getTransaction(params[0]); - return transactionByHash; + // Passthrough raw RPC — see docs/RPC_PASSTHROUGH_AUDIT.md. ethers v6 + // `provider.getTransaction(...)` returns a `TransactionResponse` class + // whose field names diverge from the JSON-RPC spec: `gasLimit` vs + // `gas`, `data` vs `input`, `index` vs `transactionIndex`, nested + // `signature.{v,r,s}` vs flat. After structured-clone the methods are + // gone too. This is the polling endpoint Uniswap (and most dApps) use + // to track a tx after `eth_sendTransaction`; the wrapper shape was + // breaking that handoff. + return withRpcFailover(p => p.send('eth_getTransactionByHash', params), { tag: TAG + ' eth_getTransactionByHash' }); }; const handleWeb3ClientVersion = async () => { - const provider = await getProvider(); - const clientVersion = await provider.send('web3_clientVersion', []); - return clientVersion; + return withRpcFailover(p => p.send('web3_clientVersion', []), { tag: TAG + ' web3_clientVersion' }); }; const handleEthCall = async params => { - const provider = await getProvider(); - const [callParams, blockTag, stateOverride] = params; - const callResult = await provider.call(callParams, blockTag, stateOverride); - return callResult; + // ethers v6 provider.call(tx) ignores extra args, dropping blockTag AND + // stateOverride. Uniswap's pre-quote simulation uses stateOverride to + // model the not-yet-broadcasted Permit2 approval; if we drop it the + // simulation reverts and the quote is rejected (/v1/swap returns 404). + // Passthrough raw RPC so the dApp's params arrive byte-identical. + return withRpcFailover(p => p.send('eth_call', params), { tag: TAG + ' eth_call' }); }; const handleEthMaxPriorityFeePerGas = async () => { - const provider = await getProvider(); - const feeData = await provider.getFeeData(); + const feeData = await withRpcFailover(p => p.getFeeData(), { tag: TAG + ' eth_maxPriorityFeePerGas' }); return feeData.maxPriorityFeePerGas ? '0x' + feeData.maxPriorityFeePerGas.toString(16) : '0x0'; }; const handleEthMaxFeePerGas = async () => { - const provider = await getProvider(); - const feeData = await provider.getFeeData(); + const feeData = await withRpcFailover(p => p.getFeeData(), { tag: TAG + ' eth_maxFeePerGas' }); return feeData.maxFeePerGas ? '0x' + feeData.maxFeePerGas.toString(16) : '0x0'; }; const handleEthEstimateGas = async params => { - const provider = await getProvider(); - const estimateGas = await provider.estimateGas(params[0]); - return '0x' + estimateGas.toString(16); + // ethers v6 provider.estimateGas(tx) takes 1 arg and drops blockTag. + // Passthrough raw RPC for spec-compliant behavior. + return withRpcFailover(p => p.send('eth_estimateGas', params), { tag: TAG + ' eth_estimateGas' }); }; const handleEthGasPrice = async () => { - const provider = await getProvider(); - const feeData = await provider.getFeeData(); + const feeData = await withRpcFailover(p => p.getFeeData(), { tag: TAG + ' eth_gasPrice' }); return feeData.gasPrice ? '0x' + feeData.gasPrice.toString(16) : '0x0'; }; +const handleEthFeeHistory = async params => { + // Raw passthrough — modern dApps (incl. Uniswap's UI via ethers v6 fee + // estimator) call eth_feeHistory for percentile-based fee math. Without + // this case the request hits the default branch and throws "method not + // supported", forcing the dApp onto a stale eth_gasPrice fallback. + return withRpcFailover(p => p.send('eth_feeHistory', params), { tag: TAG + ' eth_feeHistory' }); +}; + const handleEthGetCode = async params => { - const provider = await getProvider(); - const code = await provider.getCode(params[0], params[1]); - return code; + return withRpcFailover(p => p.getCode(params[0], params[1]), { tag: TAG + ' eth_getCode' }); }; const handleEthGetStorageAt = async params => { - const provider = await getProvider(); - const storage = await provider.getStorageAt(params[0], params[1], params[2]); - return storage; + // Raw passthrough: ethers v6 renamed `getStorageAt` → `getStorage`, + // so the helper isn't where we used to call it. Sending the JSON-RPC + // method directly is byte-exact with what the dApp asked for, and + // sidesteps the v5/v6 method-name divergence entirely. + return withRpcFailover(p => p.send('eth_getStorageAt', params), { tag: TAG + ' eth_getStorageAt' }); }; const handleEthGetTransactionCount = async params => { - const provider = await getProvider(); - const transactionCount = await provider.getTransactionCount(params[0], params[1]); + const transactionCount = await withRpcFailover(p => p.getTransactionCount(params[0], params[1]), { + tag: TAG + ' eth_getTransactionCount', + }); return '0x' + transactionCount.toString(16); }; const handleEthSendRawTransaction = async params => { - const provider = await getProvider(); - const txResponse = await provider.broadcastTransaction(params[0]); - return txResponse.hash; + // Decode-friendly handoff log — captures the dApp-supplied raw tx + // (already signed externally) BEFORE we relay it to the RPC. Paste + // params[0] into an EVM tx decoder to inspect its contents. + console.log(`[HANDOFF] dApp → BEX (eth_sendRawTransaction) rawTx=${params[0]}`); + // Route through the failover-aware helper so raw signed tx submissions + // get the same RPC iteration / last-resort fallback / already-known + // handling as eth_sendTransaction. expectedFrom is unknown (the dApp + // already signed externally), so the signer-mismatch check is skipped. + return await broadcastTransaction(params[0]); }; // Helper function to switch to a provider and update contexts @@ -402,7 +429,7 @@ const switchToProvider = async (currentProvider: any, KEEPKEY_WALLET: any, tag: }; // Handle wallet_switchEthereumChain - switch to existing chain only -const handleWalletSwitchEthereumChain = async (params, KEEPKEY_WALLET) => { +const handleWalletSwitchEthereumChain = async (params, KEEPKEY_WALLET, requestInfo: any) => { const tag = TAG + ' | handleWalletSwitchEthereumChain | '; console.log(tag, 'Switch Chain params: ', params); @@ -412,34 +439,95 @@ const handleWalletSwitchEthereumChain = async (params, KEEPKEY_WALLET) => { const chainIdHex = params[0].chainId; const chainIdDecimal = parseInt(chainIdHex, 16); - const chainId = chainIdDecimal.toString(); const networkId = 'eip155:' + chainIdDecimal; console.log(tag, 'networkId: ', networkId); - // Check if chain exists in our defaults - if (EIP155_CHAINS[networkId]) { - console.log(tag, 'Chain found in defaults, switching...'); - const currentProvider = { - chainId: chainId, - caip: EIP155_CHAINS[networkId].caip, - networkId, - name: EIP155_CHAINS[networkId].name, - providerUrl: EIP155_CHAINS[networkId].rpc, - }; - await switchToProvider(currentProvider, KEEPKEY_WALLET, tag); - return null; - } - - // Check if chain exists in storage (previously added custom chain) + // 1) User-managed custom chain (rpcUrls overridden in Add Network UI) + // — always wins, even if Pioneer also knows the chain. The user + // explicitly told us which RPC to use; respect that. const storedChainData = await blockchainDataStorage.getBlockchainData(networkId); if (storedChainData) { - console.log(tag, 'Chain found in storage, switching...'); + console.log(tag, 'Chain found in custom storage, switching...'); await switchToProvider(storedChainData, KEEPKEY_WALLET, tag); return null; } - // Chain not found - return 4902 per EIP-3326 - console.log(tag, 'Chain not found, returning 4902 error'); + // 2) Pioneer registry — one-step add+switch. Pioneer knows ~196 EVM + // chains today; for any of those we provision the provider, persist + // to local storage so subsequent switches are zero-RTT, and switch. + // No user prompt — the chain is already trusted (Pioneer is our + // canonical catalog) and the dApp explicitly asked to switch. + const pioneerChain = await getChainInfo(networkId); + if (pioneerChain?.rpc) { + console.log(tag, 'Chain found in Pioneer registry, provisioning + switching...'); + try { + // @ts-expect-error storage typing is loose + await blockchainDataStorage.addBlockchainData(networkId, { + chainId: pioneerChain.chainId, + caip: pioneerChain.caip, + name: pioneerChain.name, + symbol: pioneerChain.symbol, + explorer: pioneerChain.explorer, + explorerAddressLink: pioneerChain.explorerAddressLink, + explorerTxLink: pioneerChain.explorerTxLink, + blockExplorerUrls: pioneerChain.explorer ? [pioneerChain.explorer] : [], + providerUrl: pioneerChain.rpc, + providers: pioneerChain.rpcs, + nativeCurrency: { + name: pioneerChain.symbol || pioneerChain.name, + symbol: pioneerChain.symbol || 'ETH', + decimals: pioneerChain.decimals, + }, + type: 'evm', + }); + await blockchainStorage.addBlockchain(networkId); + } catch (e) { + console.warn(tag, 'Failed to persist Pioneer-discovered chain (continuing with switch anyway):', e); + } + await switchToProvider( + { + chainId: pioneerChain.chainId, + caip: pioneerChain.caip, + networkId, + name: pioneerChain.name, + providerUrl: pioneerChain.rpc, + providers: pioneerChain.rpcs, + }, + KEEPKEY_WALLET, + tag, + ); + return null; + } + + // 3) Chain unknown to both local storage AND Pioneer — fall back to + // the chain-not-enabled info card and throw 4902. This should only + // happen for very obscure / new chains; everything in active use is + // in Pioneer's catalog. + try { + const eventId = (requestInfo?.id as string) || uuidv4(); + if (requestInfo) requestInfo.id = eventId; + // @ts-expect-error storage typing is loose + await requestStorage.addEvent({ + id: eventId, + networkId, + chain: 'ethereum', + href: requestInfo?.href, + siteUrl: requestInfo?.siteUrl, + requestInfo, + type: 'chain_not_enabled', + request: params, + unsignedTx: { chainIdHex, chainIdDecimal, networkId }, + status: 'request', + timestamp: new Date().toISOString(), + }); + setApprovalBadge(true); + await openSidePanel(requestInfo); + chrome.runtime.sendMessage({ type: 'TRANSACTION_CONTEXT_UPDATED', id: eventId }).catch(() => {}); + } catch (e) { + console.warn(tag, 'Failed to surface chain-not-enabled popup:', e); + } + + console.log(tag, 'Chain not enabled, returning 4902 error'); throw createProviderRpcError( 4902, `Unrecognized chain ID "${chainIdHex}". Try adding the chain using wallet_addEthereumChain first.`, @@ -510,9 +598,18 @@ const handleWalletAddEthereumChain = async (params, KEEPKEY_WALLET, requestInfo, console.log(tag, 'Cleaned provider config:', newProvider); - // Require user approval before adding chain + // Require user approval before adding chain. The event id MUST match + // requestInfo.id — methods.ts:requireApproval() keys its eth_sign_response + // listener on requestInfo.id, and the sidebar echoes the stored event.id + // back. The previous code stored a fresh uuid while leaving requestInfo.id + // alone, so approvals never resolved and every wallet_addEthereumChain + // silently timed out after 10 minutes. Match the pattern used by + // handleSigningMethods / handleTransfer below: mutate requestInfo.id to a + // uuid first (collision-safe across concurrent dApp requests) and then + // use it as the event id. + requestInfo.id = uuidv4(); const approvalEvent = { - id: uuidv4(), + id: requestInfo.id, networkId, chain: 'ethereum', type: 'wallet_addEthereumChain', @@ -527,6 +624,9 @@ const handleWalletAddEthereumChain = async (params, KEEPKEY_WALLET, requestInfo, await requestStorage.addEvent(approvalEvent); const approval = await requireApproval(networkId, requestInfo, 'ethereum', 'wallet_addEthereumChain', params[0]); if (!approval?.success) { + // UI removes the event on reject, but guard against duplicate state if + // reject came from the approval timeout instead of the user button. + await requestStorage.removeEventById(requestInfo.id).catch(() => {}); throw createProviderRpcError(4001, 'User rejected adding the chain'); } @@ -537,6 +637,20 @@ const handleWalletAddEthereumChain = async (params, KEEPKEY_WALLET, requestInfo, // Switch to the newly added chain await switchToProvider(newProvider, KEEPKEY_WALLET, tag); + + // Unlike signing methods this flow has no txHash and no on-device step, + // so neither signMessage nor sendTransaction emit anything for us. Clean + // up the pending event ourselves and reuse `signature_complete` — it's + // the contract the sidebar uses to dismiss the overlay without trying + // to build a TxidPage (transaction_complete would demand a txHash). + await requestStorage.removeEventById(requestInfo.id).catch(() => {}); + chrome.runtime + .sendMessage({ + action: 'signature_complete', + eventId: requestInfo.id, + }) + .catch(() => {}); + return null; }; @@ -602,8 +716,60 @@ const handleSigningMethods = async (method, params, requestInfo, ADDRESS, KEEPKE console.log(tag, 'networkId:', networkId); if (!networkId) throw Error('Failed to set context before sending!'); // Require user approval - let unsignedTx = params[0]; + const unsignedTx = params[0]; requestInfo.id = uuidv4(); + + // Compute the fee-floor warning ONCE up front so the side-panel can render + // the banner without doing its own RPC. Only meaningful for tx-signing + // methods (eth_sendTransaction / eth_signTransaction); message-signing + // flows have no fees to warn about. Also probes nonce state in the same + // round-trip — pending vs latest tells us whether this tx will replace + // an in-flight one (the EIP-1559 replacement-underpriced footgun). + let feeWarning: FeeWarning | null = null; + let nonceInfo: { latest: number; pending: number; willReplace: boolean } | null = null; + if ((method === 'eth_sendTransaction' || method === 'eth_signTransaction') && unsignedTx) { + try { + const fromAddr = unsignedTx.from || ADDRESS; + // Run all four reads against the same provider per failover + // attempt — cleaner UX than splitting them across URLs (which + // would risk inconsistent baseFee/nonce snapshots) and the + // payload is small enough that retrying the bundle is cheap. + const [feeData, latestBlock, latestNonce, pendingNonce] = await withRpcFailover( + p => + Promise.all([ + p.getFeeData(), + p.getBlock('latest'), + fromAddr ? p.getTransactionCount(fromAddr, 'latest') : Promise.resolve(0), + fromAddr ? p.getTransactionCount(fromAddr, 'pending') : Promise.resolve(0), + ]), + { tag: TAG + ' fee-warning preflight' }, + ); + feeWarning = buildFeeWarning({ + chainId: unsignedTx.chainId ?? currentProvider?.chainId ?? 1, + dappMaxFeePerGas: unsignedTx.maxFeePerGas, + dappMaxPriorityFeePerGas: unsignedTx.maxPriorityFeePerGas, + baseFeeWei: latestBlock?.baseFeePerGas ?? null, + oracleMaxFeePerGas: feeData.maxFeePerGas ?? null, + oracleMaxPriorityFeePerGas: feeData.maxPriorityFeePerGas ?? null, + }); + if (feeWarning) console.log(tag, 'fee warning attached:', feeWarning); + // Pending > latest means there's already a tx queued at nonce=latest, + // and the new tx (whose nonce we'll derive at sign time, also from + // 'pending') will be the *next* slot. willReplace flags the case + // where the dApp explicitly set a nonce equal to a pending one. + const dappNonce = + unsignedTx.nonce && typeof unsignedTx.nonce === 'string' + ? parseInt(unsignedTx.nonce.replace(/^0x/, ''), 16) + : null; + const willReplace = dappNonce !== null && dappNonce < pendingNonce; + nonceInfo = { latest: latestNonce, pending: pendingNonce, willReplace }; + console.log(tag, 'nonce info:', nonceInfo); + } catch (e) { + // Don't block signing on a failed fee oracle — degrade silently. + console.warn(tag, 'fee/nonce probe failed:', e); + } + } + const event = { id: requestInfo.id, networkId, @@ -619,6 +785,8 @@ const handleSigningMethods = async (method, params, requestInfo, ADDRESS, KEEPKE chain: 'ethereum', //TODO I dont like this requestInfo, unsignedTx, + feeWarning, // null when fees are fine; otherwise side-panel renders the banner + nonceInfo, // null on non-tx flows; { latest, pending, willReplace } otherwise type: method, request: params, status: 'request', @@ -661,7 +829,6 @@ const handleTransfer = async (params, requestInfo, ADDRESS, KEEPKEY_WALLET, requ if (!networkId) throw Error('Failed to set context before sending!'); // Build EVM transfer locally - const provider = await getProvider(); const amountWei = '0x' + parseEther(params[0].amount?.amount || params[0].amount || '0').toString(16); const chainId = currentProviderCtx?.chainId || '1'; @@ -675,18 +842,25 @@ const handleTransfer = async (params, requestInfo, ADDRESS, KEEPKEY_WALLET, requ data: '0x', }; - // Get nonce and gas - const nonce = await provider.getTransactionCount(ADDRESS, 'latest'); + // Each read fails over independently — see signTransaction for the + // same pattern. 'pending' nonce so an in-flight tx from this account + // doesn't get reused. + const nonce = await withRpcFailover(p => p.getTransactionCount(ADDRESS, 'pending'), { + tag: tag + ' transfer.nonce', + }); unsignedTx.nonce = '0x' + nonce.toString(16); try { - let estimatedGas = await provider.estimateGas({ from: ADDRESS, to: unsignedTx.to, value: unsignedTx.value }); + let estimatedGas = await withRpcFailover( + p => p.estimateGas({ from: ADDRESS, to: unsignedTx.to, value: unsignedTx.value }), + { tag: tag + ' transfer.estimateGas' }, + ); const gasBuffer = BigInt(estimatedGas) / BigInt(5); estimatedGas = BigInt(estimatedGas) + gasBuffer; unsignedTx.gasLimit = '0x' + estimatedGas.toString(16); } catch (e) { unsignedTx.gasLimit = '0x' + BigInt(21000).toString(16); } - const feeData = await provider.getFeeData(); + const feeData = await withRpcFailover(p => p.getFeeData(), { tag: tag + ' transfer.feeData' }); if (feeData.maxFeePerGas) { unsignedTx.maxFeePerGas = '0x' + feeData.maxFeePerGas.toString(16); unsignedTx.maxPriorityFeePerGas = feeData.maxPriorityFeePerGas @@ -747,7 +921,7 @@ const handleTransfer = async (params, requestInfo, ADDRESS, KEEPKEY_WALLET, requ await requestStorage.updateEventById(requestInfo.id, requestInfo); // Broadcast the transaction - const txid = await broadcastTransaction(signedTx); + const txid = await broadcastTransaction(signedTx, response.unsignedTx?.from); console.log(tag, 'txid:', txid); // Update storage with transaction hash @@ -757,6 +931,7 @@ const handleTransfer = async (params, requestInfo, ADDRESS, KEEPKEY_WALLET, requ // Notify transaction completion chrome.runtime.sendMessage({ action: 'transaction_complete', + eventId: requestInfo.id, txHash: txid, explorerTxLink: currentProviderCtx?.explorerTxLink, networkId, @@ -820,6 +995,9 @@ export const handleEthereumRequest = async ( case 'eth_gasPrice': return await handleEthGasPrice(); + case 'eth_feeHistory': + return await handleEthFeeHistory(params); + case 'eth_getCode': return await handleEthGetCode(params); @@ -833,7 +1011,7 @@ export const handleEthereumRequest = async ( return await handleEthSendRawTransaction(params); case 'wallet_switchEthereumChain': - return await handleWalletSwitchEthereumChain(params, KEEPKEY_WALLET); + return await handleWalletSwitchEthereumChain(params, KEEPKEY_WALLET, requestInfo); case 'wallet_addEthereumChain': return await handleWalletAddEthereumChain(params, KEEPKEY_WALLET, requestInfo, requireApproval); @@ -890,10 +1068,10 @@ const processApprovedEvent = async (method: string, params: any, KEEPKEY_WALLET: case 'personal_sign': // EIP-191 personal_sign: params = [message, address]. Prefer the dApp-supplied // address so multi-account wallets sign with the correct derivation path. - result = await signMessage(params[0], KEEPKEY_WALLET, params[1] || ADDRESS); + result = await signMessage(params[0], KEEPKEY_WALLET, params[1] || ADDRESS, id); break; case 'eth_sign': - result = await signMessage(params[1], KEEPKEY_WALLET, params[0]); + result = await signMessage(params[1], KEEPKEY_WALLET, params[0], id); break; case 'eth_sendTransaction': result = await sendTransaction(params, KEEPKEY_WALLET, ADDRESS, id); @@ -901,11 +1079,22 @@ const processApprovedEvent = async (method: string, params: any, KEEPKEY_WALLET: case 'eth_signTypedData': case 'eth_signTypedData_v3': case 'eth_signTypedData_v4': - result = await signTypedData(params, KEEPKEY_WALLET, ADDRESS); + result = await signTypedData(params, KEEPKEY_WALLET, ADDRESS, id); break; - case 'eth_signTransaction': - result = await signTransaction(params[0], KEEPKEY_WALLET); + case 'eth_signTransaction': { + // Mirror sendTransaction's pre-flight: pin chainId/from from the + // active provider/account, then apply any feeChoice the user picked + // in the side-panel fee-warning banner. Without this, `Use suggested` + // and `Custom…` are no-ops for eth_signTransaction (regression + // surfaced in PR #55 review). + const tx = params[0]; + const currentProvider = await web3ProviderStorage.getWeb3Provider(); + if (currentProvider?.chainId) tx.chainId = currentProvider.chainId; + tx.from = ADDRESS; + await applyFeeChoiceFromStorage(tx, id, ' | eth_signTransaction | '); + result = await signTransaction(tx, KEEPKEY_WALLET); break; + } default: console.error(TAG, `Unsupported event type: ${method}`); throw createProviderRpcError(4200, `Method ${method} not supported`); @@ -919,7 +1108,7 @@ const processApprovedEvent = async (method: string, params: any, KEEPKEY_WALLET: } }; -const signMessage = async (message, KEEPKEY_WALLET, ADDRESS: string) => { +const signMessage = async (message, KEEPKEY_WALLET, ADDRESS: string, eventId?: string) => { const tag = TAG + ' [signMessage] '; try { console.log(tag, '**** message: ', message); @@ -947,6 +1136,7 @@ const signMessage = async (message, KEEPKEY_WALLET, ADDRESS: string) => { // Notify popup that signature is complete chrome.runtime.sendMessage({ action: 'signature_complete', + eventId, signature: signatureHex, }); @@ -981,15 +1171,33 @@ const signTransaction = async (transaction: any, KEEPKEY_WALLET: any) => { if (!transaction.to) throw createProviderRpcError(4000, 'Invalid transaction: missing to'); if (!transaction.chainId) throw createProviderRpcError(4000, 'Invalid transaction: missing chainId'); - const provider = await getProvider(); + // Each preflight RPC call is wrapped in withRpcFailover so a + // rate-limited URL doesn't break signing before broadcast can help. + // Calls are independent; nonce / gas / fee don't have to come from + // the same RPC — any working candidate is fine. let nonce; if (!transaction.nonce) { - // Get the nonce from the provider for the account - nonce = await provider.getTransactionCount(transaction.from, 'latest'); + // Use 'pending' so we count any in-mempool tx from this account. + // 'latest' would only see confirmed txs — re-attempting a swap while + // a prior attempt is still pending would reuse the same nonce, hit + // EIP-1559's replacement-underpriced rule (need +10% on both fees), + // and silently sit in mempool until evicted. That's exactly the + // "pending forever, eth_getTransactionByHash returns null" symptom. + nonce = await withRpcFailover(p => p.getTransactionCount(transaction.from, 'pending'), { + tag: tag + ' nonce', + }); transaction.nonce = '0x' + nonce.toString(16); } + // JSON-RPC eth_sendTransaction params use `gas` (per spec); ethers and + // some legacy callers also send `gasLimit`. Honor either before falling + // through to estimation — re-estimating when the dApp already sized the + // call risks underprovisioning complex routes (e.g. Universal Router). + if (!transaction.gasLimit && transaction.gas) { + transaction.gasLimit = transaction.gas; + } + if (!transaction.gasLimit) { console.log(tag, 'No gas limit provided, estimating...'); try { @@ -999,11 +1207,15 @@ const signTransaction = async (transaction: any, KEEPKEY_WALLET: any) => { data: transaction.data, }); - let estimatedGas: any = await provider.estimateGas({ - from: transaction.from, - to: transaction.to, - data: transaction.data, - }); + let estimatedGas: any = await withRpcFailover( + p => + p.estimateGas({ + from: transaction.from, + to: transaction.to, + data: transaction.data, + }), + { tag: tag + ' estimateGas' }, + ); console.log(tag, 'Estimated gas:', estimatedGas.toString()); @@ -1057,7 +1269,7 @@ const signTransaction = async (transaction: any, KEEPKEY_WALLET: any) => { input.gasPrice = transaction.gasPrice; } else { // Fetch fee data if not provided - const feeData = await provider.getFeeData(); + const feeData = await withRpcFailover(p => p.getFeeData(), { tag: tag + ' feeData' }); input.gasPrice = feeData.gasPrice ? '0x' + feeData.gasPrice.toString(16) : undefined; } @@ -1066,6 +1278,14 @@ const signTransaction = async (transaction: any, KEEPKEY_WALLET: any) => { const output = await sdk.eth.ethSignTransaction(input); console.log(`${tag} Transaction output: `, output); + // Decode-friendly handoff log. Paste `serialized` into any EVM tx + // decoder (e.g. https://flightwallet.github.io/decode-eth-tx/) to + // diff against the dApp's expectations. Also logs the structured + // input so the unsigned tx is recoverable without RLP-parsing. + console.log( + `[HANDOFF] vault → BEX (eth_signTransaction)\n input=${JSON.stringify(input)}\n serialized=${output.serialized}\n r=${output.r} s=${output.s} v=${output.v}`, + ); + return output.serialized; } catch (e) { console.error(`${tag} Error: `, e); @@ -1091,7 +1311,7 @@ const signTransaction = async (transaction: any, KEEPKEY_WALLET: any) => { } }; -const signTypedData = async (params: any, KEEPKEY_WALLET: any, ADDRESS: string) => { +const signTypedData = async (params: any, KEEPKEY_WALLET: any, ADDRESS: string, eventId?: string) => { const tag = ' | signTypedData | '; try { console.log(tag, '**** params: ', params); @@ -1107,15 +1327,21 @@ const signTypedData = async (params: any, KEEPKEY_WALLET: any, ADDRESS: string) console.log(tag, '**** HDWalletPayload: ', JSON.stringify(HDWalletPayload)); const sdk = wallet.getSdk(); const signedMessage = await sdk.eth.ethSignTypedData(HDWalletPayload); - console.log(tag, '**** signedMessage: ', signedMessage); + console.log('[HANDOFF] vault → BEX (eth_signTypedData_v4 raw):', JSON.stringify(signedMessage)); // EIP-1193: eth_signTypedData_v4 must return hex signature string, not object. // Vault SDK returns { address, signature } — extract just the signature. const signatureHex = signedMessage?.signature || signedMessage; + const sigType = typeof signatureHex; + const sigLen = sigType === 'string' ? signatureHex.length : 'n/a'; + console.log( + `[HANDOFF] BEX → dApp (eth_signTypedData_v4 final): type=${sigType} len=${sigLen} value=${signatureHex}`, + ); // Notify popup that signature is complete chrome.runtime.sendMessage({ action: 'signature_complete', + eventId, signature: signatureHex, }); @@ -1142,38 +1368,445 @@ const signTypedData = async (params: any, KEEPKEY_WALLET: any, ADDRESS: string) } }; -const broadcastTransaction = async (signedTx: string) => { +/** + * Schedule a one-shot check that the broadcast tx is actually visible + * to our RPC. We've seen low-tip txs accepted by the entry node, never + * propagated to miners, and silently evicted within ~30s. The dApp polls + * eth_getTransactionByHash through us and gets null forever. This logs + * a clear warning and emits a runtime message so the side-panel (or any + * listener) can surface it. See RETRO_uniswap_swap_dropped_tx.md. + * + * Two delivery mechanisms: + * - Short delays (< 30s): setTimeout. Best-effort; only fires if the + * MV3 service worker is still alive. The 8s check usually hits while + * the SW is still warm from the broadcast. + * - Long delays (>= 30s): chrome.alarms. Survives SW suspension — + * the alarm wakes the SW, registering the listener at module load. + * Production minimum delay is 30s; we use 45s for the eviction probe. + */ +const DROP_CHECK_ALARM_PREFIX = 'eth-drop-check-'; + +// Map hash → URL that successfully accepted the broadcast. Drop-check +// then queries that exact RPC instead of running getProvider() again, +// which could pick a *different* RPC that never saw the tx — leading to +// false-positive drop warnings (especially after a last-resort +// fallback succeeded). Cleared after the longest scheduled check +// (45s) by performDropCheck. Service-worker restart wipes this; in +// that case we fall back to getProvider() — best effort. +const dropCheckUrlByHash = new Map(); + +const performDropCheck = async (hash: string, scheduledDelayMs: number) => { + try { + const successUrl = dropCheckUrlByHash.get(hash); + let provider; + if (successUrl) { + // 4s transport timeout: drop-check is best-effort; we'd rather + // miss a check than block the service worker on a slow RPC. + provider = makeStaticProvider(successUrl, 0, { timeoutMs: 4000 }); + } else { + // No bound URL (post-restart, or scheduled before this PR + // landed). Fall back to whichever provider is current. + provider = await getProvider(); + } + const tx = await provider.getTransaction(hash); + if (tx == null) { + console.warn( + `[DROP-CHECK] tx ${hash} not visible on RPC ~${scheduledDelayMs}ms after broadcast — likely underpriced and evicted from mempool. dApp polling will hang.`, + ); + chrome.runtime + .sendMessage({ action: 'tx_drop_warning', txHash: hash, delayMs: scheduledDelayMs }) + .catch(() => {}); + } else { + console.log( + `[DROP-CHECK] tx ${hash} visible on RPC at ~${scheduledDelayMs}ms (block=${tx.blockNumber ?? 'pending'})`, + ); + } + } catch (e) { + console.warn('[DROP-CHECK] failed to query tx', hash, e); + } finally { + // Clean up after the *latest* scheduled check (45s ≥ 8s window). + if (scheduledDelayMs >= 45_000) dropCheckUrlByHash.delete(hash); + } +}; + +const scheduleDropCheck = (hash: string, delayMs: number, successUrl?: string) => { + if (successUrl) dropCheckUrlByHash.set(hash, successUrl); + if (delayMs < 30_000) { + setTimeout(() => performDropCheck(hash, delayMs), delayMs); + return; + } + // Encode delay in alarm name so the listener can recover it without + // a separate storage round-trip. Alarms are unique by name; suffixing + // with delayMs lets us schedule multiple checks for the same hash. + const alarmName = `${DROP_CHECK_ALARM_PREFIX}${hash}-${delayMs}`; + try { + chrome.alarms.create(alarmName, { when: Date.now() + delayMs }); + } catch (e) { + console.warn('[DROP-CHECK] alarm scheduling failed, falling back to setTimeout:', e); + setTimeout(() => performDropCheck(hash, delayMs), delayMs); + } +}; + +// Registered at module load — re-runs on every service-worker startup, +// which is exactly when the alarm fires and wakes the SW. (URL hint +// is not recovered across SW restart; drop-check falls back to +// getProvider in that case.) +if (typeof chrome !== 'undefined' && chrome.alarms?.onAlarm) { + chrome.alarms.onAlarm.addListener(alarm => { + if (!alarm.name.startsWith(DROP_CHECK_ALARM_PREFIX)) return; + const rest = alarm.name.slice(DROP_CHECK_ALARM_PREFIX.length); + const lastDash = rest.lastIndexOf('-'); + if (lastDash <= 0) return; + const hash = rest.slice(0, lastDash); + const delayMs = Number(rest.slice(lastDash + 1)); + void performDropCheck(hash, Number.isFinite(delayMs) ? delayMs : 0); + }); +} + +/** + * Classify a broadcast error so the failover loop knows whether to try + * the next RPC or stop and surface it. + * + * - 'definitive' → the tx itself is rejected. Trying another RPC will + * only return the same answer. Stop and surface. + * - 'already-known' → the tx is already in mempool somewhere. Treat as + * success; pull the hash from the bytes. + * - 'transient' → rate limit / network / 5xx — retry next URL. + */ +type BroadcastErrorKind = 'definitive' | 'already-known' | 'transient'; +const classifyBroadcastError = (msg: string): BroadcastErrorKind => { + const m = msg.toLowerCase(); + // RPC says "we already have this hash" — tx is in mempool. Success. + if (m.includes('already known') || m.includes('already in mempool') || m.includes('transaction already in pool')) + return 'already-known'; + // Tx-level rejections — same outcome on any RPC. + if ( + m.includes('insufficient funds') || + m.includes('nonce too low') || + m.includes('replacement transaction underpriced') || + m.includes('intrinsic gas too low') || + m.includes('gas required exceeds') || + m.includes('exceeds block gas limit') + ) + return 'definitive'; + // Everything else (rate limit, 5xx, network, ethers SERVER_ERROR) is + // worth re-trying against the next URL. + return 'transient'; +}; + +const mapDefinitiveError = (msg: string): ProviderRpcError => { + const m = msg.toLowerCase(); + if (m.includes('insufficient funds')) { + return createProviderRpcError(4000, 'Insufficient balance to complete this transaction.'); + } + if (m.includes('nonce too low')) { + return createProviderRpcError(4000, 'Transaction nonce conflict. Please try again.'); + } + if (m.includes('replacement transaction underpriced')) { + return createProviderRpcError(4000, 'Transaction fee too low. Try with a higher gas price.'); + } + if (m.includes('gas required exceeds') || m.includes('exceeds block gas limit')) { + return createProviderRpcError(4000, 'Transaction requires more gas than available.'); + } + return createProviderRpcError(4000, `Error broadcasting transaction: ${msg}`); +}; + +/** + * Build the prioritized RPC candidate list for the active EVM provider. + * Pioneer-discovered URLs always come first; hardcoded last-resort URLs + * are appended (so a stale entry can never preempt a live Pioneer + * node); duplicates are removed; URLs in the failedRpcs cooldown are + * skipped (or all-cleared if every candidate is cooling). + * + * Shared by broadcastTransaction and withRpcFailover so both paths + * obey the same priority + cooldown semantics. + */ +async function getCandidateRpcs(): Promise<{ + availableRpcs: string[]; + networkId: string; + chainIdRaw: string | number; +}> { + const currentProvider = await web3ProviderStorage.getWeb3Provider(); + if (!currentProvider) { + throw createProviderRpcError(4900, 'Provider not configured'); + } + + const networkId: string = + currentProvider.networkId || + (parseChainId(currentProvider.chainId) != null ? `eip155:${parseChainId(currentProvider.chainId)}` : ''); + + const pioneerUrls: string[] = + currentProvider.providers && currentProvider.providers.length > 0 + ? currentProvider.providers + : currentProvider.providerUrl + ? [currentProvider.providerUrl] + : []; + const lastResort = networkId ? getLastResortRpcs(networkId) : []; + + const seen = new Set(); + const candidates: string[] = []; + for (const u of [...pioneerUrls, ...lastResort]) { + const t = (u || '').trim(); + if (t && !seen.has(t)) { + seen.add(t); + candidates.push(t); + } + } + if (candidates.length === 0) { + throw createProviderRpcError(4900, 'No RPC URLs available'); + } + + const now = Date.now(); + for (const [url, failedAt] of failedRpcs) { + if (now - failedAt >= RPC_RETRY_DELAY) failedRpcs.delete(url); + } + let availableRpcs = candidates.filter(url => { + const failedAt = failedRpcs.get(url); + return !(failedAt && now - failedAt < RPC_RETRY_DELAY); + }); + if (availableRpcs.length === 0) { + failedRpcs.clear(); + availableRpcs = candidates.slice(); + } + + return { availableRpcs, networkId, chainIdRaw: currentProvider.chainId }; +} + +/** + * Heuristic: is this RPC error worth retrying against a different URL? + * Used by withRpcFailover (read calls). Broadcast has its own + * classifier because it has additional tx-level definitive cases + * (insufficient funds, nonce too low, etc.). + * + * Includes "method-rejection" patterns because narrow-purpose RPCs in + * Pioneer's catalog (Flashbots' rpc.flashbots.net is the canonical + * example — only supports eth_sendRawTransaction / eth_chainId / + * eth_blockNumber, rejects everything else with HTTP 403 + JSON-RPC + * code -32601 "rpc method is not whitelisted") would otherwise be + * sticky: their pre-flight `getBlockNumber()` test passes, so they get + * picked first on every read, and every read fails 403. Treating the + * rejection as transient lets the loop blacklist them for 60s and try + * the next URL. + */ +const isTransientRpcError = (errMsg: string): boolean => { + const m = errMsg.toLowerCase(); + return ( + m.includes('rate limit') || + m.includes('throttle') || + m.includes('429') || + m.includes('timeout') || + m.includes('econnreset') || + m.includes('etimedout') || + m.includes('network') || + m.includes('server_error') || + m.includes('exceeded maximum retry') || + /\b5\d{2}\b/.test(m) || // 5xx HTTP code + // Method-rejection: this URL doesn't support this method. Try next. + m.includes('rpc method is not whitelisted') || + m.includes('method not found') || + m.includes('method not supported') || + m.includes('method does not exist') || + m.includes('-32601') || + // Narrow to ethers' transport-level wrapper text. A bare `.includes('403')` + // would misfire on revert reasons or hex payloads that happen to + // contain "403", causing a successfully-rejected eth_call to be + // replayed across every URL and pointlessly cool them all. + m.includes('server response 403') || + m.includes('http 403') + ); +}; + +/** + * Run a read-style RPC call across the failover candidate list. Used + * for preflight calls (nonce, gas estimate, fee data) where any working + * RPC will do. Definitive errors (revert, invalid params, ABI errors) + * surface immediately — they'll fail the same way on every RPC. + * Transient errors (rate-limit, 5xx, network) fall through to the next. + * + * Each attempt gets a fresh pinned-network provider with a per-HTTP + * timeout via FetchRequest, so a hung URL doesn't stall the loop. + */ +async function withRpcFailover( + op: (provider: JsonRpcProvider, url: string) => Promise, + options?: { timeoutMs?: number; tag?: string }, +): Promise { + const tag = (options?.tag ?? TAG) + ' | withRpcFailover | '; + const { availableRpcs, networkId, chainIdRaw } = await getCandidateRpcs(); + const errors: { url: string; error: string }[] = []; + let lastTransientError: any = null; + const now = Date.now(); + for (const url of availableRpcs) { + try { + const provider = makeStaticProvider(url, networkId || chainIdRaw, { + timeoutMs: options?.timeoutMs ?? 5000, + }); + return await op(provider, url); + } catch (e: any) { + const errMsg = String(e?.message || e); + if (!isTransientRpcError(errMsg)) { + // Definitive (revert, invalid params, etc.) — won't help to + // try another RPC. Surface to caller. + throw e; + } + console.warn(tag, `RPC ${url} transient failure, trying next:`, errMsg); + errors.push({ url, error: errMsg }); + failedRpcs.set(url, now); + lastTransientError = e; + } + } + console.error(tag, 'All RPC endpoints failed:', errors); + if (lastTransientError) throw lastTransientError; + throw createProviderRpcError( + 4900, + `All ${availableRpcs.length} RPC endpoints failed: ${errors[errors.length - 1]?.error || 'unknown'}`, + ); +} + +const broadcastTransaction = async (signedTx: string, expectedFrom?: string) => { const tag = TAG + ' | broadcastTransaction | '; + + // ---- Pre-flight: decode + signer-recovery check (unchanged) ---- + // ethers' `Transaction.from(...)` parses the serialized RLP and + // exposes `.from` as the ECDSA-recovered signer. If any of {chainId + // encoding, type-2 envelope, v/r/s} is malformed by the signing + // pipeline, the recovered address will be a deterministic-but-wrong + // address — *not* the user's. Fail closed before broadcast: an RPC + // that accepts the bytes against the wrong account is the worst + // outcome (funds move from a wallet the user doesn't control). See + // RETRO_uniswap_swap_dropped_tx.md and feedback_eip712_diagnosis.md. + let parsedHash: string | null = null; + let recoveredFrom: string | null = null; + let signerMismatch = false; try { - const provider = await getProvider(); + const parsed = Transaction.from(signedTx); + parsedHash = parsed.hash ?? null; + recoveredFrom = parsed.from ?? null; + const expectedNorm = expectedFrom ? expectedFrom.toLowerCase() : null; + const recoveredNorm = recoveredFrom ? recoveredFrom.toLowerCase() : null; + const match = expectedNorm && recoveredNorm ? expectedNorm === recoveredNorm : null; + console.log( + `[DECODE] signed tx parsed:\n` + + ` type=${parsed.type} chainId=${parsed.chainId} nonce=${parsed.nonce}\n` + + ` to=${parsed.to} value=${parsed.value?.toString()} gasLimit=${parsed.gasLimit?.toString()}\n` + + ` maxFeePerGas=${parsed.maxFeePerGas?.toString()} maxPriorityFeePerGas=${parsed.maxPriorityFeePerGas?.toString()} gasPrice=${parsed.gasPrice?.toString()}\n` + + ` data=${parsed.data?.slice(0, 80)}${(parsed.data?.length ?? 0) > 80 ? '…' : ''}\n` + + ` recoveredFrom=${recoveredFrom ?? '(no signature)'} expectedFrom=${expectedFrom ?? '(unknown)'} match=${match}\n` + + ` hash=${parsed.hash} signature=${JSON.stringify(parsed.signature?.toJSON())}`, + ); + signerMismatch = match === false; + } catch (decodeErr) { + console.warn(`[DECODE] failed to parse signed tx — bytes are likely malformed:`, decodeErr); + } + if (signerMismatch) { + const msg = `Refusing to broadcast: recovered signer ${recoveredFrom} ≠ expected ${expectedFrom}. The signed bytes do not represent a tx from your account.`; + console.error(`[DECODE] ❌ MALFORMED-HEX ${msg}`); + throw createProviderRpcError(4000, msg); + } - console.log(tag, 'provider: ', provider); - console.log(tag, 'Broadcasting transaction: ', signedTx); + // ---- Build candidate URL list (Pioneer + last-resort, dedup, cooldown) ---- + const { availableRpcs, networkId, chainIdRaw } = await getCandidateRpcs(); + const now = Date.now(); - const txResponse = await provider.broadcastTransaction(signedTx); - console.log('Transaction response:', txResponse); - return txResponse.hash; - } catch (e) { - console.error(tag, e); + // ---- Failover loop ---- + const errors: { url: string; error: string }[] = []; + for (const url of availableRpcs) { + try { + console.log(`[HANDOFF] BEX → RPC (broadcast attempt) url=${url} signedTx=${signedTx}`); + // 4s per HTTP attempt + no internal throttle retries: ensures a + // 429/dead URL doesn't stall the failover loop. ethers' default + // FetchRequest retries 429/5xx with exponential backoff for ~30s, + // which is exactly the lag this loop is meant to eliminate. + const provider = makeStaticProvider(url, networkId || chainIdRaw, { timeoutMs: 4000 }); + const txResponse = await provider.broadcastTransaction(signedTx); + console.log( + `[HANDOFF] RPC → BEX (broadcast success) hash=${txResponse?.hash} url=${url} from=${txResponse?.from} nonce=${txResponse?.nonce}`, + ); + // Two-stage drop check: 8s catches "never landed in mempool" cases; + // 45s catches "landed briefly then evicted". Fire-and-forget; do + // not block the dApp response. Bind to the URL that ACCEPTED the + // broadcast — querying a different RPC (e.g. the active provider) + // can produce false-positive drop warnings if it never saw the tx. + if (txResponse?.hash) { + scheduleDropCheck(txResponse.hash, 8_000, url); + scheduleDropCheck(txResponse.hash, 45_000, url); + } + return txResponse.hash; + } catch (e: any) { + const errMsg = String(e?.message || e); + const kind = classifyBroadcastError(errMsg); + + if (kind === 'definitive') { + // Tx-level rejection. Other RPCs will say the same thing — + // surface immediately rather than walking the whole list. + console.error(tag, `Broadcast definitive error on ${url} — not failing over:`, errMsg); + throw mapDefinitiveError(errMsg); + } - // Extract meaningful error message - const errorMessage = e?.message || JSON.stringify(e); + if (kind === 'already-known') { + // Tx is in mempool somewhere. Use the locally-recovered hash and + // treat as success. Drop-check binds to *this* URL since it's + // the one that knows about the tx. + if (parsedHash) { + console.log(tag, `Broadcast on ${url} returned already-known; using parsed hash:`, parsedHash); + scheduleDropCheck(parsedHash, 8_000, url); + scheduleDropCheck(parsedHash, 45_000, url); + return parsedHash; + } + // No parsed hash — fall through to next URL. + } - // Transform network-specific errors - if (errorMessage.includes('insufficient funds')) { - throw createProviderRpcError(4000, 'Insufficient balance to complete this transaction.'); - } else if (errorMessage.includes('nonce too low')) { - throw createProviderRpcError(4000, 'Transaction nonce conflict. Please try again.'); - } else if (errorMessage.includes('replacement transaction underpriced')) { - throw createProviderRpcError(4000, 'Transaction fee too low. Try with a higher gas price.'); - } else if (errorMessage.includes('gas required exceeds')) { - throw createProviderRpcError(4000, 'Transaction requires more gas than available.'); - } else if (errorMessage.includes('timeout')) { - throw createProviderRpcError(4000, 'Network timeout. Please try again.'); + console.warn(tag, `RPC ${url} broadcast failed (${kind}), trying next:`, errMsg); + errors.push({ url, error: errMsg }); + failedRpcs.set(url, now); } + } - // Generic fallback - throw createProviderRpcError(4000, `Error broadcasting transaction: ${errorMessage}`); + // All candidates failed — surface the last error. + console.error(tag, 'All RPC endpoints failed broadcast:', errors); + const lastErr = errors[errors.length - 1]?.error || 'unknown error'; + if (lastErr.toLowerCase().includes('timeout')) { + throw createProviderRpcError(4000, 'Network timeout. Please try again.'); + } + throw createProviderRpcError( + 4900, + `All ${availableRpcs.length} RPC endpoints failed broadcast. Last error: ${lastErr}`, + ); +}; + +/** + * Apply the user's fee-warning choice if the side-panel banner attached one + * to the approval event. 'dapp' = no override; 'suggested' = use the wallet's + * bumped values; 'custom' = use the user-typed values. Reading from storage + * (not param plumbing) so the side-panel UI can mutate the choice + * asynchronously without changing handler signatures. + */ +type EvmTxRequest = { + maxFeePerGas?: string; + maxPriorityFeePerGas?: string; + [key: string]: unknown; +}; + +const applyFeeChoiceFromStorage = async (transaction: EvmTxRequest, id: string, tag: string) => { + try { + const storedEvent = await requestStorage.getEventById(id); + const feeChoice: FeeChoice | undefined = storedEvent?.feeChoice; + if (feeChoice && feeChoice.source !== 'dapp' && storedEvent?.feeWarning) { + const w = storedEvent.feeWarning as FeeWarning; + if (feeChoice.source === 'suggested') { + transaction.maxFeePerGas = w.suggestedMaxFeePerGas; + transaction.maxPriorityFeePerGas = w.suggestedMaxPriorityFeePerGas; + } else if (feeChoice.source === 'custom') { + if (feeChoice.customMaxFeePerGas) transaction.maxFeePerGas = feeChoice.customMaxFeePerGas; + if (feeChoice.customMaxPriorityFeePerGas) + transaction.maxPriorityFeePerGas = feeChoice.customMaxPriorityFeePerGas; + } + console.log( + tag, + `fee override applied (${feeChoice.source}): maxFee=${transaction.maxFeePerGas} priority=${transaction.maxPriorityFeePerGas}`, + ); + } + } catch (e) { + console.warn(tag, 'failed to read feeChoice from storage:', e); } }; @@ -1189,10 +1822,12 @@ const sendTransaction = async (params: any, KEEPKEY_WALLET: any, ADDRESS: string transaction.chainId = chainId; transaction.from = ADDRESS; + await applyFeeChoiceFromStorage(transaction, id, tag); + const signedTx = await signTransaction(transaction, KEEPKEY_WALLET); console.log(tag, 'signedTx:', signedTx); - const txHash = await broadcastTransaction(signedTx); + const txHash = await broadcastTransaction(signedTx, transaction.from); console.log(tag, 'txHash:', txHash); const response = await requestStorage.getEventById(id); @@ -1204,6 +1839,7 @@ const sendTransaction = async (params: any, KEEPKEY_WALLET: any, ADDRESS: string //push event chrome.runtime.sendMessage({ action: 'transaction_complete', + eventId: id, txHash: txHash, explorerTxLink: currentProvider.explorerTxLink, networkId: currentProvider.networkId, @@ -1212,6 +1848,11 @@ const sendTransaction = async (params: any, KEEPKEY_WALLET: any, ADDRESS: string return txHash; } catch (e) { console.error(e); + // Pass ProviderRpcErrors through unchanged so the dApp sees the + // precise reason (insufficient funds, nonce conflict, all RPCs + // failed, etc.) instead of a generic "Error sending transaction" + // that swallows our broadcastTransaction classifier output. + if (e && typeof (e as { code?: unknown }).code === 'number') throw e; throw createProviderRpcError(4000, 'Error sending transaction', e); } }; diff --git a/chrome-extension/src/background/chains/feeFloors.ts b/chrome-extension/src/background/chains/feeFloors.ts new file mode 100644 index 0000000..2669902 --- /dev/null +++ b/chrome-extension/src/background/chains/feeFloors.ts @@ -0,0 +1,225 @@ +/* + * Per-chain minimum fee floors for EIP-1559 transactions. + * + * Used by signTransaction to detect dApp-suggested fees that would + * leave a tx stuck pending in mempool. The actual floor at sign time + * is the higher of: + * + * - the per-chain static floor below (set from typical mempool + * median during the worst congestion of a normal day, not the + * all-time high — these are "below this and you'll wait" not + * "below this and you can't mine"), and + * + * - currentBaseFee * 1.1 (always-correct floor — a tx with + * maxFeePerGas < baseFee literally cannot be included, and 10% + * headroom absorbs single-block base-fee spikes). + * + * Numbers below are in wei. To convert: 1 gwei = 1e9 wei. + */ + +const GWEI = 1_000_000_000n; + +/** + * Static per-chain floor, keyed by hex chainId (matches the chainId + * format used by the Pioneer chain registry and dApp eth_sendTransaction + * params). Decimal chainIds are normalised via normalizeChainId() before + * lookup. + */ +const STATIC_FLOOR_WEI: Record = { + '0x1': 1n * GWEI, // Ethereum — 1 gwei + '0x89': 30n * GWEI, // Polygon — 30 gwei + '0x38': 3n * GWEI, // BSC — 3 gwei + '0xa': GWEI / 1000n, // Optimism — 0.001 gwei + '0xa4b1': GWEI / 100n, // Arbitrum — 0.01 gwei + '0x2105': GWEI / 1000n, // Base — 0.001 gwei + '0xa86a': 25n * GWEI, // Avalanche — 25 gwei + '0x144': GWEI / 40n, // zkSync Era — 0.025 gwei +}; + +/** + * Per-chain minimum *priority tip* (maxPriorityFeePerGas) for reliable + * propagation. A tx with low tip may pass the maxFee floor (because dApps + * often pad maxFee to baseFee*1.5+ for headroom) yet still sit in a + * single mempool node and never get gossiped to miners. We've seen txs + * land on etherscan briefly with sub-1-gwei tips, then evict — see + * RETRO_uniswap_swap_dropped_tx.md. + */ +const STATIC_PRIORITY_FLOOR_WEI: Record = { + '0x1': 1n * GWEI, // Ethereum — 1 gwei tip is the practical floor for inclusion + '0x89': 25n * GWEI, // Polygon — 25 gwei + '0x38': 1n * GWEI, // BSC — 1 gwei + '0xa': 0n, // Optimism — sequencer-driven, no tip floor + '0xa4b1': 0n, // Arbitrum — sequencer-driven + '0x2105': 0n, // Base — sequencer-driven + '0xa86a': 1n * GWEI, // Avalanche — 1 gwei + '0x144': 0n, // zkSync Era — sequencer-driven +}; + +function normalizeChainId(chainId: string | number): string { + if (typeof chainId === 'number') return '0x' + chainId.toString(16); + if (typeof chainId === 'string') { + if (chainId.startsWith('0x')) return chainId.toLowerCase(); + if (/^[0-9]+$/.test(chainId)) return '0x' + parseInt(chainId, 10).toString(16); + } + return '0x1'; // unknown shape → conservative default +} + +/** + * Compute the effective fee floor for a chain at this moment. + * Returns the higher of the static per-chain floor and (baseFee * 1.1). + * If baseFee is unknown (null/undefined), only the static floor applies. + */ +export function getFeeFloor(chainId: string | number, currentBaseFeeWei: bigint | null | undefined): bigint { + const normalized = normalizeChainId(chainId); + const staticFloor = STATIC_FLOOR_WEI[normalized] ?? GWEI; // unknown chains → 1 gwei default + if (currentBaseFeeWei == null) return staticFloor; + // 10% headroom = baseFee * 11 / 10 + const dynamicFloor = (currentBaseFeeWei * 11n) / 10n; + return dynamicFloor > staticFloor ? dynamicFloor : staticFloor; +} + +/** Per-chain priority-tip floor. Unknown chains default to 1 gwei. */ +export function getPriorityFeeFloor(chainId: string | number): bigint { + const normalized = normalizeChainId(chainId); + return STATIC_PRIORITY_FLOOR_WEI[normalized] ?? GWEI; +} + +/** Convert any of (hex string, decimal string, number, bigint) to bigint. */ +export function toBigInt(v: string | number | bigint | undefined | null): bigint | null { + if (v == null) return null; + if (typeof v === 'bigint') return v; + if (typeof v === 'number') return BigInt(v); + if (typeof v === 'string') { + if (v.startsWith('0x')) return BigInt(v); + return BigInt(v); + } + return null; +} + +export interface FeeWarning { + /** dApp-supplied maxFeePerGas, hex string */ + dappMaxFeePerGas: string; + /** dApp-supplied maxPriorityFeePerGas, hex string */ + dappMaxPriorityFeePerGas: string; + /** Wallet's recommended bump, hex string */ + suggestedMaxFeePerGas: string; + /** Wallet's recommended priority, hex string */ + suggestedMaxPriorityFeePerGas: string; + /** maxFee floor at this moment, hex string */ + floorWei: string; + /** Priority-tip floor for this chain, hex string */ + priorityFloorWei: string; + /** Effective tip miners would actually see: min(maxPriority, maxFee - baseFee), hex */ + effectiveTipWei: string; + /** Current network base fee, hex string (or null if unknown) */ + baseFeeWei: string | null; + /** Which check failed: 'maxFee', 'tip', or 'both'. */ + trigger: 'maxFee' | 'tip' | 'both'; + /** Human reason — surfaced in the side-panel banner */ + reason: string; + /** Chain we evaluated against */ + chainId: string; +} + +/** + * Build a FeeWarning if the dApp's fees fall below the chain floor. + * Returns null when no warning is needed (fees are fine, or fields missing). + * + * Suggested values: max(dappPriority, ourPriorityOracle) for the tip, and + * floor*1.5 for the maxFeePerGas (gives mining headroom across a few blocks). + */ +export function buildFeeWarning(opts: { + chainId: string | number; + dappMaxFeePerGas: string | undefined; + dappMaxPriorityFeePerGas: string | undefined; + baseFeeWei: bigint | null | undefined; + oracleMaxFeePerGas: bigint | null | undefined; + oracleMaxPriorityFeePerGas: bigint | null | undefined; +}): FeeWarning | null { + if (!opts.dappMaxFeePerGas) return null; + const dappMax = toBigInt(opts.dappMaxFeePerGas); + const dappPriority = toBigInt(opts.dappMaxPriorityFeePerGas) ?? 0n; + if (dappMax == null) return null; + + const floor = getFeeFloor(opts.chainId, opts.baseFeeWei); + const priorityFloor = getPriorityFeeFloor(opts.chainId); + + // Effective tip = what miners actually receive. EIP-1559 caps the tip at + // (maxFeePerGas - baseFee), so a high maxFee with a low maxPriority still + // pays only maxPriority. With a tight maxFee, the tip can be even lower. + const baseFeeForTip = opts.baseFeeWei ?? 0n; + const headroom = dappMax > baseFeeForTip ? dappMax - baseFeeForTip : 0n; + const effectiveTip = dappPriority < headroom ? dappPriority : headroom; + + const failsMaxFee = dappMax < floor; + const failsTip = effectiveTip < priorityFloor; + if (!failsMaxFee && !failsTip) return null; + + const baseFee = opts.baseFeeWei ?? floor; + const oraclePriority = opts.oracleMaxPriorityFeePerGas ?? GWEI / 10n; + const tipFloorMin = oraclePriority > priorityFloor ? oraclePriority : priorityFloor; + const suggestedPriority = tipFloorMin > dappPriority ? tipFloorMin : dappPriority; + // Aim for ~3 blocks of base-fee headroom: baseFee*2 + tip is the + // ethers-default formula. Cap below the suggested priority + 2*baseFee + // so we never silently 10x the dApp's fees. + const oracleSuggestion = + opts.oracleMaxFeePerGas && opts.oracleMaxFeePerGas > floor + ? opts.oracleMaxFeePerGas + : baseFee * 2n + suggestedPriority; + // Invariant: suggestedMax must leave at least `suggestedPriority` on top + // of baseFee, otherwise EIP-1559 caps the effective tip at + // (suggestedMax - baseFee) which lands below priorityFloor and the + // "use suggested" choice still trips the same warning. Picking the oracle + // value alone (when baseFee is low and priorityFloor is high) regressed + // exactly into this hole — see PR #55 review. + const tipHeadroomFloor = baseFee + suggestedPriority; + const suggestedMax = oracleSuggestion > tipHeadroomFloor ? oracleSuggestion : tipHeadroomFloor; + + const trigger: 'maxFee' | 'tip' | 'both' = failsMaxFee && failsTip ? 'both' : failsMaxFee ? 'maxFee' : 'tip'; + + const baseFeeGwei = opts.baseFeeWei != null ? Number(opts.baseFeeWei) / 1e9 : null; + const dappGwei = Number(dappMax) / 1e9; + const tipGwei = Number(effectiveTip) / 1e9; + const floorGwei = Number(floor) / 1e9; + const tipFloorGwei = Number(priorityFloor) / 1e9; + + let reason: string; + if (trigger === 'maxFee') { + reason = + baseFeeGwei != null + ? `dApp's maxFee ${dappGwei.toFixed(3)} gwei is below the ${floorGwei.toFixed(3)} gwei floor (base fee ${baseFeeGwei.toFixed(3)} gwei). Tx may sit pending.` + : `dApp's maxFee ${dappGwei.toFixed(3)} gwei is below the ${floorGwei.toFixed(3)} gwei network minimum. Tx may sit pending.`; + } else if (trigger === 'tip') { + reason = `Effective miner tip is only ${tipGwei.toFixed(3)} gwei (recommended ≥ ${tipFloorGwei.toFixed(3)} gwei). Low-tip txs can be accepted by an entry node, never gossiped to miners, and silently dropped.`; + } else { + reason = `Both maxFee (${dappGwei.toFixed(3)} gwei < floor ${floorGwei.toFixed(3)}) and tip (${tipGwei.toFixed(3)} gwei < ${tipFloorGwei.toFixed(3)}) are too low. Tx will likely sit pending or be evicted.`; + } + + return { + dappMaxFeePerGas: opts.dappMaxFeePerGas, + dappMaxPriorityFeePerGas: opts.dappMaxPriorityFeePerGas ?? '0x0', + suggestedMaxFeePerGas: '0x' + suggestedMax.toString(16), + suggestedMaxPriorityFeePerGas: '0x' + suggestedPriority.toString(16), + floorWei: '0x' + floor.toString(16), + priorityFloorWei: '0x' + priorityFloor.toString(16), + effectiveTipWei: '0x' + effectiveTip.toString(16), + baseFeeWei: opts.baseFeeWei != null ? '0x' + opts.baseFeeWei.toString(16) : null, + trigger, + reason, + chainId: normalizeChainId(opts.chainId), + }; +} + +/** + * The user's choice from the side-panel fee-warning banner. Stored on + * the approval event under `feeChoice` and read back in signTransaction + * to override the dApp-supplied fees before sending to the vault. + */ +export interface FeeChoice { + /** 'dapp' = use dApp's original; 'suggested' = use wallet bump; 'custom' = use the user-typed values below */ + source: 'dapp' | 'suggested' | 'custom'; + /** Only set when source === 'custom' */ + customMaxFeePerGas?: string; + /** Only set when source === 'custom' */ + customMaxPriorityFeePerGas?: string; +} diff --git a/chrome-extension/src/background/chains/lastResortRpcs.ts b/chrome-extension/src/background/chains/lastResortRpcs.ts new file mode 100644 index 0000000..a33a0d4 --- /dev/null +++ b/chrome-extension/src/background/chains/lastResortRpcs.ts @@ -0,0 +1,47 @@ +/** + * Last-resort EVM RPC URLs for the broadcast failover loop. + * + * # Why this exists + * + * Pioneer is the source of truth for chain RPCs (see `registry.ts`). + * BUT during a broadcast we sometimes need to try multiple URLs because + * a single endpoint rate-limited (the Tenderly-on-Optimism case from + * 2026-04-30). When Pioneer's full list is exhausted — or when Pioneer + * itself is unreachable — we still want the user's transaction to land. + * + * # The rule + * + * **Pioneer URLs always run FIRST. This list runs LAST.** + * + * The historical objection to a hardcoded RPC table (see + * `feedback_no_hardcoded_rpcs.md`) was that it could *block* live + * Pioneer nodes when entries went stale, forcing emergency releases to + * fix individual URL rot. Appending the table to the end of the loop + * removes that risk: a stale entry can only fail; it can never preempt + * a working Pioneer-discovered URL. + * + * # How to maintain + * + * Keep this list small (2 URLs per chain is enough). Pick canonical + * public endpoints that have been historically reliable: chain-team + * official RPCs, drpc.org, publicnode.com. Don't use anything that + * needs an API key. + * + * If a chain isn't here, broadcast failover relies entirely on the + * Pioneer list — which is fine for the long tail. + */ + +const RPCS: Record = { + 'eip155:1': ['https://ethereum-rpc.publicnode.com', 'https://eth.drpc.org'], + 'eip155:10': ['https://mainnet.optimism.io', 'https://optimism-rpc.publicnode.com'], + 'eip155:56': ['https://bsc-rpc.publicnode.com', 'https://bsc-dataseed.bnbchain.org'], + 'eip155:137': ['https://polygon-rpc.com', 'https://polygon-bor-rpc.publicnode.com'], + 'eip155:324': ['https://mainnet.era.zksync.io'], + 'eip155:8453': ['https://mainnet.base.org', 'https://base-rpc.publicnode.com'], + 'eip155:42161': ['https://arb1.arbitrum.io/rpc', 'https://arbitrum-one-rpc.publicnode.com'], + 'eip155:43114': ['https://api.avax.network/ext/bc/C/rpc', 'https://avalanche-c-chain-rpc.publicnode.com'], +}; + +export function getLastResortRpcs(networkId: string): string[] { + return RPCS[networkId] ? [...RPCS[networkId]] : []; +} diff --git a/chrome-extension/src/background/chains/litecoinHandler.ts b/chrome-extension/src/background/chains/litecoinHandler.ts index fa56338..f3b7e7a 100644 --- a/chrome-extension/src/background/chains/litecoinHandler.ts +++ b/chrome-extension/src/background/chains/litecoinHandler.ts @@ -3,6 +3,7 @@ import { v4 as uuidv4 } from 'uuid'; import { Chain, ChainToNetworkId, shortListSymbolToCaip, caipToNetworkId } from '../chainConfig'; import * as wallet from '../wallet'; import { createProviderRpcError } from '../utils'; +import { fetchJsonWithTimeout } from '../fetchUtils'; const TAG = ' | litecoinHandler | '; @@ -41,24 +42,22 @@ export const handleLitecoinRequest = async ( isMax: params[0].isMax, }; - const buildTx = async function () { - try { - const buildResponse = await fetch('https://api.keepkey.info/api/v1/buildTx', { + // Build before approval — see bitcoinHandler for the race rationale. + let unsignedTx: any; + try { + unsignedTx = await fetchJsonWithTimeout( + 'https://api.keepkey.info/api/v1/buildTx', + { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ...sendPayload, pubkeys }), - }); - const unsignedTx = await buildResponse.json(); - const storedEvent = await requestStorage.getEventById(requestInfo.id); - storedEvent.unsignedTx = unsignedTx; - await requestStorage.updateEventById(requestInfo.id, storedEvent); - chrome.runtime.sendMessage({ action: 'utxo_build_tx', unsignedTx: requestInfo }); - } catch (e) { - console.error(e); - chrome.runtime.sendMessage({ action: 'transaction_error', error: JSON.stringify(e) }); - } - }; - buildTx(); + }, + { timeoutMs: 15000, retries: 1 }, + ); + } catch (e) { + console.error(tag, 'buildTx failed:', e); + throw createProviderRpcError(4000, `Failed to build transaction: ${(e as Error)?.message || e}`); + } const event = { id: requestInfo.id, @@ -74,6 +73,7 @@ export const handleLitecoinRequest = async ( injectScriptVersion: requestInfo.version, chain: 'litecoin', requestInfo, + unsignedTx, type: 'transfer', request: params, status: 'request', @@ -92,18 +92,22 @@ export const handleLitecoinRequest = async ( response.signedTx = signedTx; await requestStorage.updateEventById(requestInfo.id, response); - const broadcastResponse = await fetch('https://api.keepkey.info/api/v1/broadcastTx', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ caip, signedTx: signedTx.serializedTx || signedTx }), - }); - let txHash = await broadcastResponse.json(); + let txHash: any = await fetchJsonWithTimeout( + 'https://api.keepkey.info/api/v1/broadcastTx', + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ caip, signedTx: signedTx.serializedTx || signedTx }), + }, + { timeoutMs: 15000, retries: 1 }, + ); if (txHash.txHash) txHash = txHash.txHash; if (txHash.txid) txHash = txHash.txid; response.txid = txHash; await requestStorage.updateEventById(requestInfo.id, response); chrome.runtime.sendMessage({ action: 'transaction_complete', + eventId: requestInfo.id, txHash, explorerTxLink: 'https://blockchair.com/litecoin/transaction/', }); diff --git a/chrome-extension/src/background/chains/mayaHandler.ts b/chrome-extension/src/background/chains/mayaHandler.ts index 28ce90d..fc0c265 100644 --- a/chrome-extension/src/background/chains/mayaHandler.ts +++ b/chrome-extension/src/background/chains/mayaHandler.ts @@ -3,6 +3,7 @@ import { v4 as uuidv4 } from 'uuid'; import { Chain, ChainToNetworkId, shortListSymbolToCaip, caipToNetworkId } from '../chainConfig'; import * as wallet from '../wallet'; import { createProviderRpcError } from '../utils'; +import { fetchJsonWithTimeout } from '../fetchUtils'; const TAG = ' | mayaHandler | '; @@ -43,16 +44,19 @@ export const handleMayaRequest = async ( let unsignedTx: any; try { - const buildResponse = await fetch('https://api.keepkey.info/api/v1/buildTx', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ ...sendPayload, pubkeys }), - }); - unsignedTx = await buildResponse.json(); + unsignedTx = await fetchJsonWithTimeout( + 'https://api.keepkey.info/api/v1/buildTx', + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ ...sendPayload, pubkeys }), + }, + { timeoutMs: 15000, retries: 1 }, + ); console.log(tag, 'unsignedTx: ', unsignedTx); } catch (e) { console.error(tag, 'buildTx failed:', e); - throw createProviderRpcError(4000, 'Failed to build transaction'); + throw createProviderRpcError(4000, `Failed to build transaction: ${(e as Error)?.message || e}`); } const event = { @@ -90,12 +94,15 @@ export const handleMayaRequest = async ( response.signedTx = signedTx; await requestStorage.updateEventById(requestInfo.id, response); - const broadcastResponse = await fetch('https://api.keepkey.info/api/v1/broadcastTx', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ caip, signedTx: signedTx.serializedTx || signedTx }), - }); - let txHash = await broadcastResponse.json(); + let txHash: any = await fetchJsonWithTimeout( + 'https://api.keepkey.info/api/v1/broadcastTx', + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ caip, signedTx: signedTx.serializedTx || signedTx }), + }, + { timeoutMs: 15000, retries: 1 }, + ); if (txHash.txHash) txHash = txHash.txHash; if (txHash.txid) txHash = txHash.txid; @@ -103,6 +110,7 @@ export const handleMayaRequest = async ( await requestStorage.updateEventById(requestInfo.id, response); chrome.runtime.sendMessage({ action: 'transaction_complete', + eventId: requestInfo.id, txHash, explorerTxLink: 'https://www.mayascan.org/tx/', }); diff --git a/chrome-extension/src/background/chains/osmosisHandler.ts b/chrome-extension/src/background/chains/osmosisHandler.ts index 339c31c..6dbc92f 100644 --- a/chrome-extension/src/background/chains/osmosisHandler.ts +++ b/chrome-extension/src/background/chains/osmosisHandler.ts @@ -3,6 +3,7 @@ import { v4 as uuidv4 } from 'uuid'; import { Chain, ChainToNetworkId, shortListSymbolToCaip, caipToNetworkId } from '../chainConfig'; import * as wallet from '../wallet'; import { createProviderRpcError } from '../utils'; +import { fetchJsonWithTimeout } from '../fetchUtils'; const TAG = ' | osmosisHandler | '; @@ -43,16 +44,19 @@ export const handleOsmosisRequest = async ( let unsignedTx: any; try { - const buildResponse = await fetch('https://api.keepkey.info/api/v1/buildTx', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ ...sendPayload, pubkeys }), - }); - unsignedTx = await buildResponse.json(); + unsignedTx = await fetchJsonWithTimeout( + 'https://api.keepkey.info/api/v1/buildTx', + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ ...sendPayload, pubkeys }), + }, + { timeoutMs: 15000, retries: 1 }, + ); console.log(tag, 'unsignedTx: ', unsignedTx); } catch (e) { console.error(tag, 'buildTx failed:', e); - throw createProviderRpcError(4000, 'Failed to build transaction'); + throw createProviderRpcError(4000, `Failed to build transaction: ${(e as Error)?.message || e}`); } const event = { @@ -90,12 +94,15 @@ export const handleOsmosisRequest = async ( response.signedTx = signedTx; await requestStorage.updateEventById(requestInfo.id, response); - const broadcastResponse = await fetch('https://api.keepkey.info/api/v1/broadcastTx', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ caip, signedTx: signedTx.serializedTx || signedTx }), - }); - let txHash = await broadcastResponse.json(); + let txHash: any = await fetchJsonWithTimeout( + 'https://api.keepkey.info/api/v1/broadcastTx', + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ caip, signedTx: signedTx.serializedTx || signedTx }), + }, + { timeoutMs: 15000, retries: 1 }, + ); if (txHash.txHash) txHash = txHash.txHash; if (txHash.txid) txHash = txHash.txid; @@ -103,6 +110,7 @@ export const handleOsmosisRequest = async ( await requestStorage.updateEventById(requestInfo.id, response); chrome.runtime.sendMessage({ action: 'transaction_complete', + eventId: requestInfo.id, txHash, explorerTxLink: 'https://www.mintscan.io/osmosis/tx/', }); diff --git a/chrome-extension/src/background/chains/registry.ts b/chrome-extension/src/background/chains/registry.ts new file mode 100644 index 0000000..1fe728e --- /dev/null +++ b/chrome-extension/src/background/chains/registry.ts @@ -0,0 +1,226 @@ +/** + * Pioneer-sourced EVM chain registry. + * + * Replaces the static EIP155_CHAINS table that used to live in chains.ts. + * Pioneer is the source of truth for chain metadata + RPC URLs — querying + * it on demand removes the "release every time a chain is added" pain + * point and keeps the BEX in sync with the vault automatically. + * + * Endpoint: GET /api/v1/discovery/caip/{caip} + * eip155:56/slip44:60 → + * { + * chainId: 'eip155:56', + * name: 'BNB Smart Chain', + * symbol: 'BNB', + * decimals: 18, + * icon, color, + * explorer / explorerTxLink / explorerAddressLink (with {{txid}}/{{address}} templates), + * rpcUrl: , rpcUrls: [], + * } + * + * The shape we expose mirrors what callers used to read off EIP155_CHAINS + * (chainId hex, name, primary rpc, caip, explorerTxLink prefix) plus the + * extras Pioneer hands us for free (full rpc list, decimals, icon). + * + * In-memory cache only. Pioneer is the source of truth; persisting to + * chrome.storage would just create a new staleness vector. + */ + +import { FetchRequest, JsonRpcProvider } from 'ethers'; + +const PIONEER_API = 'https://api.keepkey.info'; +const CACHE_TTL_MS = 60 * 60 * 1000; // 1 hour +const FAILURE_TTL_MS = 60 * 1000; // negative-cache misses for a minute +const FETCH_TIMEOUT_MS = 8000; + +const TAG = ' | chains/registry | '; + +/** + * Construct a JsonRpcProvider with a *pinned* network. Without this, + * ethers v6 calls eth_chainId on the first RPC call to detect the + * network and retries every 1s indefinitely if the URL is slow / dead / + * rate-limited — generating background spam from any timed-out call + * site (`Promise.race(getBalance, timeout)` only rejects the awaiting + * promise; the abandoned provider keeps retrying). + * + * Pin the network up front so a bad URL fails fast on the actual call + * instead of looping on detection forever. + * + * `timeoutMs` (optional): when set, builds a `FetchRequest` with a + * per-HTTP-attempt timeout AND disables ethers' throttle-retry + * behavior. Without it, ethers will silently retry 429/5xx responses + * with exponential backoff for ~30s before surfacing the error — which + * stalls the broadcast failover loop. Pass a short value (e.g. 4000) + * for paths that need snappy fall-through. + */ +function parseChainIdFromArg(networkOrChainId: string | number): number | null { + if (typeof networkOrChainId === 'number') { + return Number.isFinite(networkOrChainId) ? networkOrChainId : null; + } + const s = networkOrChainId.trim(); + const m = /^eip155:(\d+)$/.exec(s); + if (m) return parseInt(m[1], 10); + const n = /^0x/i.test(s) ? parseInt(s, 16) : parseInt(s, 10); + return Number.isFinite(n) ? n : null; +} + +export function makeStaticProvider( + url: string, + networkOrChainId: string | number, + options?: { timeoutMs?: number }, +): JsonRpcProvider { + const chainId = parseChainIdFromArg(networkOrChainId); + const cleanUrl = url.trim(); + const timeoutMs = options?.timeoutMs; + + // Path A: bounded HTTP timeout + no throttle retries. Use FetchRequest + // so we hit ethers' transport layer, not just the awaiting promise. + if (timeoutMs && timeoutMs > 0) { + const fetchReq = new FetchRequest(cleanUrl); + fetchReq.timeout = timeoutMs; + // Disable the implicit throttle-retry loop ethers does on 429/5xx. + // We're handling failover ourselves at a higher level — internal + // retries here just delay surfacing the error. + fetchReq.setThrottleParams({ maxAttempts: 1 }); + return chainId != null + ? new JsonRpcProvider(fetchReq, chainId, { staticNetwork: true }) + : new JsonRpcProvider(fetchReq); + } + + // Path B: default behavior preserved for non-broadcast call sites. + return chainId != null + ? new JsonRpcProvider(cleanUrl, chainId, { staticNetwork: true }) + : new JsonRpcProvider(cleanUrl); +} + +export interface ChainInfo { + chainId: string; // hex, e.g. '0x38' + caip: string; // 'eip155:56/slip44:60' + networkId: string; // 'eip155:56' + name: string; // 'BNB Smart Chain' + symbol: string; // 'BNB' + rpc: string; // primary RPC URL (best per Pioneer) + rpcs: string[]; // primary first, then fallbacks + explorer: string; // base explorer URL (no template) + explorerTxLink: string; // prefix — caller appends txid + explorerAddressLink: string; // prefix — caller appends address + decimals: number; // native currency decimals + icon?: string; + color?: string; +} + +interface CacheEntry { + value: ChainInfo | null; + at: number; +} + +const cache = new Map(); +const inflight = new Map>(); + +function decimalFromNetworkId(networkId: string): number | null { + const parts = networkId.split(':'); + if (parts.length !== 2 || parts[0] !== 'eip155') return null; + const n = parseInt(parts[1], 10); + return Number.isFinite(n) ? n : null; +} + +/** + * Pioneer encodes explorer URLs with `{{txid}}` / `{{address}}` + * placeholders. The rest of the codebase uses bare prefixes, so strip + * the template suffix. + */ +function stripTemplate(url: string | undefined, token: string): string { + if (!url) return ''; + const idx = url.indexOf(token); + return idx >= 0 ? url.slice(0, idx) : url; +} + +async function fetchFromPioneer(networkId: string): Promise { + const decimal = decimalFromNetworkId(networkId); + if (decimal == null) return null; + + const caip = `${networkId}/slip44:60`; + const url = `${PIONEER_API}/api/v1/discovery/caip/${encodeURIComponent(caip)}`; + + let resp: Response; + try { + resp = await fetch(url, { signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) }); + } catch (e: any) { + console.warn(TAG, 'Pioneer fetch failed for', networkId, e?.message || e); + return null; + } + if (!resp.ok) { + if (resp.status !== 404) { + console.warn(TAG, 'Pioneer returned', resp.status, 'for', networkId); + } + return null; + } + + let body: any; + try { + body = await resp.json(); + } catch { + return null; + } + // Discovery 404 returns `null` body as 200 in some deployments — bail + // if we don't have a recognizable shape. + if (!body || (!body.chainId && !body.assetId)) return null; + + const rpcUrls: string[] = Array.isArray(body.rpcUrls) ? body.rpcUrls.filter((u: any) => typeof u === 'string') : []; + const primary: string = body.rpcUrl || rpcUrls[0] || ''; + const ordered = primary ? [primary, ...rpcUrls.filter(u => u !== primary)] : rpcUrls.slice(); + + return { + chainId: '0x' + decimal.toString(16), + caip: body.assetId || caip, + networkId, + name: body.name || `Chain ${decimal}`, + symbol: body.symbol || '', + rpc: primary, + rpcs: ordered, + explorer: body.explorer || '', + explorerTxLink: stripTemplate(body.explorerTxLink, '{{txid}}'), + explorerAddressLink: stripTemplate(body.explorerAddressLink, '{{address}}'), + decimals: typeof body.decimals === 'number' ? body.decimals : 18, + icon: body.icon, + color: body.color, + }; +} + +/** + * Resolve chain metadata by networkId (e.g. 'eip155:56'). Returns null + * if Pioneer doesn't recognize the chain — callers should fall back to + * whatever surfacing they want (the chain-not-enabled popup, a 4902 + * error, etc.). Concurrent calls for the same networkId share a single + * in-flight request. + */ +export async function getChainInfo(networkId: string): Promise { + if (!networkId) return null; + const now = Date.now(); + + const cached = cache.get(networkId); + if (cached) { + const ttl = cached.value ? CACHE_TTL_MS : FAILURE_TTL_MS; + if (now - cached.at < ttl) return cached.value; + } + + const existing = inflight.get(networkId); + if (existing) return existing; + + const p = fetchFromPioneer(networkId) + .then(value => { + cache.set(networkId, { value, at: Date.now() }); + return value; + }) + .finally(() => { + inflight.delete(networkId); + }); + inflight.set(networkId, p); + return p; +} + +/** Convenience: just the primary RPC URL or null. */ +export async function getEvmRpc(networkId: string): Promise { + const info = await getChainInfo(networkId); + return info?.rpc || null; +} diff --git a/chrome-extension/src/background/chains/rippleHandler.ts b/chrome-extension/src/background/chains/rippleHandler.ts index cb18769..88b4775 100644 --- a/chrome-extension/src/background/chains/rippleHandler.ts +++ b/chrome-extension/src/background/chains/rippleHandler.ts @@ -3,6 +3,7 @@ import { v4 as uuidv4 } from 'uuid'; import { Chain, ChainToNetworkId, shortListSymbolToCaip, caipToNetworkId } from '../chainConfig'; import * as wallet from '../wallet'; import { createProviderRpcError } from '../utils'; +import { fetchJsonWithTimeout } from '../fetchUtils'; const TAG = ' | rippleHandler | '; @@ -43,16 +44,19 @@ export const handleRippleRequest = async ( let unsignedTx: any; try { - const buildResponse = await fetch('https://api.keepkey.info/api/v1/buildTx', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ ...sendPayload, pubkeys }), - }); - unsignedTx = await buildResponse.json(); + unsignedTx = await fetchJsonWithTimeout( + 'https://api.keepkey.info/api/v1/buildTx', + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ ...sendPayload, pubkeys }), + }, + { timeoutMs: 15000, retries: 1 }, + ); console.log(tag, 'unsignedTx: ', unsignedTx); } catch (e) { console.error(tag, 'buildTx failed:', e); - throw createProviderRpcError(4000, 'Failed to build transaction'); + throw createProviderRpcError(4000, `Failed to build transaction: ${(e as Error)?.message || e}`); } const event = { @@ -90,12 +94,15 @@ export const handleRippleRequest = async ( response.signedTx = signedTx; await requestStorage.updateEventById(requestInfo.id, response); - const broadcastResponse = await fetch('https://api.keepkey.info/api/v1/broadcastTx', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ caip, signedTx: signedTx.serializedTx || signedTx }), - }); - let txHash = await broadcastResponse.json(); + let txHash: any = await fetchJsonWithTimeout( + 'https://api.keepkey.info/api/v1/broadcastTx', + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ caip, signedTx: signedTx.serializedTx || signedTx }), + }, + { timeoutMs: 15000, retries: 1 }, + ); if (txHash.txHash) txHash = txHash.txHash; if (txHash.txid) txHash = txHash.txid; @@ -103,6 +110,7 @@ export const handleRippleRequest = async ( await requestStorage.updateEventById(requestInfo.id, response); chrome.runtime.sendMessage({ action: 'transaction_complete', + eventId: requestInfo.id, txHash, explorerTxLink: 'https://xrpscan.com/tx/', }); diff --git a/chrome-extension/src/background/chains/rpcFailover.ts b/chrome-extension/src/background/chains/rpcFailover.ts new file mode 100644 index 0000000..c53f154 --- /dev/null +++ b/chrome-extension/src/background/chains/rpcFailover.ts @@ -0,0 +1,129 @@ +/** + * EVM RPC failover for read-style calls keyed by networkId. + * + * The active-provider failover (`withRpcFailover` in ethereumHandler.ts) + * iterates the URLs the user is currently connected to. This module + * covers the *read* sites that resolve a networkId on demand — + * GET_ASSET_BALANCE, GET_EVM_BALANCE, VALIDATE_ERC20_TOKEN — where the + * caller passes "give me a working RPC for chain X" rather than "use + * whatever the user picked". + * + * Priority order matches the rest of the codebase: + * 1. user override (custom RPC from Add Network UI / blockchainDataStorage) + * 2. Pioneer-discovered URLs (registry.getChainInfo) + * 3. last-resort hardcoded list (lastResortRpcs) + * + * Per-attempt timeout via makeStaticProvider's transport-level config + * stops a hung URL from stalling the loop. + */ + +import type { JsonRpcProvider } from 'ethers'; +import { blockchainDataStorage } from '@extension/storage'; +import { getChainInfo, makeStaticProvider } from './registry'; +import { getLastResortRpcs } from './lastResortRpcs'; + +const FAILED_RPC_COOLDOWN_MS = 60_000; +// Distinct from ethereumHandler's failedRpcs map. The active-provider +// path and the by-networkId reads have different rate-limit blast +// radii (active provider = current chain only; reads = any chain), so +// keeping the cooldowns independent prevents one slow network from +// blocking the other. +const failedRpcs = new Map(); + +const isTransientRpcError = (errMsg: string): boolean => { + const m = errMsg.toLowerCase(); + return ( + m.includes('rate limit') || + m.includes('throttle') || + m.includes('429') || + m.includes('timeout') || + m.includes('econnreset') || + m.includes('etimedout') || + m.includes('network') || + m.includes('server_error') || + m.includes('exceeded maximum retry') || + /\b5\d{2}\b/.test(m) // 5xx + ); +}; + +async function buildCandidates(networkId: string): Promise { + const customChain = await blockchainDataStorage.getBlockchainData(networkId); + const customUrls: string[] = + customChain?.providers && customChain.providers.length > 0 + ? customChain.providers + : customChain?.providerUrl + ? [customChain.providerUrl] + : []; + const pioneer = await getChainInfo(networkId); + const pioneerUrls: string[] = pioneer?.rpcs || []; + const lastResort = getLastResortRpcs(networkId); + + const seen = new Set(); + const ordered: string[] = []; + for (const u of [...customUrls, ...pioneerUrls, ...lastResort]) { + const t = (u || '').trim(); + if (t && !seen.has(t)) { + seen.add(t); + ordered.push(t); + } + } + return ordered; +} + +function applyCooldown(candidates: string[]): string[] { + const now = Date.now(); + for (const [url, failedAt] of failedRpcs) { + if (now - failedAt >= FAILED_RPC_COOLDOWN_MS) failedRpcs.delete(url); + } + const available = candidates.filter(url => { + const failedAt = failedRpcs.get(url); + return !(failedAt && now - failedAt < FAILED_RPC_COOLDOWN_MS); + }); + // If every candidate is cooling, clear and try them all rather than + // hard-failing — the same convention as ethereumHandler's getProvider. + if (available.length === 0 && candidates.length > 0) { + failedRpcs.clear(); + return candidates.slice(); + } + return available; +} + +/** + * Run an RPC operation with failover across the candidate list for + * `networkId`. Definitive errors (revert, invalid params) surface + * immediately. Transient errors (rate limit, 5xx, network, timeout) + * fail over to the next URL. + */ +export async function withRpcFailoverByNetworkId( + networkId: string, + op: (provider: JsonRpcProvider, url: string) => Promise, + options?: { timeoutMs?: number }, +): Promise { + const candidates = await buildCandidates(networkId); + const available = applyCooldown(candidates); + if (available.length === 0) { + throw new Error(`No RPC URLs available for ${networkId}`); + } + + const errors: { url: string; error: string }[] = []; + let lastErr: unknown = null; + const now = Date.now(); + for (const url of available) { + try { + const provider = makeStaticProvider(url, networkId, { timeoutMs: options?.timeoutMs ?? 5000 }); + return await op(provider, url); + } catch (e: any) { + const errMsg = String(e?.message || e); + if (!isTransientRpcError(errMsg)) { + // Definitive — won't help to try another RPC. + throw e; + } + console.warn(`[rpcFailover] ${networkId} ${url} transient failure, trying next:`, errMsg); + errors.push({ url, error: errMsg }); + failedRpcs.set(url, now); + lastErr = e; + } + } + if (lastErr) throw lastErr; + throw new Error(`All ${available.length} RPC endpoints failed for ${networkId}`); +} diff --git a/chrome-extension/src/background/chains/solanaHandler.ts b/chrome-extension/src/background/chains/solanaHandler.ts index cc36b93..98594a6 100644 --- a/chrome-extension/src/background/chains/solanaHandler.ts +++ b/chrome-extension/src/background/chains/solanaHandler.ts @@ -1,7 +1,8 @@ import { requestStorage } from '@extension/storage'; import { v4 as uuidv4 } from 'uuid'; import * as wallet from '../wallet'; -import { createProviderRpcError } from '../utils'; +import { createProviderRpcError, createTimeoutError } from '../utils'; +import { requireMessageSigningFirmware } from '../firmware'; const TAG = ' | solanaHandler | '; @@ -34,7 +35,9 @@ async function getSolanaRpcUrl(): Promise { cachedRpcTimestamp = now; return url; } - } catch { /* try next */ } + } catch { + /* try next */ + } } return SOLANA_RPC_URLS[0]; // fallback to primary } @@ -47,6 +50,19 @@ export function resetSolanaState() { cachedAddress = null; } +/** + * Prefetch the Solana pubkey at startup so the network shows up in the + * dropdown without waiting for a dapp-initiated Solana call. Non-throwing — + * silently skips when no device + no cached address. + */ +export async function prefetchSolanaPubkey(): Promise { + try { + await getSolanaAddress(); + } catch (e: any) { + console.log(TAG, 'Solana prefetch skipped:', e?.message || e); + } +} + /** Convert a number[] to base64 string (chunked to avoid call-stack limit) */ function toBase64(arr: number[]): string { const CHUNK = 8192; @@ -66,6 +82,117 @@ function fromBase64(b64: string): number[] { } } +// ---------- Solana tx builder (inline, no @solana/web3.js dep) ---------- + +const BASE58_ALPHABET = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz'; + +function base58Decode(str: string): Uint8Array { + const bytes: number[] = [0]; + for (const char of str) { + const idx = BASE58_ALPHABET.indexOf(char); + if (idx === -1) throw new Error('Invalid base58 character'); + let carry = idx; + for (let j = 0; j < bytes.length; j++) { + carry += bytes[j] * 58; + bytes[j] = carry & 0xff; + carry >>= 8; + } + while (carry > 0) { + bytes.push(carry & 0xff); + carry >>= 8; + } + } + for (const char of str) { + if (char !== '1') break; + bytes.push(0); + } + return new Uint8Array(bytes.reverse()); +} + +/** Solana compact-u16 varint. 1 byte <128, 2 bytes <16384, else 3 bytes. */ +function encodeCompactU16(n: number): number[] { + if (n < 0 || n > 0xffff) throw new Error('compact-u16 out of range'); + if (n < 0x80) return [n]; + if (n < 0x4000) return [(n & 0x7f) | 0x80, (n >> 7) & 0x7f]; + return [(n & 0x7f) | 0x80, ((n >> 7) & 0x7f) | 0x80, (n >> 14) & 0x03]; +} + +async function getLatestBlockhash(): Promise { + const rpcUrl = await getSolanaRpcUrl(); + let resp: Response; + try { + resp = await fetch(rpcUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'getLatestBlockhash' }), + signal: AbortSignal.timeout(10000), + }); + } catch (e: any) { + throw createProviderRpcError(-32603, `Solana RPC blockhash fetch failed: ${e.message}`); + } + const data = await resp.json().catch(() => ({})); + const blockhash = data?.result?.value?.blockhash; + if (!blockhash) throw createProviderRpcError(-32603, 'Solana RPC returned no blockhash'); + return blockhash; +} + +/** + * Build a legacy Solana System Program transfer transaction with a + * 64-byte zero signature placeholder. Vault's /solana/sign-transaction + * replaces bytes 1..65 with the real Ed25519 signature before returning. + * + * Layout: + * sig_count(cu16=1) | zero_sig(64) | header(3) | num_keys(cu16=3) + * sender(32) | recipient(32) | system_program(32=zeros) + * blockhash(32) | num_instructions(cu16=1) + * program_idx(u8=2) | num_accounts(cu16=2) | 0 1 + * data_len(cu16=12) | instruction(u32_le=2) | lamports(u64_le) + */ +function buildSolanaTransferTx( + senderBase58: string, + recipientBase58: string, + lamports: bigint, + blockhashBase58: string, +): Uint8Array { + const senderKey = base58Decode(senderBase58); + const recipientKey = base58Decode(recipientBase58); + const blockhashBytes = base58Decode(blockhashBase58); + if (senderKey.length !== 32) throw createProviderRpcError(4000, 'Invalid sender pubkey'); + if (recipientKey.length !== 32) + throw createProviderRpcError(4000, `Invalid recipient address (expected 32 bytes, got ${recipientKey.length})`); + if (blockhashBytes.length !== 32) throw createProviderRpcError(-32603, 'Invalid blockhash length'); + + const systemProgram = new Uint8Array(32); // 32 zero bytes + + const data = new Uint8Array(12); + const dv = new DataView(data.buffer); + dv.setUint32(0, 2, true); // SystemProgram::Transfer discriminator + dv.setBigUint64(4, lamports, true); + + const out: number[] = []; + // Signature section — placeholder + out.push(...encodeCompactU16(1)); + for (let i = 0; i < 64; i++) out.push(0); + // Message header: (num_required_signatures, num_readonly_signed, num_readonly_unsigned) + out.push(1, 0, 1); + // Account keys + out.push(...encodeCompactU16(3)); + for (const b of senderKey) out.push(b); + for (const b of recipientKey) out.push(b); + for (const b of systemProgram) out.push(b); + // Recent blockhash + for (const b of blockhashBytes) out.push(b); + // Instructions + out.push(...encodeCompactU16(1)); + out.push(2); // program_id_index → systemProgram + out.push(...encodeCompactU16(2)); + out.push(0, 1); // sender (signer), recipient + out.push(...encodeCompactU16(12)); + for (const b of data) out.push(b); + + return new Uint8Array(out); +} + // BIP44 path for Solana: m/44'/501'/0'/0' const SOLANA_ADDRESS_N = [ 0x80000000 + 44, // 0x8000002C @@ -132,8 +259,11 @@ async function getSolanaAddress(): Promise { /** Build the event object for popup approval flow */ function buildEvent(requestInfo: any, method: string, params: any[]) { + // Ensure requestInfo.id is set so callers downstream (including message payloads + // that tag chrome.runtime events with eventId) reference the same id we store. + if (!requestInfo.id) requestInfo.id = uuidv4(); return { - id: requestInfo.id || uuidv4(), + id: requestInfo.id, networkId: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp', chain: 'solana', href: requestInfo.href, @@ -215,11 +345,13 @@ async function signTransactionViaRest(txBase64: string): Promise<{ signature: st raw_tx: txBase64, address_n: SOLANA_ADDRESS_N, }), - signal: AbortSignal.timeout(30000), + // Wait on the user holding the device button — see the matching + // comment in signMessageViaRest for the rationale. + signal: AbortSignal.timeout(300_000), }); } catch (e: any) { if (e.name === 'TimeoutError' || e.name === 'AbortError') { - throw createProviderRpcError(-32603, 'Vault signing timed out'); + throw createTimeoutError('Vault signing timed out'); } throw createProviderRpcError(-32603, `Vault connection failed: ${e.message}`); } @@ -264,11 +396,16 @@ async function signMessageViaRest(messageBase64: string): Promise { message: messageBase64, address_n: SOLANA_ADDRESS_N, }), - signal: AbortSignal.timeout(30000), + // Hardware signing waits on the user reading the message and + // confirming on-device. Match the injected-script callback ceiling + // (5 min) so we never time out *before* the user has a chance to + // act. Aborting earlier produced a red "Vault sign-message timed + // out" panel even though the device was still happily waiting. + signal: AbortSignal.timeout(300_000), }); } catch (e: any) { if (e.name === 'TimeoutError' || e.name === 'AbortError') { - throw createProviderRpcError(-32603, 'Vault sign-message timed out'); + throw createTimeoutError('Vault sign-message timed out'); } throw createProviderRpcError(-32603, `Vault connection failed: ${e.message}`); } @@ -290,41 +427,250 @@ async function signMessageViaRest(messageBase64: string): Promise { } /** - * Broadcast a signed Solana transaction via Solana JSON-RPC. - * Vault has NO broadcast endpoint — we send directly to Solana RPC. + * POST /solana/sign-offchain-message — domain-separated envelope. + * + * Firmware constructs: + * "\xff" || "solana offchain" || version || format || length || msg + * and Ed25519-signs the envelope (NOT the bare message). Verifiers must + * reconstruct the same envelope before checking the signature — see the + * `solana_signOffchainMessage` case below for the verifier guidance we + * surface to dApps. + * + * version: 0 is the only currently-defined revision. + * messageFormat: + * 0 = restricted ASCII (printable + space, max 1212 bytes) + * 1 = UTF-8 (max 1212 bytes; firmware rejects format 2) + * + * Response: 64-byte Ed25519 signature + 32-byte public key, hex. */ -async function broadcastTransaction(signedTxBase64: string): Promise { - const rpcUrl = await getSolanaRpcUrl(); - let response: Response; +async function signOffchainMessageViaRest( + messageHex: string, + version: number, + messageFormat: number, +): Promise<{ publicKey: string; signature: string }> { + const apiKey = getApiKey(); + let resp: Response; try { - response = await fetch(rpcUrl, { + resp = await fetch(`${VAULT_URL}/solana/sign-offchain-message`, { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${apiKey}` }, body: JSON.stringify({ - jsonrpc: '2.0', - id: 1, - method: 'sendTransaction', - params: [signedTxBase64, { encoding: 'base64' }], + address_n: SOLANA_ADDRESS_N, + message: messageHex, + is_text: false, // Pre-encoded to hex above so the vault doesn't second-guess. + version, + message_format: messageFormat, + show_display: true, }), - signal: AbortSignal.timeout(30000), + signal: AbortSignal.timeout(300_000), }); } catch (e: any) { if (e.name === 'TimeoutError' || e.name === 'AbortError') { - throw createProviderRpcError(-32603, 'Solana RPC broadcast timed out'); + throw createTimeoutError('Vault Solana sign-offchain timed out'); } - throw createProviderRpcError(-32603, `Solana RPC connection failed: ${e.message}`); + throw createProviderRpcError(-32603, `Vault connection failed: ${e.message}`); + } + if (!resp.ok) { + const text = await resp.text().catch(() => ''); + throw createProviderRpcError(-32603, `Vault Solana sign-offchain failed (${resp.status}): ${text}`); + } + const result = await resp.json(); + if (!result?.signature || !result?.publicKey) { + throw createProviderRpcError(-32603, 'Vault returned no Solana off-chain signature'); } + return { publicKey: result.publicKey, signature: result.signature }; +} + +function bytesToHex(bytes: Uint8Array | number[]): string { + let out = ''; + for (let i = 0; i < bytes.length; i++) out += (bytes[i] & 0xff).toString(16).padStart(2, '0'); + return out; +} - if (!response.ok) { - throw createProviderRpcError(-32603, `Solana RPC broadcast failed: ${response.status}`); +/** + * Encode bytes to base58 (Bitcoin alphabet — same as Solana). Pairs + * with the existing `base58Decode` defined for the tx-builder path + * above; both share `BASE58_ALPHABET`. Used in the rare "already + * processed" broadcast-recovery path to derive the tx signature + * locally from the signed bytes. + */ +function bytesToBase58(bytes: Uint8Array): string { + if (bytes.length === 0) return ''; + let zeros = 0; + while (zeros < bytes.length && bytes[zeros] === 0) zeros++; + const digits: number[] = [0]; + for (let i = zeros; i < bytes.length; i++) { + let carry = bytes[i]; + for (let j = 0; j < digits.length; j++) { + carry += digits[j] << 8; + digits[j] = carry % 58; + carry = (carry / 58) | 0; + } + while (carry > 0) { + digits.push(carry % 58); + carry = (carry / 58) | 0; + } } + let out = ''; + for (let i = 0; i < zeros; i++) out += '1'; + for (let i = digits.length - 1; i >= 0; i--) out += BASE58_ALPHABET[digits[i]]; + return out; +} - const result = await response.json(); - if (result.error) { - throw createProviderRpcError(-32603, `Solana RPC error: ${result.error.message}`); +/** + * A signed Solana transaction's first 64 bytes after the signature + * count are the first signature, which IS the transaction's canonical + * signature (and what `sendTransaction` returns). We can derive it + * locally without an RPC round-trip — useful when an RPC reports + * "already processed" (the tx is in mempool somewhere; the dApp still + * needs the signature). + * + * Layout: [compact-u16 sig_count] [64-byte sig × N] [message] + * For the >253 sig case the count is multi-byte, but real txs almost + * always have 1–3 sigs (one byte). We read defensively just in case. + */ +function extractFirstSignatureBase58(signedTxBase64: string): string | null { + try { + const bin = atob(signedTxBase64); + const bytes = new Uint8Array(bin.length); + for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i); + if (bytes.length < 65) return null; + // compact-u16: each byte uses low 7 bits + continuation bit. Start + // by skipping continuation bytes to find the sig array offset. + let cursor = 0; + while (cursor < bytes.length && (bytes[cursor] & 0x80) !== 0 && cursor < 3) cursor++; + cursor++; // include the final length byte + if (bytes.length < cursor + 64) return null; + return bytesToBase58(bytes.slice(cursor, cursor + 64)); + } catch { + return null; } +} - return result.result; // transaction signature (base58) +/** + * Classify a Solana sendTransaction error. + * + * - 'transient' → rate limit / network / 5xx, AND a few RPC-state + * quirks ("blockhash not found" can be RPC + * freshness for dApp-supplied txs; "account in + * use" can be a transient race) — try next URL. + * - 'already-processed' → tx is already in mempool / processed. + * Treat as success; pull sig from signed bytes. + * - 'definitive' → tx-level reject (insufficient funds, signature + * verification, block height exceeded — the + * blockhash window has truly closed). + */ +type SolanaBroadcastErrorKind = 'transient' | 'already-processed' | 'definitive'; +function classifySolanaBroadcastError(msg: string): SolanaBroadcastErrorKind { + const m = msg.toLowerCase(); + if (m.includes('already processed')) return 'already-processed'; + if ( + m.includes('insufficient funds') || + m.includes('insufficient lamports') || + m.includes('block height exceeded') || + m.includes('invalid signature') || + m.includes('signature verification') + ) { + return 'definitive'; + } + // Everything else — rate limit / network / 5xx, plus 'blockhash not + // found' (RPC freshness) and 'account in use' (transient race) — is + // worth trying the next URL. + return 'transient'; +} + +/** + * Broadcast a signed Solana transaction via Solana JSON-RPC. + * Vault has NO broadcast endpoint — we send directly to Solana RPC. + * + * Iterates SOLANA_RPC_URLS on transient failures. Health-checked URLs + * sometimes pass `getHealth` but reject `sendTransaction` (rate-limit, + * regional throttling), so the failover loop reaches further than the + * pre-flight selection in `getSolanaRpcUrl`. + */ +async function broadcastTransaction(signedTxBase64: string): Promise { + const errors: { url: string; error: string }[] = []; + // Try the cached/healthy URL first, then any others not yet attempted. + const primary = await getSolanaRpcUrl(); + const ordered = [primary, ...SOLANA_RPC_URLS.filter(u => u !== primary)]; + + for (const rpcUrl of ordered) { + let response: Response; + try { + response = await fetch(rpcUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + jsonrpc: '2.0', + id: 1, + method: 'sendTransaction', + params: [signedTxBase64, { encoding: 'base64' }], + }), + signal: AbortSignal.timeout(15000), + }); + } catch (e: any) { + const errMsg = e.name === 'TimeoutError' || e.name === 'AbortError' ? 'broadcast timed out' : e.message; + errors.push({ url: rpcUrl, error: errMsg }); + // Network/timeout — invalidate the cached health pick so the + // next caller will retest and try another candidate first. + cachedRpcUrl = null; + continue; + } + + if (!response.ok) { + // 4xx is definitive (bad request / signature). 5xx + 429 are transient. + const errMsg = `HTTP ${response.status}`; + if (response.status >= 500 || response.status === 429) { + errors.push({ url: rpcUrl, error: errMsg }); + cachedRpcUrl = null; + continue; + } + throw createProviderRpcError(-32603, `Solana RPC broadcast failed: ${errMsg}`); + } + + const result = await response.json(); + if (result.error) { + const errMsg = result.error.message || JSON.stringify(result.error); + const kind = classifySolanaBroadcastError(errMsg); + + if (kind === 'already-processed') { + // Tx is already in mempool / processed somewhere. Recover the + // signature from the signed bytes (it's deterministic; first + // sig of the signed tx == tx signature). Returning preserves + // dApp UX: the user sees a successful send and polls the + // signature normally. + const sig = extractFirstSignatureBase58(signedTxBase64); + if (sig) { + console.log(`[solana broadcast] ${rpcUrl} reports already-processed; using extracted sig ${sig}`); + return sig; + } + // Fallback: extraction failed (malformed signedTx). Treat as + // transient — maybe another RPC has the signature stored. + console.warn(`[solana broadcast] ${rpcUrl} already-processed but sig extraction failed; trying next URL`); + errors.push({ url: rpcUrl, error: errMsg }); + cachedRpcUrl = null; + continue; + } + + if (kind === 'transient') { + errors.push({ url: rpcUrl, error: errMsg }); + cachedRpcUrl = null; + continue; + } + // Definitive — won't recover on another RPC. + throw createProviderRpcError(-32603, `Solana RPC error: ${errMsg}`); + } + + return result.result; // transaction signature (base58) + } + + // All candidates failed transient. + console.error('[solana broadcast] all RPCs failed:', errors); + const last = errors[errors.length - 1]?.error || 'unknown'; + if (/timed out|timeout/i.test(last)) { + throw createTimeoutError('Solana RPC broadcast timed out'); + } + throw createProviderRpcError(-32603, `All ${ordered.length} Solana RPCs failed broadcast: ${last}`); } export const handleSolanaRequest = async ( @@ -373,7 +719,7 @@ export const handleSolanaRequest = async ( const messageBase64 = toBase64(messageArray); const signatureArray = await signMessageViaRest(messageBase64); - chrome.runtime.sendMessage({ action: 'signature_complete' }).catch(() => {}); + chrome.runtime.sendMessage({ action: 'signature_complete', eventId: requestInfo.id }).catch(() => {}); return signatureArray; } @@ -393,10 +739,103 @@ export const handleSolanaRequest = async ( // Return the fully signed transaction (vault replaces dummy sig at bytes 1-64) const signedTxArray = fromBase64(txSignResult.serializedTx); - chrome.runtime.sendMessage({ action: 'signature_complete' }).catch(() => {}); + chrome.runtime.sendMessage({ action: 'signature_complete', eventId: requestInfo.id }).catch(() => {}); return signedTxArray; } + // ---- Transfer (side-panel Send flow) ---- + // Payload shape from Transfer.tsx: + // params[0] = { caip, amount: { amount, denom }, recipient, memo, isMax } + // Build SOL transfer tx locally, stash it on the approval event so + // Transaction.tsx can render sender/recipient/amount, then sign via + // vault and broadcast via Solana RPC. + case 'transfer': { + const payload = params?.[0] || {}; + const recipient: string = payload.recipient; + const amountSol: string = payload?.amount?.amount ?? payload?.amount ?? ''; + + if (!recipient) throw createProviderRpcError(4000, 'Missing recipient'); + if (!amountSol) throw createProviderRpcError(4000, 'Missing amount'); + + const amountFloat = parseFloat(amountSol); + if (!Number.isFinite(amountFloat) || amountFloat <= 0) { + throw createProviderRpcError(4000, 'Invalid SOL amount'); + } + // 1 SOL = 1_000_000_000 lamports. Round to avoid FP leftovers. + const lamports = BigInt(Math.round(amountFloat * 1e9)); + if (lamports <= 0n) throw createProviderRpcError(4000, 'Amount too small'); + + const sender = await getSolanaAddress(); + const blockhash = await getLatestBlockhash(); + const txBytes = buildSolanaTransferTx(sender, recipient, lamports, blockhash); + const txBase64 = toBase64(Array.from(txBytes)); + + if (!requestInfo.id) requestInfo.id = uuidv4(); + const event = { + id: requestInfo.id, + networkId: SOLANA_NETWORK_ID, + chain: 'solana', + href: requestInfo.href, + language: requestInfo.language, + platform: requestInfo.platform, + referrer: requestInfo.referrer, + requestTime: requestInfo.requestTime, + scriptSource: requestInfo.scriptSource, + siteUrl: requestInfo.siteUrl, + userAgent: requestInfo.userAgent, + injectScriptVersion: requestInfo.version, + requestInfo, + unsignedTx: { + from: sender, + to: recipient, + amount: amountSol, + lamports: lamports.toString(), + blockhash, + txBase64, + }, + type: 'transfer', + request: params, + status: 'request', + timestamp: new Date().toISOString(), + }; + // @ts-expect-error + const saved = await requestStorage.addEvent(event); + if (!saved) throw createProviderRpcError(-32603, 'Failed to create approval event'); + chrome.runtime.sendMessage({ action: 'TRANSACTION_CONTEXT_UPDATED', id: event.id }).catch(() => {}); + + const approval = await requireApproval(SOLANA_NETWORK_ID, requestInfo, 'solana', method, params); + if (!approval?.success) { + throw createProviderRpcError(4001, 'User denied transaction'); + } + + const signResult = await signTransactionViaRest(txBase64); + const txSignature = await broadcastTransaction(signResult.serializedTx); + + // Persist txid so the approval UI's success state can show it. + try { + const stored = await requestStorage.getEventById(requestInfo.id); + if (stored) { + stored.txid = txSignature; + stored.status = 'broadcasted'; + await requestStorage.updateEventById(requestInfo.id, stored); + } + } catch (e) { + console.warn(tag, 'Failed to persist txid on event:', e); + } + + chrome.runtime + .sendMessage({ + action: 'transaction_complete', + eventId: requestInfo.id, + txHash: txSignature, + explorerTxLink: 'https://solscan.io/tx/', + networkId: SOLANA_NETWORK_ID, + }) + .catch(() => {}); + + return txSignature; + } + // ---- Sign and send transaction ---- case 'solana_signAndSendTransaction': { const sendTxArray: number[] = params[0]; @@ -417,6 +856,7 @@ export const handleSolanaRequest = async ( chrome.runtime .sendMessage({ action: 'transaction_complete', + eventId: requestInfo.id, txHash: txSignature, explorerTxLink: 'https://solscan.io/tx/', networkId: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp', @@ -426,7 +866,116 @@ export const handleSolanaRequest = async ( return txSignature; } + // ---- Sign off-chain message (domain-separated envelope) ---- + // + // CRITICAL — verifier guidance for callers: + // + // The signature returned here is over the Solana off-chain envelope + // (https://github.com/solana-labs/solana/blob/master/docs/src/proposals/off-chain-message-signing.md), + // NOT the bare message bytes. Verifiers MUST reconstruct: + // + // "\xff" || "solana offchain" || version (1B) || format (1B) + // || length (2B LE) || message + // + // and Ed25519-verify against THAT envelope using the returned + // `publicKey`. Verifying against the bare bytes will always fail. + // + // Use this method when you specifically need the off-chain envelope + // (some authenticators / dApps require it). For Wallet Standard + // bare-message signing, keep using `solana_signMessage`. + case 'solana_signOffchainMessage': { + await requireMessageSigningFirmware('Solana off-chain message signing'); + const arg = (params || [])[0]; + let messageBytes: number[]; + let version = 0; + // Default to UTF-8 (format 1). Format 0 (restricted ASCII) is + // stricter and most modern callers use UTF-8. + let messageFormat = 1; + + if (Array.isArray(arg)) { + messageBytes = arg as number[]; + } else if (typeof arg === 'string') { + messageBytes = Array.from(new TextEncoder().encode(arg)); + } else if (arg && typeof arg === 'object') { + const msg = (arg as any).message; + if (Array.isArray(msg)) { + messageBytes = msg as number[]; + } else if (typeof msg === 'string') { + messageBytes = Array.from(new TextEncoder().encode(msg)); + } else { + throw createProviderRpcError(4000, 'solana_signOffchainMessage: missing message'); + } + if (typeof (arg as any).version === 'number') version = (arg as any).version; + if (typeof (arg as any).messageFormat === 'number') { + messageFormat = (arg as any).messageFormat; + } else if (typeof (arg as any).message_format === 'number') { + messageFormat = (arg as any).message_format; + } + } else { + throw createProviderRpcError(4000, 'solana_signOffchainMessage: invalid params'); + } + + if (messageBytes.length === 0) { + throw createProviderRpcError(4000, 'solana_signOffchainMessage: empty message'); + } + if (messageBytes.length > 1212) { + // Firmware rejects > 1212 bytes for both format 0 and 1. + throw createProviderRpcError( + 4000, + `solana_signOffchainMessage: message too long (${messageBytes.length} bytes; max 1212 for off-chain spec)`, + ); + } + + const event = buildEvent(requestInfo, method, params); + // Decorate the event with the envelope-signing hint so the + // approval card can warn the user about the verification model. + (event as any).unsignedTx = { + kind: 'sign-offchain-message', + version, + messageFormat, + message: bytesToHex(messageBytes), + // Used by the approval UI to render the message in plain text + // when format=1 (UTF-8). Falls back to hex on decode failure. + messageUtf8: tryDecodeUtf8(messageBytes), + verifyAgainst: 'envelope', // hint for any downstream UI + }; + await requestUserApproval(event, requestInfo, method, params, requireApproval); + + const { publicKey, signature } = await signOffchainMessageViaRest( + bytesToHex(messageBytes), + version, + messageFormat, + ); + + try { + const stored = await requestStorage.getEventById(requestInfo.id); + if (stored) { + stored.signature = signature; + stored.publicKey = publicKey; + stored.status = 'completed'; + await requestStorage.updateEventById(requestInfo.id, stored); + } + } catch (e) { + console.warn(tag, 'Failed to persist off-chain signature on event:', e); + } + chrome.runtime.sendMessage({ action: 'signature_complete', eventId: requestInfo.id }).catch(() => {}); + + // Return both — caller needs `publicKey` to identify which device + // identity signed (Solana off-chain spec ties signer identity to + // the envelope) and `signature` to verify. Both hex. + return { publicKey, signature }; + } + default: throw createProviderRpcError(4200, `Unsupported Solana method: ${method}`); } }; + +/** Best-effort UTF-8 decode for the approval card; returns null on invalid bytes. */ +function tryDecodeUtf8(bytes: number[]): string | null { + try { + return new TextDecoder('utf-8', { fatal: true }).decode(new Uint8Array(bytes)); + } catch { + return null; + } +} diff --git a/chrome-extension/src/background/chains/thorchainHandler.ts b/chrome-extension/src/background/chains/thorchainHandler.ts index c676f20..fed1053 100644 --- a/chrome-extension/src/background/chains/thorchainHandler.ts +++ b/chrome-extension/src/background/chains/thorchainHandler.ts @@ -3,6 +3,7 @@ import { v4 as uuidv4 } from 'uuid'; import { Chain, ChainToNetworkId, shortListSymbolToCaip, caipToNetworkId } from '../chainConfig'; import * as wallet from '../wallet'; import { createProviderRpcError } from '../utils'; +import { fetchJsonWithTimeout } from '../fetchUtils'; const TAG = ' | thorchainHandler | '; @@ -41,19 +42,24 @@ export const handleThorchainRequest = async ( isMax: params[0].isMax, }; - // Build tx via Pioneer API + // Build tx via Pioneer API. fetchJsonWithTimeout enforces an + // AbortSignal + response.ok check + retry on 5xx — without that, + // a transient Pioneer hiccup hangs the dApp. let unsignedTx: any; try { - const buildResponse = await fetch('https://api.keepkey.info/api/v1/buildTx', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ ...sendPayload, pubkeys }), - }); - unsignedTx = await buildResponse.json(); + unsignedTx = await fetchJsonWithTimeout( + 'https://api.keepkey.info/api/v1/buildTx', + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ ...sendPayload, pubkeys }), + }, + { timeoutMs: 15000, retries: 1 }, + ); console.log(tag, 'unsignedTx: ', unsignedTx); } catch (e) { console.error(tag, 'buildTx failed:', e); - throw createProviderRpcError(4000, 'Failed to build transaction'); + throw createProviderRpcError(4000, `Failed to build transaction: ${(e as Error)?.message || e}`); } const event = { @@ -91,13 +97,16 @@ export const handleThorchainRequest = async ( response.signedTx = signedTx; await requestStorage.updateEventById(requestInfo.id, response); - // Broadcast via Pioneer API - const broadcastResponse = await fetch('https://api.keepkey.info/api/v1/broadcastTx', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ caip, signedTx: signedTx.serializedTx || signedTx }), - }); - let txHash = await broadcastResponse.json(); + // Broadcast via Pioneer API. + let txHash: any = await fetchJsonWithTimeout( + 'https://api.keepkey.info/api/v1/broadcastTx', + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ caip, signedTx: signedTx.serializedTx || signedTx }), + }, + { timeoutMs: 15000, retries: 1 }, + ); if (txHash.txHash) txHash = txHash.txHash; if (txHash.txid) txHash = txHash.txid; @@ -105,6 +114,7 @@ export const handleThorchainRequest = async ( await requestStorage.updateEventById(requestInfo.id, response); chrome.runtime.sendMessage({ action: 'transaction_complete', + eventId: requestInfo.id, txHash, explorerTxLink: 'https://runescan.io/tx/', }); diff --git a/chrome-extension/src/background/chains/tonHandler.ts b/chrome-extension/src/background/chains/tonHandler.ts new file mode 100644 index 0000000..0271d2e --- /dev/null +++ b/chrome-extension/src/background/chains/tonHandler.ts @@ -0,0 +1,607 @@ +/** + * TON (Toncoin) signing handler. + * + * The vault owns the hard parts — v4R2 BOC construction, seqno + + * wallet-state discovery, signed-BOC assembly, TonCenter broadcast, + * and a body-hash tamper check — behind three REST endpoints: + * + * POST /ton/build-transfer → { build, bodyHash, feeEstimate, ... } + * POST /ton/sign-transaction → { signature } (device signs bodyHash) + * POST /ton/finalize-transfer → { boc, txid, broadcasted } + * + * This handler just stitches those together and plugs into the + * existing requireApproval / requestStorage flow so the sidebar's + * approval overlay works identically to every other chain. + */ +import { requestStorage } from '@extension/storage'; +import { v4 as uuidv4 } from 'uuid'; +import * as wallet from '../wallet'; +import { createProviderRpcError, createTimeoutError } from '../utils'; +import { requireMessageSigningFirmware } from '../firmware'; + +const TAG = ' | tonHandler | '; +const VAULT_URL = 'http://localhost:1646'; + +// BIP-44 for TON is THREE levels, not five. SLIP-44 607. +// Path: m/44'/607'/0' — verified against the vault SDK test +// (projects/keepkey-sdk/tests/ton/get-address.js) which derives +// UQDzK5bpDKByFoIQuwKR33N3eEdmHNv9XQnSKFIdvklO51Nr from the hardware. +// Sending five levels makes firmware reject with +// {"error":"Failed to derive private key","code":9} because the TON +// derivation path table only accepts the 3-level shape. +const TON_ADDRESS_N = [ + 0x80000000 + 44, // 44' + 0x80000000 + 607, // 607' + 0x80000000 + 0, // 0' +]; + +const TON_NETWORK_ID = 'ton:-239'; +const TON_PUBKEY_NOTE = 'TON account 0'; + +let cachedAddress: string | null = null; + +export function resetTonState() { + cachedAddress = null; +} + +/** Bearer API key from the vault SDK — matches the Solana handler's auth path. */ +function getApiKey(): string { + const sdk = wallet.getSdk(); + const key = sdk.getClient?.()?.getApiKey?.(); + if (!key) { + throw createProviderRpcError(-32603, 'API key not available — vault may not be connected'); + } + return key; +} + +/** TON addresses are either 48-char UQ/EQ base64url or a 0:hex/−1:hex + * raw form. If we get anything ETH-shaped (0x + 40 hex) that's a bug + * upstream and we must not persist it — otherwise the sidebar ends up + * displaying an ETH address under the TON row and Pioneer's accountInfo + * endpoint 422s on every balance call. */ +function isPlausibleTonAddress(addr: string): boolean { + if (!addr || typeof addr !== 'string') return false; + if (/^0x[0-9a-fA-F]{40}$/.test(addr)) return false; // ETH — reject + if (/^(UQ|EQ|kQ|0Q)[A-Za-z0-9_-]{46}$/.test(addr)) return true; // user/bounce/testnet + if (/^(0|-1):[0-9a-fA-F]{64}$/.test(addr)) return true; // raw + return false; +} + +/** Fetch the device's TON address; cache on the shared pubkey store so + * watch-only sessions work without replugging the device. */ +export async function getTonAddress(): Promise { + if (cachedAddress) return cachedAddress; + + const walletAddress = wallet.getAddressForNetwork(TON_NETWORK_ID); + if (walletAddress) { + // A previous bad cache could hand us an ETH-shaped string — refuse + // to reuse it and fall through to a fresh device derivation. + if (isPlausibleTonAddress(walletAddress)) { + cachedAddress = walletAddress; + return walletAddress; + } + console.warn(TAG, 'Discarding implausible cached TON address:', walletAddress); + } + + if (!wallet.isDeviceConnected()) { + const reachable = await wallet.probeDevice(); + if (!reachable) { + throw createProviderRpcError( + -32603, + 'KeepKey device not connected and no cached TON address. Plug in your device to derive one.', + ); + } + } + + const apiKey = getApiKey(); + let resp: Response; + try { + resp = await fetch(`${VAULT_URL}/addresses/ton`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${apiKey}`, + }, + body: JSON.stringify({ address_n: TON_ADDRESS_N, show_display: false }), + signal: AbortSignal.timeout(30000), + }); + } catch (e: any) { + throw createProviderRpcError(-32603, `Vault TON address fetch failed: ${e.message}`); + } + + if (!resp.ok) { + const text = await resp.text().catch(() => ''); + throw createProviderRpcError(-32603, `Vault TON address fetch failed (${resp.status}): ${text}`); + } + + const result = (await resp.json()) as { address?: string }; + const address = result.address; + console.log(TAG, '/addresses/ton returned:', address); + if (!address || typeof address !== 'string') { + throw createProviderRpcError(-32603, 'Vault returned invalid TON address'); + } + if (!isPlausibleTonAddress(address)) { + // Hard stop — don't pollute the pubkey cache with a non-TON-shaped + // string, which is what makes the sidebar display e.g. the ETH + // address under the TON row and causes Pioneer's /ton/accountInfo + // to 422 every poll. + throw createProviderRpcError( + -32603, + `Vault returned implausible TON address (got "${address}") — check the vault's /addresses/ton handler and the address_n [44', 607', 0', 0, 0]`, + ); + } + cachedAddress = address; + + try { + await wallet.addPubkey({ + note: TON_PUBKEY_NOTE, + networks: [TON_NETWORK_ID], + type: 'address', + address, + pubkey: address, + addressNList: TON_ADDRESS_N, + addressNListMaster: TON_ADDRESS_N, + curve: 'ed25519', + script_type: 'ton', + accountIndex: 0, + }); + } catch (e) { + console.warn(TAG, 'Failed to cache TON address:', e); + } + + return address; +} + +export async function prefetchTonAddress(): Promise { + try { + await getTonAddress(); + } catch (e: any) { + console.log(TAG, 'TON prefetch skipped:', e?.message || e); + } +} + +/** Convert a decimal TON amount string (e.g. "0.01") to nanoTON integer + * string. TON has 9 decimals. Takes care of fractional parts without + * relying on floating-point. */ +function tonToNano(amountTon: string): string { + const trimmed = (amountTon || '0').trim(); + const parts = trimmed.split('.'); + const whole = parts[0] || '0'; + const frac = (parts[1] || '').slice(0, 9).padEnd(9, '0'); + if (!/^\d+$/.test(whole) || !/^\d+$/.test(frac)) { + throw createProviderRpcError(4000, `Invalid TON amount: ${amountTon}`); + } + return String(BigInt(whole) * 1_000_000_000n + BigInt(frac)); +} + +interface TonBuildResponse { + build: any; + bodyHash: string; + rawTx: string; + seqno: number; + expireAt: number; + needsDeploy: boolean; + feeEstimate: string; +} + +async function buildTransferViaRest(params: { + fromAddress: string; + toAddress: string; + amountNano: string; + memo?: string; +}): Promise { + const apiKey = getApiKey(); + let resp: Response; + try { + resp = await fetch(`${VAULT_URL}/ton/build-transfer`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${apiKey}`, + }, + body: JSON.stringify(params), + signal: AbortSignal.timeout(30000), + }); + } catch (e: any) { + if (e.name === 'TimeoutError' || e.name === 'AbortError') { + throw createTimeoutError('Vault TON build timed out'); + } + throw createProviderRpcError(-32603, `Vault connection failed: ${e.message}`); + } + if (!resp.ok) { + const text = await resp.text().catch(() => ''); + throw createProviderRpcError(-32603, `Vault TON build failed (${resp.status}): ${text}`); + } + return (await resp.json()) as TonBuildResponse; +} + +async function signTransactionViaRest(bodyHashHex: string, toAddress: string, amountNano: string): Promise { + const apiKey = getApiKey(); + let resp: Response; + try { + resp = await fetch(`${VAULT_URL}/ton/sign-transaction`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${apiKey}`, + }, + body: JSON.stringify({ + raw_tx: bodyHashHex, + address_n: TON_ADDRESS_N, + to_address: toAddress, + amount: amountNano, + }), + signal: AbortSignal.timeout(120_000), // device confirmation can take a while + }); + } catch (e: any) { + if (e.name === 'TimeoutError' || e.name === 'AbortError') { + throw createTimeoutError('Vault TON signing timed out'); + } + throw createProviderRpcError(-32603, `Vault connection failed: ${e.message}`); + } + if (!resp.ok) { + const text = await resp.text().catch(() => ''); + throw createProviderRpcError(-32603, `Vault TON sign failed (${resp.status}): ${text}`); + } + const result = (await resp.json()) as { signature?: unknown }; + console.log(TAG, 'sign-transaction response shape:', typeof result.signature, result); + // The vault's /ton/sign-transaction handler does `return json(result)` + // without hex-encoding, unlike /tron/sign-transaction. Depending on + // hdwallet's internal shape for tonSignTx, `signature` can arrive as: + // - hex string (ideal) → use as-is + // - node-shaped Buffer `{type:"Buffer",data:[…]}` → decode data array + // - number[] (JSON-serialized Uint8Array) → hex-encode the bytes + // Normalize all three to a 64-byte hex string before handing to + // /ton/finalize-transfer, which strictly requires a hex string. + const sig = normalizeToHex(result.signature); + if (!sig) { + throw createProviderRpcError(-32603, `Vault returned unrecognized TON signature shape: ${JSON.stringify(result)}`); + } + if (sig.length !== 128) { + throw createProviderRpcError( + -32603, + `Vault TON signature has wrong length (got ${sig.length} hex chars, expected 128 for 64 bytes)`, + ); + } + return sig; +} + +/** Coerce any of the plausible signature encodings into lowercase hex. */ +function normalizeToHex(value: unknown): string | null { + if (typeof value === 'string') { + // Strip 0x prefix if someone added it; finalize expects raw hex. + return value.replace(/^0x/i, '').toLowerCase(); + } + if (Array.isArray(value)) { + return bytesToHex(value as number[]); + } + if (value && typeof value === 'object') { + const asBuffer = value as { type?: string; data?: unknown }; + if (asBuffer.type === 'Buffer' && Array.isArray(asBuffer.data)) { + return bytesToHex(asBuffer.data as number[]); + } + // Last-ditch: a Uint8Array-like object where numeric indices carry the bytes. + const keys = Object.keys(value as object); + const numericOnly = keys.length > 0 && keys.every(k => /^\d+$/.test(k)); + if (numericOnly) { + const bytes = keys.map(k => (value as any)[k]).filter(v => typeof v === 'number'); + if (bytes.length > 0) return bytesToHex(bytes); + } + } + return null; +} + +function bytesToHex(bytes: number[]): string { + return bytes.map(b => (b & 0xff).toString(16).padStart(2, '0')).join(''); +} + +/** + * POST /ton/sign-message — bare Ed25519 signature over arbitrary bytes. + * + * Firmware fences this behind the AdvancedMode policy: with AdvancedMode + * disabled (the default), the device returns a Failure that surfaces here + * as a vault HTTP error containing some variant of "AdvancedMode" in the + * body. Detect that and rewrite the message into something a dApp / + * end-user can act on, instead of "vault returned 400". + */ +async function tonSignMessageViaRest( + message: string, + isText: boolean, +): Promise<{ publicKey: string; signature: string }> { + const apiKey = getApiKey(); + let resp: Response; + try { + resp = await fetch(`${VAULT_URL}/ton/sign-message`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${apiKey}` }, + body: JSON.stringify({ + address_n: TON_ADDRESS_N, + message, + is_text: isText, + show_display: true, + }), + signal: AbortSignal.timeout(120_000), + }); + } catch (e: any) { + if (e.name === 'TimeoutError' || e.name === 'AbortError') { + throw createTimeoutError('Vault TON sign-message timed out'); + } + throw createProviderRpcError(-32603, `Vault connection failed: ${e.message}`); + } + if (!resp.ok) { + const text = await resp.text().catch(() => ''); + if (/advanced\s*mode/i.test(text)) { + // Translate the firmware-policy failure into a clear, actionable + // message — there's no automatic recovery, the user must enable + // AdvancedMode on the device first. + throw createProviderRpcError( + 4200, + 'TON message signing requires Advanced Mode on your KeepKey. Open the KeepKey Vault desktop app → Settings → Security and enable "Advanced Mode", then retry.', + ); + } + throw createProviderRpcError(-32603, `Vault TON sign-message failed (${resp.status}): ${text}`); + } + const result = await resp.json(); + if (!result?.signature || !result?.publicKey) { + throw createProviderRpcError(-32603, 'Vault returned no TON message signature'); + } + return { publicKey: result.publicKey, signature: result.signature }; +} + +async function finalizeTransferViaRest(build: any, signature: string): Promise<{ txid: string; boc: string }> { + const apiKey = getApiKey(); + let resp: Response; + try { + resp = await fetch(`${VAULT_URL}/ton/finalize-transfer`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${apiKey}`, + }, + body: JSON.stringify({ build, signature, broadcast: true }), + signal: AbortSignal.timeout(30000), + }); + } catch (e: any) { + if (e.name === 'TimeoutError' || e.name === 'AbortError') { + throw createTimeoutError('Vault TON finalize timed out'); + } + throw createProviderRpcError(-32603, `Vault connection failed: ${e.message}`); + } + if (!resp.ok) { + const text = await resp.text().catch(() => ''); + throw createProviderRpcError(-32603, `Vault TON finalize failed (${resp.status}): ${text}`); + } + const result = (await resp.json()) as { txid?: string; boc?: string; broadcasted?: boolean }; + if (!result.txid) { + throw createProviderRpcError(-32603, 'Vault returned no TON txid'); + } + return { txid: result.txid, boc: result.boc || '' }; +} + +export const handleTonRequest = async ( + method: string, + params: any[], + requestInfo: any, + _ADDRESS: string, + _KEEPKEY_WALLET: any, + requireApproval: (networkId: string, requestInfo: any, chain: any, method: string, params: any) => Promise, +): Promise => { + const tag = TAG + ' | handleTonRequest | '; + + switch (method) { + case 'request_accounts': { + const address = await getTonAddress(); + return [address]; + } + + case 'request_balance': { + // Balance lives in the sidebar's cached balances (Pioneer /api/v1/ton/accountInfo). + return [null]; + } + + case 'transfer': { + const p = params[0] || {}; + const to = p.to || p.recipient; + const rawAmount = typeof p.amount === 'object' ? p.amount?.amount : p.amount; + const memo = typeof p.memo === 'string' ? p.memo : undefined; + + if (!to) throw createProviderRpcError(4000, 'Missing TON recipient'); + if (!rawAmount) throw createProviderRpcError(4000, 'Missing TON amount'); + + const amountNano = tonToNano(String(rawAmount)); + const fromAddress = await getTonAddress(); + + // Build first so we have the unsigned body ready to display before + // we pop the approval UI. Without the build step the approval card + // would render with blank fee/bodyHash/seqno fields. + let built: TonBuildResponse; + try { + built = await buildTransferViaRest({ fromAddress, toAddress: to, amountNano, memo }); + } catch (e: any) { + console.error(tag, 'build failed:', e); + throw e; + } + + requestInfo.id = uuidv4(); + chrome.runtime.sendMessage({ action: 'TRANSACTION_CONTEXT_UPDATED', id: requestInfo.id }).catch(() => {}); + + const event = { + id: requestInfo.id, + networkId: TON_NETWORK_ID, + href: requestInfo.href, + language: requestInfo.language, + platform: requestInfo.platform, + referrer: requestInfo.referrer, + requestTime: requestInfo.requestTime, + scriptSource: requestInfo.scriptSource, + siteUrl: requestInfo.siteUrl, + userAgent: requestInfo.userAgent, + injectScriptVersion: requestInfo.version, + chain: 'ton', + requestInfo, + // Shape roughly matches other-chain events so the generic + // OtherTransaction / Tendermint cards can render the key bits. + unsignedTx: { + to, + amount: rawAmount, + amountNano, + memo, + fromAddress, + bodyHash: built.bodyHash, + seqno: built.seqno, + expireAt: built.expireAt, + needsDeploy: built.needsDeploy, + feeEstimate: built.feeEstimate, + }, + type: 'transfer', + request: params, + status: 'request', + timestamp: new Date().toISOString(), + }; + // @ts-expect-error requestStorage event shape is open + const eventSaved = await requestStorage.addEvent(event); + if (!eventSaved) throw Error('Failed to create TON event'); + + const result = await requireApproval(TON_NETWORK_ID, requestInfo, 'ton', method, params[0]); + if (!result?.success) { + throw createProviderRpcError(4001, 'User denied TON transaction'); + } + + let signature: string; + try { + signature = await signTransactionViaRest(built.bodyHash, to, amountNano); + } catch (e: any) { + chrome.runtime + .sendMessage({ + action: 'transaction_error', + eventId: requestInfo.id, + error: e?.message || 'TON signing failed', + }) + .catch(() => {}); + throw e; + } + + let txid: string; + try { + const finalized = await finalizeTransferViaRest(built.build, signature); + txid = finalized.txid; + } catch (e: any) { + chrome.runtime + .sendMessage({ + action: 'transaction_error', + eventId: requestInfo.id, + error: e?.message || 'TON broadcast failed', + }) + .catch(() => {}); + throw e; + } + + chrome.runtime + .sendMessage({ + action: 'transaction_complete', + eventId: requestInfo.id, + txHash: txid, + // tonviewer uses the external-message hash (what finalize returns) for lookup. + explorerTxLink: 'https://tonviewer.com/transaction/', + networkId: TON_NETWORK_ID, + }) + .catch(() => {}); + + return txid; + } + + // Bare Ed25519 SignMessage. AdvancedMode-gated firmware-side; we + // map that policy failure to a user-actionable error inside + // tonSignMessageViaRest. dApps pass either a UTF-8 string (default) + // or hex bytes via { message, isText: false }. + case 'ton_signMessage': + case 'signMessage': { + await requireMessageSigningFirmware('TON message signing'); + const arg = (params || [])[0]; + let messageForVault: string; + let isText: boolean; + let displayMessage: unknown; + if (typeof arg === 'string') { + messageForVault = arg; + isText = true; + displayMessage = arg; + } else if (Array.isArray(arg)) { + messageForVault = (arg as number[]).map(b => (b & 0xff).toString(16).padStart(2, '0')).join(''); + isText = false; + displayMessage = messageForVault; + } else if (arg && typeof arg === 'object') { + const msg = (arg as any).message; + const explicitIsText = (arg as any).isText ?? (arg as any).is_text; + if (typeof msg === 'string') { + messageForVault = msg; + isText = explicitIsText !== false; // default true + displayMessage = msg; + } else if (Array.isArray(msg)) { + messageForVault = (msg as number[]).map(b => (b & 0xff).toString(16).padStart(2, '0')).join(''); + isText = false; + displayMessage = messageForVault; + } else { + throw createProviderRpcError(4000, `${method}: missing or unsupported message field`); + } + } else { + throw createProviderRpcError(4000, `${method}: missing message param`); + } + + if (!requestInfo.id) requestInfo.id = uuidv4(); + const fromAddress = await getTonAddress(); + const event = { + id: requestInfo.id, + networkId: TON_NETWORK_ID, + chain: 'ton' as const, + href: requestInfo.href, + language: requestInfo.language, + platform: requestInfo.platform, + referrer: requestInfo.referrer, + requestTime: requestInfo.requestTime, + scriptSource: requestInfo.scriptSource, + siteUrl: requestInfo.siteUrl, + userAgent: requestInfo.userAgent, + injectScriptVersion: requestInfo.version, + requestInfo, + unsignedTx: { + kind: 'sign-message', + from: fromAddress, + message: displayMessage, + isText, + }, + type: method, + request: params, + status: 'request' as const, + timestamp: new Date().toISOString(), + }; + const saved = await requestStorage.addEvent(event); + if (!saved) throw createProviderRpcError(-32603, 'Failed to create approval event'); + chrome.runtime.sendMessage({ action: 'TRANSACTION_CONTEXT_UPDATED', id: event.id }).catch(() => {}); + + const approval = await requireApproval(TON_NETWORK_ID, requestInfo, 'ton', method, params); + if (!approval?.success) { + throw createProviderRpcError(4001, 'User denied TON message signing'); + } + + const { publicKey, signature } = await tonSignMessageViaRest(messageForVault, isText); + + try { + const stored = await requestStorage.getEventById(requestInfo.id); + if (stored) { + stored.signature = signature; + stored.publicKey = publicKey; + stored.status = 'completed'; + await requestStorage.updateEventById(requestInfo.id, stored); + } + } catch (e) { + console.warn(tag, 'Failed to persist TON message signature:', e); + } + chrome.runtime.sendMessage({ action: 'signature_complete', eventId: requestInfo.id }).catch(() => {}); + + return { publicKey, signature }; + } + + default: + throw createProviderRpcError(4200, `TON method not supported: ${method}`); + } +}; diff --git a/chrome-extension/src/background/chains/tronHandler.ts b/chrome-extension/src/background/chains/tronHandler.ts new file mode 100644 index 0000000..bce8f6a --- /dev/null +++ b/chrome-extension/src/background/chains/tronHandler.ts @@ -0,0 +1,1172 @@ +import { requestStorage, assetContextStorage } from '@extension/storage'; +import { v4 as uuidv4 } from 'uuid'; +import * as wallet from '../wallet'; +import { createProviderRpcError, createTimeoutError } from '../utils'; +import { requireMessageSigningFirmware } from '../firmware'; + +const TAG = ' | tronHandler | '; + +const VAULT_URL = 'http://localhost:1646'; + +// TronGrid is the canonical public JSON-RPC for Tron mainnet. The vault's +// own txbuilder (projects/keepkey-vault-v11/.../txbuilder/index.ts) uses +// the same `createtransaction` endpoint, so we stay on-path with the +// firmware-signing flow. +const TRONGRID_URL = 'https://api.trongrid.io'; + +// m/44'/195'/0'/0/0 — standard Tron account 0 +const TRON_ADDRESS_N = [0x80000000 + 44, 0x80000000 + 195, 0x80000000 + 0, 0, 0]; + +const TRON_NETWORK_ID = 'tron:27Lqcw'; +const TRON_CAIP = 'tron:27Lqcw/slip44:195'; +const TRON_PUBKEY_NOTE = 'Tron account 0'; + +// Cached address from device — populated lazily on first call. +let cachedAddress: string | null = null; + +/** Reset cached state (called when the device disconnects or wallet re-inits). */ +export function resetTronState() { + cachedAddress = null; +} + +/** + * Prefetch the Tron address at startup so it shows up in the network + * dropdown without waiting for a user-initiated Tron action. Non-throwing — + * silently skips when no device and no cached address. + */ +export async function prefetchTronPubkey(): Promise { + try { + await getTronAddress(); + } catch (e: any) { + console.log(TAG, 'Tron prefetch skipped:', e?.message || e); + } +} + +async function getTronAddress(): Promise { + if (cachedAddress) return cachedAddress; + + // Persisted pubkey cache — works in watch-only mode. + const walletAddress = wallet.getAddressForNetwork(TRON_NETWORK_ID); + if (walletAddress) { + cachedAddress = walletAddress; + return walletAddress; + } + + if (!wallet.isDeviceConnected()) { + const reachable = await wallet.probeDevice(); + if (!reachable) { + throw createProviderRpcError( + -32603, + 'KeepKey device not connected and no cached Tron address. Plug in your device to derive one.', + ); + } + } + + const sdk = wallet.getSdk(); + const result = await sdk.address.tronGetAddress({ address_n: TRON_ADDRESS_N }); + const address = result?.address || (result as any); + if (!address || typeof address !== 'string') { + throw createProviderRpcError(-32603, 'Vault returned invalid Tron address'); + } + + cachedAddress = address; + + try { + await wallet.addPubkey({ + note: TRON_PUBKEY_NOTE, + networks: [TRON_NETWORK_ID], + type: 'address', + address, + pubkey: address, + addressNList: TRON_ADDRESS_N, + addressNListMaster: TRON_ADDRESS_N, + curve: 'secp256k1', + script_type: 'tron', + accountIndex: 0, + }); + } catch (e) { + console.warn(TAG, 'Failed to cache Tron address:', e); + } + + return address; +} + +/** Build event object for the side-panel approval flow. */ +function buildEvent(requestInfo: any, method: string, params: any[], unsignedTx?: any) { + if (!requestInfo.id) requestInfo.id = uuidv4(); + return { + id: requestInfo.id, + networkId: TRON_NETWORK_ID, + chain: 'tron', + href: requestInfo.href, + language: requestInfo.language, + platform: requestInfo.platform, + referrer: requestInfo.referrer, + requestTime: requestInfo.requestTime, + scriptSource: requestInfo.scriptSource, + siteUrl: requestInfo.siteUrl, + userAgent: requestInfo.userAgent, + injectScriptVersion: requestInfo.version, + requestInfo, + unsignedTx, + type: method, + request: params, + status: 'request', + timestamp: new Date().toISOString(), + }; +} + +/** Bearer key for authenticated vault calls. */ +function getApiKey(): string { + const sdk = wallet.getSdk(); + const key = sdk.getClient?.()?.getApiKey?.(); + if (!key) { + throw createProviderRpcError(-32603, 'API key not available — vault may not be connected'); + } + return key; +} + +/** + * TRON amounts are quoted in sun: 1 TRX = 1,000,000 sun. Float multiplication + * drifts at 6 decimals, so route through integer strings to avoid a dust + * rounding loss on any amount the user actually types. + */ +function trxToSun(trxAmount: string): number { + const [whole, frac = ''] = String(trxAmount).split('.'); + const fracPadded = (frac + '000000').slice(0, 6); + const sunStr = `${whole}${fracPadded}`.replace(/^0+/, '') || '0'; + const sun = Number(sunStr); + if (!Number.isFinite(sun)) { + throw createProviderRpcError(4000, `Invalid TRX amount: ${trxAmount}`); + } + return sun; +} + +/** Ask TronGrid to build the raw protobuf transaction (raw_data_hex). */ +async function buildTronTransfer(from: string, to: string, sunAmount: number): Promise { + let resp: Response; + try { + resp = await fetch(`${TRONGRID_URL}/wallet/createtransaction`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + owner_address: from, + to_address: to, + amount: sunAmount, + visible: true, // Pass base58 addresses (not hex) — simpler for the UI layer. + }), + signal: AbortSignal.timeout(15000), + }); + } catch (e: any) { + throw createProviderRpcError(-32603, `TronGrid createtransaction failed: ${e.message}`); + } + if (!resp.ok) { + const text = await resp.text().catch(() => ''); + throw createProviderRpcError(-32603, `TronGrid build failed (${resp.status}): ${text}`); + } + const tx: any = await resp.json(); + if (tx?.Error) { + // TronGrid returns `{Error: "no OwnerAccount"}` for unactivated accounts — + // surface that as a readable message instead of the raw API string. + if (String(tx.Error).includes('no OwnerAccount')) { + throw createProviderRpcError( + -32603, + `Account ${from} is not activated on Tron. Send TRX to this address first to activate it.`, + ); + } + throw createProviderRpcError(-32603, `TronGrid build error: ${tx.Error}`); + } + if (!tx?.raw_data_hex) { + throw createProviderRpcError(-32603, 'TronGrid did not return raw_data_hex'); + } + return tx; +} + +/** + * Base58 → 21-byte hex for Tron. Prefixed with 0x41; ABI encoding + * for a Solidity `address` drops the 0x41 and left-pads the remaining + * 20 bytes into a 32-byte slot. + * + * Inline minimal decoder to avoid a bs58 dep in the background bundle. + * Matches the injected-side decoder in tron-provider.ts so the two + * sides encode/decode consistently. + */ +const B58_ALPHABET = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz'; +function base58DecodeToHex(addr: string): string { + const bytes: number[] = [0]; + for (const char of addr) { + const idx = B58_ALPHABET.indexOf(char); + if (idx === -1) throw createProviderRpcError(4000, `Invalid base58 character in address: ${addr}`); + let carry = idx; + for (let j = 0; j < bytes.length; j++) { + carry += bytes[j] * 58; + bytes[j] = carry & 0xff; + carry >>= 8; + } + while (carry > 0) { + bytes.push(carry & 0xff); + carry >>= 8; + } + } + for (const char of addr) { + if (char !== '1') break; + bytes.push(0); + } + const full = bytes.reverse(); + // Drop the 4-byte checksum; keep 0x41 + 20-byte hash + const payload = full.slice(0, 21); + if (payload.length !== 21 || payload[0] !== 0x41) { + throw createProviderRpcError(4000, `Invalid Tron address: ${addr}`); + } + return payload.map(b => b.toString(16).padStart(2, '0')).join(''); +} + +/** + * Convert a human-readable token amount ("1.5") to integer base units + * using the token's decimals. Float math drifts at high decimals, so + * we route through a padded-string conversion, matching trxToSun's + * approach for the native 6-decimal case. + */ +function toBaseUnits(amount: string, decimals: number): bigint { + const [whole, frac = ''] = String(amount).split('.'); + const fracPadded = (frac + '0'.repeat(decimals)).slice(0, decimals); + const joined = `${whole}${fracPadded}`.replace(/^0+/, '') || '0'; + try { + return BigInt(joined); + } catch { + throw createProviderRpcError(4000, `Invalid token amount: ${amount}`); + } +} + +/** + * ABI-encode the parameters for TRC-20 `transfer(address,uint256)`. + * Returns 128 hex chars: 64 for the address (20 bytes left-padded to + * 32), 64 for the amount (uint256 big-endian). + */ +function encodeTrc20TransferParam(recipientBase58: string, amount: bigint): string { + const hex21 = base58DecodeToHex(recipientBase58); // "41xxxx...20bytes" + const hex20 = hex21.slice(2); // strip the 0x41 prefix for ABI encoding + const addrPadded = hex20.padStart(64, '0'); + const amountPadded = amount.toString(16).padStart(64, '0'); + return addrPadded + amountPadded; +} + +/** + * Extract the TRC-20 contract address from an asset caip. + * Accepts both `tron:27Lqcw/token:T...` (our canonical) and + * `tron:0x2b6653dc/token:T...` (Pioneer's alternate) forms. + * Returns null for native-TRX caips. + */ +function parseTrc20Caip(caip: string): string | null { + const m = String(caip || '').match(/^tron:[^/]+\/(?:token|trc20):(T[1-9A-HJ-NP-Za-km-z]{33})$/); + return m ? m[1] : null; +} + +/** + * Ask TronGrid to build the unsigned TriggerSmartContract tx for a + * TRC-20 `transfer(address,uint256)`. Same response shape as + * /createtransaction so signing + broadcast reuse the native path. + */ +async function buildTrc20Transfer( + from: string, + contractAddress: string, + recipient: string, + amountBaseUnits: bigint, +): Promise { + let resp: Response; + try { + resp = await fetch(`${TRONGRID_URL}/wallet/triggersmartcontract`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + owner_address: from, + contract_address: contractAddress, + function_selector: 'transfer(address,uint256)', + parameter: encodeTrc20TransferParam(recipient, amountBaseUnits), + // 100 TRX fee limit — matches the ballpark TronLink uses for + // USDT transfers. Too low and the tx revert-burns energy + // without moving tokens. + fee_limit: 100_000_000, + call_value: 0, + visible: true, + }), + signal: AbortSignal.timeout(15000), + }); + } catch (e: any) { + throw createProviderRpcError(-32603, `TronGrid triggersmartcontract failed: ${e.message}`); + } + if (!resp.ok) { + const text = await resp.text().catch(() => ''); + throw createProviderRpcError(-32603, `TronGrid TRC-20 build failed (${resp.status}): ${text}`); + } + const body: any = await resp.json(); + if (body?.result?.result !== true) { + const msg = body?.result?.message + ? Buffer.from(String(body.result.message), 'hex').toString('utf8').trim() + : JSON.stringify(body?.result || body); + throw createProviderRpcError(-32603, `TronGrid TRC-20 build rejected: ${msg}`); + } + const tx = body?.transaction; + if (!tx?.raw_data_hex) { + throw createProviderRpcError(-32603, 'TronGrid did not return raw_data_hex for TRC-20 build'); + } + return tx; +} + +/** + * Sign the raw tx via the vault. Returns the 65-byte signature as a hex string. + * The vault's handler emulates emuWrap for the device, so this call may block + * until the user confirms on the KeepKey. + */ +async function signTronViaRest( + rawDataHex: string, + toAddress: string, + amountRaw: string | number | bigint, +): Promise { + const apiKey = getApiKey(); + let resp: Response; + try { + resp = await fetch(`${VAULT_URL}/tron/sign-transaction`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${apiKey}`, + }, + body: JSON.stringify({ + addressNList: TRON_ADDRESS_N, + raw_tx: rawDataHex, + to_address: toAddress, + // Stringify without going through Number — 18-decimal TRC-20 + // amounts exceed Number.MAX_SAFE_INTEGER in base units and + // would silently round before hitting the vault. Handle bigint + // explicitly since String(bigint) works but the intent is + // clearer and future-proofed. + amount: typeof amountRaw === 'bigint' ? amountRaw.toString() : String(amountRaw), + }), + signal: AbortSignal.timeout(60000), + }); + } catch (e: any) { + if (e.name === 'TimeoutError' || e.name === 'AbortError') { + throw createTimeoutError('Vault Tron signing timed out'); + } + throw createProviderRpcError(-32603, `Vault connection failed: ${e.message}`); + } + + if (!resp.ok) { + const text = await resp.text().catch(() => ''); + throw createProviderRpcError(-32603, `Vault Tron sign failed (${resp.status}): ${text}`); + } + + const result = await resp.json(); + const signature = result?.signature; + if (!signature || typeof signature !== 'string') { + throw createProviderRpcError(-32603, 'Vault returned no Tron signature'); + } + return signature; +} + +/** Strip 0x and validate even-length hex; throws on garbage input. */ +function ensureHex(input: string, label: string, expectedBytes?: number): string { + const stripped = String(input).replace(/^0x/i, ''); + if (!/^[0-9a-fA-F]*$/.test(stripped)) { + throw createProviderRpcError(4000, `${label}: invalid hex`); + } + if (stripped.length % 2 !== 0) { + throw createProviderRpcError(4000, `${label}: odd-length hex`); + } + if (expectedBytes !== undefined && stripped.length !== expectedBytes * 2) { + throw createProviderRpcError(4000, `${label}: expected ${expectedBytes} bytes, got ${stripped.length / 2}`); + } + return stripped.toLowerCase(); +} + +/** + * POST /tron/sign-message — TIP-191 personal_sign. + * + * `isText: true` (default) sends `message` as UTF-8; `isText: false` + * sends it as raw hex bytes. The vault returns the 65-byte recoverable + * signature as a hex string + the recovered base58 address (signer). + */ +async function tronSignMessageViaRest( + message: string, + isText: boolean, +): Promise<{ + address: string; + signature: string; +}> { + const apiKey = getApiKey(); + let resp: Response; + try { + resp = await fetch(`${VAULT_URL}/tron/sign-message`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${apiKey}` }, + body: JSON.stringify({ + addressNList: TRON_ADDRESS_N, + message, + is_text: isText, + show_display: true, + }), + signal: AbortSignal.timeout(120_000), + }); + } catch (e: any) { + if (e.name === 'TimeoutError' || e.name === 'AbortError') { + throw createTimeoutError('Vault Tron sign-message timed out'); + } + throw createProviderRpcError(-32603, `Vault connection failed: ${e.message}`); + } + if (!resp.ok) { + const text = await resp.text().catch(() => ''); + throw createProviderRpcError(-32603, `Vault Tron sign-message failed (${resp.status}): ${text}`); + } + const result = await resp.json(); + if (!result?.address || !result?.signature) { + throw createProviderRpcError(-32603, 'Vault returned no Tron message signature'); + } + return { address: result.address, signature: result.signature }; +} + +/** POST /tron/verify-message — TIP-191 verify. Returns `{ verified }`. */ +async function tronVerifyMessageViaRest( + address: string, + signatureHex: string, + message: string, + isText: boolean, +): Promise { + const apiKey = getApiKey(); + // Normalise the signature to bare hex up front so a malformed input + // surfaces here rather than as a generic vault 400. + const sig = ensureHex(signatureHex, 'tron_verifyMessage.signature', 65); + let resp: Response; + try { + resp = await fetch(`${VAULT_URL}/tron/verify-message`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${apiKey}` }, + body: JSON.stringify({ address, signature: sig, message, is_text: isText }), + signal: AbortSignal.timeout(30_000), + }); + } catch (e: any) { + throw createProviderRpcError(-32603, `Vault connection failed: ${e.message}`); + } + if (!resp.ok) { + const text = await resp.text().catch(() => ''); + throw createProviderRpcError(-32603, `Vault Tron verify failed (${resp.status}): ${text}`); + } + const result = await resp.json(); + return !!result?.verified; +} + +/** + * POST /tron/sign-typed-hash — TIP-712 typed-data signing (hash mode). + * Caller pre-computes the 32-byte domainSeparator and message hashes per + * the TIP-712 spec; device assembles `keccak256("\x19\x01" || ds || msg)` + * and signs. + */ +async function tronSignTypedHashViaRest( + domainSeparatorHash: string, + messageHash: string | undefined, +): Promise<{ address: string; signature: string }> { + const apiKey = getApiKey(); + const ds = ensureHex(domainSeparatorHash, 'tron_signTypedHash.domain_separator_hash', 32); + const mh = + messageHash !== undefined && messageHash !== null && messageHash !== '' + ? ensureHex(messageHash, 'tron_signTypedHash.message_hash', 32) + : undefined; + let resp: Response; + try { + resp = await fetch(`${VAULT_URL}/tron/sign-typed-hash`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${apiKey}` }, + body: JSON.stringify({ + addressNList: TRON_ADDRESS_N, + domain_separator_hash: ds, + ...(mh ? { message_hash: mh } : {}), + }), + signal: AbortSignal.timeout(120_000), + }); + } catch (e: any) { + if (e.name === 'TimeoutError' || e.name === 'AbortError') { + throw createTimeoutError('Vault Tron sign-typed-hash timed out'); + } + throw createProviderRpcError(-32603, `Vault connection failed: ${e.message}`); + } + if (!resp.ok) { + const text = await resp.text().catch(() => ''); + throw createProviderRpcError(-32603, `Vault Tron sign-typed-hash failed (${resp.status}): ${text}`); + } + const result = await resp.json(); + if (!result?.address || !result?.signature) { + throw createProviderRpcError(-32603, 'Vault returned no Tron typed-hash signature'); + } + return { address: result.address, signature: result.signature }; +} + +/** + * Convert a Tron hex address (e.g. `41xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx`) + * to its base58 form (T...). Uses SHA-256 double-hash for the checksum, + * via WebCrypto (available in service workers). + * + * Only used from the tron_sign path when the tx was built without + * `visible: true` and comes through with hex addresses — we normalise to + * base58 for the approval UI and vault signing hint. + */ +async function hexAddressToBase58(hexAddr: string): Promise { + const hex = hexAddr.toLowerCase().replace(/^0x/, ''); + if (hex.length !== 42 || !hex.startsWith('41')) { + throw new Error(`Invalid Tron hex address: ${hexAddr}`); + } + const payload = Uint8Array.from(hex.match(/.{2}/g)!.map(b => parseInt(b, 16))); + const h1 = new Uint8Array(await crypto.subtle.digest('SHA-256', payload)); + const h2 = new Uint8Array(await crypto.subtle.digest('SHA-256', h1)); + const checksum = h2.slice(0, 4); + const full = new Uint8Array(payload.length + 4); + full.set(payload, 0); + full.set(checksum, payload.length); + + const ALPHA = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz'; + const digits: number[] = [0]; + for (const byte of full) { + let carry = byte; + for (let j = 0; j < digits.length; j++) { + carry += digits[j] << 8; + digits[j] = carry % 58; + carry = (carry / 58) | 0; + } + while (carry > 0) { + digits.push(carry % 58); + carry = (carry / 58) | 0; + } + } + // Leading zero bytes → leading '1's. + let out = ''; + for (const b of full) { + if (b !== 0) break; + out += '1'; + } + for (let i = digits.length - 1; i >= 0; i--) out += ALPHA[digits[i]]; + return out; +} + +interface DecodedTronTx { + kind: 'trx-transfer' | 'trc20-transfer' | 'contract-call'; + ownerAddress: string; // base58 + toAddress: string; // base58 — recipient for transfers, contract address for generic calls + // Raw base-units as a DECIMAL STRING so 18-decimal TRC-20 amounts + // survive the trip to the vault. Going through Number would truncate + // at ~2^53 base units (9e15 — fine for 6-decimal TRX/USDT, broken + // for 18-decimal tokens). + amountRaw: string; + displayAmount: string; // human-readable + contractAddress?: string; // base58, non-native + functionSelector?: string; // hex, contract-call only +} + +/** + * Decode the first contract from a tronweb-built transaction into the + * fields we need for approval UX and the vault signing hint. + * + * Supports: + * - TransferContract (native TRX) + * - TriggerSmartContract with `transfer(address,uint256)` (TRC20) + * + * Throws on any other contract type so dApps fail fast with a clear + * message instead of the device rejecting an un-parseable tx. + */ +async function decodeTronTx(tx: any): Promise { + const contract = tx?.raw_data?.contract?.[0]; + if (!contract) throw createProviderRpcError(4000, 'Transaction has no contract'); + + const value = contract.parameter?.value; + if (!value) throw createProviderRpcError(4000, 'Contract has no parameter value'); + + const visible = tx.visible === true; + const normalizeAddr = async (addr: string): Promise => { + if (!addr) throw createProviderRpcError(4000, 'Missing address'); + if (addr.startsWith('T') && addr.length === 34) return addr; + if (visible) return addr; + return hexAddressToBase58(addr); + }; + + if (contract.type === 'TransferContract') { + const v = value as { amount: number; owner_address: string; to_address: string }; + return { + kind: 'trx-transfer', + ownerAddress: await normalizeAddr(v.owner_address), + toAddress: await normalizeAddr(v.to_address), + amountRaw: String(v.amount), + displayAmount: String(v.amount / 1_000_000), + }; + } + + if (contract.type === 'TriggerSmartContract') { + const v = value as { contract_address: string; owner_address: string; data: string; call_value?: number }; + const data = String(v.data || '') + .toLowerCase() + .replace(/^0x/, ''); + const selector = data.slice(0, 8); + const contractAddress = await normalizeAddr(v.contract_address); + const ownerAddress = await normalizeAddr(v.owner_address); + + // transfer(address,uint256) — the canonical TRC20 transfer path. + // Decode to show the real recipient + token amount in the approval + // UI; this is by far the most common smart-contract call on Tron. + if (selector === 'a9059cbb' && data.length >= 8 + 64 * 2) { + const recipientHex = data.slice(8 + 24, 8 + 64); + const amountHex = data.slice(8 + 64, 8 + 64 * 2); + const amount = BigInt('0x' + amountHex); + // Tron hex addresses are prefixed with 0x41. ABI `address` pads to + // 20 bytes (no prefix), so re-prepend before base58-encoding. + const recipientBase58 = await hexAddressToBase58('41' + recipientHex); + return { + kind: 'trc20-transfer', + ownerAddress, + toAddress: recipientBase58, + amountRaw: amount.toString(), + displayAmount: amount.toString(), + contractAddress, + }; + } + + // Generic contract call (swaps, stake, approve, etc.). The firmware + // parses the raw_data itself and signs based on what it finds; our + // hints here are purely for the side-panel approval UI. Route + // `call_value` (TRX attached to the call) to amountRaw so swaps + // that spend native TRX display the right outgoing amount. + const callValue = typeof v.call_value === 'number' ? v.call_value : 0; + return { + kind: 'contract-call', + ownerAddress, + toAddress: contractAddress, + amountRaw: String(callValue), + displayAmount: String(callValue / 1_000_000), + contractAddress, + functionSelector: selector, + }; + } + + throw createProviderRpcError(4200, `Tron contract type "${contract.type}" is not supported by KeepKey yet.`); +} + +/** Broadcast a signed Tron tx via TronGrid. Returns the txid. */ +async function broadcastTron(signedTx: any): Promise { + let resp: Response; + try { + resp = await fetch(`${TRONGRID_URL}/wallet/broadcasttransaction`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(signedTx), + signal: AbortSignal.timeout(30000), + }); + } catch (e: any) { + throw createProviderRpcError(-32603, `TronGrid broadcast failed: ${e.message}`); + } + if (!resp.ok) { + const text = await resp.text().catch(() => ''); + throw createProviderRpcError(-32603, `TronGrid broadcast failed (${resp.status}): ${text}`); + } + const result = await resp.json(); + if (result?.code && result.code !== 'SUCCESS' && !result?.result) { + const msg = result.message + ? Buffer.from(String(result.message), 'hex').toString('utf8').trim() + : JSON.stringify(result); + throw createProviderRpcError(-32603, `Tron broadcast rejected: ${msg}`); + } + const txid = result?.txid || signedTx?.txID; + if (!txid) throw createProviderRpcError(-32603, 'Tron broadcast returned no txid'); + return String(txid); +} + +export const handleTronRequest = async ( + method: string, + params: any[], + requestInfo: any, + _ADDRESS: string, + _KEEPKEY_WALLET: any, + requireApproval: (networkId: string, requestInfo: any, chain: any, method: string, params: any) => Promise, +): Promise => { + const tag = TAG + ' | handleTronRequest | '; + console.log(tag, 'method:', method); + + switch (method) { + case 'request_accounts': + case 'tron_requestAccounts': + case 'tron_connect': + case 'tron_getAccount': { + return await getTronAddress(); + } + + case 'request_pubkeys': { + return wallet.getPubkeys(TRON_NETWORK_ID); + } + + // dApp flow (window.tronWeb.trx.sign). Payload: params[0] is a full + // transaction built by tronweb — { txID, raw_data, raw_data_hex, ... }. + // We parse the first contract to extract display hints (to_address, + // amount) the vault / firmware need, run the approval loop, then + // return the signed tx for the dApp to broadcast itself. + case 'tron_sign': + case 'signTransaction': { + const tx = params?.[0]; + if (!tx || typeof tx !== 'object' || !tx.raw_data_hex) { + throw createProviderRpcError(4000, 'tron_sign expects a built transaction with raw_data_hex'); + } + + const decoded = await decodeTronTx(tx); + const sender = await getTronAddress(); + + if (decoded.ownerAddress && decoded.ownerAddress !== sender) { + // dApps occasionally hard-code an owner address from a cached + // session; surface the mismatch so the dApp can recover rather + // than letting the device reject it silently. + throw createProviderRpcError( + 4001, + `Transaction owner (${decoded.ownerAddress}) does not match connected KeepKey address (${sender}).`, + ); + } + + if (!requestInfo.id) requestInfo.id = uuidv4(); + // Always emit type='transfer'. The shared "other" approval + // renderer (pages/side-panel/src/approval/other/*) only has a + // cased handler for 'transfer' + reads `unsignedTx.payment.*` — + // anything else shows "Unknown Method" / N/A. Downstream code + // that cares about kind can still branch on `contractAddress` / + // `functionSelector` on the event. + // + // decimals: native TRX is 6 (sun); TRC-20 transfer()s use the + // displayAmount's decimals which we don't know from raw_data + // alone — default to 0 so the UI shows raw base units, which is + // at least correct, not misleading. Contract-call rows attach + // call_value (TRX, 6 decimals). Callers that want prettier + // token display should teach decodeTronTx to look up decimals + // from assetData or an on-chain call — not a fix for this PR. + const decimals = decoded.kind === 'trc20-transfer' ? 0 : 6; + // For TRC-20 events, scope the caip to the specific token + // contract so the UI's ctx.caip-match gate only fires when the + // side-panel had THIS token selected. Otherwise a dApp USDT + // transfer with the user on the TRX asset page would match + // (both sides = tron:27Lqcw/slip44:195) and render with the TRX + // symbol/icon — the exact leak #48's gate was meant to block. + // + // Native TRX and generic contract-call stay on TRON_CAIP: + // trx-transfer is native TRX so matching the TRX context is + // correct; contract-call renders its own Contract/Function UI + // with a hardcoded 'TRX' fallback on the call_value row, so a + // partial caip-match can't leak the wrong symbol into anything + // user-facing. + const eventCaip = + decoded.kind === 'trc20-transfer' && decoded.contractAddress + ? `${TRON_NETWORK_ID}/token:${decoded.contractAddress}` + : TRON_CAIP; + const event = buildEvent(requestInfo, 'transfer', params, { + caip: eventCaip, + from: sender, + to: decoded.toAddress, + amount: decoded.displayAmount, + // String, not number — preserves precision for 18-decimal TRC-20. + amountRaw: decoded.amountRaw, + decimals, + kind: decoded.kind, // 'trx-transfer' | 'trc20-transfer' | 'contract-call' for downstream branches + contractAddress: decoded.contractAddress, + functionSelector: decoded.functionSelector, + tronGridTx: tx, + rawDataHex: tx.raw_data_hex, + // Shape the "other" approval UI reads. `amount` stays as a raw + // base-units decimal string — formatAmount in the UI does the + // BigInt-safe division by `decimals`. + // + // symbol: + // trx-transfer / contract-call → 'TRX' (call_value on a + // contract call is always native TRX; stating it + // explicitly in the handler lets the UI render the amount + // row without falling through to asset-context guesswork) + // trc20-transfer → undefined (we don't know + // the token's symbol without an on-chain `symbol()` call + // or an assetData lookup — both are out of scope here; + // the UI's caip-match gate on asset context will decline + // to show a wrong symbol) + payment: { + destination: decoded.toAddress, + amount: decoded.amountRaw, + decimals, + symbol: decoded.kind === 'trc20-transfer' ? undefined : 'TRX', + }, + }); + // @ts-expect-error addEvent is untyped on the storage wrapper + const saved = await requestStorage.addEvent(event); + if (!saved) throw createProviderRpcError(-32603, 'Failed to create approval event'); + chrome.runtime.sendMessage({ action: 'TRANSACTION_CONTEXT_UPDATED', id: event.id }).catch(() => {}); + + const approval = await requireApproval(TRON_NETWORK_ID, requestInfo, 'tron', method, params); + if (!approval?.success) { + throw createProviderRpcError(4001, 'User denied transaction'); + } + + const signatureHex = await signTronViaRest(tx.raw_data_hex, decoded.toAddress, decoded.amountRaw); + + // Preserve any existing `signature` array from the dApp (multi-sig + // case) and append ours; most dApps pass in an unsigned tx so this + // ends up [ours]. + const existing: string[] = Array.isArray(tx.signature) ? tx.signature : []; + const signedTx = { ...tx, signature: [...existing, signatureHex] }; + + // Persist signedTx on the event for debugging/history. Broadcast is + // the dApp's job — they'll call tronWeb.trx.sendRawTransaction next. + try { + const stored = await requestStorage.getEventById(requestInfo.id); + if (stored) { + stored.signedTx = signedTx; + stored.status = 'completed'; + await requestStorage.updateEventById(requestInfo.id, stored); + } + } catch (e) { + console.warn(tag, 'Failed to persist signedTx:', e); + } + + // Dismiss the side-panel approval overlay. We don't emit + // `transaction_complete` (that's for flows where we also broadcast + // and have a txHash) — for dApp signing the broadcast happens + // client-side and the dApp's own UI confirms success. + chrome.runtime.sendMessage({ action: 'signature_complete', eventId: requestInfo.id }).catch(() => {}); + + return signedTx; + } + + // TIP-191 personal_sign. dApps reach this through three names: + // - tron_signMessage / signMessage → V1 (TronWeb): message is hex + // - signMessageV2 → V2 (TronWeb): message is UTF-8 + // V1 vs V2 is just the wire encoding of the message bytes; both go + // through the same firmware path and produce a 65-byte recoverable + // signature. Handle the encoding here so the handler can stay + // ignorant of the dApp-side variant. + case 'tron_signMessage': + case 'signMessage': + case 'signMessageV2': { + await requireMessageSigningFirmware('Tron message signing'); + const raw = params?.[0]; + if (raw === undefined || raw === null) { + throw createProviderRpcError(4000, `${method} expects a message as the first param`); + } + const isV2 = method === 'signMessageV2'; + let messageForVault: string; + let isText: boolean; + if (typeof raw === 'string') { + if (isV2) { + // V2 always treats input as text — even if it looks 0x-hex, the + // signing surface signs the literal characters per the TronWeb + // spec. + messageForVault = raw; + isText = true; + } else { + // V1 — message is hex with or without 0x. + messageForVault = ensureHex(raw, `${method}.message`); + isText = false; + } + } else if (Array.isArray(raw)) { + // Some dApps pass Uint8Array-like arrays of bytes. + messageForVault = raw.map((b: number) => (b & 0xff).toString(16).padStart(2, '0')).join(''); + isText = false; + } else { + throw createProviderRpcError(4000, `${method}: unsupported message type ${typeof raw}`); + } + + if (!requestInfo.id) requestInfo.id = uuidv4(); + const event = buildEvent(requestInfo, method, params, { + kind: 'sign-message', + from: await getTronAddress(), + message: raw, + // Kept for the approval card so it can render hex vs text without + // having to recompute the encoding decision. + isText, + }); + // @ts-expect-error addEvent is untyped on the storage wrapper + const saved = await requestStorage.addEvent(event); + if (!saved) throw createProviderRpcError(-32603, 'Failed to create approval event'); + chrome.runtime.sendMessage({ action: 'TRANSACTION_CONTEXT_UPDATED', id: event.id }).catch(() => {}); + + const approval = await requireApproval(TRON_NETWORK_ID, requestInfo, 'tron', method, params); + if (!approval?.success) { + throw createProviderRpcError(4001, 'User denied message signing'); + } + + const { signature } = await tronSignMessageViaRest(messageForVault, isText); + // TronWeb V1/V2 both expect a 0x-prefixed hex signature. + const result = '0x' + ensureHex(signature, `${method}.response.signature`, 65); + + try { + const stored = await requestStorage.getEventById(requestInfo.id); + if (stored) { + stored.signature = result; + stored.status = 'completed'; + await requestStorage.updateEventById(requestInfo.id, stored); + } + } catch (e) { + console.warn(tag, 'Failed to persist Tron message signature:', e); + } + chrome.runtime.sendMessage({ action: 'signature_complete', eventId: requestInfo.id }).catch(() => {}); + + return result; + } + + // TIP-191 verify — non-standard utility, exposed only as + // `tron_verifyMessage` to make clear it doesn't match TronWeb's + // signatures: + // - TronWeb V1 verifyMessage(message, signature, address) → boolean + // - TronWeb V2 verifyMessageV2(message, signature) → recovered address + // Our endpoint requires `address` and returns a boolean, which is + // V1-shaped but firmware-routed. Standard verification is + // client-side and doesn't need the wallet — TronWeb's static + // verifyMessage* utilities are the intended path. We keep this + // case so internal tooling can round-trip through the device, but + // we DON'T expose it on `tronWeb.trx` where the V2 contract is + // wrong. + case 'tron_verifyMessage': { + await requireMessageSigningFirmware('Tron message verification'); + const arg = (params || [])[0]; + let address: string | undefined; + let signature: string | undefined; + let messageRaw: unknown; + let isText = true; + if (arg && typeof arg === 'object') { + address = (arg as any).address; + signature = (arg as any).signature; + messageRaw = (arg as any).message; + const explicit = (arg as any).isText ?? (arg as any).is_text; + if (typeof explicit === 'boolean') isText = explicit; + } else { + // Positional [message, signature, address] — historical V1 shape. + messageRaw = (params || [])[0]; + signature = (params || [])[1] as string | undefined; + address = (params || [])[2] as string | undefined; + // Heuristic: bare string + 0x-prefixed even-length hex → hex bytes. + if (typeof messageRaw === 'string' && /^0x[0-9a-fA-F]*$/i.test(messageRaw)) { + isText = false; + } + } + if (typeof address !== 'string' || !address) { + throw createProviderRpcError(4000, 'tron_verifyMessage: address is required'); + } + if (typeof signature !== 'string' || !signature) { + throw createProviderRpcError(4000, 'tron_verifyMessage: signature is required'); + } + let messageForVault: string; + if (typeof messageRaw === 'string') { + messageForVault = isText ? messageRaw : ensureHex(messageRaw, 'tron_verifyMessage.message'); + } else if (Array.isArray(messageRaw)) { + messageForVault = (messageRaw as number[]).map(b => (b & 0xff).toString(16).padStart(2, '0')).join(''); + isText = false; + } else { + throw createProviderRpcError(4000, `tron_verifyMessage: unsupported message type ${typeof messageRaw}`); + } + return await tronVerifyMessageViaRest(address, signature, messageForVault, isText); + } + + // TIP-712 typed-data signing in HASH MODE. Caller pre-computes the + // 32-byte domainSeparator + message hashes per the TIP-712 spec + // and passes them in. This is NOT TronWeb's `_signTypedData` / + // `signTypedData(domain, types, value)` — those take the full + // struct and do the hashing internally. We don't ship a struct → + // hashes implementation, so we expose only the lower-level hash + // surface and don't claim TronWeb compatibility. + case 'tron_signTypedHash': { + await requireMessageSigningFirmware('Tron typed-data signing'); + const arg = (params || [])[0]; + let dsHash: string | undefined; + let msgHash: string | undefined; + if (arg && typeof arg === 'object') { + // Accept either { domainSeparatorHash, messageHash } + // or snake-case { domain_separator_hash, message_hash }. + dsHash = arg.domainSeparatorHash ?? arg.domain_separator_hash; + msgHash = arg.messageHash ?? arg.message_hash; + } else if (typeof arg === 'string') { + // Positional: [domainSeparatorHash, messageHash?] + dsHash = arg; + msgHash = (params || [])[1]; + } + if (!dsHash) { + throw createProviderRpcError( + 4000, + `${method} expects { domainSeparatorHash, messageHash? } or two 32-byte hex strings`, + ); + } + + if (!requestInfo.id) requestInfo.id = uuidv4(); + const event = buildEvent(requestInfo, method, params, { + kind: 'sign-typed-hash', + from: await getTronAddress(), + domainSeparatorHash: dsHash, + messageHash: msgHash, + }); + // @ts-expect-error addEvent is untyped on the storage wrapper + const saved = await requestStorage.addEvent(event); + if (!saved) throw createProviderRpcError(-32603, 'Failed to create approval event'); + chrome.runtime.sendMessage({ action: 'TRANSACTION_CONTEXT_UPDATED', id: event.id }).catch(() => {}); + + const approval = await requireApproval(TRON_NETWORK_ID, requestInfo, 'tron', method, params); + if (!approval?.success) { + throw createProviderRpcError(4001, 'User denied typed-data signing'); + } + + const { signature } = await tronSignTypedHashViaRest(dsHash, msgHash); + const result = '0x' + ensureHex(signature, `${method}.response.signature`, 65); + try { + const stored = await requestStorage.getEventById(requestInfo.id); + if (stored) { + stored.signature = result; + stored.status = 'completed'; + await requestStorage.updateEventById(requestInfo.id, stored); + } + } catch (e) { + console.warn(tag, 'Failed to persist Tron typed-hash signature:', e); + } + chrome.runtime.sendMessage({ action: 'signature_complete', eventId: requestInfo.id }).catch(() => {}); + return result; + } + + // Side-panel Send flow. Payload: + // params[0] = { caip, amount: { amount, denom }, recipient, memo, isMax } + // + // Branches on caip — a `tron:*/token:T...` or `tron:*/trc20:T...` + // caip means the user picked a TRC-20 token (USDT, USDC, etc.) from + // the asset list and we need to build a TriggerSmartContract with + // the contract's `transfer(address,uint256)` selector. A plain + // `tron:*/slip44:195` caip is a native TRX transfer. Without this + // split, clicking USDT and hitting send would invisibly send TRX + // instead (the caip was being discarded). + case 'transfer': { + const payload = params?.[0] || {}; + const recipient: string = payload.recipient; + const amountStr: string = payload?.amount?.amount ?? payload?.amount ?? ''; + const caip: string = payload?.caip || TRON_CAIP; + + if (!recipient) throw createProviderRpcError(4000, 'Missing recipient'); + if (!amountStr) throw createProviderRpcError(4000, 'Missing amount'); + + const amountFloat = parseFloat(amountStr); + if (!Number.isFinite(amountFloat) || amountFloat <= 0) { + throw createProviderRpcError(4000, 'Invalid amount'); + } + + const sender = await getTronAddress(); + const trc20Contract = parseTrc20Caip(caip); + + let unsignedGrid: any; + let signHintTo: string; + let signHintAmountRaw: string; + let decimals = 6; // TRX native and USDT/USDC-TRC20 are all 6 + + if (trc20Contract) { + // TRC-20 path. Decimals come from the asset context the + // side-panel just set via SET_ASSET_CONTEXT; Pioneer populates + // them on the token row. Default to 6 (USDT/USDC on Tron) as a + // best-effort fallback so a missing asset context doesn't block + // the send — the vault firmware will display the raw amount + // either way. + try { + const assetCtx = await assetContextStorage.get(); + if ((assetCtx as any)?.caip === caip && typeof (assetCtx as any)?.decimals === 'number') { + decimals = (assetCtx as any).decimals; + } + } catch { + /* fall through with default */ + } + const amountBase = toBaseUnits(amountStr, decimals); + if (amountBase <= 0n) throw createProviderRpcError(4000, 'Amount too small'); + + unsignedGrid = await buildTrc20Transfer(sender, trc20Contract, recipient, amountBase); + signHintTo = recipient; + signHintAmountRaw = amountBase.toString(); + } else { + // Native TRX path. + const sunAmount = trxToSun(amountStr); + if (sunAmount <= 0) throw createProviderRpcError(4000, 'Amount too small'); + unsignedGrid = await buildTronTransfer(sender, recipient, sunAmount); + signHintTo = recipient; + signHintAmountRaw = String(sunAmount); + } + + // Persist the full TronGrid response on the event. Use + // type='transfer' for both native and TRC-20: the approval UI's + // RequestMethodCard renders "Unknown Method" for anything it + // doesn't recognise, and there's no semantic win from a separate + // sub-kind at the UI layer — the `contractAddress` and `caip` + // fields on unsignedTx let downstream code distinguish when it + // matters. `unsignedTx.payment.{destination,amount}` is the + // shape RequestDetailsCard reads to show the approval details; + // amount stays raw base-units so the UI's /decimals division + // works across 6- and 18-decimal tokens alike. + if (!requestInfo.id) requestInfo.id = uuidv4(); + const event = buildEvent(requestInfo, 'transfer', params, { + caip, + from: sender, + to: recipient, + amount: amountStr, + amountRaw: signHintAmountRaw, + decimals, + // `kind` flags the approval UI to render the correct label + // ("Token Transfer" for TRC-20 vs "transfer" for native TRX). + // Without this, side-panel TRC-20 sends looked identical to + // native TRX sends in the approval pane even though we were + // actually signing a `transfer(address,uint256)` contract call. + kind: trc20Contract ? 'trc20-transfer' : 'trx-transfer', + contractAddress: trc20Contract || undefined, + tronGridTx: unsignedGrid, + rawDataHex: unsignedGrid.raw_data_hex, + payment: { + destination: recipient, + amount: signHintAmountRaw, + decimals, + // For TRC-20 sends from the side panel the user just clicked + // the asset, so the global assetContext caip WILL match + // event.caip and the UI's caip-gated fallback will pick up + // the right symbol. Leaving it undefined here keeps the + // handler from baking in a symbol we can't verify without + // an on-chain lookup. + symbol: trc20Contract ? undefined : 'TRX', + }, + }); + // @ts-expect-error addEvent is untyped on the storage wrapper + const saved = await requestStorage.addEvent(event); + if (!saved) throw createProviderRpcError(-32603, 'Failed to create approval event'); + chrome.runtime.sendMessage({ action: 'TRANSACTION_CONTEXT_UPDATED', id: event.id }).catch(() => {}); + + const approval = await requireApproval(TRON_NETWORK_ID, requestInfo, 'tron', method, params); + if (!approval?.success) { + throw createProviderRpcError(4001, 'User denied transaction'); + } + + const signatureHex = await signTronViaRest(unsignedGrid.raw_data_hex, signHintTo, signHintAmountRaw); + + // Tron broadcasttransaction wants the TronGrid response enriched with a + // `signature` array (hex strings, one per input). Single-sig for both + // native and TRC-20 transfers. + const signedTx = { ...unsignedGrid, signature: [signatureHex] }; + + const txid = await broadcastTron(signedTx); + + try { + const stored = await requestStorage.getEventById(requestInfo.id); + if (stored) { + stored.txid = txid; + stored.status = 'broadcasted'; + await requestStorage.updateEventById(requestInfo.id, stored); + } + } catch (e) { + console.warn(tag, 'Failed to persist txid:', e); + } + + chrome.runtime + .sendMessage({ + action: 'transaction_complete', + eventId: requestInfo.id, + txHash: txid, + explorerTxLink: 'https://tronscan.org/#/transaction/', + networkId: TRON_NETWORK_ID, + }) + .catch(() => {}); + + return txid; + } + + default: + throw createProviderRpcError(4200, `Unsupported Tron method: ${method}`); + } +}; diff --git a/chrome-extension/src/background/fetchUtils.ts b/chrome-extension/src/background/fetchUtils.ts new file mode 100644 index 0000000..8de7dd1 --- /dev/null +++ b/chrome-extension/src/background/fetchUtils.ts @@ -0,0 +1,95 @@ +/** + * Shared fetch helpers for background-side network calls. + * + * Two reasons this exists: + * + * 1. **Timeouts.** Default `fetch()` has no timeout. A stalled HTTP + * request can hang the awaiting code path indefinitely — the most + * visible failure mode is the popup / portfolio loader sitting at a + * spinner forever when Pioneer or a localhost service is degraded. + * + * 2. **Transient retries.** Pioneer (and similar single-endpoint + * services) occasionally return 5xx or transient network errors. + * A small retry budget eats those without bothering the user. + * + * Use: + * - `fetchJsonWithTimeout` for endpoints whose response we parse as + * JSON (the common case in this codebase). + * - `fetchWithTimeout` when you need the raw `Response` (status + * check, streaming, etc.). + */ + +export interface FetchOptions { + /** Total per-attempt budget. Default 8000ms. */ + timeoutMs?: number; + /** Number of retry attempts on transient errors (5xx, network). 0 = no retry. Default 0. */ + retries?: number; + /** Override what counts as "transient" if you have endpoint-specific knowledge. */ + retryOn?: (status: number) => boolean; +} + +const DEFAULT_TIMEOUT_MS = 8000; +const DEFAULT_RETRIES = 0; + +const isTransientStatus = (status: number) => status >= 500 && status < 600; + +/** + * fetch with a per-attempt AbortSignal.timeout and optional retry on + * transient errors. Throws on the final failure. + */ +export async function fetchWithTimeout( + url: string, + init: RequestInit = {}, + options: FetchOptions = {}, +): Promise { + const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS; + const maxAttempts = (options.retries ?? DEFAULT_RETRIES) + 1; + const retryOn = options.retryOn ?? isTransientStatus; + let lastErr: unknown; + + for (let attempt = 1; attempt <= maxAttempts; attempt++) { + try { + const resp = await fetch(url, { + ...init, + signal: init.signal ?? AbortSignal.timeout(timeoutMs), + }); + // Retry only on caller-classified transient statuses. Definitive + // errors (4xx) are returned to the caller for normal handling. + if (!resp.ok && retryOn(resp.status) && attempt < maxAttempts) { + const backoffMs = 200 * 2 ** (attempt - 1); + console.warn(`[fetchWithTimeout] ${url} attempt ${attempt} got ${resp.status}; retrying in ${backoffMs}ms`); + await new Promise(r => setTimeout(r, backoffMs)); + continue; + } + return resp; + } catch (e: any) { + lastErr = e; + // AbortError (timeout) and network errors are worth retrying. + if (attempt < maxAttempts) { + const backoffMs = 200 * 2 ** (attempt - 1); + console.warn(`[fetchWithTimeout] ${url} attempt ${attempt} threw ${e?.name || ''}; retrying in ${backoffMs}ms`); + await new Promise(r => setTimeout(r, backoffMs)); + continue; + } + } + } + throw lastErr; +} + +/** + * fetch + parse JSON with timeout + retry. Throws on transport + * failure, non-OK response, or JSON parse failure. Caller doesn't have + * to remember to check `resp.ok` separately. + */ +export async function fetchJsonWithTimeout( + url: string, + init: RequestInit = {}, + options: FetchOptions = {}, +): Promise { + const resp = await fetchWithTimeout(url, init, options); + if (!resp.ok) { + const body = await resp.text().catch(() => ''); + throw new Error(`HTTP ${resp.status} from ${url}: ${body}`); + } + return (await resp.json()) as T; +} diff --git a/chrome-extension/src/background/firmware.ts b/chrome-extension/src/background/firmware.ts new file mode 100644 index 0000000..a2bdb08 --- /dev/null +++ b/chrome-extension/src/background/firmware.ts @@ -0,0 +1,70 @@ +/** + * Shared firmware-version gate for the message-signing endpoints + * (Tron TIP-191/TIP-712, TON Ed25519, Solana off-chain). + * + * The hdwallet methods + REST endpoints landed in the vault commit + * `7e6dfc9 feat(rpc,sdk,rest): expose 5 new message-signing methods` + * and depend on firmware that ships those types. Anything older than + * 7.14.1 returns Failure_UnknownMessage and the user has no useful + * recovery path other than upgrading — surface that as a clean error + * before we round-trip the device. + * + * 7.14.0 was an internal stop-gap that was never released; treat the + * minimum as 7.14.1 to avoid handing users a "supported" device that + * actually ignores half these methods. + */ +import * as wallet from './wallet'; +import { createProviderRpcError } from './utils'; + +const REQUIRED = { major: 7, minor: 14, patch: 1 } as const; + +interface FirmwareVersion { + major: number; + minor: number; + patch: number; +} + +/** Vault REST returns snake_case (see formatFeatures); raw hdwallet uses camelCase. */ +function parseVersion(features: any): FirmwareVersion | null { + if (!features) return null; + const major = Number(features.major_version ?? features.majorVersion); + const minor = Number(features.minor_version ?? features.minorVersion); + const patch = Number(features.patch_version ?? features.patchVersion); + if (![major, minor, patch].every(Number.isFinite)) return null; + return { major, minor, patch }; +} + +function meetsMin(v: FirmwareVersion): boolean { + if (v.major !== REQUIRED.major) return v.major > REQUIRED.major; + if (v.minor !== REQUIRED.minor) return v.minor > REQUIRED.minor; + return v.patch >= REQUIRED.patch; +} + +async function readVersion(): Promise { + const cached = parseVersion(wallet.getDeviceInfo()?.features); + if (cached) return cached; + // Cache miss (cold start, just-reconnected device) — force a probe. + await wallet.probeDevice(); + return parseVersion(wallet.getDeviceInfo()?.features); +} + +/** + * Throw a user-facing error if the connected KeepKey is on firmware + * older than 7.14.1. Use `label` to identify the call site in the + * error message (e.g. "Tron message signing"). + */ +export async function requireMessageSigningFirmware(label: string): Promise { + const v = await readVersion(); + if (!v) { + throw createProviderRpcError( + -32603, + `${label} requires firmware 7.14.1 — could not read device firmware version. Plug in your KeepKey and try again.`, + ); + } + if (!meetsMin(v)) { + throw createProviderRpcError( + 4200, + `${label} requires firmware 7.14.1 or later. Your KeepKey is on ${v.major}.${v.minor}.${v.patch}. Update via the KeepKey Vault desktop app and retry.`, + ); + } +} diff --git a/chrome-extension/src/background/index.ts b/chrome-extension/src/background/index.ts index 2c87b73..fb0935c 100644 --- a/chrome-extension/src/background/index.ts +++ b/chrome-extension/src/background/index.ts @@ -7,8 +7,13 @@ globalThis.Buffer = Buffer; import packageJson from '../../package.json'; import * as wallet from './wallet'; -import { resetSolanaState } from './chains/solanaHandler'; +import { deriveUtxoAddress } from './utxoDerive'; +import { resetSolanaState, prefetchSolanaPubkey } from './chains/solanaHandler'; +import { resetTonState, prefetchTonAddress } from './chains/tonHandler'; +import { resetTronState, prefetchTronPubkey } from './chains/tronHandler'; import { handleWalletRequest } from './methods'; +import { setApprovalBadge } from './popup'; +import { fetchJsonWithTimeout } from './fetchUtils'; import { JsonRpcProvider, formatEther } from 'ethers'; import { ChainToNetworkId, Chain, COIN_MAP_LONG, shortListSymbolToCaip, NetworkIdToChain } from './chainConfig'; import { @@ -21,13 +26,25 @@ import { ethAccountsStorage, customEvmNetworksStorage, } from '@extension/storage'; -import { EIP155_CHAINS } from './chains'; +import { getChainInfo, makeStaticProvider } from './chains/registry'; +import { withRpcFailoverByNetworkId } from './chains/rpcFailover'; import { formatUserError } from './utils'; +import { filterSpamTokens } from './spamFilter'; const TAG = ' | background/index.js | '; console.log('Background script loaded'); console.log('Version:', packageJson.version); +// Make clicking the extension icon open the side panel. Required because +// `chrome.sidePanel.open()` from a dApp-triggered approval flow isn't a +// user gesture and may be ignored — the icon click is the guaranteed +// fallback path. No-op on Firefox (no sidePanel API). +if (chrome.sidePanel?.setPanelBehavior) { + chrome.sidePanel + .setPanelBehavior({ openPanelOnActionClick: true }) + .catch(e => console.warn(TAG, 'setPanelBehavior failed', e)); +} + const PIONEER_API = 'https://api.keepkey.info'; const KEEPKEY_STATES = { @@ -40,15 +57,45 @@ const KEEPKEY_STATES = { }; let KEEPKEY_STATE = 0; -function updateIcon() { - let iconPath = './icon-128.png'; +// MV3 service workers sometimes fail `chrome.action.setIcon({path})` with +// "Failed to fetch" when the worker has just (re-)started — the extension's +// file-map isn't always ready to serve its own packaged assets immediately. +// Guards: +// 1. Deduplicate: don't re-invoke the API when the path hasn't changed +// (checkKeepKey fires updateIcon every 5s; 99% of those calls are +// redundant and each one is a chance to hit the transient error). +// 2. Retry by re-running updateIcon() — NOT by replaying the captured +// path. If KEEPKEY_STATE flipped during the 500 ms gap, re-running +// reads the current state and applies whatever is correct now, +// preventing a stale "online" icon from painting over a subsequent +// "errored" transition. +let lastIconPath: string | null = null; +let iconRetryPending = false; + +function currentIconPath(): string { // Show green/online icon when connected (state 2) or paired (state 5) - if (KEEPKEY_STATE === 2 || KEEPKEY_STATE === 5) iconPath = './icon-128-online.png'; + return KEEPKEY_STATE === 2 || KEEPKEY_STATE === 5 ? './icon-128-online.png' : './icon-128.png'; +} + +function updateIcon() { + const iconPath = currentIconPath(); + if (iconPath === lastIconPath) return; + lastIconPath = iconPath; chrome.action.setIcon({ path: iconPath }, () => { - if (chrome.runtime.lastError) { - console.error('Error setting icon:', chrome.runtime.lastError); - } + const err = chrome.runtime.lastError; + if (!err) return; + // Clear the dedupe so the retry path can actually re-apply an icon + // (even if it's the same string) and then call updateIcon() again. + // Re-running reads CURRENT state, so a state flip during the 500ms + // backoff doesn't leave us painting a stale icon. + lastIconPath = null; + if (iconRetryPending) return; // one retry in flight is enough + iconRetryPending = true; + setTimeout(() => { + iconRetryPending = false; + updateIcon(); + }, 500); }); } @@ -67,10 +114,79 @@ function pushStateChangeEvent() { let lastDeviceProbeAt = 0; const DEVICE_PROBE_INTERVAL_MS = 15_000; +// Separate, longer throttle for the "already-connected, verify deviceId hasn't +// changed" re-probe. Vault + device hot-swap is rare enough that 30s latency +// on detection is fine; keeping this longer than the view-only probe avoids +// doubling the getFeatures traffic on the steady-state path. +let lastDeviceVerifyAt = 0; +const DEVICE_VERIFY_INTERVAL_MS = 30_000; + +/** + * Called when we detect that the vault is now paired with a different + * KeepKey than the one we had cached. Clears every piece of state keyed + * to the previous device and re-fetches from the new one. + */ +async function handleDeviceSwitch(newDeviceInfo: any) { + const tag = TAG + ' | handleDeviceSwitch | '; + console.warn(tag, 'Device swap detected. Purging caches and re-fetching.'); + + // In-memory + storage cache owned by wallet.ts + await wallet.handleDeviceSwitch(newDeviceInfo); + + // Balance caches — pubkey-keyed, so they're poisoned by the old device + cachedBalances = []; + balancesFetchInProgress = null; + + // Per-chain address caches (Solana/Tron/TON each keep their own lookup + // cache above the pubkey layer) + resetSolanaState(); + resetTronState(); + resetTonState(); + + // Re-fetch against the new device. refreshPubkeys re-probes and pulls + // a fresh pubkey batch, then updates state.initialized. + try { + await wallet.refreshPubkeys(); + + // refreshPubkeys only hits getDefaultPaths() — the big batched + // derivation. Solana, Tron, and TON addresses are *dynamically* + // added at onStart via these prefetches (SOL needs solanaGetAddress, + // TRX needs tronGetAddress, TON needs tonGetAddress — none of which + // are in the xpub.getPublicKeys batch). Without re-running them on + // a device swap, those three chains end up with stale per-chain + // caches (zeroed out by resetXState above) and no pubkeys, so + // they'd vanish from the network dropdown until a user manually + // visited the asset or reloaded the extension. + // + // Fire in parallel — each is non-throwing, so an individual chain + // failure won't take the others down. + await Promise.allSettled([prefetchSolanaPubkey(), prefetchTronPubkey(), prefetchTonAddress()]); + + pushStateChangeEvent(); + pushBalancesUpdated(); + // Kick a fresh balance fetch in the background so the dashboard + // swaps to the new device's balances without waiting for the next + // user-triggered refresh. + fetchBalancesFromPioneer(true).catch(e => console.warn(tag, 'Post-switch balance fetch failed:', e)); + } catch (e) { + console.error(tag, 'Failed to re-fetch pubkeys after device switch:', (e as Error)?.message || e); + pushStateChangeEvent(); + } +} + +// Singleflight guard: a stalled localhost:1646 request would otherwise +// stack probes every 5s, generating overlapping async work and stale +// state transitions. If the previous tick is still running, skip this +// one. Combined with AbortSignal.timeout(3000) below, the worst case is +// one stalled request hung for 3s before the next tick can run. +let healthPollInflight = false; + async function checkKeepKey() { + if (healthPollInflight) return; + healthPollInflight = true; const prevState = KEEPKEY_STATE; try { - const response = await fetch('http://localhost:1646/docs'); + const response = await fetch('http://localhost:1646/docs', { signal: AbortSignal.timeout(3000) }); if (response.ok) { if (KEEPKEY_STATE < 2) { KEEPKEY_STATE = 2; // Set state to connected @@ -96,6 +212,28 @@ async function checkKeepKey() { // First-run case: init failed earlier (no device, no cache) — retry. lastDeviceProbeAt = now; onStart(); + } else if (wallet.isInitialized() && wallet.isDeviceConnected()) { + // Steady state: vault-up, device-connected. Periodically re-probe + // features to verify the same physical device is still paired. A + // hot-swap to a different KeepKey will look identical to the /docs + // endpoint but returns a different device_id from getFeatures. + const mayVerify = now - lastDeviceVerifyAt >= DEVICE_VERIFY_INTERVAL_MS; + if (mayVerify) { + lastDeviceVerifyAt = now; + const beforeId = wallet.getDeviceId(); + wallet + .probeDevice() + .then(ok => { + if (!ok) return; // probe failure — next tick will set state=4 + const afterId = wallet.getDeviceId(); + if (beforeId && afterId && beforeId !== afterId) { + handleDeviceSwitch(wallet.getDeviceInfo()).catch(e => + console.error(TAG, 'handleDeviceSwitch failed:', e), + ); + } + }) + .catch(e => console.warn(TAG, 'Feature re-probe failed:', (e as Error)?.message || e)); + } } } } catch (error: any) { @@ -106,10 +244,14 @@ async function checkKeepKey() { // so a hot-swapped device doesn't sign against a stale cached address. if (prevState === 2 || prevState === 5) { resetSolanaState(); + resetTronState(); + resetTonState(); } KEEPKEY_STATE = 4; // Set state to errored updateIcon(); if (KEEPKEY_STATE !== prevState) pushStateChangeEvent(); + } finally { + healthPollInflight = false; } } @@ -119,13 +261,21 @@ setInterval(checkKeepKey, 5000); updateIcon(); console.log('Background loaded'); -const provider = new JsonRpcProvider(EIP155_CHAINS['eip155:1'].rpc); - let ADDRESS = ''; // ---- Balance fetching via Pioneer API ---- let cachedBalances: any[] = []; let balancesFetchInProgress: Promise | null = null; +// Monotonic sequence so an earlier, slower fetch can't clobber a later fetch's +// result when they overlap. Bumped each time a new fetch actually starts work +// (not for calls that return the in-flight dedup promise). +let latestFetchId = 0; + +function pushBalancesUpdated() { + chrome.runtime.sendMessage({ type: 'BALANCES_UPDATED' }).catch(() => { + // No popup/sidebar listening — ignore. + }); +} // All EVM CAPIPs (deduplicated) — used to fan out EVM wildcard addresses const EVM_CAIPS = [...new Set(Object.values(shortListSymbolToCaip).filter(caip => caip.startsWith('eip155:')))]; @@ -134,6 +284,7 @@ async function fetchBalancesFromPioneer(forceRefresh = false): Promise { // Deduplicate concurrent calls — but honor forceRefresh if (balancesFetchInProgress && !forceRefresh) return balancesFetchInProgress; + const myFetchId = ++latestFetchId; const thisPromise: Promise = (async () => { try { const allPubkeys = wallet.getPubkeys(); @@ -181,58 +332,112 @@ async function fetchBalancesFromPioneer(forceRefresh = false): Promise { console.log(`[fetchBalances] Sending ${pioneerPubkeys.length} pubkeys to Pioneer API`); console.log(`[fetchBalances] Sample pubkeys:`, pioneerPubkeys.slice(0, 3)); - // Use /api/v1/charts/portfolio endpoint — blocking, includes Zapper/Unchained token fetch - const portfolioUrl = `${PIONEER_API}/api/v1/charts/portfolio`; - - // Split into address-based (EVM, Cosmos, etc.) and xpub-based (UTXO) batches - // to prevent a bad xpub from poisoning the entire request - const addressPubkeys = pioneerPubkeys.filter( - p => !p.pubkey.startsWith('xpub') && !p.pubkey.startsWith('zpub') && !p.pubkey.startsWith('ypub'), - ); - const xpubPubkeys = pioneerPubkeys.filter( - p => p.pubkey.startsWith('xpub') || p.pubkey.startsWith('zpub') || p.pubkey.startsWith('ypub'), - ); + // One call, one endpoint: /api/v1/portfolio (GetPortfolioBalances). + // Matches the vault's flow exactly — it's the only endpoint that + // runs Pioneer's token auto-discovery (ERC-20 via Unchained, SPL + // for Solana, TRC-20 for Tron). The older /charts/portfolio served + // natives from a pre-computed cache with Zapper-provided EVM tokens + // but dropped SPL/TRC-20 discovery; splitting traffic across both + // was the reason USDT-Tron showed $0 in the BEX while the vault + // dashboard had it. Slower per call (no pre-warm cache), but + // correctness > speed for balance display. + + // Normalize Pioneer's response networkIds to the canonical form + // used throughout the codebase. Two distinct problems, same shape: + // + // 1. CASING — Pioneer echoes mixed-case IDs (Solana, Tron) back + // lowercased. Side-panel uses strict equality against + // ChainToNetworkId, so lowercased entries get excluded. + // + // 2. TRON'S TWO IDS — Pioneer emits native TRX under the CAIP-2 + // genesis hash id `tron:27Lqcw`, but TRC-20 tokens under the + // hex chain-id `tron:0x2b6653dc`. Both refer to Tron mainnet. + // Without aliasing, USDT-TRON is a ghost — the row is present + // in the balance cache but no Tron-filtered view ever finds it. + // + // Source rewrites (any of these lowercased or exact) → canonical: + const NETWORK_ID_ALIASES: Record = { + 'solana:5eykt4usfv8p8njdtrepy1vzqkqzkvdp': 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp', + 'tron:27lqcw': 'tron:27Lqcw', + 'tron:0x2b6653dc': 'tron:27Lqcw', + }; + const normalizeCasing = (entry: any) => { + const caip = entry.caip || ''; + const netId = entry.networkId || ''; + const canonical = NETWORK_ID_ALIASES[netId.toLowerCase()]; + if (canonical) { + entry.networkId = canonical; + // Rewrite caip's network-id prefix too, keeping the path + // segment ("slip44:195", "token:TR7NHq...") intact. + const slashIdx = caip.indexOf('/'); + if (slashIdx > 0) { + entry.caip = canonical + caip.slice(slashIdx); + } + } + return entry; + }; - const fetchBatch = async (batch: typeof pioneerPubkeys, label: string) => { + const fetchPortfolio = async (batch: typeof pioneerPubkeys) => { if (batch.length === 0) return { balances: [] as any[], tokens: [] as any[] }; try { - const response = await fetch(portfolioUrl, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ pubkeys: batch, forceRefresh }), - }); - if (!response.ok) { - console.warn(`[fetchBalances] ${label} batch returned ${response.status}`); - return { balances: [] as any[], tokens: [] as any[] }; - } - const json = await response.json(); - const data = json?.data || {}; - console.log( - `[fetchBalances] ${label} batch: ${data.balances?.length || 0} balances, ${data.tokens?.length || 0} tokens`, + const url = `${PIONEER_API}/api/v1/portfolio${forceRefresh ? '?forceRefresh=true' : ''}`; + // 12s budget: portfolio is the heaviest Pioneer endpoint + // (cold token discovery), so default 8s is too tight on first + // load. One retry on 5xx absorbs single transient failures. + const json = await fetchJsonWithTimeout( + url, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + // Pioneer's api_key security reads the Authorization + // header verbatim (no Bearer prefix). Any unique + // `key:public-*` string works for anonymous reads; the + // timestamp is just a cache-busting nonce. + Authorization: `key:public-${Date.now()}`, + }, + body: JSON.stringify({ pubkeys: batch }), + }, + { timeoutMs: 12000, retries: 1 }, ); - return { balances: data.balances || [], tokens: data.tokens || [] }; + // Unwrap: /portfolio returns { balances: [...] } at the top + // level; some deployments wrap in { data: { balances } } via + // middleware, so handle both. + const allEntries: any[] = json?.balances || json?.data?.balances || []; + const natives: any[] = []; + const tokens: any[] = []; + for (const raw of allEntries) { + const entry = normalizeCasing({ ...raw }); + // Match vault's classification exactly: token if caip path + // is not slip44:/native:, or type === 'token', or explicit + // isNative===false + contract. Covers ERC-20 (erc20:), + // SPL (spl:/token:), TRC-20 (trc20:/token:) uniformly. + const caipPath = (entry.caip || '').split('/')[1] || ''; + const isTokenByCaip = caipPath && !caipPath.startsWith('slip44:') && !caipPath.startsWith('native:'); + const isTokenByType = entry.type === 'token' || (entry.isNative === false && entry.contract); + if (isTokenByCaip || isTokenByType) tokens.push(entry); + else natives.push(entry); + } + console.log(`[fetchBalances] portfolio: ${natives.length} natives, ${tokens.length} tokens`); + return { balances: natives, tokens }; } catch (e: any) { - console.warn(`[fetchBalances] ${label} batch error:`, e.message); + console.warn('[fetchBalances] portfolio error:', e.message); return { balances: [] as any[], tokens: [] as any[] }; } }; - // Fetch both batches in parallel - const [addressResult, xpubResult] = await Promise.all([ - fetchBatch(addressPubkeys, 'address'), - fetchBatch(xpubPubkeys, 'xpub'), - ]); - - const rawBalances: any[] = [...addressResult.balances, ...xpubResult.balances]; - const rawTokens: any[] = [...addressResult.tokens, ...xpubResult.tokens]; + const portfolioResult = await fetchPortfolio(pioneerPubkeys); + const rawBalances: any[] = [...portfolioResult.balances]; + const rawTokens: any[] = [...portfolioResult.tokens]; if (rawBalances.length === 0 && rawTokens.length === 0) { console.warn('[fetchBalances] Pioneer returned 0 balances for', pioneerPubkeys.length, 'pubkeys'); return cachedBalances; } - // Transform native balances - const balances: any[] = rawBalances.map((b: any) => { + // Transform native balances. `let` because we reassign after spam + // filtering below; token entries are appended earlier, filtered later. + let balances: any[] = rawBalances.map((b: any) => { const caip = b.caip || ''; const networkId = b.networkId || caip.split('/')[0] || ''; return { @@ -249,33 +454,31 @@ async function fetchBalancesFromPioneer(forceRefresh = false): Promise { }; }); - // Add token balances (ERC-20s etc.) - // /charts/portfolio returns tokens in nested format: - // { assetCaip, networkId, pubkey, token: { symbol, name, balance, price, balanceUSD, icon, decimal } } - // OR flat format from /charts: { caip, symbol, balance, ... } + // Add token balances. /portfolio returns tokens in flat format + // alongside natives (ERC-20, SPL, TRC-20 all classified already). for (const t of rawTokens) { - const isNested = t.token && typeof t.token === 'object'; - const tok = isNested ? t.token : t; - const caip = t.assetCaip || t.caip || ''; + const caip = t.caip || ''; const networkId = t.networkId || caip.split('/')[0] || ''; - const contractMatch = caip.match(/\/erc20:(0x[a-fA-F0-9]+)/); + // Pioneer's token caips vary by chain: erc20 (EVM), spl+token + // (Solana), token+trc20 (Tron). Match all so `contractAddress` + // is populated uniformly; fall back to `t.contract` which + // Pioneer also emits for TRC-20 / SPL discovery rows. + const contractMatch = caip.match(/\/(?:erc20|spl|trc20|token):([^\s]+)/); balances.push({ networkId, caip, - symbol: tok.symbol || tok.ticker || '', - name: tok.name || tok.symbol || '', - balance: String(tok.balance ?? '0'), - valueUsd: String(isNested ? (tok.balanceUSD ?? '0') : (tok.valueUsd ?? '0')), - priceUsd: String(isNested ? (tok.price ?? '0') : (tok.priceUsd ?? '0')), + symbol: t.symbol || t.ticker || '', + name: t.name || t.symbol || '', + balance: String(t.balance ?? '0'), + valueUsd: String(t.valueUsd ?? '0'), + priceUsd: String(t.priceUsd ?? '0'), icon: - tok.icon || - tok.image || - (caip ? `https://api.keepkey.info/coins/${btoa(caip).replace(/=+$/, '')}.png` : ''), - decimals: tok.decimal || tok.decimals, + t.icon || t.image || (caip ? `https://api.keepkey.info/coins/${btoa(caip).replace(/=+$/, '')}.png` : ''), + decimals: t.decimals ?? t.decimal, isNative: false, token: true, address: t.pubkey || t.address || '', - contractAddress: contractMatch ? contractMatch[1] : tok.contractAddress || tok.contract || '', + contractAddress: contractMatch ? contractMatch[1] : t.contract || t.contractAddress || '', }); } @@ -294,7 +497,7 @@ async function fetchBalancesFromPioneer(forceRefresh = false): Promise { if (!chainData?.providerUrl) continue; try { - const rpcProvider = new JsonRpcProvider(chainData.providerUrl); + const rpcProvider = makeStaticProvider(chainData.providerUrl, networkId); const rawBal = await Promise.race([ rpcProvider.getBalance(evmAddress), new Promise((_, reject) => setTimeout(() => reject(new Error('timeout')), 5000)), @@ -323,10 +526,53 @@ async function fetchBalancesFromPioneer(forceRefresh = false): Promise { console.warn('[fetchBalances] Custom chain enrichment error:', e.message); } - cachedBalances = balances; + const preFilterCount = balances.length; + balances = filterSpamTokens(balances); + if (balances.length !== preFilterCount) { + console.log( + `[fetchBalances] Spam filter dropped ${preFilterCount - balances.length}/${preFilterCount} token entries`, + ); + } + console.log( `[fetchBalances] Got ${balances.length} balance entries (${balances.filter((b: any) => b.isNative).length} native, ${balances.filter((b: any) => !b.isNative).length} tokens)`, ); + // Only commit if we are still the most recent fetch AND our pubkey + // snapshot hasn't been invalidated by a subsequent addPubkey. The + // id check alone is not enough: concurrent prefetches all bump + // latestFetchId at start, so a fetch that *started last* (highest + // id) wins the id check even if its snapshot was taken *before* + // prefetchTonAddress / prefetchSolanaPubkey / prefetchTronPubkey + // landed their dynamic pubkey. That's exactly how a + // post-prefetch "committed" snapshot can be missing TON — the + // fetch that came in latest was also the one that missed the + // add. Compare pubkey counts now vs at snapshot; if the set has + // grown, supersede ourselves so the next (already queued) + // force-refetch that DID see the new pubkey can commit cleanly. + const currentPubkeyCount = wallet.getPubkeys().length; + const snapshotStale = currentPubkeyCount > allPubkeys.length; + if (myFetchId === latestFetchId && !snapshotStale) { + cachedBalances = balances; + // Native-row summary keyed by networkId — makes it easy to spot + // a chain that got dropped silently between fetches. One line + // per fetch commit; if a balance looks missing on the dashboard, + // this is the quickest place to see whether the cache actually + // has the row at all. + const nativeSummary = balances + .filter((b: any) => b.isNative) + .map((b: any) => `${b.networkId}=${b.balance}`) + .join('; '); + console.log(`[fetchBalances] #${myFetchId} committed. natives: ${nativeSummary}`); + pushBalancesUpdated(); + } else if (snapshotStale) { + console.log( + `[fetchBalances] discarding #${myFetchId} — pubkey set grew from ${allPubkeys.length} to ${currentPubkeyCount} since snapshot`, + ); + } else { + console.log( + `[fetchBalances] discarding result from superseded fetch #${myFetchId} (latest: #${latestFetchId})`, + ); + } return balances; } catch (e: any) { console.error('[fetchBalances] Error:', e.message || e); @@ -344,11 +590,100 @@ async function fetchBalancesFromPioneer(forceRefresh = false): Promise { return thisPromise; } +/** + * Drop pending approval events left in `requestStorage` from a previous + * service-worker lifecycle. + * + * Why: `requireApproval` (in methods.ts) stores the event, sets the + * badge, and waits for an `eth_sign_response` message via an in-memory + * `chrome.runtime.onMessage` listener. When the SW dies mid-flight (MV3 + * idle eviction, manual reload, dev rebuild) the storage entry survives + * but the listener doesn't. A user who clicks Approve in the side panel + * after restart sends a message into the void; nothing happens; the + * dApp eventually times out at 5min. + * + * BUT — `requestStorage` is also briefly used as a "post-broadcast + * holding pen" for some chains. EVM writes `txid` into the same entry + * after broadcast (ethereumHandler.ts), and the side-panel TxidPage + * only moves the entry to `approvalStorage` when the user clicks + * Close. Solana flips status to 'broadcasted'; TON to 'completed'. We + * MUST NOT cancel those — the tx has already gone out and the user is + * still seeing the success page. + * + * Heuristic for "truly pending and now orphaned": + * - no broadcast artifact (txid / txHash / signedTx) AND + * - status is undefined or 'request' + * + * Anything else is post-action; preserve and let the normal UI path + * complete the lifecycle (Close → moveTo approvalStorage). + */ +function isOrphanedPendingEvent(ev: any): boolean { + if (!ev) return false; + if (ev.txid || ev.txHash || ev.signedTx) return false; + const status = ev.status; + if (status && status !== 'request') return false; + return true; +} + +async function clearOrphanedApprovalEvents() { + const tag = TAG + ' | clearOrphanedApprovalEvents | '; + try { + const events = (await requestStorage.getEvents()) || []; + if (events.length === 0) return; + const orphans = events.filter(isOrphanedPendingEvent); + if (orphans.length === 0) { + console.log(tag, `${events.length} event(s) in storage; all post-broadcast — preserving for UI completion`); + return; + } + console.log( + tag, + `dropping ${orphans.length} orphaned pending event(s) (preserving ${events.length - orphans.length} post-broadcast)`, + ); + for (const ev of orphans) { + // Notify side-panel UI (no-op if no panel is listening). The + // dApp side already received a port-closed error when the SW + // died, so we don't need to signal there. + chrome.runtime + .sendMessage({ + action: 'transaction_error', + eventId: ev.id, + error: 'Request cancelled — wallet restarted', + kind: 'cancelled', + }) + .catch(() => {}); + try { + await requestStorage.removeEventById(ev.id); + } catch (e) { + console.warn(tag, 'failed to remove orphan', ev.id, e); + } + } + // The badge was set when the request was created; the cleanup + // that would have unset it died with the SW. Reset only if every + // event in storage is now gone — otherwise a preserved post- + // broadcast entry may still legitimately want the badge. + const remaining = (await requestStorage.getEvents()) || []; + if (remaining.length === 0) setApprovalBadge(false); + } catch (e) { + console.warn(tag, 'unexpected error', e); + } +} + +// Fire as the SW spawns — BEFORE the 5s wallet-init delay below, so the +// side panel doesn't have a window to render and accept clicks on a +// stale orphan event during boot. Async; we don't await at top level +// (would block other listener registrations in MV3). +void clearOrphanedApprovalEvents(); + const onStart = async function () { const tag = TAG + ' | onStart | '; try { console.log(tag, 'Starting...'); + // Orphan cleanup runs at module-load time (above) so the side + // panel can't render stale events during the 5s wallet-init + // delay. Don't repeat it here. resetSolanaState(); // clear stale cached address before re-init + resetTronState(); + resetTonState(); await wallet.init(); console.log(tag, 'Wallet initialized'); @@ -423,23 +758,53 @@ const onStart = async function () { pushStateChangeEvent(); } - const defaultProvider: any = { - chainId: '0x1', - caip: 'eip155:1/slip44:60', - blockExplorerUrls: ['https://etherscan.io'], - name: 'Ethereum', - providerUrl: 'https://eth.llamarpc.com', - fallbacks: [], - }; - // Get current provider + // Get current provider — only build a default if none is stored. + // Source the default from Pioneer rather than a hardcoded URL so + // we never ship a stale RPC behind a release. const currentProvider = await web3ProviderStorage.getWeb3Provider(); if (!currentProvider) { - console.log(tag, 'No provider set, setting default provider'); - await web3ProviderStorage.saveWeb3Provider(defaultProvider); + console.log(tag, 'No provider set, fetching default ETH config from Pioneer'); + const ethInfo = await getChainInfo('eip155:1'); + if (ethInfo?.rpc) { + await web3ProviderStorage.saveWeb3Provider({ + chainId: ethInfo.chainId, + caip: ethInfo.caip, + blockExplorerUrls: ethInfo.explorer ? [ethInfo.explorer] : [], + name: ethInfo.name, + providerUrl: ethInfo.rpc, + fallbacks: ethInfo.rpcs.slice(1), + } as any); + } else { + console.warn(tag, 'Pioneer did not return ETH chain info — leaving provider unset until user picks one'); + } } - // Fetch balances in background (non-blocking) + // Fetch balances in background (non-blocking). First pass covers EVM/UTXO + // quickly — on first run the Solana / Tron / TON pubkeys haven't been + // derived yet, so they won't be in this request. fetchBalancesFromPioneer().catch(e => console.warn(tag, 'Initial balance fetch failed:', e)); + + // Solana / Tron / TON addresses are derived outside the batch xpub + // flow (firmware message type is separate). Each prefetch internally + // calls wallet.addPubkey, so once they all settle the wallet has the + // complete pubkey set. We then fire ONE force-refresh against Pioneer + // /portfolio with everyone present — that's the request whose + // snapshot won't be invalidated mid-flight, so its commit actually + // lands. + // + // Why not chain a force-fetch after each individual prefetch (the + // old shape)? Because they ran in parallel: each fetch's snapshot + // misses the pubkeys still being added by the other two prefetches, + // and the staleness guard at fetchBalancesFromPioneer's commit step + // discards them. With three parallel prefetches, only the last one + // to commit could survive — and if that one was Tron/TON without + // Solana having fully landed yet, SPL tokens never made it into + // cachedBalances. Users saw "No tokens" until they hit the manual + // Discover button (which by coincidence runs after the slowest + // prefetch finally lands). + Promise.allSettled([prefetchSolanaPubkey(), prefetchTronPubkey(), prefetchTonAddress()]) + .then(() => fetchBalancesFromPioneer(true)) + .catch(e => console.warn(tag, 'Post-prefetch balance fetch failed:', e)); } else { console.error(tag, 'FAILED TO INIT, No Ethereum address found'); } @@ -466,12 +831,44 @@ chrome.runtime.onMessage.addListener((message: any, sender: any, sendResponse: a const { requestInfo } = message; const { method, params, chain } = requestInfo; + // Tag the request with the sender's browser tab/window so the + // approval side panel opens in the SAME window the dApp lives + // in — not whichever web tab was focused last. Using "most + // recently accessed" risked surfacing a signing prompt in a + // completely different browser window than the one that + // triggered it, which is a real phishing / mis-sign risk now + // that the sidebar is the sole approval surface. + if (sender?.tab) { + requestInfo.__senderTabId = sender.tab.id; + requestInfo.__senderWindowId = sender.tab.windowId; + } + if (method) { try { // KEEPKEY_WALLET and ADDRESS are passed for backward compat with handler signatures const result = await handleWalletRequest(requestInfo, chain, method, params, null, ADDRESS); + + // [HANDOFF] log: emit params + result on a single line per call so a + // dApp-flow audit can be reconstructed by `grep '[HANDOFF]'` in the + // background console. Especially valuable for read-side RPCs (eth_call, + // eth_getBalance, eth_estimateGas, eth_getCode, eth_getTransactionCount) + // that build the dApp's view of wallet state — if any of those return a + // value that contradicts mainnet, the dApp builds a doomed request body + // (e.g. wrong Permit2 nonce → /v1/swap 404). + const resultType = typeof result; + const resultPreview = + resultType === 'string' + ? `len=${(result as string).length} value=${result}` + : `value=${JSON.stringify(result)}`; + console.log( + `[HANDOFF] BEX → content script (${chain}/${method})\n params=${JSON.stringify(params)}\n type=${resultType} ${resultPreview}`, + ); sendResponse({ result }); } catch (error) { + console.log( + `[HANDOFF] BEX → content script (${chain}/${method}) ERROR\n params=${JSON.stringify(params)}\n error=`, + error, + ); sendResponse({ error: formatUserError(error) }); } } else { @@ -480,45 +877,6 @@ chrome.runtime.onMessage.addListener((message: any, sender: any, sendResponse: a break; } - case 'open_sidebar': - case 'OPEN_SIDEBAR': { - console.log(tag, 'Opening sidebar ** '); - chrome.tabs.query({}, tabs => { - if (chrome.runtime.lastError) { - console.error('Error querying tabs:', chrome.runtime.lastError); - return; - } - - const webPageTabs = tabs.filter(tab => { - return ( - tab.url && - !tab.url.startsWith('chrome://') && - !tab.url.startsWith('chrome-extension://') && - !tab.url.startsWith('about:') - ); - }); - - if (webPageTabs.length > 0) { - webPageTabs.sort((a, b) => b.lastAccessed - a.lastAccessed); - const tab = webPageTabs[0]; - const windowId = tab.windowId; - - console.log(tag, 'Opening sidebar in tab:', tab); - - chrome.sidePanel.open({ windowId }, () => { - if (chrome.runtime.lastError) { - console.error('Error opening side panel:', chrome.runtime.lastError); - } else { - console.log('Side panel opened successfully.'); - } - }); - } else { - console.error('No suitable web page tabs found to open the side panel.'); - } - }); - break; - } - case 'GET_KEEPKEY_STATE': { sendResponse({ state: KEEPKEY_STATE }); break; @@ -574,8 +932,13 @@ chrome.runtime.onMessage.addListener((message: any, sender: any, sendResponse: a case 'RESET_APP': { console.log(tag, 'Resetting app...'); + // Reply FIRST so the caller sees the ack before the service worker + // reload tears down the message channel. Every other handler in + // this file returns `{ success: true }` — align here too so UI + // callers that branch on `response?.success` don't log/toast a + // false failure on a successful reset. + sendResponse({ success: true }); chrome.runtime.reload(); - sendResponse({ result: true }); break; } @@ -608,12 +971,15 @@ chrome.runtime.onMessage.addListener((message: any, sender: any, sendResponse: a if (!tx) throw new Error('Invalid request: missing tx'); if (!source) throw new Error('Invalid request: missing source'); - const response = await fetch(`${PIONEER_API}/api/v1/insight`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ tx, source }), - }); - const result = await response.json(); + const result = await fetchJsonWithTimeout( + `${PIONEER_API}/api/v1/insight`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ tx, source }), + }, + { timeoutMs: 8000, retries: 1 }, + ); console.log(tag, 'GET_TX_INSIGHT result:', result); sendResponse(result); } catch (error: any) { @@ -627,7 +993,10 @@ chrome.runtime.onMessage.addListener((message: any, sender: any, sendResponse: a try { const providerInfo = await web3ProviderStorage.getWeb3Provider(); if (!providerInfo) throw Error('Failed to get provider info'); - const evmProvider = new JsonRpcProvider(providerInfo.providerUrl); + const evmProvider = makeStaticProvider( + providerInfo.providerUrl, + providerInfo.networkId || providerInfo.chainId, + ); const feeData = await evmProvider.getFeeData(); sendResponse(feeData); } catch (error: any) { @@ -671,12 +1040,54 @@ chrome.runtime.onMessage.addListener((message: any, sender: any, sendResponse: a if (!asset.address && asset.pubkeys?.[0]?.address) { asset.address = asset.pubkeys[0].address; } + + // UTXO assets coming from non-header paths (global + // Receive, dashboard balance click, asset list) don't + // carry a specific note/script_type. Without one, + // GET_PUBKEY_CONTEXT falls back to scoped[0] and + // Receive shows a different address from what the + // header dropdown displays. + // + // Default selection mirrors what the header builders do + // so all entry points stay in sync: + // - BTC: first p2wpkh (buildBtcAccounts marks Native + // Segwit as isDefault). + // - Other UTXO (LTC, DOGE, DASH, BCH): first scoped + // pubkey in chainConfig order — buildUtxoAccounts + // uses items.length === 0, so e.g. LTC defaults to + // legacy p2pkh (configured before p2wpkh) and we + // must NOT silently shift it to native segwit. + const BTC_GENESIS_PREFIX = 'bip122:000000000019d6689c085ae165831e93'; + if (asset.networkId.startsWith('bip122:') && !asset.note && !asset.script_type) { + const scoped = wallet.getPubkeys(asset.networkId); + const isBtc = asset.networkId.startsWith(BTC_GENESIS_PREFIX); + const preferred = isBtc + ? scoped.find((pk: any) => pk.script_type === 'p2wpkh') || scoped[0] + : scoped[0]; + if (preferred) { + asset.note = preferred.note; + asset.script_type = preferred.script_type; + } + } } - // Track previous address/chain to detect changes for dApp notification - const prevAddress = ADDRESS; - const prevProvider = await web3ProviderStorage.getWeb3Provider(); - const prevChainId = prevProvider?.chainId; + // Enrich asset with cached native balance so Send/Transfer can + // read a scalar `balance`. GET_ASSETS returns the catalog (no + // balance), so without this step the Transfer component saw + // `undefined` and fell back to 0 — Max/50% became no-ops and the + // "Sending X SOL" hero read 0. Prefer exact caip match, then the + // native row for the network. + if (asset.networkId && !asset.balance) { + const exact = asset.caip && cachedBalances.find((b: any) => b.caip === asset.caip); + const nativeFallback = cachedBalances.find((b: any) => b.networkId === asset.networkId && b.isNative); + const match = exact || nativeFallback; + if (match) { + asset.balance = match.balance; + if (!asset.priceUsd) asset.priceUsd = match.priceUsd; + if (!asset.valueUsd) asset.valueUsd = match.valueUsd; + if (!asset.icon && match.icon) asset.icon = match.icon; + } + } // Update global ADDRESS for EVM signing when account changes if (asset.networkId?.startsWith('eip155:') && asset.address) { @@ -688,31 +1099,32 @@ chrome.runtime.onMessage.addListener((message: any, sender: any, sendResponse: a await assetContextStorage.updateContext(asset); // If eip155 then set web3 provider - let newChainId: string | undefined; if (asset.networkId && asset.networkId.includes('eip155')) { // Try to get provider data from custom chains first (user-added networks) let providerData = await blockchainDataStorage.getBlockchainData(asset.networkId); - // Fallback to static chain list if not found in custom storage + // Fall through to Pioneer registry if user hasn't added + // this chain manually. Pioneer is the source of truth for + // RPC + chain metadata; misses here mean Pioneer doesn't + // know the chain (rare — its catalog has 196+ EVMs). if (!providerData) { - const chainInfo = EIP155_CHAINS[asset.networkId]; + const chainInfo = await getChainInfo(asset.networkId); if (chainInfo) { providerData = { chainId: chainInfo.chainId, caip: chainInfo.caip, - blockExplorerUrls: [], + blockExplorerUrls: chainInfo.explorer ? [chainInfo.explorer] : [], name: chainInfo.name, providerUrl: chainInfo.rpc, - fallbacks: [], + fallbacks: chainInfo.rpcs.slice(1), }; } else { - console.error(tag, 'Network not found in custom or static chains:', asset.networkId); + console.error(tag, 'Network not found in custom storage or Pioneer:', asset.networkId); } } if (providerData) { await web3ProviderStorage.saveWeb3Provider(providerData); - newChainId = providerData.chainId; } } @@ -723,36 +1135,6 @@ chrome.runtime.onMessage.addListener((message: any, sender: any, sendResponse: a }) .catch(() => {}); - // EIP-1193: Notify dApps of account/chain changes via content script relay - if (asset.networkId?.startsWith('eip155:')) { - const addressChanged = asset.address && asset.address !== prevAddress; - const chainChanged = newChainId && newChainId !== prevChainId; - - if (addressChanged || chainChanged) { - chrome.tabs.query({}, tabs => { - for (const tab of tabs) { - if (!tab.id) continue; - if (addressChanged) { - chrome.tabs - .sendMessage(tab.id, { - type: 'ACCOUNTS_CHANGED', - accounts: [ADDRESS], - }) - .catch(() => {}); - } - if (chainChanged) { - chrome.tabs - .sendMessage(tab.id, { - type: 'CHAIN_CHANGED', - provider: { chainId: newChainId }, - }) - .catch(() => {}); - } - } - }); - } - } - sendResponse(asset); } catch (error) { console.error('Error setting asset context:', error); @@ -765,9 +1147,48 @@ chrome.runtime.onMessage.addListener((message: any, sender: any, sendResponse: a } case 'GET_PUBKEY_CONTEXT': { - // Return first pubkey as context - const pubkeys = wallet.getPubkeys(); - sendResponse({ pubkeyContext: pubkeys.length > 0 ? pubkeys[0] : null }); + // Scope to the currently selected asset so Receive shows the correct + // address. Returning pubkeys[0] unconditionally meant a multi-account + // or multi-chain wallet would surface account-0 / Bitcoin for every + // asset switch — a foot-gun serious enough to send funds to the + // wrong place. Fall back to pubkeys[0] only if no asset context is + // set (cold-start before any selection). + try { + const ctx = await assetContextStorage.get(); + const allPubkeys = wallet.getPubkeys(); + let chosen: any = null; + + if (ctx?.networkId) { + const scoped = wallet.getPubkeys(ctx.networkId); + if (scoped.length > 0) { + // Match priority: note → script_type → accountIndex → + // scoped[0]. Note is the only identifier that's unique + // across every chainConfig path; script_type collapses + // BTC account 0 / account 1 (both p2wpkh) and would + // always pick the first one. accountIndex is fine for + // multi-account EVM but is unset on UTXO header rows. + const ctxNote = (ctx as any).note; + const ctxScriptType = (ctx as any).script_type; + if (ctxNote) { + chosen = scoped.find((pk: any) => pk.note === ctxNote); + } + if (!chosen && ctxScriptType) { + chosen = scoped.find((pk: any) => pk.script_type === ctxScriptType); + } + if (!chosen && (ctx as any).accountIndex !== undefined) { + chosen = scoped.find((pk: any) => pk.accountIndex === (ctx as any).accountIndex); + } + if (!chosen) chosen = scoped[0]; + } + } + + if (!chosen) chosen = allPubkeys[0] ?? null; + sendResponse({ pubkeyContext: chosen }); + } catch (e) { + console.error('GET_PUBKEY_CONTEXT failed:', e); + const pubkeys = wallet.getPubkeys(); + sendResponse({ pubkeyContext: pubkeys[0] ?? null }); + } break; } @@ -796,6 +1217,56 @@ chrome.runtime.onMessage.addListener((message: any, sender: any, sendResponse: a break; } + // Derive a UTXO receive address locally from the xpub held in the + // matching pubkey entry. Needed because /api/pubkeys/batch returns + // UTXO rows with { pubkey: "", address: "" } — showing the + // xpub as an address was the endless-spinner / wrong-address bug on + // the Receive page. Pubkey-to-address is pure BIP32 + script-type + // encoding, so no device round-trip is required; this works in + // view-only mode too. Not cached: derivation is microseconds, and + // a session-storage cache keyed without the xpub would surface the + // previous device's address after a hot-swap. + case 'GET_UTXO_ADDRESS': { + const { networkId, scriptType, note } = message as { + networkId: string; + scriptType?: string; + note?: string; + }; + try { + const scoped = wallet.getPubkeys(networkId); + if (scoped.length === 0) { + sendResponse({ error: 'No pubkey for network' }); + break; + } + // `note` is unique per path config, so match it first — + // multiple accounts can share a script_type (e.g. several BTC + // p2wpkh accounts), and matching by script_type first would + // always pick the first one regardless of which account the + // caller asked for. Raw pubkey objects use snake_case + // `script_type`, matching chainConfig.ts and the SDK request + // shape; do not rename them here. + const match = + (note && scoped.find((pk: any) => pk.note === note)) || + (scriptType && scoped.find((pk: any) => pk.script_type === scriptType)) || + scoped[0]; + const xpub: string | undefined = match.pubkey || match.master; + if (!xpub) { + sendResponse({ error: 'No xpub on pubkey entry' }); + break; + } + const address = deriveUtxoAddress({ + xpub, + scriptType: match.script_type, + networkId, + }); + sendResponse({ address, scriptType: match.script_type }); + } catch (e: any) { + console.error('GET_UTXO_ADDRESS failed:', e); + sendResponse({ error: e?.message || 'deriveUtxoAddress failed' }); + } + break; + } + case 'ADD_ACCOUNT_PATH': { const { path } = message; try { @@ -854,7 +1325,12 @@ chrome.runtime.onMessage.addListener((message: any, sender: any, sendResponse: a const { accountIndex: removeIdx } = message; try { const accounts = await ethAccountsStorage.removeAccount(removeIdx); - sendResponse({ success: true, accounts }); + // Without clearing runtime state the signer and pubkey list keep + // the removed account — the UI shows it gone while the wallet + // still holds it, and the next request could sign against the + // supposedly-removed account. + await wallet.removePathByNote(`Ethereum account ${removeIdx}`); + sendResponse({ success: true, accounts, pubkeys: wallet.getPubkeys() }); } catch (error) { console.error('Error removing ETH account:', error); sendResponse({ error: 'Failed to remove ETH account' }); @@ -877,6 +1353,30 @@ chrome.runtime.onMessage.addListener((message: any, sender: any, sendResponse: a const { network } = message; try { const networks = await customEvmNetworksStorage.addNetwork(network); + // Mirror into the storages the SET_ASSET_CONTEXT handler reads + // for provider config. Without this, the header dropdown renders + // the new network (from customEvmNetworksStorage) but selecting + // it falls through to the Pioneer registry, which won't have + // the user's custom RPC URL — only the chain's public ones. + // Persisting here keeps the user's overrides authoritative. + const cleanRpc = (network.rpc || '').trim(); + const cleanExplorer = (network.explorerUrl || '').trim(); + const chainIdHex = '0x' + Number(network.chainId).toString(16); + await blockchainDataStorage.addBlockchainData(network.networkId, { + chainId: chainIdHex, + caip: `${network.networkId}/slip44:60`, + name: network.name, + symbol: network.symbol, + explorer: cleanExplorer, + explorerAddressLink: cleanExplorer ? `${cleanExplorer}/address/` : '', + explorerTxLink: cleanExplorer ? `${cleanExplorer}/tx/` : '', + blockExplorerUrls: cleanExplorer ? [cleanExplorer] : [], + providerUrl: cleanRpc, + providers: cleanRpc ? [cleanRpc] : [], + nativeCurrency: { name: network.symbol, symbol: network.symbol, decimals: 18 }, + type: 'evm', + } as any); + await blockchainStorage.addBlockchain(network.networkId); sendResponse({ success: true, networks }); } catch (error) { console.error('Error adding custom EVM network:', error); @@ -889,6 +1389,29 @@ chrome.runtime.onMessage.addListener((message: any, sender: any, sendResponse: a const { networkId: removeNetId } = message; try { const networks = await customEvmNetworksStorage.removeNetwork(removeNetId); + await blockchainStorage.removeBlockchain(removeNetId); + // blockchainDataStorage has no remove API; drop the key via the + // raw set helper so we don't leave an orphaned provider entry. + await blockchainDataStorage.set((prev: any) => { + if (!prev || !(removeNetId in prev)) return prev || {}; + const next = { ...prev }; + delete next[removeNetId]; + return next; + }); + // If the removed network was actively selected, the asset + // context and web3 provider still point at it — the signer + // would keep using a chain the user just deleted. Clear both + // and tell the sidebar so it can drop its drawer / header + // selection. + const currentCtx = await assetContextStorage.get().catch(() => null); + const currentProvider = await web3ProviderStorage.getWeb3Provider().catch(() => null); + if ((currentCtx as any)?.networkId === removeNetId) { + await assetContextStorage.clearContext().catch(() => {}); + chrome.runtime.sendMessage({ type: 'ASSET_CONTEXT_CLEARED' }).catch(() => {}); + } + if ((currentProvider as any)?.networkId === removeNetId) { + await web3ProviderStorage.clearWeb3Provider().catch(() => {}); + } sendResponse({ success: true, networks }); } catch (error) { console.error('Error removing custom EVM network:', error); @@ -920,15 +1443,14 @@ chrome.runtime.onMessage.addListener((message: any, sender: any, sendResponse: a console.log(tag, 'GET_ASSET_BALANCE'); const { networkId } = message; - // Get RPC provider for the network - const chainInfo = EIP155_CHAINS[networkId]; - if (chainInfo && ADDRESS) { - const evmProvider = new JsonRpcProvider(chainInfo.rpc); - const balance = await evmProvider.getBalance(ADDRESS); - sendResponse('0x' + balance.toString(16)); - } else { + if (!ADDRESS) { sendResponse('0'); + break; } + // Fail over across user-override → Pioneer → last-resort if + // any candidate rate-limits or 5xx's. + const balance = await withRpcFailoverByNetworkId(networkId, p => p.getBalance(ADDRESS)); + sendResponse('0x' + balance.toString(16)); } catch (error) { console.error('Error fetching balance:', error); sendResponse({ error: 'Failed to fetch balance' }); @@ -940,8 +1462,11 @@ chrome.runtime.onMessage.addListener((message: any, sender: any, sendResponse: a try { const { networkId } = message; const chainId = networkId.replace('eip155:', ''); - const response = await fetch(`${PIONEER_API}/api/v1/nodes?chainId=${encodeURIComponent(chainId)}`); - const data = await response.json(); + const data = await fetchJsonWithTimeout( + `${PIONEER_API}/api/v1/nodes?chainId=${encodeURIComponent(chainId)}`, + {}, + { timeoutMs: 5000, retries: 1 }, + ); sendResponse(data); } catch (error) { console.error('Error fetching asset info:', error); @@ -1041,31 +1566,28 @@ chrome.runtime.onMessage.addListener((message: any, sender: any, sendResponse: a break; } - // Find RPC URL — try custom chains first, then static list - let rpcUrl: string | undefined; + // Resolve display metadata. The actual RPC call goes through + // withRpcFailoverByNetworkId below, which iterates the same + // priority list (custom → Pioneer → last-resort) on transient + // failure. If neither custom nor Pioneer knows the chain, the + // failover helper itself throws — caught and surfaced. let chainName = evmNetworkId; let chainSymbol = 'ETH'; - const customChain = await blockchainDataStorage.getBlockchainData(evmNetworkId); - if (customChain?.providerUrl) { - rpcUrl = customChain.providerUrl; + if (customChain) { chainName = customChain.name || evmNetworkId; chainSymbol = customChain.nativeCurrency?.symbol || customChain.symbol || 'ETH'; - } else if (EIP155_CHAINS[evmNetworkId]) { - rpcUrl = EIP155_CHAINS[evmNetworkId].rpc; - chainName = EIP155_CHAINS[evmNetworkId].name; - } - - if (!rpcUrl) { - sendResponse({ balance: '0', valueUsd: '0', error: 'No RPC for network' }); - break; + } else { + const pioneerChain = await getChainInfo(evmNetworkId); + if (pioneerChain) { + chainName = pioneerChain.name; + chainSymbol = pioneerChain.symbol || 'ETH'; + } } - const rpcProvider = new JsonRpcProvider(rpcUrl); - const rawBal = await Promise.race([ - rpcProvider.getBalance(evmAddress), - new Promise((_, reject) => setTimeout(() => reject(new Error('timeout')), 8000)), - ]); + const rawBal = await withRpcFailoverByNetworkId(evmNetworkId, p => p.getBalance(evmAddress), { + timeoutMs: 8000, + }); const balStr = formatEther(rawBal); // Try to get USD price from cached balances for this network @@ -1094,10 +1616,19 @@ chrome.runtime.onMessage.addListener((message: any, sender: any, sendResponse: a case 'GET_CHARTS': { try { + const { networkIds } = message; let balances = cachedBalances; if (balances.length === 0 && wallet.isInitialized()) { balances = await fetchBalancesFromPioneer(); } + // Honor the networkIds filter the UI hooks send. Previously this + // parameter was ignored and "discover tokens for this network" + // returned the global set, making stale/unrelated balances leak + // into single-network views. + if (Array.isArray(networkIds) && networkIds.length > 0) { + const allow = new Set(networkIds); + balances = balances.filter((b: any) => allow.has(b.networkId)); + } const totalValueUsd = balances.reduce((sum: number, b: any) => sum + parseFloat(b.valueUsd || '0'), 0); sendResponse({ success: true, @@ -1121,12 +1652,15 @@ chrome.runtime.onMessage.addListener((message: any, sender: any, sendResponse: a const payload: any = { networkId, contractAddress }; if (userAddress) payload.userAddress = userAddress; - const response = await fetch(`${PIONEER_API}/api/v1/tokens/metadata`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(payload), - }); - const data = await response.json(); + const data = await fetchJsonWithTimeout( + `${PIONEER_API}/api/v1/tokens/metadata`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }, + { timeoutMs: 8000, retries: 1 }, + ); sendResponse({ success: true, data }); } catch (error: any) { console.error('Error looking up token metadata:', error); @@ -1142,24 +1676,27 @@ chrome.runtime.onMessage.addListener((message: any, sender: any, sendResponse: a throw new Error('userAddress and token are required'); } - const response = await fetch(`${PIONEER_API}/api/v1/tokens/custom`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - userAddress, - token: { - networkId: token.networkId, - address: token.address, - caip: token.caip, - name: token.name, - symbol: token.symbol, - decimals: token.decimals, - icon: token.icon, - coingeckoId: token.coingeckoId, - }, - }), - }); - const data = await response.json(); + const data = await fetchJsonWithTimeout( + `${PIONEER_API}/api/v1/tokens/custom`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + userAddress, + token: { + networkId: token.networkId, + address: token.address, + caip: token.caip, + name: token.name, + symbol: token.symbol, + decimals: token.decimals, + icon: token.icon, + coingeckoId: token.coingeckoId, + }, + }), + }, + { timeoutMs: 8000, retries: 1 }, + ); sendResponse({ success: data?.success || false, data }); } catch (error: any) { console.error('Error adding custom token:', error); @@ -1178,8 +1715,7 @@ chrome.runtime.onMessage.addListener((message: any, sender: any, sendResponse: a let url = `${PIONEER_API}/api/v1/tokens/custom?userAddress=${encodeURIComponent(userAddress)}`; if (networkId) url += `&networkId=${encodeURIComponent(networkId)}`; - const response = await fetch(url); - const data = await response.json(); + const data = await fetchJsonWithTimeout(url, {}, { timeoutMs: 8000, retries: 1 }); const tokens = data?.data?.tokens || data?.tokens || []; sendResponse({ success: true, tokens }); } catch (error: any) { @@ -1196,10 +1732,11 @@ chrome.runtime.onMessage.addListener((message: any, sender: any, sendResponse: a throw new Error('networkId and address are required'); } - const response = await fetch( + const data = await fetchJsonWithTimeout( `${PIONEER_API}/api/v1/tokens/balances?networkId=${encodeURIComponent(networkId)}&address=${encodeURIComponent(address)}`, + {}, + { timeoutMs: 8000, retries: 1 }, ); - const data = await response.json(); const tokens = data?.data?.tokens || data?.tokens || []; sendResponse({ success: true, tokens }); } catch (error: any) { @@ -1216,12 +1753,15 @@ chrome.runtime.onMessage.addListener((message: any, sender: any, sendResponse: a throw new Error('userAddress, networkId, and tokenAddress are required'); } - const response = await fetch(`${PIONEER_API}/api/v1/tokens/custom`, { - method: 'DELETE', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ userAddress, networkId, tokenAddress }), - }); - const data = await response.json(); + const data = await fetchJsonWithTimeout( + `${PIONEER_API}/api/v1/tokens/custom`, + { + method: 'DELETE', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ userAddress, networkId, tokenAddress }), + }, + { timeoutMs: 8000, retries: 1 }, + ); sendResponse({ success: data?.success || false, data }); } catch (error: any) { console.error('Error removing custom token:', error); @@ -1244,15 +1784,6 @@ chrome.runtime.onMessage.addListener((message: any, sender: any, sendResponse: a break; } - // Get RPC provider for the network - const chainInfo = EIP155_CHAINS[networkId]; - if (!chainInfo) { - sendResponse({ valid: false, error: 'Unsupported network' }); - break; - } - - const rpcProvider = new JsonRpcProvider(chainInfo.rpc); - // ERC-20 ABI for name, symbol, and decimals const ERC20_ABI = [ 'function name() view returns (string)', @@ -1261,13 +1792,25 @@ chrome.runtime.onMessage.addListener((message: any, sender: any, sendResponse: a ]; const { Contract } = await import('ethers'); - const tokenContract = new Contract(contractAddress, ERC20_ABI, rpcProvider); - const [name, symbol, decimals] = await Promise.all([ - tokenContract.name(), - tokenContract.symbol(), - tokenContract.decimals(), - ]); + // Run all three reads against the same provider per failover + // attempt — splitting them across providers would risk a + // partial result if one URL rate-limits mid-validation. + // ERC20 reads are read-only views; if they fail, the next + // candidate URL gets the whole bundle. + const { name, symbol, decimals } = await withRpcFailoverByNetworkId( + networkId, + async rpcProvider => { + const tokenContract = new Contract(contractAddress, ERC20_ABI, rpcProvider); + const [n, s, d] = await Promise.all([ + tokenContract.name(), + tokenContract.symbol(), + tokenContract.decimals(), + ]); + return { name: n, symbol: s, decimals: d }; + }, + { timeoutMs: 8000 }, + ); const caip = `${networkId}/erc20:${contractAddress.toLowerCase()}`; @@ -1298,6 +1841,16 @@ chrome.runtime.onMessage.addListener((message: any, sender: any, sendResponse: a break; } + case 'CLEAR_APPROVAL_BADGE': { + // Sent from info-only side-panel surfaces (e.g. chain-not-enabled + // card) that bypass the requireApproval flow. The standard + // approval path manages its own badge in popup.ts; this lets + // out-of-band cards clean up after themselves. + setApprovalBadge(false); + sendResponse({ success: true }); + break; + } + case 'GET_CACHED_PUBKEYS_STATUS': { const { pubkeyStorage } = await import('@extension/storage'); try { @@ -1380,12 +1933,6 @@ exampleSidebarStorage console.error('Error fetching sidebar storage:', error); }); -chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { - if (message.type === 'getMaskingSettings') { - chrome.storage.local.get(['enableMetaMaskMasking', 'enableXfiMasking', 'enableKeplrMasking'], result => { - console.log('getMaskingSettings result: ', result); - sendResponse(result); - }); - return true; - } -}); +// Masking settings are read directly by the content script from +// chrome.storage.local before injection; there's no background handler +// for them. diff --git a/chrome-extension/src/background/methods.ts b/chrome-extension/src/background/methods.ts index 9150c24..05e24d3 100644 --- a/chrome-extension/src/background/methods.ts +++ b/chrome-extension/src/background/methods.ts @@ -14,64 +14,24 @@ import { handleOsmosisRequest } from './chains/osmosisHandler'; import { handleMayaRequest } from './chains/mayaHandler'; import { handleRippleRequest } from './chains/rippleHandler'; import { handleSolanaRequest } from './chains/solanaHandler'; -import { createProviderRpcError, ProviderRpcError, formatUserError } from './utils'; +import { handleTronRequest } from './chains/tronHandler'; +import { handleTonRequest } from './chains/tonHandler'; +import type { ProviderRpcError } from './utils'; +import { createProviderRpcError, formatUserError } from './utils'; +import { openSidePanel, setApprovalBadge } from './popup'; const TAG = ' | METHODS | '; -let isPopupOpen = false; // Flag to track popup state -let popupWindowId: number | null = null; // Track the popup window ID - -const openPopup = function () { - const tag = TAG + ' | openPopup | '; - try { - // If popup is already open, focus it instead of creating a new one - if (isPopupOpen && popupWindowId !== null) { - console.log(tag, 'Popup already open, focusing existing window:', popupWindowId); - chrome.windows.update(popupWindowId, { focused: true }).catch(err => { - console.error(tag, 'Failed to focus existing popup, creating new one:', err); - isPopupOpen = false; - popupWindowId = null; - openPopup(); - }); - return; - } - - console.log(tag, 'Opening popup'); - isPopupOpen = true; - chrome.windows.create( - { - url: chrome.runtime.getURL('popup/index.html'), // Adjust the URL to your popup file - type: 'popup', - width: 360, - height: 900, - }, - window => { - if (chrome.runtime.lastError) { - console.error('Error creating popup:', chrome.runtime.lastError); - isPopupOpen = false; - popupWindowId = null; - } else { - console.log('Popup window created:', window); - popupWindowId = window?.id || null; - - // Listen for when the popup is closed - chrome.windows.onRemoved.addListener(function windowClosedListener(windowId) { - if (windowId === popupWindowId) { - console.log(tag, 'Popup closed, resetting state'); - isPopupOpen = false; - popupWindowId = null; - chrome.windows.onRemoved.removeListener(windowClosedListener); - } - }); - } - }, - ); - } catch (e) { - console.error(tag, e); - isPopupOpen = false; - popupWindowId = null; - } -}; +// Approval requests are dApp-triggered, which means we're NOT inside a user +// gesture. `chrome.sidePanel.open()` requires a recent user gesture, so the +// call below may be ignored. The fallback path is the action badge plus +// `setPanelBehavior({openPanelOnActionClick: true})` wired in index.ts — +// the user clicks the extension icon (a real user gesture), the sidebar +// opens, and its `requestStorage` subscription picks up the pending event. +// +// Hard timeout on the promise so nothing hangs forever if the user +// ignores the request. Matches the sidebar's event-age eviction window. +const APPROVAL_TIMEOUT_MS = 10 * 60_000; /* "requestInfo": { @@ -153,22 +113,40 @@ const requireApproval = async function ( // throw new Error('Event not saved'); // } - openPopup(); + setApprovalBadge(true); + await openSidePanel(requestInfo); - // Wait for user's decision and return the result + // Wait for user's decision. Resolves on ANY of: + // - user approves/rejects in sidebar (eth_sign_response arrives) + // - APPROVAL_TIMEOUT_MS elapses without a response (treated as reject) return new Promise(resolve => { - const listener = (message: any, sender: chrome.runtime.MessageSender, sendResponse: any) => { - if (message.action === 'eth_sign_response' && message.response.eventId === requestInfo.id) { + let settled = false; + let timer: ReturnType | null = null; + + const cleanup = () => { + chrome.runtime.onMessage.removeListener(listener); + if (timer != null) clearTimeout(timer); + setApprovalBadge(false); + }; + + const listener = (message: any) => { + if (message?.action === 'eth_sign_response' && message?.response?.eventId === requestInfo.id) { + if (settled) return; + settled = true; console.log(tag, 'Received eth_sign_response for event:', message.response.eventId); - chrome.runtime.onMessage.removeListener(listener); - if (message.response.decision === 'accept') { - resolve({ success: true }); - } else { - resolve({ success: false }); - } + cleanup(); + resolve({ success: message.response.decision === 'accept' }); } }; chrome.runtime.onMessage.addListener(listener); + + timer = setTimeout(() => { + if (settled) return; + settled = true; + console.log(tag, 'Approval timed out, rejecting for event:', requestInfo.id); + cleanup(); + resolve({ success: false }); + }, APPROVAL_TIMEOUT_MS); }); } catch (e) { console.error(tag, e); @@ -176,16 +154,6 @@ const requireApproval = async function ( } }; -const requireUnlock = async function () { - const tag = TAG + ' | requireUnlock | '; - try { - console.log(tag, 'requireUnlock for domain'); - // openPopup(); - } catch (e) { - console.error(e); - } -}; - export const handleWalletRequest = async ( requestInfo: any, chain: string, @@ -253,6 +221,15 @@ export const handleWalletRequest = async ( return await handleSolanaRequest(method, params, requestInfo, ADDRESS, __KEEPKEY_WALLET, requireApproval); break; } + case 'tron': + case 'trx': { + return await handleTronRequest(method, params, requestInfo, ADDRESS, __KEEPKEY_WALLET, requireApproval); + break; + } + case 'ton': { + return await handleTonRequest(method, params, requestInfo, ADDRESS, __KEEPKEY_WALLET, requireApproval); + break; + } default: { console.log(tag, `Chain ${chain} not supported`); throw createProviderRpcError(4200, `Chain ${chain} not supported`); @@ -280,9 +257,15 @@ export const handleWalletRequest = async ( errorMessage = formatUserError({ message: errorMessage }); //push error to the popup + // Forward `kind` so the side panel can render category-specific UI + // (e.g. friendly retry card for timeouts) without regex-matching + // the message. + const kind = (error as ProviderRpcError).kind; chrome.runtime.sendMessage({ action: 'transaction_error', + eventId: requestInfo?.id, error: errorMessage, + kind, }); if ((error as ProviderRpcError).code && (error as ProviderRpcError).message) { diff --git a/chrome-extension/src/background/popup.ts b/chrome-extension/src/background/popup.ts new file mode 100644 index 0000000..378f820 --- /dev/null +++ b/chrome-extension/src/background/popup.ts @@ -0,0 +1,62 @@ +/** + * Side-panel surfacing helpers shared between the approval flow + * (methods.ts) and chain handlers that need to surface info-only + * notifications without going through the requireApproval path + * (e.g. wallet_switchEthereumChain on an unknown chain — must + * still throw 4902 to the dApp per EIP-3326, but the user should + * see *why* nothing happened). + * + * Lives in its own module to break the methods.ts ↔ chain-handler + * circular import that would otherwise form. + */ + +const TAG = ' | popup | '; + +const findTargetWindowId = async (preferred?: number | null): Promise => { + try { + // ALWAYS prefer the sender tab's own window when we know it — that's + // the browser window the dApp is running in, and the side panel + // MUST open there. + if (preferred != null) { + try { + const w = await chrome.windows.get(preferred); + if (w?.id != null) return w.id; + } catch { + // Window closed between request and surface — fall through. + } + } + const current = await chrome.windows.getLastFocused({}); + return current?.id ?? null; + } catch { + return null; + } +}; + +export const openSidePanel = async (requestInfo: any): Promise => { + const tag = TAG + ' | openSidePanel | '; + if (!chrome.sidePanel?.open) return; + try { + const windowId = await findTargetWindowId(requestInfo?.__senderWindowId); + if (windowId == null) { + console.warn(tag, 'No target window found — user must click the extension icon to open the panel'); + return; + } + try { + await chrome.sidePanel.open({ windowId }); + console.log(tag, 'Side panel opened for windowId:', windowId); + } catch (e) { + console.warn(tag, 'sidePanel.open failed (likely no user gesture), falling back to badge', e); + } + } catch (e) { + console.error(tag, e); + } +}; + +export const setApprovalBadge = (pending: boolean) => { + try { + chrome.action.setBadgeText({ text: pending ? '!' : '' }); + if (pending) chrome.action.setBadgeBackgroundColor({ color: '#e74c3c' }); + } catch (e) { + console.warn(TAG, 'setApprovalBadge failed', e); + } +}; diff --git a/chrome-extension/src/background/spamFilter.ts b/chrome-extension/src/background/spamFilter.ts new file mode 100644 index 0000000..37ef0a0 --- /dev/null +++ b/chrome-extension/src/background/spamFilter.ts @@ -0,0 +1,305 @@ +/** + * Token Spam Filter — multi-tier heuristic detection. + * Ported from keepkey-vault-v11 spamFilter.ts. + * + * Detection order (first match wins): + * 1. User override (chrome.storage.local 'visible'/'hidden') — absolute precedence + * 2. Name/symbol contains URL or phishing keywords → CONFIRMED spam + * 3. Symbol has suspicious characters or excessive length → CONFIRMED spam + * 4. Known stablecoin symbol with value < $0.50 → CONFIRMED spam + * 5. Dust airdrop: huge quantity (>1M) + near-zero unit price (<$0.0001) → CONFIRMED spam + * 6. Value < $1 → POSSIBLE spam + * 7. Otherwise → clean + */ + +export const KNOWN_STABLECOINS = [ + 'USDT', + 'USDC', + 'DAI', + 'BUSD', + 'UST', + 'TUSD', + 'USDD', + 'USDP', + 'GUSD', + 'PYUSD', + 'FRAX', + 'LUSD', + 'SUSD', + 'ALUSD', + 'FEI', + 'MIM', + 'DOLA', + 'AGEUR', + 'EURT', + 'EURS', +]; + +/** Well-known legitimate token symbols — exempt from dust-airdrop heuristic */ +const KNOWN_LEGIT_SYMBOLS = new Set([ + // Top tokens by market cap + 'ETH', + 'BTC', + 'WETH', + 'WBTC', + 'BNB', + 'MATIC', + 'POL', + 'AVAX', + 'SOL', + 'DOT', + 'ADA', + 'LINK', + 'UNI', + 'AAVE', + 'MKR', + 'CRV', + 'COMP', + 'SNX', + 'SUSHI', + 'YFI', + 'LDO', + 'RPL', + 'ARB', + 'OP', + 'FTM', + 'ATOM', + 'OSMO', + 'RUNE', + 'CACAO', + 'XRP', + 'DOGE', + 'LTC', + 'BCH', + 'DASH', + 'ZEC', + 'ETC', + // Wrapped / bridged + 'WAVAX', + 'WBNB', + 'WMATIC', + 'WPOL', + 'WFTM', + // Major stablecoins + ...KNOWN_STABLECOINS, + // Major DeFi / governance + 'GRT', + 'ENS', + 'APE', + 'SHIB', + 'PEPE', + 'WLD', + 'IMX', + 'RNDR', + 'FET', + 'OCEAN', + 'SAND', + 'MANA', + 'AXS', + 'GALA', + 'ILV', + 'BLUR', + 'PENDLE', + 'ENA', + 'ETHFI', + 'STX', + 'INJ', + 'TIA', + 'SEI', + 'SUI', + 'APT', + 'NEAR', + 'FIL', + 'AR', + // LSTs / LRTs + 'STETH', + 'RETH', + 'CBETH', + 'WSTETH', + 'SWETH', + 'EETH', + 'WEETH', + 'METH', + 'RSETH', + // FOX + 'FOX', +]); + +export type SpamLevel = 'confirmed' | 'possible' | null; +export type TokenVisibilityStatus = 'visible' | 'hidden'; + +export interface SpamResult { + isSpam: boolean; + level: SpamLevel; + reason: string; +} + +/** Balance entry shape used by the extension background */ +export interface TokenBalanceEntry { + symbol?: string; + name?: string; + balance?: string; + valueUsd?: string; + priceUsd?: string; + caip?: string; + isNative?: boolean; + [key: string]: any; +} + +// ── Heuristic helpers ──────────────────────────────────────────────── + +/** URL-like patterns in name or symbol — nearly always phishing */ +const URL_PATTERN = /(?:\.[a-z]{2,6}(?:\/|$))|https?:|www\./i; + +/** Phishing action words that appear in scam token names */ +const PHISHING_KEYWORDS = /\b(claim|visit|reward|bonus|airdrop|free|voucher|gift|redeem|activate|eligible)\b/i; + +/** Symbols should be short alphanumeric; these chars indicate scam */ +const SUSPICIOUS_SYMBOL_CHARS = /[./:$!@#%^&*()+=\[\]{}|\\<>,?~`'"]/; + +/** Max reasonable symbol length — real tokens are 2-11 chars */ +const MAX_SYMBOL_LENGTH = 11; + +/** + * Detect whether a token is spam. + */ +export function detectSpamToken(token: TokenBalanceEntry, userOverride?: TokenVisibilityStatus | null): SpamResult { + // ── Tier 0: User override — absolute precedence ────────────────── + if (userOverride === 'visible') { + return { isSpam: false, level: null, reason: 'User marked as safe' }; + } + if (userOverride === 'hidden') { + return { isSpam: true, level: 'confirmed', reason: 'User marked as hidden' }; + } + + const usd = parseFloat(token.valueUsd || '0'); + const sym = (token.symbol || '').toUpperCase(); + const name = token.name || ''; + + // ── Tier 1: Name/symbol contains URL → CONFIRMED spam ──────────── + if (URL_PATTERN.test(name) || URL_PATTERN.test(token.symbol || '')) { + return { + isSpam: true, + level: 'confirmed', + reason: 'Name/symbol contains URL — phishing token', + }; + } + + // ── Tier 2: Name contains phishing keywords → CONFIRMED spam ───── + if (PHISHING_KEYWORDS.test(name)) { + return { + isSpam: true, + level: 'confirmed', + reason: 'Name contains phishing keyword', + }; + } + + // ── Tier 3: Suspicious symbol characters or length → CONFIRMED ─── + if (SUSPICIOUS_SYMBOL_CHARS.test(token.symbol || '') || (token.symbol || '').length > MAX_SYMBOL_LENGTH) { + return { + isSpam: true, + level: 'confirmed', + reason: 'Symbol has suspicious characters or is too long', + }; + } + + // ── Tier 4: Fake stablecoin (symbol matches but value way off) ─── + if (KNOWN_STABLECOINS.includes(sym) && usd < 0.5) { + return { + isSpam: true, + level: 'confirmed', + reason: `Fake ${sym} — real ${sym} is ~$1.00, this has $${usd.toFixed(2)}`, + }; + } + + // ── Tier 5: Dust airdrop heuristic ─────────────────────────────── + if (!KNOWN_LEGIT_SYMBOLS.has(sym)) { + const qty = parseFloat(token.balance || '0'); + const price = parseFloat(token.priceUsd || '0'); + + if (qty > 1_000_000 && price < 0.0001) { + return { + isSpam: true, + level: 'confirmed', + reason: `Dust airdrop — ${qty.toLocaleString()} units at $${price.toFixed(8)}/unit`, + }; + } + + // Moderate quantity + zero price but somehow has USD value (manipulated) + if (qty > 10_000 && price === 0 && usd > 0) { + return { + isSpam: true, + level: 'confirmed', + reason: 'Suspicious — large quantity with $0 price but non-zero value', + }; + } + } + + // ── Tier 6: Low value → POSSIBLE spam ──────────────────────────── + if (usd < 1) { + return { + isSpam: true, + level: 'possible', + reason: `Low value ($${usd.toFixed(4)}) — common airdrop spam pattern`, + }; + } + + // ── Clean — passed all checks ──────────────────────────────────── + return { isSpam: false, level: null, reason: 'Passed all spam checks' }; +} + +/** + * Filter spam tokens from a balance array. + * + * Only `confirmed` spam (URL-shaped names, phishing keywords, suspicious + * symbols, fake stablecoins, dust airdrops) and user-marked-hidden tokens + * are dropped. `possible` spam (the low-USD-value heuristic) is KEPT — + * the tier-6 `< $1` rule is too coarse to drop silently when there is no + * override UI: legitimate small holdings, fresh custom tokens, testnet- + * like balances, and anything with missing price data all land there. + * Callers that want to visually de-emphasize possible-spam can call + * `detectSpamToken` themselves and render accordingly. + * + * Native chain balances are always kept regardless of classification. + */ +export function filterSpamTokens( + balances: TokenBalanceEntry[], + overrides?: Map, +): TokenBalanceEntry[] { + return balances.filter(b => { + if (b.isNative) return true; + + const override = overrides?.get(b.caip?.toLowerCase() || '') ?? null; + const result = detectSpamToken(b, override); + return !(result.isSpam && result.level === 'confirmed'); + }); +} + +// ── Token visibility persistence (chrome.storage.local) ──────────── + +const STORAGE_KEY = 'keepkey-token-visibility'; + +export async function getTokenVisibilityMap(): Promise> { + return new Promise(resolve => { + chrome.storage.local.get(STORAGE_KEY, data => { + const raw = data[STORAGE_KEY] || {}; + resolve(new Map(Object.entries(raw) as [string, TokenVisibilityStatus][])); + }); + }); +} + +export async function setTokenVisibility(caip: string, status: TokenVisibilityStatus): Promise { + const map = await getTokenVisibilityMap(); + map.set(caip.toLowerCase(), status); + return new Promise(resolve => { + chrome.storage.local.set({ [STORAGE_KEY]: Object.fromEntries(map) }, resolve); + }); +} + +export async function removeTokenVisibility(caip: string): Promise { + const map = await getTokenVisibilityMap(); + map.delete(caip.toLowerCase()); + return new Promise(resolve => { + chrome.storage.local.set({ [STORAGE_KEY]: Object.fromEntries(map) }, resolve); + }); +} diff --git a/chrome-extension/src/background/utils.ts b/chrome-extension/src/background/utils.ts index 076a898..5019e32 100644 --- a/chrome-extension/src/background/utils.ts +++ b/chrome-extension/src/background/utils.ts @@ -1,15 +1,33 @@ +/** + * Categorical hint for the side panel — lets the UI key off a stable + * field instead of regex-matching `message`. Add cases as we identify + * other error categories worth distinct UI treatment. + */ +export type ProviderErrorKind = 'timeout'; + export interface ProviderRpcError extends Error { code: number; data?: unknown; + kind?: ProviderErrorKind; } -export const createProviderRpcError = (code: number, message: string, data?: unknown): ProviderRpcError => { +export const createProviderRpcError = ( + code: number, + message: string, + data?: unknown, + kind?: ProviderErrorKind, +): ProviderRpcError => { const error = new Error(message) as ProviderRpcError; error.code = code; if (data) error.data = data; + if (kind) error.kind = kind; return error; }; +/** Convenience for timeout errors so callers don't have to remember the kind string. */ +export const createTimeoutError = (message: string): ProviderRpcError => + createProviderRpcError(-32603, message, undefined, 'timeout'); + /** * Translate vault SdkError ("No device connected") into a user-facing message. * All other errors pass through unchanged. diff --git a/chrome-extension/src/background/utxoDerive.ts b/chrome-extension/src/background/utxoDerive.ts new file mode 100644 index 0000000..6268299 --- /dev/null +++ b/chrome-extension/src/background/utxoDerive.ts @@ -0,0 +1,194 @@ +/** + * Local UTXO receive-address derivation from an extended pubkey. + * + * Replaces the old GET_UTXO_ADDRESS round-trip to the device — pubkey → + * address is pure BIP32 + script-type encoding, no signing involved, so + * the device isn't needed. Lets the Receive page render even in + * view-only mode (cached pubkeys, no device attached). + */ +import { HDKey } from '@scure/bip32'; +import { base58check, bech32 } from '@scure/base'; +import { sha256 } from '@noble/hashes/sha2'; +import { ripemd160 } from '@noble/hashes/legacy'; + +const bs58check = base58check(sha256); + +interface CoinParams { + p2pkhVersion: number; + p2shVersion: number; + bech32Hrp?: string; + cashaddrPrefix?: string; +} + +// Keyed by the `chain` portion of the bip122 networkId (block hash of genesis). +const COIN_BY_GENESIS: Record = { + // Bitcoin + '000000000019d6689c085ae165831e93': { p2pkhVersion: 0x00, p2shVersion: 0x05, bech32Hrp: 'bc' }, + // Bitcoin Cash + '000000000000000000651ef99cb9fcbe': { p2pkhVersion: 0x00, p2shVersion: 0x05, cashaddrPrefix: 'bitcoincash' }, + // Litecoin + '12a765e31ffd4059bada1e25190f6e98': { p2pkhVersion: 0x30, p2shVersion: 0x32, bech32Hrp: 'ltc' }, + // Dogecoin + '00000000001a91e3dace36e2be3bf030': { p2pkhVersion: 0x1e, p2shVersion: 0x16 }, + // Dash + '000007d91d1254d60e2dd1ae58038307': { p2pkhVersion: 0x4c, p2shVersion: 0x10 }, +}; + +function paramsForNetwork(networkId: string): CoinParams { + // bip122:/slip44: + const m = /^bip122:([0-9a-f]+)/i.exec(networkId); + const genesis = m?.[1]; + const params = genesis ? COIN_BY_GENESIS[genesis] : undefined; + if (!params) throw new Error(`Unsupported UTXO networkId: ${networkId}`); + return params; +} + +function hash160(pubkey: Uint8Array): Uint8Array { + return ripemd160(sha256(pubkey)); +} + +// @scure/bip32 only parses the canonical xpub version (0x0488B21E). Vault +// returns ypub/zpub/Ltub/Mtub for BIP49/84 paths — same payload, different +// version bytes. Rewrite to xpub bytes so HDKey can parse, then derive. +const XPUB_VERSION = Uint8Array.from([0x04, 0x88, 0xb2, 0x1e]); + +function normalizeToXpub(extKey: string): string { + const decoded = bs58check.decode(extKey); + if (decoded.length !== 78) throw new Error(`Invalid extended key length: ${decoded.length}`); + decoded.set(XPUB_VERSION, 0); + return bs58check.encode(decoded); +} + +function deriveReceiveChildPubkey(extKey: string): Uint8Array { + const hd = HDKey.fromExtendedKey(normalizeToXpub(extKey)); + const child = hd.derive('m/0/0'); + if (!child.publicKey) throw new Error('Derivation produced no public key'); + return child.publicKey; +} + +function encodeP2PKH(pubkey: Uint8Array, version: number): string { + const payload = new Uint8Array(21); + payload[0] = version; + payload.set(hash160(pubkey), 1); + return bs58check.encode(payload); +} + +function encodeP2SHP2WPKH(pubkey: Uint8Array, p2shVersion: number): string { + // redeemScript = OP_0 <0x14> + const h = hash160(pubkey); + const redeem = new Uint8Array(22); + redeem[0] = 0x00; + redeem[1] = 0x14; + redeem.set(h, 2); + const payload = new Uint8Array(21); + payload[0] = p2shVersion; + payload.set(hash160(redeem), 1); + return bs58check.encode(payload); +} + +function encodeP2WPKH(pubkey: Uint8Array, hrp: string): string { + const program = hash160(pubkey); + const words = bech32.toWords(program); + return bech32.encode(hrp, [0, ...words], 90); +} + +// CashAddr — BCH's bech32-variant address format. +// Spec: https://github.com/bitcoincashorg/bitcoincash.org/blob/master/spec/cashaddr.md +const CASHADDR_CHARSET = 'qpzry9x8gf2tvdw0s3jn54khce6mua7l'; +const CASHADDR_GENERATORS: bigint[] = [ + BigInt('0x98f2bc8e61'), + BigInt('0x79b76d99e2'), + BigInt('0xf33e5fb3c4'), + BigInt('0xae2eabe2a8'), + BigInt('0x1e4f43e470'), +]; + +const ONE = BigInt(1); +const SHIFT_5 = BigInt(5); +const SHIFT_35 = BigInt(35); +const MASK_35 = BigInt('0x07ffffffff'); + +function cashaddrPolymod(values: number[]): bigint { + let chk = ONE; + for (const v of values) { + const top = chk >> SHIFT_35; + chk = ((chk & MASK_35) << SHIFT_5) ^ BigInt(v); + for (let i = 0; i < 5; i++) { + if (((top >> BigInt(i)) & ONE) === ONE) chk ^= CASHADDR_GENERATORS[i]; + } + } + return chk ^ ONE; +} + +function cashaddrPrefixToValues(prefix: string): number[] { + const out: number[] = []; + for (let i = 0; i < prefix.length; i++) out.push(prefix.charCodeAt(i) & 0x1f); + return out; +} + +function cashaddrConvertBits(data: Uint8Array | number[], from: number, to: number, pad: boolean): number[] { + let acc = 0; + let bits = 0; + const out: number[] = []; + const maxv = (1 << to) - 1; + for (const value of data) { + if (value < 0 || value >> from !== 0) throw new Error('Invalid value for convertBits'); + acc = (acc << from) | value; + bits += from; + while (bits >= to) { + bits -= to; + out.push((acc >> bits) & maxv); + } + } + if (pad && bits > 0) out.push((acc << (to - bits)) & maxv); + return out; +} + +function encodeCashAddr(pubkey: Uint8Array, prefix: string): string { + const h = hash160(pubkey); + // P2KH = type 0; size bits for 20-byte hash = 0 + const versionByte = (0 << 3) | 0; + const payload = [versionByte, ...h]; + const data = cashaddrConvertBits(payload, 8, 5, true); + const checksumValues = [...cashaddrPrefixToValues(prefix), 0, ...data, 0, 0, 0, 0, 0, 0, 0, 0]; + const checksum = cashaddrPolymod(checksumValues); + const checksumChars: number[] = []; + const MASK_5 = BigInt(0x1f); + for (let i = 0; i < 8; i++) { + checksumChars.push(Number((checksum >> BigInt(5 * (7 - i))) & MASK_5)); + } + let body = ''; + for (const v of [...data, ...checksumChars]) body += CASHADDR_CHARSET[v]; + return `${prefix}:${body}`; +} + +export interface DeriveArgs { + xpub: string; + scriptType?: string; + networkId: string; +} + +export function deriveUtxoAddress({ xpub, scriptType, networkId }: DeriveArgs): string { + const params = paramsForNetwork(networkId); + const pubkey = deriveReceiveChildPubkey(xpub); + + // BCH always returns cashaddr — keepkey firmware does the same, and a + // base58 "1..." legacy BCH address looks like a BTC address to users. + if (params.cashaddrPrefix) { + return encodeCashAddr(pubkey, params.cashaddrPrefix); + } + + const st = (scriptType || 'p2pkh').toLowerCase(); + switch (st) { + case 'p2pkh': + return encodeP2PKH(pubkey, params.p2pkhVersion); + case 'p2sh-p2wpkh': + return encodeP2SHP2WPKH(pubkey, params.p2shVersion); + case 'p2wpkh': { + if (!params.bech32Hrp) throw new Error(`No bech32 HRP for network ${networkId}`); + return encodeP2WPKH(pubkey, params.bech32Hrp); + } + default: + throw new Error(`Unsupported script type: ${scriptType}`); + } +} diff --git a/chrome-extension/src/background/wallet.ts b/chrome-extension/src/background/wallet.ts index b151b45..f9760c9 100644 --- a/chrome-extension/src/background/wallet.ts +++ b/chrome-extension/src/background/wallet.ts @@ -71,7 +71,7 @@ export async function init(): Promise { apiKey: savedApiKey, baseUrl: 'http://localhost:1646', serviceName: 'KeepKey Browser Extension', - serviceImageUrl: 'https://api.keepkey.info/coins/keepkey.png', + serviceImageUrl: 'https://pioneers.dev/coins/keepkey.png', }); state.sdk = sdk; @@ -98,6 +98,22 @@ export async function init(): Promise { if (!state.deviceInfo) { state.deviceInfo = { label: 'KeepKey', model: 'KeepKey', deviceId: 'unknown' }; } + } else { + // Device is reachable — validate that any cached pubkeys match this + // specific device. Service worker restarts and hot-swaps can both + // leave the pubkey cache pointing at a previously-connected device; + // reusing it would surface the wrong addresses and stale balances. + try { + const cached = await pubkeyStorage.loadPubkeys(); + const cachedId = cached?.deviceInfo?.deviceId; + const currentId = state.deviceInfo?.deviceId; + if (cachedId && currentId && cachedId !== 'unknown' && cachedId !== currentId) { + console.warn(tag, `Device changed: cache=${cachedId} → probed=${currentId}. Invalidating cache.`); + await pubkeyStorage.clearPubkeys(); + } + } catch (e) { + console.warn(tag, 'Cache validation failed:', (e as Error)?.message || e); + } } // Set up paths @@ -205,8 +221,28 @@ async function fetchPubkeys(): Promise { } } catch (e) { console.error(tag, 'Error fetching pubkeys from device:', e); - // Device call failed — downgrade to view-only and use cache if available state.deviceConnected = false; + + // Auth errors mean the vault's pairing with this extension is broken + // (e.g. user re-paired with a different device, or vault rotated the + // key). Silently falling back to cached pubkeys from the *previous* + // device would mask the re-pair requirement — the user would see + // stale addresses with no explanation. Throw so the sidebar can + // prompt re-pair and the cached pubkeys are only used when we've + // chosen view-only mode, not when auth is rejecting us. + if (isAuthError(e)) { + console.warn(tag, 'Auth error from vault — clearing stale API key and failing fast'); + try { + await keepKeyApiKeyStorage.saveApiKey(''); + } catch { + /* ignore */ + } + throw new Error('Vault auth failed. Your KeepKey needs to be re-paired with the extension.'); + } + + // Non-auth failure (network blip, device busy, etc.) — view-only via + // cache is acceptable. We keep deviceConnected=false so signing paths + // will re-probe. if (cachedPubkeys.length > 0) { console.warn(tag, 'Falling back to', cachedPubkeys.length, 'cached pubkeys (view-only)'); state.pubkeys = cachedPubkeys; @@ -216,6 +252,26 @@ async function fetchPubkeys(): Promise { } } +/** + * Heuristic for "the vault rejected our credentials." Covers the + * error shapes we've seen from the vault-sdk client: HTTP 401 wrapped + * in a fetch error, plus bare messages that include those substrings. + * Conservative — misclassifying a non-auth failure as auth would just + * wipe the API key and force re-pairing, which is recoverable; the + * opposite direction (missing a real auth error and silently serving + * stale pubkeys) is the bug we're fixing. + */ +function isAuthError(e: unknown): boolean { + const msg = ((e as Error)?.message || String(e)).toLowerCase(); + return ( + msg.includes('401') || + msg.includes('unauthorized') || + msg.includes('auth') || + msg.includes('invalid api key') || + msg.includes('not paired') + ); +} + /** * Get the SDK instance, re-initializing if needed. */ @@ -253,6 +309,32 @@ export function addPath(path: PathConfig): void { state.paths.push(path); } +/** + * Remove a path (and its corresponding pubkey) by matching note. + * Counterpart to addPath so callers that remove accounts from storage + * can also clear the runtime signer state — otherwise the wallet keeps + * signing against a path that no longer appears in the UI. + * Persists the updated pubkey list if a device is known. + */ +export async function removePathByNote(note: string): Promise { + const tag = TAG + ' | removePathByNote | '; + const beforePaths = state.paths.length; + const beforePubkeys = state.pubkeys.length; + state.paths = state.paths.filter(p => p.note !== note); + state.pubkeys = state.pubkeys.filter((pk: any) => pk.note !== note); + console.log( + tag, + `Removed ${beforePaths - state.paths.length} paths and ${beforePubkeys - state.pubkeys.length} pubkeys for note: ${note}`, + ); + if (state.deviceInfo) { + try { + await pubkeyStorage.savePubkeys(state.pubkeys, state.deviceInfo); + } catch (e) { + console.warn(tag, 'Failed to persist pubkeys after removal:', e); + } + } +} + /** * Append a pubkey entry to state and persist to cache. Used for addresses * derived outside of the batch xpub flow (e.g. Solana via solanaGetAddress). @@ -340,6 +422,46 @@ export function getDeviceInfo() { return state.deviceInfo; } +/** + * Current device id, or null if unknown. Used by background polling to + * detect device hot-swaps without duplicating the probe logic. + */ +export function getDeviceId(): string | null { + const id = state.deviceInfo?.deviceId; + return id && id !== 'unknown' ? id : null; +} + +/** + * Tear down everything keyed to the previously-connected device so the + * next `refreshPubkeys()` / `fetchPubkeys()` starts clean: + * - in-memory pubkeys (cleared) + * - paths (rebuilt to defaults — refreshPubkeys does NOT repopulate + * them, so leaving paths empty would silently send an empty batch + * to the device and return zero pubkeys) + * - persisted pubkey cache (wiped) + * - `deviceConnected` flag (forces re-probe on next call) + * + * We intentionally keep `state.sdk` alive — the vault REST client can + * happily serve the new device once pubkeys are re-fetched — and we + * keep `state.deviceInfo` populated with the freshly-probed device's + * info (passed in by the caller) so UI can show the new label + * immediately without waiting for the re-fetch to finish. + */ +export async function handleDeviceSwitch(newDeviceInfo: WalletState['deviceInfo']): Promise { + const tag = TAG + ' | handleDeviceSwitch | '; + console.warn(tag, 'Device switch detected — clearing caches'); + state.pubkeys = []; + state.paths = getDefaultPaths(); + state.deviceInfo = newDeviceInfo; + state.deviceConnected = false; + state.initialized = false; + try { + await pubkeyStorage.clearPubkeys(); + } catch (e) { + console.warn(tag, 'Failed to clear pubkey storage:', e); + } +} + /** * Get the full wallet state (for backward compat with APP references). */ diff --git a/chrome-extension/src/injected/injected.ts b/chrome-extension/src/injected/injected.ts index 863ee41..ec68e63 100644 --- a/chrome-extension/src/injected/injected.ts +++ b/chrome-extension/src/injected/injected.ts @@ -10,9 +10,9 @@ import type { } from './types'; import { KeepKeySolanaWallet } from './solana-wallet-standard'; import { registerSolanaWallet } from './solana-wallet-register'; +import { KeepKeyTronProvider } from './tron-provider'; (function () { - const TAG = ' | KeepKeyInjected | '; const VERSION = '2.1.0'; const MAX_RETRY_COUNT = 3; const RETRY_DELAY = 100; // ms @@ -32,20 +32,60 @@ import { registerSolanaWallet } from './solana-wallet-register'; // Check for existing injection with version comparison if (kWindow.keepkeyInjectionState) { const existing = kWindow.keepkeyInjectionState; - console.warn(TAG, `Existing injection detected v${existing.version}, current v${VERSION}`); // Only skip if same or newer version if (existing.version >= VERSION) { - console.log(TAG, 'Skipping injection, newer or same version already present'); return; } - console.log(TAG, 'Upgrading injection to newer version'); } // Set injection state kWindow.keepkeyInjectionState = injectionState; - console.log(TAG, `Initializing KeepKey Injection v${VERSION}`); + // Read masking settings from the - - diff --git a/pages/popup/package.json b/pages/popup/package.json deleted file mode 100644 index d5720cd..0000000 --- a/pages/popup/package.json +++ /dev/null @@ -1,49 +0,0 @@ -{ - "name": "@extension/popup", - "version": "0.0.26", - "description": "chrome extension - popup", - "private": true, - "sideEffects": true, - "files": [ - "dist/**" - ], - "scripts": { - "clean:node_modules": "pnpx rimraf node_modules", - "clean:turbo": "rimraf .turbo", - "clean": "pnpm clean:turbo && pnpm clean:node_modules", - "build": "vite build", - "dev": "cross-env __DEV__=true vite build --mode development", - "lint": "eslint . --ext .ts,.tsx", - "lint:fix": "pnpm lint --fix", - "prettier": "prettier . --write --ignore-path ../../.prettierignore", - "type-check": "tsc --noEmit" - }, - "dependencies": { - "@chakra-ui/icons": "^2.1.1", - "@chakra-ui/react": "^2.8.2", - "@emotion/react": "^11.13.3", - "@emotion/styled": "^11.13.0", - "@extension/content-runtime-script": "workspace:*", - "@extension/shared": "workspace:*", - "@extension/storage": "workspace:*", - "axios": "^1.7.7", - "ethers": "^6.13.2", - "framer-motion": "^11.5.4", - "react-code-blocks": "^0.1.6", - "react-confetti": "^6.1.0", - "react-json-view": "^1.21.3" - }, - "devDependencies": { - "@extension/tailwindcss-config": "workspace:*", - "@extension/tsconfig": "workspace:*", - "@extension/vite-config": "workspace:*", - "cross-env": "^7.0.3", - "postcss-load-config": "^6.0.1" - }, - "postcss": { - "plugins": { - "tailwindcss": {}, - "autoprefixer": {} - } - } -} diff --git a/pages/popup/public/sounds/send.mp3 b/pages/popup/public/sounds/send.mp3 deleted file mode 100644 index 4e98931..0000000 Binary files a/pages/popup/public/sounds/send.mp3 and /dev/null differ diff --git a/pages/popup/src/Popup.tsx b/pages/popup/src/Popup.tsx deleted file mode 100644 index 47187fb..0000000 --- a/pages/popup/src/Popup.tsx +++ /dev/null @@ -1,12 +0,0 @@ -import { withErrorBoundary, withSuspense } from '@extension/shared'; -import EventsViewer from './components/Events'; - -const Popup = () => { - return ( -
- -
- ); -}; - -export default withErrorBoundary(withSuspense(Popup,
Loading ...
),
Error Occur
); diff --git a/pages/popup/src/components/Events.tsx b/pages/popup/src/components/Events.tsx deleted file mode 100644 index 96c691b..0000000 --- a/pages/popup/src/components/Events.tsx +++ /dev/null @@ -1,99 +0,0 @@ -import React, { useEffect, useState, useCallback } from 'react'; -import { Box, Spinner } from '@chakra-ui/react'; -import { requestStorage } from '@extension/storage'; -import Transaction from './Transaction'; - -const EventsViewer = () => { - const [events, setEvents] = useState([]); - const [currentIndex, setCurrentIndex] = useState(0); - const [loading, setLoading] = useState(true); - - // Function to calculate the age of the event in minutes - const getEventAgeInMinutes = (timestamp: string) => { - const eventTime = new Date(timestamp).getTime(); - const currentTime = Date.now(); - const ageInMinutes = (currentTime - eventTime) / 60000; // Convert milliseconds to minutes - return ageInMinutes; - }; - - // Optimized event fetching to prevent endless loops - const fetchEvents = useCallback(async () => { - setLoading(true); // Show spinner while fetching events - const storedEvents = await requestStorage.getEvents(); - const validEvents = []; - - for (const event of storedEvents) { - const ageInMinutes = getEventAgeInMinutes(event.timestamp); - if (ageInMinutes <= 10) { - validEvents.push(event); // Keep events that are within 10 minutes - } else { - await requestStorage.removeEventById(event.id); // Remove events older than 10 minutes - } - } - - // Set the valid events and reverse them to show latest first - setEvents(validEvents.reverse()); - setLoading(false); // Stop spinner after events are loaded - - // If no events are found, close the window - // if (validEvents.length === 0) { - // window.close(); - // } - }, []); - - useEffect(() => { - fetchEvents(); - }, [fetchEvents]); - - const nextEvent = () => { - if (currentIndex < events.length - 1) { - setCurrentIndex(currentIndex + 1); - resetTransactionState(); - } - }; - - const previousEvent = () => { - if (currentIndex > 0) { - setCurrentIndex(currentIndex - 1); - resetTransactionState(); - } - }; - - const clearRequestEvents = async () => { - await requestStorage.clearEvents(); - fetchEvents(); - setCurrentIndex(0); - }; - - // Reset transaction state when switching between events - const resetTransactionState = () => { - // Here you can reset any transaction-related state - setLoading(false); - }; - - return ( - - {/* Show spinner if events are being fetched */} - {loading && } - - {/* Only show event details if events are loaded */} - {events.length > 0 && !loading ? ( - - {/* Show the age of the current event */} - {/**/} - {/* Chain: {events[currentIndex].chain}*/} - {/*
*/} - {/* Event Age: {Math.floor(getEventAgeInMinutes(events[currentIndex].timestamp))} minutes*/} - {/*
*/} - - {/* Pass the current event to the Transaction component */} - -
- ) : ( -
No events
- )} -
- ); -}; - -export default EventsViewer; diff --git a/pages/popup/src/components/evm/index.tsx b/pages/popup/src/components/evm/index.tsx deleted file mode 100644 index 898d8ed..0000000 --- a/pages/popup/src/components/evm/index.tsx +++ /dev/null @@ -1,81 +0,0 @@ -import { - Avatar, - Button, - Card, - CardHeader, - Flex, - Spinner, - Stack, - Tabs, - TabList, - TabPanels, - Tab, - TabPanel, - Divider, -} from '@chakra-ui/react'; -import React, { useEffect, useState } from 'react'; -import RequestFeeCard from './RequestFeeCard'; -import RequestDataCard from './RequestDataCard'; -import RequestDetailsCard from './RequestDetailsCard'; -import ContractDetailsCard from './ContractDetailsCard'; -import RequestMethodCard from './RequestMethodCard'; -import ProjectInfoCard from './ProjectInfoCard'; - -export function EvmTransaction({ transaction, reloadEvents, handleResponse }: any) { - return ( - - - - - - - - - {/*Insight*/} - Details - Fees - Raw - - - - {/* Contract Tab */} - {/**/} - {/* */} - {/**/} - - {/* Review Tab */} - - - - - {/* Fees Tab */} - - {transaction.type !== 'personal_sign' && ( - <> - - - )} - - - {/* Raw Data Tab */} - - - - - - - - - - - - - - ); -} - -export default EvmTransaction; diff --git a/pages/popup/src/components/other/RequestDetailsCard.tsx b/pages/popup/src/components/other/RequestDetailsCard.tsx deleted file mode 100644 index 5e36102..0000000 --- a/pages/popup/src/components/other/RequestDetailsCard.tsx +++ /dev/null @@ -1,71 +0,0 @@ -import { useState, useEffect } from 'react'; -import { Box, Divider, Flex, Table, Tbody, Tr, Td, Badge, Avatar } from '@chakra-ui/react'; - -export default function RequestDetailsCard({ transaction }: any) { - const [isNative, setIsNative] = useState(true); // Toggle for hex/native - const [assetContext, setAssetContext] = useState(null); - - // Function to get asset context - const requestAssetContext = () => { - return new Promise((resolve, reject) => { - chrome.runtime.sendMessage({ type: 'GET_ASSET_CONTEXT' }, response => { - if (chrome.runtime.lastError) { - return reject(chrome.runtime.lastError); - } - resolve(response); - }); - }); - }; - - // Fetch the asset context on component mount - useEffect(() => { - const fetchAssetContext = async () => { - try { - const context: any = await requestAssetContext(); - setAssetContext(context); - } catch (error) { - console.error('Failed to get asset context:', error); - } - }; - - fetchAssetContext(); - }, []); - - return ( -
- - {/* Display the Avatar for the asset */} - {assetContext && ( - - - - )} - - - - - - - - - - - - - - - - -
- To: - {transaction?.unsignedTx?.payment?.destination || 'N/A'}
- Amount: - {transaction?.unsignedTx?.payment?.amount / 1000000 || 'N/A'}
- destinationTag: - {transaction?.unsignedTx?.payment?.destinationTag || 'none'}
-
- -
-
- ); -} diff --git a/pages/popup/src/components/utxo/CoinControlCard.tsx b/pages/popup/src/components/utxo/CoinControlCard.tsx deleted file mode 100644 index 7ea4214..0000000 --- a/pages/popup/src/components/utxo/CoinControlCard.tsx +++ /dev/null @@ -1,164 +0,0 @@ -import React, { useState, useEffect } from 'react'; -import { - Box, - Text, - VStack, - HStack, - Button, - Slider, - SliderTrack, - SliderFilledTrack, - SliderThumb, - Card, - CardBody, - Badge, - Flex, - Divider, -} from '@chakra-ui/react'; -import { ExternalLinkIcon } from '@chakra-ui/icons'; - -export default function CoinControl({ transaction }) { - const [inputs, setInputs] = useState([]); - const [outputs, setOutputs] = useState([]); - const [adjustedFee, setAdjustedFee] = useState(0); - const [assetContext, setAssetContext] = useState({ priceUsd: 30000 }); // Placeholder for asset price - const [feeOption, setFeeOption] = useState('medium'); - const [customFeeRate, setCustomFeeRate] = useState(10); - - const recommendedFees = { high: 20, medium: 10, low: 5 }; - - useEffect(() => { - if (transaction && transaction.unsignedTx) { - setInputs(transaction.unsignedTx.inputs || []); - setOutputs(transaction.unsignedTx.outputs || []); - setAdjustedFee(transaction.unsignedTx.fee || 0); - } - }, [transaction]); - - // Update fee based on selected option - useEffect(() => { - let feeRate = recommendedFees.medium; - - if (feeOption === 'high') feeRate = recommendedFees.high; - else if (feeOption === 'low') feeRate = recommendedFees.low; - else if (feeOption === 'custom') feeRate = customFeeRate; - - const txSizeInBytes = 190; // Example tx size - const newFee = Math.ceil(txSizeInBytes * feeRate); - setAdjustedFee(newFee); - }, [feeOption, customFeeRate]); - - const feeInUsd = (adjustedFee / 1e8 * assetContext.priceUsd).toFixed(2); // Fee in USD - - // Simple Transaction Diagram - const renderTransactionDiagram = () => { - return ( - - Transaction Diagram - - {/* Inputs */} - - {inputs.map((input, index) => ( - - Input {index + 1} - - - ))} - - - {/* Transaction Node */} - - Transaction - - - {/* Outputs & Fees */} - - {outputs.map((output, index) => ( - - - {output.addressType === 'change' ? 'Change' : 'Recipient'} - - ))} - - {/* Fee Line */} - - - Fee - - - - - ); - }; - - return ( - - - {/* Transaction Diagram */} - {renderTransactionDiagram()} - - {/* Inputs/Outputs Sections */} - - Inputs - - {inputs.map((input, index) => ( - - - - Amount: {input.amount} sats - Script Type: {input.scriptType} - - - - ))} - - - Outputs - - {outputs.map((output, index) => ( - - - - Address: {output.address} - - {output.addressType === 'change' ? 'Change' : 'Recipient'} - - - - - ))} - - - - ); -} diff --git a/pages/popup/src/components/utxo/RequestMethodCard.tsx b/pages/popup/src/components/utxo/RequestMethodCard.tsx deleted file mode 100644 index ab78676..0000000 --- a/pages/popup/src/components/utxo/RequestMethodCard.tsx +++ /dev/null @@ -1,51 +0,0 @@ -import { Box, Flex, Text, Heading, Icon } from '@chakra-ui/react'; -import { CheckCircleIcon, WarningIcon, InfoIcon, QuestionIcon } from '@chakra-ui/icons'; - -const getMethodInfo = (txType: string, hasSmartContractExecution: boolean) => { - switch (txType) { - case 'transfer': - return { - title: 'transfer', - description: 'This transaction is a basic transfer', - icon: , - color: 'yellow.500', - }; - default: - return { - title: 'Unknown Method', - description: 'Verify before proceeding', - icon: , - color: 'yellow.500', - }; - } -}; - -/** - * Component - */ -export default function RequestMethodCard({ transaction }: any) { - const hasSmartContractExecution = - transaction.request?.data && transaction.request.data.length > 0 && transaction.request.data !== '0x'; - - const { title, description, icon, color } = getMethodInfo(transaction.type, hasSmartContractExecution); - - return ( - - - {icon && ( - - {icon} - - )} - - {title} - - - - - {description} - - - - ); -} diff --git a/pages/popup/src/index.tsx b/pages/popup/src/index.tsx deleted file mode 100644 index 8f0b076..0000000 --- a/pages/popup/src/index.tsx +++ /dev/null @@ -1,32 +0,0 @@ -import { createRoot } from 'react-dom/client'; -import { useEffect } from 'react'; -import { ChakraProvider, useColorMode } from '@chakra-ui/react'; -import { theme } from '@src/styles/theme'; -import Popup from '@src/Popup'; - -const ForceDarkMode = ({ children }: { children: React.ReactNode }) => { - const { setColorMode } = useColorMode(); - - useEffect(() => { - setColorMode('dark'); - }, [setColorMode]); - - return <>{children}; -}; - -function init() { - const appContainer = document.querySelector('#app-container'); - if (!appContainer) { - throw new Error('Can not find #app-container'); - } - const root = createRoot(appContainer); - root.render( - - - - - , - ); -} - -init(); diff --git a/pages/popup/src/styles/theme/config.ts b/pages/popup/src/styles/theme/config.ts deleted file mode 100644 index 0138c05..0000000 --- a/pages/popup/src/styles/theme/config.ts +++ /dev/null @@ -1,5 +0,0 @@ -import type { ThemeConfig } from '@chakra-ui/react'; - -export const config: ThemeConfig = { - disableTransitionOnChange: false, -}; diff --git a/pages/popup/src/styles/theme/index.ts b/pages/popup/src/styles/theme/index.ts deleted file mode 100644 index 99b93ca..0000000 --- a/pages/popup/src/styles/theme/index.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { extendTheme } from '@chakra-ui/react'; -import { config } from './config'; - -// Define the extended KeepKey-themed color palette -const colors = { - keepKeyGold: { - 50: '#fffaf0', - 100: '#f4e5b2', - 200: '#e8cc84', - 300: '#ddb356', - 400: '#d29929', - 500: '#b57f1e', - 600: '#916419', - 700: '#6d4a13', - 800: '#49300e', - 900: '#251807', - }, - keepKeyBlack: { - 50: '#e5e5e5', - 100: '#b8b8b8', - 200: '#8a8a8a', - 300: '#5c5c5c', - 400: '#3d3d3d', - 500: '#1f1f1f', - 600: '#1a1a1a', - 700: '#141414', - 800: '#0f0f0f', - 900: '#0a0a0a', - }, -}; - -export const theme = extendTheme({ - initialColorMode: 'dark', - useSystemColorMode: false, - colors: { - keepKeyGold: colors.keepKeyGold, - gray: colors.keepKeyBlack, - }, - fonts: { - heading: 'Plus Jakarta Sans, sans-serif', - body: 'Plus Jakarta Sans, sans-serif', - }, - components: { - // Button: { - // baseStyle: { - // fontWeight: 'bold', - // }, - // variants: { - // solid: (props: any) => ({ - // bg: props.colorMode === 'dark' ? 'keepKeyGold.500' : 'keepKeyGold.400', - // color: 'white', - // _hover: { - // bg: 'keepKeyGold.600', - // }, - // }), - // }, - // defaultProps: { - // size: 'md', - // variant: 'solid', - // }, - // }, - // You can extend other components here in a similar fashion - }, - config, -}); diff --git a/pages/popup/tsconfig.json b/pages/popup/tsconfig.json deleted file mode 100644 index 0837c72..0000000 --- a/pages/popup/tsconfig.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "extends": "@extension/tsconfig/base", - "compilerOptions": { - "baseUrl": ".", - "paths": { - "@src/*": ["src/*"] - }, - "types": ["chrome", "../../vite-env.d.ts"] - }, - "include": ["src"] -} diff --git a/pages/popup/vite.config.mts b/pages/popup/vite.config.mts deleted file mode 100644 index 74de3ee..0000000 --- a/pages/popup/vite.config.mts +++ /dev/null @@ -1,17 +0,0 @@ -import { resolve } from 'node:path'; -import { withPageConfig } from '@extension/vite-config'; - -const rootDir = resolve(__dirname); -const srcDir = resolve(rootDir, 'src'); - -export default withPageConfig({ - resolve: { - alias: { - '@src': srcDir, - }, - }, - publicDir: resolve(rootDir, 'public'), - build: { - outDir: resolve(rootDir, '..', '..', 'dist', 'popup'), - }, -}); diff --git a/pages/side-panel/index.html b/pages/side-panel/index.html index 27859dc..b782a11 100644 --- a/pages/side-panel/index.html +++ b/pages/side-panel/index.html @@ -3,6 +3,11 @@ Side Panel + + + diff --git a/pages/side-panel/package.json b/pages/side-panel/package.json index 342a733..80751b3 100644 --- a/pages/side-panel/package.json +++ b/pages/side-panel/package.json @@ -1,6 +1,6 @@ { "name": "@extension/sidepanel", - "version": "0.0.26", + "version": "0.0.29", "description": "chrome extension - side panel", "private": true, "sideEffects": true, @@ -30,7 +30,10 @@ "date-fns": "^4.1.0", "framer-motion": "^11.5.4", "qrcode": "^1.5.4", - "react-icons": "^5.5.0" + "react-code-blocks": "^0.1.6", + "react-confetti": "^6.1.0", + "react-icons": "^5.5.0", + "react-json-view": "^1.21.3" }, "devDependencies": { "@extension/tailwindcss-config": "workspace:*", diff --git a/pages/side-panel/public/logo_vertical.svg b/pages/side-panel/public/logo_vertical.svg deleted file mode 100644 index 5768b87..0000000 --- a/pages/side-panel/public/logo_vertical.svg +++ /dev/null @@ -1,124 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/pages/side-panel/public/logo_vertical_dark.svg b/pages/side-panel/public/logo_vertical_dark.svg deleted file mode 100644 index b4089d8..0000000 --- a/pages/side-panel/public/logo_vertical_dark.svg +++ /dev/null @@ -1,117 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/pages/side-panel/src/SidePanel.tsx b/pages/side-panel/src/SidePanel.tsx index 3a757d0..01ee95a 100644 --- a/pages/side-panel/src/SidePanel.tsx +++ b/pages/side-panel/src/SidePanel.tsx @@ -23,6 +23,7 @@ import { } from '@chakra-ui/react'; import { ArrowUpIcon, ArrowDownIcon, ChevronLeftIcon } from '@chakra-ui/icons'; import { withErrorBoundary, withSuspense } from '@extension/shared'; +import { requestStorage } from '@extension/storage'; import Connect from './components/Connect'; import Loading from './components/Loading'; @@ -34,6 +35,11 @@ import { Receive } from './components/Receive'; import AssetDetail from './components/AssetDetail'; import DonutChart from './components/DonutChart'; import NetworkAccountHeader from './components/NetworkAccountHeader'; +import Transaction from './approval/Transaction'; + +// Events older than this are dropped on load — an abandoned-tab pending +// request shouldn't hijack the sidebar forever. +const MAX_EVENT_AGE_MINUTES = 10; const HEADER_HEIGHT = '60px'; @@ -45,6 +51,13 @@ const SidePanel = () => { const [isConnecting, setIsConnecting] = useState(false); const [isRefreshing, setIsRefreshing] = useState(false); const [selectedAsset, setSelectedAsset] = useState(null); + const [balancesInitialLoading, setBalancesInitialLoading] = useState(true); + const [pendingEvent, setPendingEvent] = useState(null); + // "Add blockchain" picker takeover — lifted out of so the home + // button and dashboard-hiding logic can see it. Keeping it inside Balances + // meant SidePanel kept rendering the donut/balance/Send-Receive block on + // top of the picker, and the home button couldn't reset it. + const [showAddBlockchain, setShowAddBlockchain] = useState(false); // Disclosures for drawers/modals const { isOpen: isSettingsOpen, onOpen: onSettingsOpen, onClose: onSettingsClose } = useDisclosure(); @@ -59,11 +72,13 @@ const SidePanel = () => { const total = response.balances.reduce((sum: number, b: any) => sum + parseFloat(b.valueUsd || '0'), 0); setTotalUsdBalance(total); } + setBalancesInitialLoading(false); }); }, []); useEffect(() => { if (keepkeyState === 5) { + setBalancesInitialLoading(true); fetchTotalBalance(); } }, [keepkeyState, fetchTotalBalance]); @@ -83,13 +98,34 @@ const SidePanel = () => { chrome.runtime.sendMessage({ type: 'CLEAR_ASSET_CONTEXT' }); }; + // Shield-badge "home" action — collapse any open drawers and clear context + // so the user lands back on the Balances view. + const handleGoHome = () => { + if (isAssetDetailOpen) handleAssetDetailClose(); + if (isSendOpen) onSendClose(); + if (isReceiveOpen) onReceiveClose(); + if (transactionContext) setTransactionContext(null); + if (showAddBlockchain) setShowAddBlockchain(false); + setSelectedAsset(null); + }; + + // Prefer native chain rows over ERC-20 / SPL tokens when picking a + // default for global Send / Receive. Picking the highest-USD raw row + // meant a stablecoin or token could hijack the default action — a + // behavior change from the asset-centric UX and a surprise for users + // who expect "Send" to mean "send from my main chain wallet". + const pickDefaultAsset = () => { + if (balances.length === 0) return null; + const byUsd = (a: any, b: any) => parseFloat(b.valueUsd || '0') - parseFloat(a.valueUsd || '0'); + const natives = balances.filter((b: any) => b.isNative).sort(byUsd); + if (natives.length > 0) return natives[0]; + return [...balances].sort(byUsd)[0]; + }; + // Handle global send action const handleGlobalSend = () => { - if (balances.length > 0) { - const sortedBalances = [...balances].sort( - (a, b) => parseFloat(b.valueUsd || '0') - parseFloat(a.valueUsd || '0'), - ); - const defaultToken = sortedBalances[0]; + const defaultToken = pickDefaultAsset(); + if (defaultToken) { chrome.runtime.sendMessage({ type: 'SET_ASSET_CONTEXT', asset: defaultToken }, () => { onSendOpen(); }); @@ -98,11 +134,8 @@ const SidePanel = () => { // Handle global receive action const handleGlobalReceive = () => { - if (balances.length > 0) { - const sortedBalances = [...balances].sort( - (a, b) => parseFloat(b.valueUsd || '0') - parseFloat(a.valueUsd || '0'), - ); - const defaultToken = sortedBalances[0]; + const defaultToken = pickDefaultAsset(); + if (defaultToken) { chrome.runtime.sendMessage({ type: 'SET_ASSET_CONTEXT', asset: defaultToken }, () => { onReceiveOpen(); }); @@ -139,6 +172,41 @@ const SidePanel = () => { } }; + // Subscribe to requestStorage so any dApp-triggered approval request shown + // here takes over the panel as an overlay. Abandoned events beyond the age + // window are evicted on load so a stuck request can't wedge the UI. + const fetchPendingEvent = useCallback(async () => { + try { + const events = (await requestStorage.getEvents()) || []; + const now = Date.now(); + const fresh: any[] = []; + for (const ev of events) { + const ageMs = now - new Date(ev.timestamp).getTime(); + if (ageMs <= MAX_EVENT_AGE_MINUTES * 60_000) { + fresh.push(ev); + } else { + void requestStorage.removeEventById(ev.id); + } + } + // Newest-first — matches popup behavior; user sees the freshest request. + fresh.reverse(); + setPendingEvent(fresh[0] ?? null); + } catch (e) { + console.error('SidePanel: fetchPendingEvent failed', e); + setPendingEvent(null); + } + }, []); + + useEffect(() => { + fetchPendingEvent(); + const unsubscribe = requestStorage.subscribe?.(() => { + fetchPendingEvent(); + }); + return () => { + if (typeof unsubscribe === 'function') unsubscribe(); + }; + }, [fetchPendingEvent]); + // Listen for state changes and external asset context updates (e.g. dApp wallet_addEthereumChain) useEffect(() => { const messageListener = (message: any) => { @@ -150,27 +218,47 @@ const SidePanel = () => { } if (message.type === 'ASSET_CONTEXT_UPDATED' && message.assetContext?.networkId) { const ctx = message.assetContext; + // Pass the full context through. The old projection dropped + // accountIndex, pubkeys, contractAddress, decimals, balances — + // anything the asset-detail / send / receive flows read to + // stay consistent with the rest of the sidebar. Fill in the + // display-required fields with sensible fallbacks when the + // context was minimally populated. const asset = { - networkId: ctx.networkId, + ...ctx, caip: ctx.caip || ctx.networkId, name: ctx.name || ctx.networkId, symbol: ctx.symbol || ctx.nativeCurrency?.symbol || '', icon: ctx.icon || '', address: ctx.address || '', }; + // Update the selected-asset state so an already-open drawer + // reflects the new context, but DON'T auto-open. This listener + // fires on every SET_ASSET_CONTEXT — including our own header + // auto-default sync on cold start and dApp-triggered chain + // switches — and users shouldn't have a drawer surface + // unprompted. Explicit opens go through handleAssetSelect + // (asset list / header click), which both setSelectedAsset + // AND onAssetDetailOpen. setSelectedAsset(asset); - onAssetDetailOpen(); } if (message.type === 'ASSET_CONTEXT_CLEARED') { setSelectedAsset(null); } + // Background pushes this after cachedBalances is refreshed. Without it, + // cold-start shows a pre-Solana snapshot because the panel only fetches + // once on state=5 and never re-queries when the background later lands + // Solana + SPL tokens after the initial fetch. + if (message.type === 'BALANCES_UPDATED') { + fetchTotalBalance(); + } }; chrome.runtime.onMessage.addListener(messageListener); return () => { chrome.runtime.onMessage.removeListener(messageListener); }; - }, [onAssetDetailOpen]); + }, [fetchTotalBalance]); // Format currency for display const formatCurrency = (value: number) => { @@ -212,16 +300,18 @@ const SidePanel = () => { case 4: return ; case 5: - return ; + return ( + + ); default: return ( - KeepKey + KeepKey Welcome to KeepKey @@ -249,14 +339,37 @@ const SidePanel = () => { } }; + // Pending dApp approval takes over the panel. We intentionally skip rendering + // the usual header/balances below so the user can't accidentally navigate + // while an approval is live — matches the old popup's singular-focus UX. + if (pendingEvent) { + return ( + + + + ); + } + return ( {/* Sticky header — floats above drawers */} - + @@ -264,21 +377,23 @@ const SidePanel = () => { {/* Scrollable body below header */} - {/* Total Balance & Quick Actions - Only when paired and on home screen */} - {keepkeyState === 5 && !transactionContext && ( + {/* Total Balance & Quick Actions - Only when paired and on home screen, after initial load */} + {keepkeyState === 5 && !transactionContext && !balancesInitialLoading && !showAddBlockchain && ( {balances.length > 0 && totalUsdBalance > 0 && ( )} - + + Total balance + + {formatCurrency(totalUsdBalance)} - + + + + + + ); +} diff --git a/pages/popup/src/components/Transaction.tsx b/pages/side-panel/src/approval/Transaction.tsx similarity index 65% rename from pages/popup/src/components/Transaction.tsx rename to pages/side-panel/src/approval/Transaction.tsx index 40af830..84069a6 100644 --- a/pages/popup/src/components/Transaction.tsx +++ b/pages/side-panel/src/approval/Transaction.tsx @@ -3,6 +3,7 @@ import EvmTransaction from './evm'; import UtxoTransaction from './utxo'; import OtherTransaction from './other'; import TendermintTransaction from './tendermint'; +import ChainNotEnabledCard from './ChainNotEnabledCard'; import { approvalStorage, requestStorage } from '@extension/storage/dist/lib'; import { Flex, Spinner, Alert, AlertIcon, Button, Icon } from '@chakra-ui/react'; import { WarningIcon } from '@chakra-ui/icons'; @@ -22,17 +23,37 @@ const requestAssetContext = () => { }); }; -const Transaction = ({ event, reloadEvents }: { event: any; reloadEvents: () => void }) => { +const Transaction = ({ + event, + reloadEvents, + onDismiss, +}: { + event: any; + reloadEvents: () => void; + onDismiss: () => void; +}) => { const [transactionType, setTransactionType] = useState(null); const [txHash, setTxHash] = useState(null); const [awaitingDeviceApproval, setAwaitingDeviceApproval] = useState(false); const [transactionInProgress, setTransactionInProgress] = useState(false); const [errorMessage, setErrorMessage] = useState(null); + // Categorical hint forwarded from the background so we can render + // category-specific UI (e.g. timeout) without regex-matching `message`. + const [errorKind, setErrorKind] = useState(null); const [showTxidPage, setShowTxidPage] = useState(false); const [assetContext, setAssetContext] = useState(null); // Local state for asset context const [explorerUrl, setExplorerUrl] = useState(null); const [showRefreshWarning, setShowRefreshWarning] = useState(false); + // Some approved requests don't touch the device at all (config-only + // flows like wallet_addEthereumChain — the background stores the RPC, + // switches the web3 provider, then emits signature_complete straight + // away). Showing the "Please approve on your KeepKey" overlay for + // these flashes a wrong instruction at the user before the dismiss + // fires. Track them as a distinct state with a less alarming copy. + const NO_DEVICE_STEP_TYPES = new Set(['wallet_addEthereumChain']); + const hasDeviceStep = !NO_DEVICE_STEP_TYPES.has(event?.type); + // Fetch the assetContext on component mount useEffect(() => { requestAssetContext() @@ -50,16 +71,6 @@ const Transaction = ({ event, reloadEvents }: { event: any; reloadEvents: () => // No need to construct it here }, []); - const openSidebar = () => { - chrome.runtime.sendMessage({ type: 'OPEN_SIDEBAR' }, response => { - if (response?.success) { - console.log('Sidebar opened successfully'); - } else { - console.error('Failed to open sidebar:', response?.error); - } - }); - }; - const cancelRequest = () => { chrome.runtime.sendMessage({ type: 'RESET_APP' }, response => { if (response?.success) { @@ -80,8 +91,16 @@ const Transaction = ({ event, reloadEvents }: { event: any; reloadEvents: () => await requestStorage.removeEventById(event.id); reloadEvents(); } else if (decision === 'accept') { - openSidebar(); - setAwaitingDeviceApproval(true); + if (hasDeviceStep) { + setAwaitingDeviceApproval(true); + } else { + // Config-only flow (e.g. wallet_addEthereumChain). No device + // prompt; show the generic in-progress spinner instead so the + // user sees acknowledgement while the background finishes + // writing the chain and emits signature_complete, which will + // dismiss the overlay via onDismiss. + setTransactionInProgress(true); + } } } catch (error) { console.error('Error handling response:', error); @@ -91,6 +110,10 @@ const Transaction = ({ event, reloadEvents }: { event: any; reloadEvents: () => useEffect(() => { const handleMessage = (message: any) => { console.log('message received:', message); + // Only handle messages addressed to THIS event. Messages without an + // eventId are legacy/unscoped; accept them for backward compatibility + // so nothing hangs if an older handler is still in flight. + if (message?.eventId && message.eventId !== event.id) return; if (message.action === 'transaction_complete') { // Play success sound after device signs transaction try { @@ -121,25 +144,23 @@ const Transaction = ({ event, reloadEvents }: { event: any; reloadEvents: () => console.error('Error playing sound:', e); } - // For signatures (not transactions), clean up and close popup - console.log('Signature complete, closing popup'); + // For signatures (not transactions), clean up and dismiss the overlay + console.log('Signature complete, dismissing approval overlay'); setAwaitingDeviceApproval(false); setTransactionInProgress(false); - // Remove the event from storage and close popup + // Remove the event from storage and dismiss overlay requestStorage .removeEventById(event.id) .then(() => { - // Close the popup window after a brief delay to show success setTimeout(() => { - window.close(); + onDismiss(); }, 500); }) .catch(error => { console.error('Error removing event:', error); - // Close anyway even if there's an error setTimeout(() => { - window.close(); + onDismiss(); }, 500); }); } else if (message.action === 'transaction_error') { @@ -152,24 +173,23 @@ const Transaction = ({ event, reloadEvents }: { event: any; reloadEvents: () => errorDetails.includes('user rejected') || errorDetails.includes('User rejected') ) { - console.log('User denied transaction, closing popup'); - // Remove event and close popup after brief delay + console.log('User denied transaction, dismissing approval overlay'); requestStorage .removeEventById(event.id) .then(() => { setTimeout(() => { - window.close(); + onDismiss(); }, 1000); }) .catch(() => { - // Close anyway even if removal fails setTimeout(() => { - window.close(); + onDismiss(); }, 1000); }); } else { // Show error for other types of failures setErrorMessage(errorText); + setErrorKind(typeof message.kind === 'string' ? message.kind : null); setTransactionInProgress(false); } } @@ -208,6 +228,12 @@ const Transaction = ({ event, reloadEvents }: { event: any; reloadEvents: () => case 'solana': setTransactionType('other'); break; + case 'ton': + setTransactionType('other'); + break; + case 'tron': + setTransactionType('other'); + break; default: setTransactionType('unknown'); } @@ -222,7 +248,7 @@ const Transaction = ({ event, reloadEvents }: { event: any; reloadEvents: () => await requestStorage.removeEventById(event.id); await approvalStorage.addEvent(updatedEvent); reloadEvents(); - chrome.runtime.sendMessage({ action: 'open_sidebar' }); + onDismiss(); } catch (error) { console.error('Error closing tab and storing event:', error); } @@ -252,11 +278,36 @@ const Transaction = ({ event, reloadEvents }: { event: any; reloadEvents: () => } }; + // Info-only "chain not enabled" surface — no approval flow, just lets + // the user see why nothing happened after a dApp wallet_switchEthereumChain + // and points them at Chainlist. The 4902 has already gone back to the + // dApp by the time this renders. + if (event?.type === 'chain_not_enabled') { + return ; + } + if (errorMessage) { + // Treat timeouts as a soft retryable state, not a fatal error. The + // device is fine, the dApp request is just one-shot — the user has + // to reject and re-initiate from the dApp page. Loud red copy made + // this feel like something broke. + // + // Categorization comes from the typed `kind` forwarded by the + // background. A regex on `errorMessage` is the legacy fallback and + // exists only so older builds in the wild (or paths that haven't + // adopted createTimeoutError yet) still show the friendlier card. + const isTimeout = errorKind === 'timeout' || /timed out|timeout/i.test(errorMessage); + const status = isTimeout ? 'warning' : 'error'; + const heading = isTimeout ? 'Took too long' : 'Error Occurred'; + const body = isTimeout + ? 'No response from your KeepKey in time. Reject the request in the dApp, then try again.' + : errorMessage; + const buttonScheme = isTimeout ? 'yellow' : 'red'; + const buttonLabel = 'Close'; return ( borderRadius="lg"> -

Error Occurred

-

{errorMessage}

+

{heading}

+

{body}

-
@@ -285,6 +336,21 @@ const Transaction = ({ event, reloadEvents }: { event: any; reloadEvents: () => return ; } + // No-device config flow that's been accepted: hide the review form + // and the "approve on device" overlay entirely. Render a minimal + // "Saving…" state while the background finishes writing the config + // and fires signature_complete (which dismisses via onDismiss). + if (transactionInProgress && !hasDeviceStep) { + return ( + + +

+ {event?.type === 'wallet_addEthereumChain' ? 'Saving network configuration…' : 'Processing…'} +

+
+ ); + } + return (
{transactionInProgress && } diff --git a/pages/popup/src/components/TxidPage.tsx b/pages/side-panel/src/approval/TxidPage.tsx similarity index 97% rename from pages/popup/src/components/TxidPage.tsx rename to pages/side-panel/src/approval/TxidPage.tsx index ce07333..5d24423 100644 --- a/pages/popup/src/components/TxidPage.tsx +++ b/pages/side-panel/src/approval/TxidPage.tsx @@ -34,11 +34,7 @@ const TxidPage = ({ txHash, explorerUrl, onClose }: { txHash: string; explorerUr }; const handleClose = () => { - if (onClose) { - onClose(); - } else { - window.close(); - } + onClose?.(); }; const truncatedHash = txHash.length > 20 ? `${txHash.slice(0, 10)}...${txHash.slice(-10)}` : txHash; diff --git a/pages/popup/src/components/evm/ChainSelect.tsx b/pages/side-panel/src/approval/evm/ChainSelect.tsx similarity index 100% rename from pages/popup/src/components/evm/ChainSelect.tsx rename to pages/side-panel/src/approval/evm/ChainSelect.tsx diff --git a/pages/popup/src/components/evm/ContractDetailsCard.tsx b/pages/side-panel/src/approval/evm/ContractDetailsCard.tsx similarity index 98% rename from pages/popup/src/components/evm/ContractDetailsCard.tsx rename to pages/side-panel/src/approval/evm/ContractDetailsCard.tsx index 1c0e621..4a1cf3a 100644 --- a/pages/popup/src/components/evm/ContractDetailsCard.tsx +++ b/pages/side-panel/src/approval/evm/ContractDetailsCard.tsx @@ -100,7 +100,7 @@ export default function ContractDetailsCard({ transaction }: ContractDetailsCard - + Pioneer Summary: diff --git a/pages/side-panel/src/approval/evm/FeeWarningBanner.tsx b/pages/side-panel/src/approval/evm/FeeWarningBanner.tsx new file mode 100644 index 0000000..8fc5b6b --- /dev/null +++ b/pages/side-panel/src/approval/evm/FeeWarningBanner.tsx @@ -0,0 +1,177 @@ +import { Box, Button, Flex, HStack, Input, Stack, Text, useToast } from '@chakra-ui/react'; +import React, { useState } from 'react'; +import { requestStorage } from '@extension/storage'; + +/** Mirrors FeeWarning from chrome-extension/src/background/chains/feeFloors.ts */ +type FeeWarning = { + dappMaxFeePerGas: string; + dappMaxPriorityFeePerGas: string; + suggestedMaxFeePerGas: string; + suggestedMaxPriorityFeePerGas: string; + floorWei: string; + /** Optional — present on builds with tip-aware warning. */ + priorityFloorWei?: string; + /** Optional — present on builds with tip-aware warning. */ + effectiveTipWei?: string; + /** Optional — present on builds with tip-aware warning. */ + trigger?: 'maxFee' | 'tip' | 'both'; + baseFeeWei: string | null; + reason: string; + chainId: string; +}; + +type FeeChoice = + | { source: 'dapp' | 'suggested' } + | { source: 'custom'; customMaxFeePerGas: string; customMaxPriorityFeePerGas: string }; + +const hexToGwei = (h: string | null | undefined): string => { + if (!h) return '—'; + try { + const wei = BigInt(h); + // Render with up to 4 decimals of gwei precision for legibility. + const gweiTimes1e4 = Number((wei * 10000n) / 1_000_000_000n) / 10000; + return gweiTimes1e4.toString(); + } catch { + return h; + } +}; + +const gweiToHex = (g: string): string => { + const n = parseFloat(g); + if (!isFinite(n) || n < 0) throw new Error('invalid gwei'); + // gwei → wei via integer math to avoid float drift on small values + const wei = BigInt(Math.round(n * 1e9)); + return '0x' + wei.toString(16); +}; + +interface Props { + eventId: string; + warning: FeeWarning; + /** Selection state — parent EvmTransaction reads this to decide whether to enable Approve. */ + choice: FeeChoice | null; + onChoiceChange: (c: FeeChoice | null) => void; +} + +export default function FeeWarningBanner({ eventId, warning, choice, onChoiceChange }: Props) { + const toast = useToast(); + const [customMode, setCustomMode] = useState(false); + const [customMax, setCustomMax] = useState(hexToGwei(warning.suggestedMaxFeePerGas)); + const [customPriority, setCustomPriority] = useState(hexToGwei(warning.suggestedMaxPriorityFeePerGas)); + + const persist = async (next: FeeChoice) => { + try { + await requestStorage.updateEventById(eventId, { feeChoice: next }); + onChoiceChange(next); + } catch (e: any) { + toast({ status: 'error', title: 'Could not save fee choice', description: String(e?.message || e) }); + } + }; + + const pickDapp = () => persist({ source: 'dapp' }); + const pickSuggested = () => persist({ source: 'suggested' }); + + const applyCustom = () => { + let maxHex: string, prioHex: string; + try { + maxHex = gweiToHex(customMax); + prioHex = gweiToHex(customPriority); + } catch { + toast({ status: 'error', title: 'Custom fees must be positive numbers' }); + return; + } + persist({ source: 'custom', customMaxFeePerGas: maxHex, customMaxPriorityFeePerGas: prioHex }); + }; + + const isPicked = (s: FeeChoice['source']) => choice?.source === s; + + return ( + + + + + {warning.trigger === 'tip' + ? '⚠ Low miner tip — tx may be dropped' + : warning.trigger === 'both' + ? '⚠ Both maxFee and tip too low' + : '⚠ Low fee — tx may sit pending'} + + + + {warning.reason} + + + + + + + + + {customMode && ( + + + + Max fee (gwei) + + setCustomMax(e.target.value)} placeholder="e.g. 1.5" /> + + + + Priority (gwei) + + setCustomPriority(e.target.value)} + placeholder="e.g. 0.5" + /> + + + + )} + + + Base fee: {hexToGwei(warning.baseFeeWei)} gwei · maxFee floor: {hexToGwei(warning.floorWei)} gwei + {warning.priorityFloorWei !== undefined && ( + <> + {' · tip floor: '} + {hexToGwei(warning.priorityFloorWei)} gwei + + )} + {warning.effectiveTipWei !== undefined && ( + <> + {' · effective tip: '} + {hexToGwei(warning.effectiveTipWei)} gwei + + )} + + + + ); +} diff --git a/pages/popup/src/components/evm/HarpyDetailsCard.tsx b/pages/side-panel/src/approval/evm/HarpyDetailsCard.tsx similarity index 100% rename from pages/popup/src/components/evm/HarpyDetailsCard.tsx rename to pages/side-panel/src/approval/evm/HarpyDetailsCard.tsx diff --git a/pages/side-panel/src/approval/evm/NonceInfoRow.tsx b/pages/side-panel/src/approval/evm/NonceInfoRow.tsx new file mode 100644 index 0000000..f87723f --- /dev/null +++ b/pages/side-panel/src/approval/evm/NonceInfoRow.tsx @@ -0,0 +1,52 @@ +import { Box, HStack, Link, Text } from '@chakra-ui/react'; +import React from 'react'; + +type NonceInfo = { latest: number; pending: number; willReplace: boolean }; + +interface Props { + nonceInfo: NonceInfo; + /** Address whose nonces we report — for the etherscan deep-link. Optional. */ + address?: string; + /** chainId for deep-link routing — defaults to mainnet. */ + chainId?: number | string; +} + +/** + * Read-only nonce visibility. Shows the next-available nonce, plus a yellow + * warning when there's already a pending tx that this one would queue behind + * (or replace). No edit affordance in this round — manual nonce override is + * deferred to a follow-up. + */ +export default function NonceInfoRow({ nonceInfo, address, chainId }: Props) { + const { latest, pending, willReplace } = nonceInfo; + const inFlight = pending - latest; + + const explorerHref = + address && (chainId === 1 || chainId === '0x1' || chainId === undefined) + ? `https://etherscan.io/address/${address}` + : null; + + const tone = willReplace ? 'red.300' : inFlight > 0 ? 'orange.300' : 'rgba(255,255,255,0.65)'; + + let label: string; + if (willReplace) { + label = `⚠ Replaces a pending tx at nonce ${latest}. Needs +10% on both fees to evict.`; + } else if (inFlight > 0) { + label = `Nonce ${pending} (you have ${inFlight} pending tx${inFlight > 1 ? 's' : ''} ahead of this).`; + } else { + label = `Nonce ${pending} (next available).`; + } + + return ( + + + {label} + {explorerHref && ( + + view on etherscan ↗ + + )} + + + ); +} diff --git a/pages/popup/src/components/evm/ProjectInfoCard.tsx b/pages/side-panel/src/approval/evm/ProjectInfoCard.tsx similarity index 96% rename from pages/popup/src/components/evm/ProjectInfoCard.tsx rename to pages/side-panel/src/approval/evm/ProjectInfoCard.tsx index 84b4b9e..9970a24 100644 --- a/pages/popup/src/components/evm/ProjectInfoCard.tsx +++ b/pages/side-panel/src/approval/evm/ProjectInfoCard.tsx @@ -1,8 +1,7 @@ import { useMemo, useEffect, useState } from 'react'; import { Avatar, Box, Text, VStack, Stack, Badge, Image } from '@chakra-ui/react'; -// KeepKey logo URL with fallback -const KEEPKEY_LOGO = 'https://api.keepkey.info/coins/keepkey.png'; +const KEEPKEY_LOGO = '/kk-logo.png'; const KEEPKEY_LOGO_FALLBACK = '/icon-128.png'; interface IProps { diff --git a/pages/popup/src/components/evm/RequestDataCard.tsx b/pages/side-panel/src/approval/evm/RequestDataCard.tsx similarity index 100% rename from pages/popup/src/components/evm/RequestDataCard.tsx rename to pages/side-panel/src/approval/evm/RequestDataCard.tsx diff --git a/pages/popup/src/components/evm/RequestDetailsCard.tsx b/pages/side-panel/src/approval/evm/RequestDetailsCard.tsx similarity index 63% rename from pages/popup/src/components/evm/RequestDetailsCard.tsx rename to pages/side-panel/src/approval/evm/RequestDetailsCard.tsx index a48b814..d2c3ea2 100644 --- a/pages/popup/src/components/evm/RequestDetailsCard.tsx +++ b/pages/side-panel/src/approval/evm/RequestDetailsCard.tsx @@ -1,8 +1,14 @@ -import { useState, useEffect } from 'react'; +import React, { useState, useEffect, Fragment } from 'react'; import { Box, Spinner, Flex } from '@chakra-ui/react'; -import React, { Fragment } from 'react'; import LegacyTx from './txTypes/legacy'; import Eip712Tx from './txTypes/eip712'; +import PersonalSignTx from './txTypes/personalSign'; +import AddEthereumChainTx from './txTypes/addEthereumChain'; + +// Methods that don't produce an on-chain transaction — they have no +// `unsignedTx` and must not be blocked behind the "waiting for +// transaction build" spinner meant for transfers. +const NO_UNSIGNED_TX_METHODS = new Set(['personal_sign', 'eth_sign', 'wallet_addEthereumChain']); // Function to request asset context from background script const requestAssetContext = () => { @@ -41,13 +47,20 @@ export default function RequestDetailsCard({ transaction }: any) { case 'eth_signTypedData_v3': case 'eth_signTypedData': return ; + case 'personal_sign': + case 'eth_sign': + return ; + case 'wallet_addEthereumChain': + return ; default: return ; } }; - if (!transaction?.unsignedTx) { - // Show spinner if transaction.unsignedTx is not set + // Wait for the unsigned-tx build only for methods that produce one. + // Message-signing and configuration-change methods never populate it. + const skipSpinner = NO_UNSIGNED_TX_METHODS.has(transaction?.type); + if (!skipSpinner && !transaction?.unsignedTx) { return ( diff --git a/pages/popup/src/components/evm/RequestFeeCard.tsx b/pages/side-panel/src/approval/evm/RequestFeeCard.tsx similarity index 100% rename from pages/popup/src/components/evm/RequestFeeCard.tsx rename to pages/side-panel/src/approval/evm/RequestFeeCard.tsx diff --git a/pages/popup/src/components/evm/RequestMethodCard.tsx b/pages/side-panel/src/approval/evm/RequestMethodCard.tsx similarity index 78% rename from pages/popup/src/components/evm/RequestMethodCard.tsx rename to pages/side-panel/src/approval/evm/RequestMethodCard.tsx index 5e55641..a1eb73a 100644 --- a/pages/popup/src/components/evm/RequestMethodCard.tsx +++ b/pages/side-panel/src/approval/evm/RequestMethodCard.tsx @@ -51,6 +51,14 @@ const getMethodInfo = (txType: string, hasSmartContractExecution: boolean) => { color: 'yellow.400', }; + case 'wallet_addEthereumChain': + return { + title: 'Add Network', + description: 'Configures a new EVM network using an RPC the site chose. No on-device step. Verify the RPC URL.', + icon: , + color: 'yellow.400', + }; + default: return { title: 'Unknown Method', @@ -65,8 +73,12 @@ const getMethodInfo = (txType: string, hasSmartContractExecution: boolean) => { * Component */ export default function RequestMethodCard({ transaction }: any) { - const hasSmartContractExecution = - transaction.request?.data && transaction.request.data.length > 0 && transaction.request.data !== '0x'; + // `transaction.request` is the JSON-RPC params array (`[{ to, data, ... }]`), + // not an object — so `request.data` is always undefined. The unsignedTx the + // background builds from `params[0]` is the right source, and matches what + // the Details tab renders. + const data = transaction?.unsignedTx?.data ?? transaction?.request?.[0]?.data; + const hasSmartContractExecution = typeof data === 'string' && data.length > 2 && data !== '0x'; const { title, description, icon, color } = getMethodInfo(transaction.type, hasSmartContractExecution); diff --git a/pages/popup/src/components/evm/ThreatPrompt.tsx b/pages/side-panel/src/approval/evm/ThreatPrompt.tsx similarity index 100% rename from pages/popup/src/components/evm/ThreatPrompt.tsx rename to pages/side-panel/src/approval/evm/ThreatPrompt.tsx diff --git a/pages/side-panel/src/approval/evm/index.tsx b/pages/side-panel/src/approval/evm/index.tsx new file mode 100644 index 0000000..63c25f6 --- /dev/null +++ b/pages/side-panel/src/approval/evm/index.tsx @@ -0,0 +1,120 @@ +import { + Avatar, + Button, + Card, + CardHeader, + Flex, + Spinner, + Stack, + Tabs, + TabList, + TabPanels, + Tab, + TabPanel, + Divider, +} from '@chakra-ui/react'; +import React, { useEffect, useState } from 'react'; +import RequestFeeCard from './RequestFeeCard'; +import RequestDataCard from './RequestDataCard'; +import RequestDetailsCard from './RequestDetailsCard'; +import ContractDetailsCard from './ContractDetailsCard'; +import RequestMethodCard from './RequestMethodCard'; +import ProjectInfoCard from './ProjectInfoCard'; +import FeeWarningBanner from './FeeWarningBanner'; +import NonceInfoRow from './NonceInfoRow'; + +export function EvmTransaction({ transaction, reloadEvents, handleResponse }: any) { + // Block Approve until the user picks a fee strategy when a warning is + // attached. Detection lives background-side in handleSigningMethods — + // we just enforce that the user has made a choice before we sign. + const feeWarning = transaction?.feeWarning ?? null; + const initialChoice = transaction?.feeChoice ?? null; + const [feeChoice, setFeeChoice] = useState(initialChoice); + const approveBlocked = !!feeWarning && !feeChoice; + + return ( + + + + + {feeWarning && ( + + )} + {transaction?.nonceInfo && ( + + )} + + + + + {/*Insight*/} + Details + Fees + Raw + + + + {/* Contract Tab */} + {/**/} + {/* */} + {/**/} + + {/* Review Tab */} + + + + + {/* Fees Tab — skip for methods that don't produce an on-chain tx */} + + {transaction.type !== 'personal_sign' && + transaction.type !== 'eth_sign' && + transaction.type !== 'wallet_addEthereumChain' && ( + <> + + + )} + {transaction.type === 'wallet_addEthereumChain' && ( + +
+ No transaction fees — this flow only stores the RPC configuration locally. +
+
+ )} +
+ + {/* Raw Data Tab */} + + + +
+
+ + + + + + + +
+ ); +} + +export default EvmTransaction; diff --git a/pages/side-panel/src/approval/evm/txTypes/addEthereumChain.tsx b/pages/side-panel/src/approval/evm/txTypes/addEthereumChain.tsx new file mode 100644 index 0000000..6c5e36a --- /dev/null +++ b/pages/side-panel/src/approval/evm/txTypes/addEthereumChain.tsx @@ -0,0 +1,185 @@ +import React, { useMemo } from 'react'; +import { + Alert, + AlertIcon, + Badge, + Box, + Divider, + Flex, + HStack, + Link, + Table, + Tbody, + Td, + Text, + Tr, +} from '@chakra-ui/react'; +import { ExternalLinkIcon } from '@chakra-ui/icons'; + +/** + * Approval view for `wallet_addEthereumChain`. The dApp provides an RPC + * URL that the wallet will use for ALL subsequent reads on that chain + * (balances, gas price, `eth_call`, …). If we hand over to a malicious + * RPC the attacker can show fake "confirmed" balances to trick the user + * into thinking a send succeeded, fake zero-gas quotes to steer them + * into signing something else, etc. The on-device step only confirms + * outgoing transactions — not the view of the world — so this approval + * is the ONLY surface that protects against a phishing RPC. + * + * Design decisions: + * - RPC URL is always rendered in full, monospaced, word-broken. No + * truncation. Users need to see the whole host so they can spot + * typosquat domains. + * - Warn if RPC is plain http:// (cleartext — eavesdropping risk). + * - chainId shown in both hex and decimal — the dApp sends hex, but + * "1" is more recognizable than "0x1". + * - No fee tab / no gas info — this flow never triggers a signed tx. + */ +export default function AddEthereumChainTx({ transaction }: any) { + // event.request = [{ chainId, chainName, nativeCurrency, rpcUrls, blockExplorerUrls?, iconUrls? }] + const req = transaction?.request?.[0] || transaction?.requestInfo?.params?.[0] || {}; + + const parsed = useMemo(() => { + const chainIdHex: string = req.chainId || ''; + const chainIdDecimal = /^0x[0-9a-fA-F]+$/.test(chainIdHex) ? parseInt(chainIdHex, 16) : NaN; + const rpcUrls: string[] = Array.isArray(req.rpcUrls) ? req.rpcUrls.filter((u: any) => typeof u === 'string') : []; + const explorers: string[] = Array.isArray(req.blockExplorerUrls) + ? req.blockExplorerUrls.filter((u: any) => typeof u === 'string') + : []; + const native = req.nativeCurrency || {}; + return { + chainIdHex, + chainIdDecimal, + chainName: req.chainName || '(unnamed)', + rpcUrls, + explorers, + native: { + name: native.name || '', + symbol: native.symbol || '', + decimals: typeof native.decimals === 'number' ? native.decimals : undefined, + }, + }; + }, [req]); + + const primaryRpc = parsed.rpcUrls[0] || ''; + const isHttpOnly = /^http:\/\//i.test(primaryRpc); + + return ( + + + + + Adding a network lets this site choose your RPC. + + All balance lookups and gas estimates for this chain will go through the URL below. Verify it matches the + official provider before approving. + + + + + {isHttpOnly && ( + + + + RPC URL is plain http — traffic is unencrypted. Most legitimate chains offer{' '} + https. Strongly recommend rejecting. + + + )} + + + + + + + + + + + + + + + + + + + + {parsed.explorers.length > 0 && ( + + + + + )} + +
+ Chain: + + + {parsed.chainName} + + {parsed.chainIdHex} + {Number.isFinite(parsed.chainIdDecimal) ? ` (${parsed.chainIdDecimal})` : ''} + + +
+ Currency: + + + {parsed.native.symbol && ( + + {parsed.native.symbol} + + )} + {parsed.native.name && ( + + {parsed.native.name} + + )} + {parsed.native.decimals !== undefined && ( + + {parsed.native.decimals} decimals + + )} + +
+ RPC URL: + + {parsed.rpcUrls.length === 0 ? ( + + (none provided) + + ) : ( + + {parsed.rpcUrls.map((url, i) => ( + + {url} + + ))} + + )} +
+ Explorer: + + {parsed.explorers.map((url, i) => ( + + {url} + + + ))} +
+
+ +
+ ); +} diff --git a/pages/popup/src/components/evm/txTypes/eip712.tsx b/pages/side-panel/src/approval/evm/txTypes/eip712.tsx similarity index 100% rename from pages/popup/src/components/evm/txTypes/eip712.tsx rename to pages/side-panel/src/approval/evm/txTypes/eip712.tsx diff --git a/pages/popup/src/components/evm/txTypes/legacy.tsx b/pages/side-panel/src/approval/evm/txTypes/legacy.tsx similarity index 100% rename from pages/popup/src/components/evm/txTypes/legacy.tsx rename to pages/side-panel/src/approval/evm/txTypes/legacy.tsx diff --git a/pages/side-panel/src/approval/evm/txTypes/personalSign.tsx b/pages/side-panel/src/approval/evm/txTypes/personalSign.tsx new file mode 100644 index 0000000..d83159a --- /dev/null +++ b/pages/side-panel/src/approval/evm/txTypes/personalSign.tsx @@ -0,0 +1,137 @@ +import { Badge, Box, Divider, Flex, HStack, Switch, Table, Tbody, Td, Text, Textarea, Tr } from '@chakra-ui/react'; +import React, { useMemo, useState } from 'react'; + +/** + * Approval-dialog view for EIP-191 `personal_sign` (and the legacy + * `eth_sign`) requests. + * + * Why this exists: dApps pass messages to the wallet as hex-encoded UTF-8 + * text per the JSON-RPC spec. The KeepKey firmware displays the hash as + * "Sign Bytes" on-device whenever the payload contains any non-printable + * byte — `\n` (0x0A), `\r`, `\t`. Real SIWE (EIP-4361) login challenges + * (OpenSea, Uniswap, Blur, etc.) are always multi-line, so the device + * physically cannot render them as text. The user's only surface for + * reading what they're signing is this approval popup — if we show raw + * hex here too, informed consent is impossible. + * + * Mirror of the fix shipped in the KeepKey Vault at + * `projects/keepkey-vault-v11/projects/keepkey-vault/src/mainview/components/device/SigningApproval.tsx` + * (EthMessageSection). Same decode semantics: try UTF-8 with the fatal + * decoder, fall back to raw-hex display on failure, keep the raw hex + * always-available behind a toggle so power users can hash-verify. + */ +export default function PersonalSignTx({ transaction }: any) { + const method: string = transaction?.type || ''; + const params: any[] = transaction?.request || transaction?.requestInfo?.params || []; + + // JSON-RPC ordering differs between the two historical methods: + // personal_sign → params = [message, address] + // eth_sign → params = [address, message] + // If the dApp got it wrong (both params happen to be strings), we still + // pick the one that starts with `0x{even-number-of-hex-chars}` or, if + // neither does, fall back to the conventional slot. + const [rawMessage, signer] = useMemo(() => { + if (method === 'eth_sign') return [String(params?.[1] ?? ''), String(params?.[0] ?? '')]; + return [String(params?.[0] ?? ''), String(params?.[1] ?? '')]; + }, [method, params]); + + const decoded = useMemo(() => decodeEip191Message(rawMessage), [rawMessage]); + const [showHex, setShowHex] = useState(!decoded.isUtf8Text); + + return ( + + + + + + + + + + + + + + + + + +
+ Method: + {method}
+ Signer: + + {signer || 'Unknown'} +
+ Message: + + {decoded.isUtf8Text ? ( + + + {decoded.text} + + + ) : ( + + Message is not valid UTF-8 — verify the raw hex below before approving. + + )} +
+ + + Show raw hex + setShowHex(s => !s)} isChecked={showHex} /> + + + {showHex && ( +