Skip to content

release: 0.0.28 — Tron + TON, side-panel approval merge, hot-swap fixes#51

Open
BitHighlander wants to merge 29 commits intomasterfrom
develop
Open

release: 0.0.28 — Tron + TON, side-panel approval merge, hot-swap fixes#51
BitHighlander wants to merge 29 commits intomasterfrom
develop

Conversation

@BitHighlander
Copy link
Copy Markdown
Collaborator

Summary

Promote developmaster for v0.0.28. Cuts master from 0.0.26 → 0.0.28 (the in-between 0.0.27 bump was develop-only).

What's in this release (vs master)

New chains / dApp surface

Architecture

Fixes

Release hygiene (this commit)

  • Stripped 10 leftover [TON-DEBUG] console logs from production paths (background + side panel)

Test plan

  • make build produces a clean Chrome bundle (verified locally)
  • Load unpacked into Chrome, exercise: connect → asset switch → Send (BTC, ETH, SOL, TON, Tron)
  • Receive tab works while device is unplugged (cached pubkeys path)
  • Hot-swap: disconnect device mid-session, reconnect a different one — cache resets
  • dApp connect flows for Tron (MetaMask mask) and Solana (Wallet Standard)
  • wallet_addEthereumChain shows the new approval UI

🤖 Generated with Claude Code

BitHighlander and others added 23 commits April 20, 2026 18:57
When an MV3 service worker wakes up and calls set() with an updater
(e.g. requestStorage.addEvent spreading prev into a new array), the
in-memory cache may still be null because _getDataFromStorage() is
async. The updater then runs with prev = null and [...null] throws
"TypeError: a is not iterable", aborting addEvent and leaving the
approval flow in a bad state (popup opens for a request that was
never persisted).

Load the cache synchronously-ish (await from chrome.storage) in set()
if it's still null, so every updater receives a real value.

Co-authored-by: Claude Opus 4.7 (1M context) <[email protected]>
* fix: harden popup lifecycle against hangs and duplicates

Addresses three symptoms: popup hanging open after completion, multiple
popup windows opening per request, and the approval promise never
resolving when the user X's the popup.

methods.ts
- Replace in-memory isPopupOpen flag with chrome.windows.getAll lookup
  that survives service worker restarts. Focuses an existing popup
  (matched by URL) instead of opening a second one.
- Register chrome.windows.onRemoved exactly once at module load and
  fan out to per-request subscribers, instead of adding a new listener
  on every openPopup() call (previously leaked forever).
- requireApproval now resolves {success:false} when the popup closes
  without an eth_sign_response, so chain handlers no longer hang and
  the dapp's RPC call terminates.

chain handlers
- Every transaction_complete / signature_complete / transaction_error
  message now carries eventId so the popup can match it to the right
  in-flight request. ethereum threads the id through signMessage /
  signTypedData; solana's buildEvent mutates requestInfo.id so the
  match works end-to-end.

Transaction.tsx
- Ignores completion/error messages whose eventId doesn't match the
  current event. Previously any signature_complete would close the
  popup, slamming the window on unrelated queued requests.

ethereumHandler.ts
- Removes dead duplicate openPopup / requireUnlock (never called,
  would have bypassed dedup if it had been).

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>

* fix: tag outer handleWalletRequest error with eventId

The outer catch in handleWalletRequest sends transaction_error to the
popup but was the one remaining site missing the eventId scope tag.
Combined with Transaction.tsx's backward-compat rule that accepts
unscoped messages, a thrown chain handler would surface its error on
the wrong event if two events were queued — exactly the
cross-contamination this PR is fixing everywhere else.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <[email protected]>
* fix: harden popup empty state UX and make it self-healing

Addresses the "popup open but says 'no events' with no recovery"
failure mode, plus a latent crash if requestStorage returns null.

Events.tsx
- Null-guard requestStorage.getEvents() — previously a null return
  would throw on `for...of null` and white-screen the popup.
- Wrap the fetch in try/catch and render a dedicated error state
  instead of crashing.
- Subscribe to requestStorage changes so a second request arriving
  while the popup is open becomes visible immediately (no need to
  resolve the current request first).
- Auto-close the window 3s after landing in an empty state. Covers
  the case where a request was cancelled upstream, where cleanup ran
  before the window closed itself, or where the popup was opened
  with no pending request.
- Better copy in the empty / loading / error states so the user
  knows what is happening and that the window will close itself.
- Keep currentIndex in bounds if the event list shrinks beneath it.

Popup.tsx
- Replace the placeholder "Error Occur" / "Loading ..." fallbacks
  with a proper Chakra-styled error panel that includes a Close
  button, so a rendering crash doesn't leave the user with no way
  out other than clicking the OS window close button.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>

* fix: clamp Events currentIndex inline to avoid one-render crash

The useEffect-based bounds check snapped currentIndex back only AFTER
the render that caused it to go out of bounds. When requestStorage's
subscribe fired and the event list shrank beneath currentIndex, the
first render after the shrink still had the stale index — passing
events[currentIndex] = undefined into <Transaction />, which reads
event.id immediately and error-boundary crashes.

Compute a clamped safeIndex during render instead. currentIndex as
state is preserved for user-driven navigation; only the array access
is guarded.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>

* fix: auto-close popup on fetch error, not just empty state

The error-state UI copy already said the window would close itself,
but the auto-close effect bailed on fetchError, leaving the user stuck
on a dead-end error screen — the exact failure mode this PR is trying
to remove.

Collapse the two conditions into a single shouldAutoClose predicate so
any non-actionable state (empty OR fetch failure) triggers the timer.
Cleanup still runs correctly on recovery (error clears → new events
arrive → previous cleanup cancels the timer, new effect short-circuits).

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <[email protected]>
* fix: replace broken remote logo refs with local assets

The client referenced api.keepkey.info/coins/{keepkey,pioneerMan,ethereum}.png
shortnames that never existed on the CDN (all 403). The CDN only serves
CAIP-base64-encoded filenames, which the dynamic asset-icon code already
generates correctly. This change fixes every hardcoded shortname usage:

- KeepKey brand marks across popup + side-panel switch to bundled /kk-logo.png
  (and chrome.runtime.getURL for the EIP-6963 announce icon and manifest
  web_accessible_resources so dapps can load it).
- AddDappModal default icon now uses caipToIcon('eip155:1/slip44:60'), which
  the CDN does serve.
- NetworkDropdown hides the avatar entirely when no network is selected
  instead of rendering Chakra's default silhouette from a 403 fallback.
- headerUtils.getIconUrl last-resort fallback no longer builds
  btoa(chainSymbol) URLs (always 403); returns '' so Chakra Avatar falls
  back to an initial letter.
- wallet.ts serviceImageUrl points at pioneers.dev (chrome-extension:// URLs
  can't load in the vault desktop UI).

Third-party image hosts also replaced with bundled assets:
- Animated kk.gif (5.6MB from i.ibb.co) converted to kk.webp (286KB, 240px,
  15fps) — 95% smaller, same animation.
- MetaMask fox and Keplr logos downloaded from their official repos.
- Xfi src dropped (Coming Soon overlay covers the avatar; letter fallback).

Net: zero remote image hosts remain outside the working CAIP CDN pattern.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>

* fix: move new branded assets to chrome-extension/public root

Absolute src="/kk.webp" and /brand/... paths resolve to the extension
origin root (chrome-extension://<id>/kk.webp). Vite's base:'' does not
rewrite these, so emitting them under pages/side-panel/public/ (→
dist/side-panel/) was a 404. Moving to chrome-extension/public/ lands
them at the dist root and matches the already-working /kk-logo.png
convention.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <[email protected]>
SidePanel.tsx default-state welcome screen uses <img src="/logo_vertical.svg">
as an opacity-0.25 watermark. The file was in pages/side-panel/public/, so
Vite emitted it to dist/side-panel/logo_vertical.svg while the absolute
path requested chrome-extension://<id>/logo_vertical.svg — 404. Same class
of bug that #41 fixed for kk.webp and brand icons.

Moving both vertical logos to chrome-extension/public/ lands them at the
dist root and matches the working /kk-logo.png / /kk.webp convention.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
Previous encode (q=50, 15fps) produced visible "tracing" — residual inter-frame
prediction errors that looked like motion trails. Bumping quality to 90 and
frame rate to 20fps eliminates the smear while still landing at 560KB (90%
smaller than the original 5.6MB GIF).

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
The ffmpeg-produced animated WebP had broken frame disposal — every frame
composited on top of the previous, leaving "tracing" ghosts. Falling back
to a properly-optimized GIF (240px, 192 colors, coalesced + layered
optimize) lands at 690KB and animates correctly.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
- Prefetch Solana pubkey during onStart so the network shows up in the
  dropdown without waiting for a dapp-initiated request. Previously the
  Solana pubkey was only derived lazily on first dapp interaction, so the
  network list silently skipped it on fresh sessions.
- Hide the total balance + Send/Receive action row during the initial
  balance fetch. Showing $0.00 + disabled buttons while loading caused a
  jittery shift once balances arrived.
- Center the Balances loading spinner full-height with a caption and
  smoother spin. Replaces the top-anchored inline spinner that looked
  cramped above the card list.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
* feat: spice up balance loading screen with hero spinner and skeleton rows

Replace the plain centered spinner + "Loading balances…" text with a
multi-layer animated hero spinner (triple counter-rotating rings, pulsing
glow, breathing center dot) above skeleton cards that mirror the real asset
row layout. Adds a subtle kk-logo watermark behind everything and a teal
shimmer sweep that travels across each skeleton card staggered by 150ms.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>

* fix: show Solana balance and SPL tokens in side panel

Three layered bugs were each sufficient to zero out Solana balance display;
all three are fixed together because fixing any one alone leaves the chain
still broken:

1. Wrong CAIP in shortListSymbolToCaip['SOL'] / shortListNameToCaip.solana.
   Previously pointed at wrapped-SOL SPL token
   (solana:.../solana:so111…, all lowercase). Now points at native SOL
   (solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501), matching
   pioneer-caip's ChainToCaip and vault-v11's config.

2. Wrong Pioneer endpoint for Solana. /charts/portfolio returns empty
   {balances:[], tokens:[]} for any Solana pubkey (verified via direct
   curl). Vault-v11 uses /portfolio via pioneer.GetPortfolioBalances,
   which returns natives + SPL tokens in one flat array. Route Solana
   pubkeys to a third batch hitting /portfolio with the required
   key:public-* Authorization header; EVM/UTXO still go through
   /charts/portfolio for its richer Zapper/Unchained token data.

3. Response case mismatch. Pioneer echoes CAIP/networkId back in
   lowercase regardless of request casing. The side-panel asset list
   uses canonical mixed-case network IDs from ChainToNetworkId, so
   strict b.networkId === asset.networkId comparisons in Balances.tsx
   silently dropped every Solana entry. Rewrite Solana entries to
   canonical casing before they enter the merged balances array.

Also eliminates a first-run race: the initial fetchBalancesFromPioneer()
fired before prefetchSolanaPubkey() persisted the Solana pubkey, so run 1
never included Solana at all. Chain a forced refetch on prefetch resolution
so the Solana entry lands in cachedBalances before the UI mounts.

Verified against the live Pioneer API: for the exact address the client
derives from the device at m/44'/501'/0'/0', /portfolio returns
{native SOL + 3 SPL tokens}.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>

* fix(review): prevent stale-fetch cache clobber and push balance updates to UI

Addresses two PR review findings:

1. HIGH: stale earlier fetch could clobber a later fetch's result. The
   first cold-start fetchBalancesFromPioneer() starts before the Solana
   pubkey is persisted, and the chained forceRefresh fetch runs in
   parallel. If the forced fetch finishes first (correctly populating
   cachedBalances with SOL + SPL tokens) and the original slower fetch
   finishes later, the original unconditionally overwrote cachedBalances
   back to the pre-Solana snapshot. Fix: tag each fetch with a monotonic
   latestFetchId bumped only when real work starts (not for dedup-return
   paths), and only commit to cachedBalances if this fetch is still the
   most recent (myFetchId === latestFetchId). Superseded fetches log
   their discard and return their result to direct callers without
   touching the cache.

2. MEDIUM: UI never observed late cache updates. Balances.tsx and
   SidePanel.tsx each fetched GET_APP_BALANCES once and then stopped
   listening, so the cold-start forced refetch that lands Solana after
   the panel mounts was invisible to users. Fix: background now emits
   BALANCES_UPDATED via chrome.runtime.sendMessage every time
   cachedBalances is successfully written. Balances.tsx, SidePanel.tsx,
   and Tokens.tsx subscribe to that message and re-fetch so the UI
   reflects the latest cache without the user having to manually
   refresh.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <[email protected]>
…back

networkIdToIcon() was mapping Solana to the wrapped-SOL SPL CAIP
(solana:.../solana:So111…), which returns 403 on
keepkey.info/coins/<base64-caip>.png. The Avatar component fell back to
rendering the first letter of the network name — a green "S" badge where
the logo should be. Native SOL's slip44:501 CAIP has a real icon (200),
matching pioneer-caip's ChainToCaip convention used elsewhere in the
codebase after PR #42.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
Adds thin Makefile over pnpm scripts so the stack-wide "use make for
everything" convention applies here too. Also commits the current
minified build of the Solana/EVM injected script.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
Port the approval UI into the side panel and remove the popup entirely.
Side panel is now the sole surface for both portfolio and dApp approvals.

Three-phase merge plus iterative review fixes across five rounds:

Phase A: port popup approval components to side-panel/src/approval and
subscribe SidePanel to requestStorage.
Phase B: background.methods.ts opens the side panel via
chrome.sidePanel.open; setPanelBehavior({openPanelOnActionClick:true})
+ action badge as user-gesture fallback; 10-minute approval timeout
replaces the old popup-closed escape hatch.
Phase C: delete pages/popup, its e2e specs, and the OPEN_SIDEBAR
background handler. Chrome-only; Firefox build hard-gated until a
replacement UI ships (task #5).

Iterative fixes touched: approval routing / id mismatch, PersonalSignTx
wire-up, bridge same-origin + falsy RPC handling, provider coexistence
(stop stomping window.ethereum), GET_PUBKEY_CONTEXT asset scoping,
Receive dedup by CAIP, ETH account removal runtime state, custom
network mirror into provider stores, GET_CHARTS networkId filter,
Clear-all covers every user-written storage, approval window routing,
accountIndex preservation end-to-end, full asset-context pass-through,
native-preference for default global send/receive, scalar balance
fallback in Transfer. New side-panel approval e2e smoke covers the
reject path.

Lint check is red on develop (470 errors pre-existing); this PR's
lint delta is ~0 after ignoring the ported approval subtree.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
Bump every workspace to 0.0.27 via ./update_version.sh for the
release cut. Also restore chrome-extension/public/injected.js to its
esbuild-minified form — prettier had un-minified it on a prior
lint-staged run, bloating the shipped bundle and making every build
produce a noisy diff. Add the file to .prettierignore so this stays
fixed.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
The "KeepKey Vault Required" card rendered without a background on
top of the side panel's dark theme, so the title (no color) and the
\`gray.500\` / \`gray.400\` helper text were effectively black on near-
black. Users couldn't read the "vault must be running" instruction.

Give the card an explicit dark-but-distinct background
(\`bg="gray.800"\` over the \`gray.900\` panel) with a faint border for
separation, and switch every Text to the white/whiteAlpha scale the
rest of the sidebar uses. Link color bumped to \`teal.300\` so it's
visible against gray.800.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
… fetch

TON
- New tonHandler.ts wraps the vault's three-call flow:
  /ton/build-transfer (vault owns BOC + seqno + deploy detection)
  /ton/sign-transaction (device signs the body hash)
  /ton/finalize-transfer (vault assembles + broadcasts via TonCenter).
- BIP-44 path is m/44'/607'/0' (3 levels — NOT 5). Five-level paths
  make the firmware return {"error":"Failed to derive private key"}.
- Address guard (isPlausibleTonAddress) rejects anything that isn't
  UQ/EQ/kQ/0Q + 46 base64url chars or 0:/−1: raw hex, so a stale
  cache or misrouted call can't silently poison the TON pubkey slot
  with an ETH address.
- Signature normalizer: /ton/sign-transaction returns whatever shape
  hdwallet produces (unlike /tron/sign-transaction which hex-encodes
  server-side). Coerce string / Buffer / number[] / Uint8Array-like
  into 64-byte hex before handing to /ton/finalize-transfer, which
  strictly requires a hex string.
- Balance unit fix: Pioneer's /api/v1/ton/accountInfo returns decimal
  TON already (e.g. "15.701798194"), not nanoTON — the prior /1e9
  divide turned a real 15.7 TON balance into 1.5701798194e-8 and
  made the Asset page / Send page show 0.0000 TON.

Tron
- Transaction.tsx adds a 'tron' router case so approvals render via
  OtherTransaction (same generic cards Solana/TON/Ripple use) — was
  falling through to 'unknown' → "Unknown Transaction Type".
- tronHandler already did the build / sign / TronGrid broadcast and
  the vault's /tron/sign-transaction already hex-encodes, so no
  signature-shape normalization needed on this side.

Balance fetch stabilization
- fetchBalancesFromPioneer now rejects commits from fetches whose
  pubkey snapshot was taken before a concurrent addPubkey landed.
  Without this, three parallel prefetches (Solana / Tron / TON) could
  race such that the fetch with the highest id (the "latest" winner
  of the pre-existing latestFetchId check) had a stale snapshot
  missing the dynamic pubkey. Symptom: dashboard briefly showed
  correct TON / Tron balance then "overwrote" it to 0 when a stale
  but id-latest fetch committed. Guard: compare wallet.getPubkeys()
  length at commit vs at snapshot — if it grew, supersede ourselves.
- Added a #N committed log with the native-row summary for triage.

Asset context
- assetContextStorage.updateContext now REPLACES instead of merging.
  The previous {...prev, ...newContext} let stale fields from an
  earlier asset (decimals / contractAddress / token:true) leak into
  the next asset — switching from an ERC-20 to TON would carry the
  token flag forward and mis-route the Send page.

Other
- SDK bump keepkey-vault-sdk ^2.0.1 → ^3.0.1 (required by the new
  vault feat/ton-build-transfer branch).
- shared/chainConfig adds Ton + Tron entries (Chain enum,
  ChainToNetworkId, COIN_MAP_LONG).
- chrome-extension/chainConfig mirrors shortListSymbolToCaip +
  shortListNameToCaip for the same two.
- background/index.ts: fetchTonBatch / fetchTronBatch + prefetch +
  resetState on disconnect. Valid until we land real price feeds
  for TON / TRX — both hardcode valueUsd/priceUsd=0 today.
- Extension gets TON / Tron debug logs in key hot paths; cheap and
  worth keeping for the next round of triage.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
Pioneer's /charts/portfolio silently omits TON and Tron rows when called without a queryKey. Our previous workaround used per-address /accountInfo calls that returned raw balance but no price — dashboard USD stuck at \$0.00 for both chains even when the vault's own UI showed correct values.

Append ?key=key:public-\${Date.now()} so the portfolio call returns TON and Tron with full priceUsd + valueUsd (same auth scheme /portfolio already used for Solana). Keep /accountInfo as a targeted patch for partial-response edge cases — priceless row beats missing row. Also dedupe + stale-state-aware retry on chrome.action.setIcon to fix cold-start icon flicker.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
The add-chain flow had five UI gaps that together rendered as
"Unknown Method / Verify before proceeding" above an infinite spinner:

  - RequestMethodCard had no case for wallet_addEthereumChain → default.
  - RequestDetailsCard routed it through LegacyTx, which reads fields
    off `unsignedTx` (null for this flow).
  - The `!unsignedTx` spinner guard hid any content until a tx built —
    which never happens for add-chain.
  - The fees tab tried to render RequestFeeCard for an event with no tx.
  - On Approve, Transaction.tsx set `awaitingDeviceApproval`, flashing
    "Please approve on your KeepKey" — but this flow never touches the
    device, the background just writes to blockchainStorage and fires
    signature_complete.

Fixes:

- New AddEthereumChainTx component — dedicated view with:
  - warning banner ("the site chooses the RPC"), plus a red banner if
    the RPC URL is plain http://,
  - chain name + chainId (hex and decimal),
  - native currency (symbol / name / decimals),
  - full, untruncated, monospaced RPC URL block (trust surface),
  - explorer link(s).
- Route wallet_addEthereumChain through the new component in
  RequestDetailsCard. Rename the spinner-skip set from MESSAGE_SIGN_METHODS
  to NO_UNSIGNED_TX_METHODS and add the type to it.
- RequestMethodCard gets a yellow "Add Network" header with an
  explainer that reminds the user to verify the RPC.
- Fees tab shows a "No transaction fees — this flow only stores the
  RPC configuration locally." placeholder.
- Transaction.tsx: add NO_DEVICE_STEP_TYPES. For those methods, Accept
  shows a minimal "Saving network configuration…" spinner state (the
  form and the device overlay both hidden) until the background emits
  signature_complete and onDismiss fires.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
* fix: detect KeepKey hot-swap + drop stale cache on device change

The extension treated any reachable vault as a reachable device, with no
validation that the device currently paired with the vault is the same
one whose pubkeys we cached. That made three failure modes silent:

  1. Hot-swap on same vault — checkKeepKey kept state.deviceConnected=true
     and never re-probed, so addresses/balances pinned to the old device
     indefinitely.
  2. Extension reload with a new device — init() loaded the old pubkey
     cache without comparing deviceId against the freshly-probed one, so
     cached addresses survived even a full BEX reload.
  3. Vault auth rejection (stale apiKey after re-pair) — fetchPubkeys
     caught the error and silently fell back to cached pubkeys from the
     previous device, masking the re-pair requirement.

Four narrow changes:

- wallet.init() now compares cached deviceInfo.deviceId to the freshly-
  probed deviceId when a device is reachable. Mismatch → clear storage
  before fetchPubkeys runs. (Covers case 2.)

- wallet.handleDeviceSwitch() primitive clears in-memory pubkeys/paths,
  resets the deviceConnected flag, and wipes pubkeyStorage so the next
  fetch rebuilds from the new device. Keeps SDK alive — only the
  device-specific state is dropped.

- background/index.ts checkKeepKey now runs a periodic (30s, throttled)
  getFeatures re-probe while deviceConnected. Compares before/after
  deviceId; on mismatch calls handleDeviceSwitch which clears balance
  cache, per-chain address caches (Solana/Tron/TON), and triggers a
  fresh pubkey+balance refresh. (Covers case 1.)

- fetchPubkeys no longer silently falls back to cache on auth errors.
  Detects 401 / "unauthorized" / "not paired" shapes, clears the stored
  apiKey, and throws a user-facing message for the sidebar to surface.
  Non-auth failures (network blip, device busy) still fall back to cache
  as view-only — that path is correct. (Covers case 3.)

No user-visible UX change on the happy path. Recovery from device swap
is now automatic within ~30s on steady state, immediate on extension
reload.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>

* fix: rebuild default paths on device switch so refreshPubkeys has a batch

handleDeviceSwitch cleared state.paths to [], but the caller
(background checkKeepKey) then calls wallet.refreshPubkeys() which
only probes + fetches — it does not repopulate paths. fetchPubkeys
maps state.paths into the batch sent to the device, so an empty
paths array meant an empty batch, meaning the hot-swap recovery
finished with zero pubkeys and zero balances instead of the new
device's view.

Reset to getDefaultPaths() on switch so refreshPubkeys has something
to query against.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>

* fix: re-prefetch Solana/Tron/TON pubkeys on device switch

refreshPubkeys only re-runs the default-path batch via
wallet.getPublicKeys — that batch covers BTC / LTC / ETH / Cosmos /
etc., but *not* SOL / TRX / TON. Those three are derived dynamically
at onStart via prefetchSolanaPubkey / prefetchTronPubkey /
prefetchTonAddress (each calls its chain's `*GetAddress` method and
stashes the result in a per-chain cache + adds a pubkey to state).

After a hot-swap:
  - handleDeviceSwitch clears the per-chain caches (resetXState)
  - refreshPubkeys repopulates the default batch
  - but nothing re-runs the three prefetches
  → SOL/TRX/TON vanish from the network dropdown / asset list / balance
    fetch until the user reloads the extension or manually navigates to
    those chains, which racy-triggers the lazy getAddress in each handler.

Fan out all three prefetches in parallel via Promise.allSettled after
refreshPubkeys. Non-throwing by design, so if one chain's derivation
fails (minFirmware mismatch, etc.) the other two still recover.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <[email protected]>
…etwork-ids (#47)

Two related fixes that together make TRC-20 (USDT-TRON) appear on the
dashboard for the first time.

1. One endpoint instead of two
---------------------------------
Before: the Pioneer call was split across /charts/portfolio (for
EVM/UTXO/Cosmos/TON) and /portfolio (for Solana only), with a separate
fallback for TON/TRON partial responses. /charts/portfolio is a fast
cached path backed by pre-computed charts + Zapper-provided EVM
tokens; it does NOT run Pioneer's SPL / TRC-20 / ERC-20 auto-discovery
blocks that live in balance.controller.ts. Only /portfolio
(GetPortfolioBalances) hits those — which is why the vault, which uses
/portfolio exclusively via pioneer-client, saw USDT while the BEX did
not.

Consolidated every pubkey (Solana + Tron + EVM + UTXO + Cosmos + TON
+ Ripple + THORChain) onto /portfolio in one call, matching vault's
GetPortfolioBalances flow exactly. Classification (native vs token)
uses the same rule as vault: caip path not in {slip44:, native:}
OR type === 'token' OR (isNative === false && contract).

Net: -118 lines vs. the three-batch split. Slower per call (no
pre-warm charts cache) but correct, and the user explicitly accepted
the latency trade.

2. Tron has two network-ids; alias them
------------------------------------------
Pioneer emits the native TRX balance under `tron:27Lqcw` (CAIP-2
genesis-hash convention), but TRC-20 tokens under `tron:0x2b6653dc`
(hex chain-id convention). Both refer to Tron mainnet. Pioneer's own
ChainToNetworkId declares `tron:0x2b6653dc` as canonical (vault
consumes that and works); our BEX has hardcoded `tron:27Lqcw`
throughout `chainConfig.ts` and `tronHandler.ts`. Rather than rewire
every pubkey cache entry in users' storage, alias Pioneer's response
ids at the normalizer:

  NETWORK_ID_ALIASES = {
    'tron:27lqcw'      -> 'tron:27Lqcw'          // casing
    'tron:0x2b6653dc'  -> 'tron:27Lqcw'          // cross-convention
    'solana:5eykt4...' -> 'solana:5eykt4UsFv8...' // existing Solana casing
  }

Rewrites both `networkId` and the network prefix of `caip`. The
side-panel's strict `networkId === 'tron:27Lqcw'` filter now matches
both native rows and TRC-20 token rows, so USDT-TRON appears on the
Tron asset page.

Known ceiling: the canonical-id mismatch is tech debt. Switching our
BEX to `tron:0x2b6653dc` would remove the alias entry but requires a
storage migration (existing pubkey caches have `networks:
['tron:27Lqcw']`). Deferred — alias is localized and cheap.

Co-authored-by: Claude Opus 4.7 (1M context) <[email protected]>
* fix(tron): send the right asset when user picks a TRC-20 token

The side-panel Send flow builds a `transfer` request with the asset's
caip in the payload, but the Tron handler was ignoring it — every send
called `buildTronTransfer` (TronGrid's /createtransaction endpoint,
which only builds native TRX transfers). So clicking USDT-TRON and
hitting send silently sent TRX instead.

Branch the handler on the caip:

  tron:*/token:T...   or   tron:*/trc20:T...   →  TRC-20 transfer
  tron:*/slip44:195                            →  native TRX transfer

For the TRC-20 path:
- Parse the contract address out of the caip (accepts both of
  Pioneer's Tron network-id conventions — 27Lqcw and 0x2b6653dc).
- Read decimals from assetContextStorage (populated by the side-panel
  when the user clicks the asset); default to 6 if missing so the
  common USDT/USDC case still works on cold state.
- Convert the human amount → base units via padded-string math (same
  approach as trxToSun, but parametrised by decimals). BigInt throughout
  so 18-decimal tokens don't overflow Number.
- ABI-encode `transfer(address,uint256)`:
    a9059cbb | recipient_20bytes_left_padded_to_32 | amount_uint256
  The 0x41 Tron prefix is stripped from the address before padding.
- POST /wallet/triggersmartcontract to TronGrid, get raw_data_hex back
  in the same shape as /createtransaction so signing + broadcast reuse
  the native code path.

signTronViaRest now accepts `amountRaw: string | number` — passing
Number for large TRC-20 values would silently truncate at ~2^53 base
units. Vault's endpoint stringifies internally; we just hand it through.

The approval event carries eventKind='trc20-transfer' + contractAddress
for the OtherTransaction approval UI; firmware still decodes raw_data
itself so there's no device-side regression.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>

* fix(tron): approval UI renders TRC-20 sends correctly

The approval-details renderer in `pages/side-panel/src/approval/other`
has two hardcoded assumptions:
  1. RequestMethodCard only recognizes type='transfer' — anything else
     shows "Unknown Method".
  2. RequestDetailsCard reads `unsignedTx.payment.{destination,amount}`
     and divides amount by 1,000,000 (XRP drops / TRX sun).

Previous commit emitted type='trc20-transfer' and no `payment` block,
so clicking USDT and tapping Send surfaced "Unknown Method" / N/A.
Fix it at both ends:

Handler (tronHandler.ts):
- Emit type='transfer' for both native TRX and TRC-20 — no semantic
  win from a sub-kind at the UI layer; `unsignedTx.contractAddress`
  + `caip` distinguish when downstream code actually needs to branch.
- Populate `unsignedTx.payment = { destination, amount, decimals, symbol }`
  so the renderer has what it expects. `amount` stays as a raw-base-units
  decimal string (not a Number) to preserve precision for high-decimal
  TRC-20 tokens.
- Carry `decimals` on the event (sourced from assetContextStorage for
  TRC-20, hardcoded 6 for native) so the UI can format without a
  separate asset lookup.

UI (RequestDetailsCard.tsx):
- New `formatAmount()` helper. Integer-string inputs route through a
  BigInt-safe path (slice to whole/frac, trim trailing zeros). Other
  inputs fall back to Number/Math.pow to preserve the existing Ripple
  display behavior for any legacy rows that still write a plain number.
- Decimals cascade: `payment.decimals` (event hint) → `assetContext.assets.decimals`
  → 6 (matches XRP drops and TRX sun — the two chains this renderer has
  historically served).
- Symbol pulled from `payment.symbol` or `assetContext.assets.symbol` and
  appended to the amount — tells the user at a glance what they're sending.
- Suppress the destinationTag row entirely when it's undefined (every
  non-Ripple chain previously rendered a misleading "none").

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>

* fix(ui): formatAmount handles zero-decimal assets

With decimals === 0, str.slice(0, -0) returns '' (slice treats -0 as 0
and the negative second arg short-circuits to empty) and str.slice(-0)
returns the entire string. Without guarding, "123" at 0 decimals
rendered as ".123".

Fast-path `decimals <= 0` returns the integer string as-is, which is
the correct formatting for any zero-decimal token.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>

* fix(ui): approval renderer branches on unsignedTx.kind for Tron

Previous pass unified Tron dApp events on type='transfer' so they'd
land on this renderer at all. Side-effect: contract calls (swaps,
stakes, approvals, anything other than native TRX or TRC-20 transfer)
displayed as "basic transfer" — actively misleading to the user
clicking Approve on-device.

Teach the renderer to branch on `unsignedTx.kind` (the chain-specific
refinement the Tron handler attaches):

RequestMethodCard:
- kind='contract-call'    → "Smart Contract Call" (orange warning icon,
                            copy asks user to verify before approving)
- kind='trc20-transfer'   → "Token Transfer"
- kind='trx-transfer' / missing → existing basic-transfer label

RequestDetailsCard:
- Contract-call path renders Contract + Function selector instead of
  recipient/amount — raw call_value in TRX shown only when non-zero
  (most TRC-20 flows have call_value=0; swaps spending native TRX
  have a real value worth surfacing to the user).
- Native + TRC-20 transfer paths unchanged.

This keeps the dApp integration unblocked (SunSwap-style contract
calls still sign) while giving the user accurate UX at approval time.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>

* fix(tron): carry kind on side-panel events + gate asset-context fallback

Two fixes that go together:

1. Side-panel transfer case now sets unsignedTx.kind
   ('trc20-transfer' | 'trx-transfer'). Previously clicking USDT in
   the asset list and hitting Send produced an event without `kind`,
   so the approval UI's RequestMethodCard fell through to the default
   'transfer' label — "basic transfer" rather than "Token Transfer"
   even though we were actually signing a TriggerSmartContract.

2. RequestDetailsCard's asset-context fallback is now gated on
   ctx.caip === event.caip. Without the guard, dApp-originated sign
   events (which carry their own caip — e.g. tron:*/token:TR7NHq...)
   could inherit symbol/icon/decimals from whatever the user last
   clicked in the side-panel asset list. Concrete failure mode a
   reviewer flagged: a dApp USDT-TRON approval rendering with the ETH
   icon + "ETH" symbol because ETH was the last selected asset.

   Side-panel sends keep the context fallback because the user just
   clicked the asset — caips match, fallback fires normally. Only
   the non-matching case is suppressed.

   Also gates the Avatar `src` on the same ctxMatches check so a
   wildly-off icon can't leak through to the dApp approval pane.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <[email protected]>
* feat(dapp): Tron injection + functional MetaMask masking

Two related dApp-injection improvements shipped together:

Tron dApp support
-----------------
Mount window.tronLink + window.tronWeb shims that mirror TronLink's API
surface. dApps connect via the standard "Connect TronLink" button; our
shim intercepts (isTronLink: true), opens the KeepKey approval side
panel, signs via the vault, and returns the signed tx for the dApp to
broadcast.

- New injected provider in chrome-extension/src/injected/tron-provider.ts
  * tronWeb.trx.{sign, sendRawTransaction, getBalance, getAccount}
  * tronWeb.transactionBuilder.{sendTrx, triggerSmartContract}
  * tronWeb.utils.{isAddress, toSun, fromSun, toHex}
  * Direct TronGrid for reads/builds/broadcast, extension pipeline for signing
- Background handler extended with tron_requestAccounts, tron_sign,
  tron_signMessage (throws until vault adds the endpoint)
- tron_sign decoder handles TransferContract (native TRX), TriggerSmartContract
  with transfer(address,uint256) (TRC20/USDT), and passes any other
  TriggerSmartContract through for firmware to validate
- Verified on SunSwap: detection + connect + address populate. Signing
  paths need on-device verification per firmware capability.

MetaMask masking pipeline
-------------------------
The Settings → Enable MetaMask Masking toggle was cosmetic — isMetaMask
and window.ethereum mounts were hardcoded on. Made it actually work
end-to-end, default off.

- Content script reads 'masking-settings' from chrome.storage before
  injection, stamps data-masking="{...}" on the <script> tag
- Injected script parses dataset.masking at startup and branches:
  * isMetaMask reflects enableMetaMaskMasking
  * window.ethereum mount gated on the flag (otherwise EIP-6963 only)
  * window.xfi mount gated on enableXfiMasking
- When MetaMask masking is ON, also announce via EIP-6963 with
  rdns: 'io.metamask' so SDKs that key off the canonical MetaMask rdns
  (MetaMask SDK, Dynamic.xyz's MetaMaskConnector, RainbowKit) find us.
  Same trick Rabby uses.
- Known ceiling: MetaMask SDK v0.28+ does anti-impersonation probes
  beyond EIP-6963 that we can't realistically beat. Works on simpler
  legacy sites; SDK-hardened dApps need WalletConnect (future work).
- Removed dead, wrong-key-reading getMaskingSettings handler from
  background/index.ts.
- Single diagnostic log per page load: "[KeepKey] masking: metamask=on/off ..."

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>

* fix(tron): keep TRC-20 amounts as strings through dApp sign path

decodeTronTx was holding the decoded TRC-20 amount as BigInt, then
casting to Number before stashing it on DecodedTronTx.sunAmount and
passing it to signTronViaRest + the approval event. Any 18-decimal
token (most non-stablecoin TRC-20s) exceeds Number.MAX_SAFE_INTEGER
in base units, so the value silently rounds before the vault sees it
— firmware displays the wrong amount on-device and the signed tx
spends the wrong amount on-chain.

Changes:
- DecodedTronTx.sunAmount (number) → amountRaw (decimal string).
  Renamed to signal "this is base units not native sun".
- TransferContract decode emits String(v.amount); TRC-20 decode emits
  BigInt.toString(); contract-call emits String(callValue). All paths
  preserve precision.
- signTronViaRest signature widened to `string | number | bigint`.
  Internal stringification uses bigint.toString() when applicable so
  nothing re-enters the Number casting path.
- Approval event now carries amountRaw (not sun) so downstream UIs
  that display the raw-unit hint get the untruncated value.

Native TRX amounts never overflow Number (max 90B TRX in sun = 9e16,
fits in 2^53 comfortably), but the change makes that path uniform
and the code easier to audit.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>

* fix(tron): dApp sign events land on the shared approval UI

Previous pass changed side-panel Send to emit type='transfer' +
unsignedTx.payment, but the dApp sign path (tron_sign) was still
emitting type='trc20-transfer' / 'contract-call' and had no payment
block. The shared /approval/other renderer only cases 'transfer' and
reads unsignedTx.payment.*, so dApp TRC-20 + contract-call approvals
surfaced as "Unknown Method" / N/A.

Unify on the same contract:
- type='transfer' for all Tron paths, always.
- decoded.kind now travels on unsignedTx.kind for downstream branches
  that actually care about native vs TRC-20 vs generic contract-call
  (storage/history, txid explorer link selection, etc).
- Populate unsignedTx.payment = { destination, amount, decimals, symbol }
  so RequestDetailsCard renders without a second asset-context
  round-trip.
- amount stays as a raw base-units decimal string (preserved from the
  previous amountRaw fix).
- decimals defaults:
    trx-transfer     → 6 (sun)
    contract-call    → 6 (call_value is TRX attached to the call)
    trc20-transfer   → 0, so the UI shows the raw integer we decoded.
                       We don't have the token's decimals from the
                       raw_data alone and the asset context isn't the
                       dApp's caller — showing a raw integer is less
                       misleading than applying the wrong divisor.
                       Future work: decodeTronTx can call the contract's
                       decimals() view or look up from assetData.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>

* fix(tron): set explicit TRX symbol on contract-call payment

Review follow-up: contract-call events left payment.symbol undefined,
which previously leaned on the UI's asset-context fallback for the
"TRX sent" row. Combined with the UI now gating that fallback on
ctx.caip === event.caip (#48), a dApp-initiated contract call whose
caip doesn't match the side-panel's selected asset would render the
call_value row with no symbol at all.

Two tightenings in one:
- contract-call & trx-transfer both emit symbol='TRX' explicitly —
  the call_value on any TriggerSmartContract is always native TRX,
  so stating it in the handler is accurate and removes the UI's
  need to guess.
- trc20-transfer stays symbol=undefined — we don't know the token's
  symbol without an on-chain symbol() call or an assetData lookup.
  The UI's caip-match gate on asset context will decline to show a
  wrong symbol when the dApp caip differs from whatever the side
  panel had selected.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>

* fix(tron): scope trc20-transfer event caip to the token contract

Review follow-up to the caip-match gate in #48: dApp TRC-20 events
were still emitting caip: TRON_CAIP (tron:27Lqcw/slip44:195 — the
native-TRX caip). The UI's ctx.caip === event.caip guard matched
when the user had TRX selected in the side panel, leaking the TRX
symbol/icon onto a USDT approval — exactly the failure the gate
was supposed to block.

Emit a token-scoped caip for trc20-transfer:

  tron:27Lqcw/token:${decoded.contractAddress}

Now the fallback only fires when the side-panel happened to have
this exact token selected. Any other selection (TRX, a different
TRC-20, or an unrelated chain) leaves symbol/icon empty, which
is the correct behavior — the handler still populates
payment.decimals=0 so amounts render as raw base units.

trx-transfer keeps TRON_CAIP (legitimate native TRX match desired).
contract-call keeps TRON_CAIP too — its renderer owns its own
labels (Contract:, Function:, "TRX sent:") with a hardcoded 'TRX'
fallback on the call_value row, so the caip match affects only
the avatar icon. Not worth the extra complexity.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <[email protected]>
* feat(ui): dark theme + header + asset list refresh (Tier 1–3 of design handoff)

Adopts the Claude Design handoff's visual language for the side panel —
darker base (#0b0d10), Inter + JetBrains Mono typography, gold-gradient
primary buttons, and tokenized surfaces/lines. Shield badge on the left
doubles as both "go home" button and device-status indicator (gold
pulsing when transient, green when paired, red when errored), replacing
the separate status icon.

Bug fixes along the way:
- "Add blockchain" picker now hides the dashboard block and is resettable
  from the home button (state lifted out of Balances into SidePanel).
- Asset-detail drawer no longer renders a redundant "Ethereum" title bar;
  a minimal floating back chevron replaces it.
- Header row heights unified at 32px so the Network/Account pills and
  shield all sit on the same baseline.
- TronLink badge next to "Tron" on the asset page (passive, no link —
  TronLink is an unaffiliated wallet; we just signal protocol use).

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>

* fix(side-panel): receive tab loads offline + auto token discovery

Receive tab
- Derive UTXO receive addresses locally from xpubs (BIP32 +
  script-type encoding) instead of round-tripping the device. Page
  now renders in view-only mode and survives hot-swaps. Adds
  @scure/bip32, @scure/base, @noble/hashes; new utxoDerive.ts covers
  BTC (legacy/segwit/native segwit), LTC, DOGE, DASH, BCH cashaddr.
- Fix the account dropdown — it looked up pubkeys by .address|.master
  (both empty on UTXO) so clicks were silent no-ops. Keys by
  pubkey.note now and shows derived addresses, not truncated xpubs.
- Labels read "Account 0 · Native Segwit" etc. so the three BTC
  entries that all share account index 0 are distinguishable.
- Tighten GET_UTXO_ADDRESS match order so note wins over scriptType;
  multiple p2wpkh accounts can be disambiguated.
- Center Receive + AssetDetail vertically; hide the Tokens tab on
  UTXO chains instead of rendering an empty state.

Token auto-discovery
- Replace the per-chain prefetch().then(fetch(true)) chains with a
  single Promise.allSettled([sol, tron, ton]).then(fetch(true)).
  The old shape raced three parallel fetches that the snapshot
  staleness guard then discarded — SPL/TRC-20 tokens silently never
  landed in cachedBalances, and users had to press "Discover Tokens"
  to see them.
- Wire the Discover/Refresh button to REFRESH_ALL_BALANCES so it
  forces a Pioneer round-trip instead of re-reading the cache.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>

* fix(receive): address review findings on UTXO derivation

- script_type, not scriptType: raw pubkey objects use snake_case (matches
  chainConfig.ts and the SDK request shape in wallet.ts). Reading
  .scriptType was always undefined, so segwit / native-segwit accounts
  silently fell through to the p2pkh branch and rendered legacy "1..."
  addresses for "Account 0 · Native Segwit" entries.
- Drop the chrome.storage.session derived-address cache. Local BIP32 +
  hash160 + encoding is microseconds; the cache only added a stale
  surface — it was keyed by (networkId, scriptType, note) without the
  xpub or device id, so a hot-swap in the same session could return the
  previous device's address for the same note.
- Pick the UTXO Receive default address from pubkeyContext.note (the
  header's selected account) instead of ctxAsset.pubkeys[0]. After
  SET_ASSET_CONTEXT, the asset's pubkeys field carries every pubkey on
  the network, and idx===0 was the first configured chainConfig path
  (legacy BTC), not the user's selection. New effect resolves
  addressByNote[pubkeyContext.note] when both are present, falls back
  to pubkeys[0].note only if no context is set.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>

* fix(receive): carry header-selected UTXO script_type into pubkey scope

The high-finding from the second review pass: header-selected UTXO
accounts weren't reliably carried into Receive. Header fired
SET_ASSET_CONTEXT with the selected pubkey, but the background
overwrote asset.pubkeys with all network pubkeys, and
GET_PUBKEY_CONTEXT only restored by accountIndex — UTXO accounts share
an accountIndex (BTC's Legacy / Segwit / Native Segwit all live at
account 0), so it always fell back to scoped[0] and Receive opened on
the first configured chainConfig path.

- NetworkAccountHeader.setAssetContext now puts script_type on the
  asset alongside accountIndex (snake_case to match raw pubkey shape).
- GET_PUBKEY_CONTEXT prefers script_type for the scoped lookup, then
  falls through to accountIndex (multi-account EVM) and finally
  scoped[0]. Receive's existing pubkeyContext.note → addressByNote
  resolver picks up the right entry without further changes.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>

* fix(receive): disambiguate UTXO accounts that share a script_type

Previous fix carried script_type through SET_ASSET_CONTEXT, but
multiple BTC paths share each script_type (chainConfig has account 0
+ account 1 both at p2wpkh, plus several legacy accounts). The header
also keyed rows on script_type alone, so account-1 Native Segwit
collapsed onto account-0 in the dropdown, and even if surfaced
separately GET_PUBKEY_CONTEXT would have matched the first p2wpkh
(account 0).

- AccountItem gains an optional `note` field. headerUtils' BTC and
  UTXO builders key rows on `pk.note` (unique per chainConfig path)
  and append "· Account N" to the label only when the script_type
  repeats. Only the first p2wpkh row is flagged isDefault now.
- NetworkAccountHeader.setAssetContext sends `note` alongside
  `script_type` and `accountIndex`.
- GET_PUBKEY_CONTEXT match priority is now note → script_type →
  accountIndex → scoped[0]. Note is the only identifier that's
  unique across every chainConfig path.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>

* fix(receive): make UTXO defaults agree across all entry points

Header dropdown was correct, but two entry points still desynced:

- Global Receive / dashboard / asset list: SidePanel.handleGlobalReceive
  picks a default balance row that has no note or script_type, so the
  background's GET_PUBKEY_CONTEXT fell through to scoped[0] (legacy
  BTC). The header could read "Native SegWit" while Receive surfaced
  a "1..." address. Background SET_ASSET_CONTEXT now defaults a UTXO
  asset that arrived without note/script_type to the network's first
  p2wpkh pubkey (else scoped[0]) before storing.
- Header auto-default: the useEffect that picks the default account on
  mount/network-change set local selectedAccountKey only — it never
  fired SET_ASSET_CONTEXT. Stored context could be stale or absent
  while the dropdown showed Native SegWit. Effect now calls
  setAssetContext for the chosen account when a network is selected.
  Reordered the effect to sit after setAssetContext's useCallback so
  it isn't referenced in the temporal dead zone.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>

* fix(receive): close five remaining gaps in UTXO selection plumbing

1. Header restore by note (was: accountIndex only). UTXO accounts
   share an accountIndex, so a stored "BTC Native Segwit account 0"
   round-trip would resolve to whatever chainConfig path landed first
   at accountIndex 0 — usually legacy. fetchPubkeys now seeds
   desiredNote / desiredScriptType / desiredAccountIndex from the
   stored asset context.

2. ASSET_CONTEXT_UPDATED resync. Listener feeds the same desired*
   state, so within-network context changes now retarget the dropdown
   instead of leaving selectedAccountKey pinned to a stale row. The
   auto-select effect compares before firing setAssetContext, so the
   broadcast back from our own SET_ASSET_CONTEXT can't loop.

3. Per-chain UTXO fallback in background SET_ASSET_CONTEXT. Previously
   defaulted every bip122:* asset to first p2wpkh, which silently
   shifted LTC from p2pkh-default (header's items.length===0) to
   native segwit. Now BTC keeps p2wpkh-first, other UTXO uses
   scoped[0] — both match the header builders.

4. Auto-default no longer opens AssetDetail. setAssetContext is now a
   pure data setter (sends SET_ASSET_CONTEXT, returns the asset). The
   click handlers (handleNetworkSelect / handleAccountSelect) call
   onSelectNetwork explicitly, so the auto-default effect can sync
   stored context without an unprompted drawer-open on cold start.

5. AssetDetail derives UTXO addresses. asset.pubkeys[0].address is
   empty on UTXO and Pioneer's /portfolio stuffs the xpub into
   b.address (background/index.ts:439), so the address bar / copy /
   explorer link were either blank or showing an xpub. New UTXO
   branch in the address-fetch effect calls GET_UTXO_ADDRESS with
   the asset's note + script_type.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>

* fix(side-panel): don't auto-open AssetDetail on every context broadcast

The ASSET_CONTEXT_UPDATED listener fires for every SET_ASSET_CONTEXT
the background receives — including the header auto-default sync we
just added (cold-start restore + ASSET_CONTEXT_UPDATED resync) and
dApp-driven chain switches. With onAssetDetailOpen() unconditional
in the listener, the drawer would still pop up unprompted even
after we made setAssetContext a pure data setter.

Drop the auto-open. setSelectedAsset still runs, so an already-open
drawer reflects the new context. Explicit opens already go through
handleAssetSelect (asset list click, header click), which sets
selectedAsset and calls onAssetDetailOpen together.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <[email protected]>
Pre-release cleanup before promoting develop → master.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
@BitHighlander
Copy link
Copy Markdown
Collaborator Author

🔴 HOLD — release blocker identified

Uniswap swap regression — Permit2 typed-data signs but the swap flow fails downstream. Full retro and handoff notes:

📄 RETRO_uniswap_swap_release_blocker.md (committed separately)

Do not merge until that retro's "Concrete next steps" section is closed out.

BitHighlander and others added 5 commits April 28, 2026 00:32
Captures the diagnostic conversation, hypotheses tried/ruled out,
test scaffolding built in keepkey-vault-v11/projects/keepkey-sdk,
and concrete next-step ordering for whoever picks this up.

Holds release of 0.0.28 (PR #51).

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
Per user feedback: handoff is cross-repo (keepkey-client →
keepkey-vault-v11 SDK + firmware submodule + private memory),
so every file pointer must be a full /Users/... path. Added a
File Index section so the next reader doesn't have to grep for
which clone holds which file.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
ethers v6 provider.call(tx) and provider.estimateGas(tx) both take a
single argument; passing positional (callParams, blockTag, stateOverride)
silently drops blockTag AND stateOverride. Uniswap's pre-quote flow uses
eth_call with stateOverride to simulate the swap as if the not-yet-
broadcasted Permit2 approval were in place — when we strip the override
the simulation runs against real state where the user has 0 allowance,
reverts, and Uniswap rejects the quote (/v1/swap → 404).

Switch both handlers to provider.send('eth_call', params) /
provider.send('eth_estimateGas', params) so the dApp's params arrive
byte-identical at the RPC.

Also extend [HANDOFF] instrumentation to log params alongside the
return value at every BEX → content-script boundary and at the
page-side resolve, so cross-chain audit of a swap flow is possible
via grep '[HANDOFF]'.

Drops the SET_ASSET_CONTEXT-driven EIP-1193 emit block per earlier
diagnosis (ruled out as the root cause but the timing was still wrong).

Refs: HANDOFF_uniswap_swap_v2.md, RETRO_uniswap_swap_release_blocker.md

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
End-to-end trace of EVM fee handling through BEX → vault REST →
hdwallet-keepkey → firmware. Confirmed no layer mutates fees;
what the dApp supplies is what gets signed and broadcast.

Documents:
- The two adjacent defects fixed this session (nonce 'latest'→
  'pending', JSON-RPC `gas` honored alongside `gasLimit`)
- The eth_feeHistory gap (RPC dApps depend on for percentile
  fee estimates — currently throws "method not supported")
- Recommended placement and shape of the user-visible
  fee-warning + bump policy when dApp suggests under-floor fees
- Files left uncommitted in worktree for review

Refs: HANDOFF_uniswap_swap_v2.md, RETRO_uniswap_swap_release_blocker.md

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
… logs

Three independent defects in ethereumHandler.ts that surfaced during
the Uniswap swap regression audit:

1. signTransaction + handleTransfer queried getTransactionCount with
   'latest' block tag, counting only confirmed txs. Re-attempting a
   swap while a prior attempt was still pending would reuse the same
   nonce and trigger EIP-1559's replacement-underpriced rule (requires
   +10% on both fees). The replacement tx silently sat in mempool
   until eviction — the "pending forever, eth_getTransactionByHash
   returns null" symptom we tracked. Switched both sites to 'pending'.

2. signTransaction only consulted transaction.gasLimit (camelCase),
   so the dApp's `gas` field (per JSON-RPC spec) was silently dropped
   and we re-estimated against publicnode. Now honors transaction.gas
   as a fallback before estimation.

3. Added [HANDOFF] decode-friendly logs at three more boundaries:
   eth_sendRawTransaction (dApp-supplied raw tx), the full signed
   serialized output of eth_signTransaction, and the broadcast
   request/result. Greppable end-to-end with the existing [HANDOFF]
   instrumentation.

Refs: HANDOFF_fee_pipeline_audit.md

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
Three independent improvements to the EVM signing surface, motivated
by the Uniswap stuck-pending regression diagnosed in HANDOFF_*.md.

1. eth_feeHistory passthrough — modern dApps (incl. Uniswap's UI via
   ethers v6 fee estimator) use this for percentile-based fee math.
   Previously fell through to "method not supported" → dApp dropped to
   stale eth_gasPrice fallback, supplying low fees.

2. Fee-warning + user override:
   - New chrome-extension/src/background/chains/feeFloors.ts: per-chain
     static minimums (1 gwei mainnet, 30 gwei polygon, 3 gwei BSC, etc.)
     OR'd with currentBaseFee*1.1 (always-correct dynamic floor).
   - handleSigningMethods now probes feeData + base fee, builds a
     FeeWarning blob if dApp's maxFeePerGas falls below the floor, and
     attaches it to the approval event.
   - New side-panel/src/approval/evm/FeeWarningBanner.tsx renders an
     inline banner above the tx-detail tabs with three choices: keep
     dApp's, use suggested, or custom (two number inputs, gwei). Choice
     is persisted to the event via requestStorage.updateEventById.
   - Approve button is disabled until a choice is made.
   - sendTransaction reads event.feeChoice from storage at sign time
     and overrides transaction.maxFeePerGas / maxPriorityFeePerGas
     before handing to signTransaction. Fee policy never silently
     mutates — user always picks.

3. Nonce visibility (read-only this round):
   - Same probe in handleSigningMethods also fetches latest + pending
     transaction counts and computes willReplace if the dApp set a
     nonce equal to a pending one.
   - New side-panel/src/approval/evm/NonceInfoRow.tsx renders a small
     row above the tabs: "Nonce N (next available)" green, "N
     pending tx ahead" yellow, or "Replaces pending tx — needs +10%
     on both fees" red. Etherscan deep-link for mainnet.
   - Manual nonce override + cancel-tx flow deferred to follow-up.

Build green. The user must reload the unpacked extension at
chrome://extensions for the changes to take effect.

Refs: HANDOFF_fee_pipeline_audit.md, HANDOFF_uniswap_swap_v2.md

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
@BitHighlander
Copy link
Copy Markdown
Collaborator Author

Today's progress (commit 1ad6eb1 on develop)

Three new features shipped that should resolve the Uniswap stuck-pending regression:

1. eth_feeHistory passthrough — modern dApps (incl. Uniswap UI via ethers v6 fee estimator) need this for percentile-based fee math. Previously fell through to "method not supported" → dApp dropped to stale eth_gasPrice. Now: 3-line raw passthrough.

2. Fee-warning + user override (UI)

  • Per-chain static floors (1 gwei mainnet, 30 gwei polygon, etc.) OR'd with currentBaseFee * 1.1 as the always-correct dynamic floor — see chrome-extension/src/background/chains/feeFloors.ts.
  • Background detects under-floor fees, attaches feeWarning blob to approval event.
  • Side-panel banner (pages/side-panel/src/approval/evm/FeeWarningBanner.tsx) shows three choices: keep dApp's / use suggested / custom (gwei inputs).
  • Approve button disabled until user picks. No silent bumping.
  • sendTransaction reads feeChoice at sign time and overrides maxFeePerGas/maxPriorityFeePerGas before vault signing.

3. Nonce visibility (read-only)

  • Probe latest + pending tx counts, attach nonceInfo to event.
  • Inline row above tabs: green = next-available, yellow = N pending ahead, red = "replaces pending tx, needs +10% on both fees" with etherscan deep-link.
  • Manual nonce override + cancel-tx flow deferred to follow-up.

Plus baseline fixes from earlier today (8d65cbf): nonce uses 'pending' not 'latest' in two sites, JSON-RPC gas field honored, [HANDOFF] decode-friendly logs at every tx-broadcast hop.

Reload the unpacked extension at chrome://extensions to pick up the new build.

PR title still says HOLD — won't unblock until Uniswap swap is verified end-to-end on the new build. Suggested verification: clear the two stuck pending txs first (0x3d5b…, 0x9fd9…), then retry. The new nonce-pending logic should prevent the collision pattern that caused the original failures.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant