From 32ce099aef22f7937fdd5f96a35ac6568cbf82a2 Mon Sep 17 00:00:00 2001 From: highlander Date: Wed, 20 May 2026 15:22:53 -0300 Subject: [PATCH 01/15] =?UTF-8?q?feat:=20asset=20picker=20v2=20=E2=80=94?= =?UTF-8?q?=20card=20grid=20+=202-step=20chain=E2=86=92asset=20flow?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit FROM picker: held-assets card grid sorted by USD value, with filter search and an empty-wallet state. TO picker: 2-step flow — chain selection grid (grouped: same network / already hold / all supported / not routable) → asset list within the chosen chain (sectioned: held / native / tokens). NetSwitchBanner shows same-network vs cross-chain context. Unavailable-route view surfaces alternative chains for the same symbol. Implements the v2 handoff from the design bundle. --- .../mainview/components/AssetPickerDialog.tsx | 1364 ++++++++++++----- 1 file changed, 987 insertions(+), 377 deletions(-) diff --git a/projects/keepkey-vault/src/mainview/components/AssetPickerDialog.tsx b/projects/keepkey-vault/src/mainview/components/AssetPickerDialog.tsx index ec2a9142..abd7fe4f 100644 --- a/projects/keepkey-vault/src/mainview/components/AssetPickerDialog.tsx +++ b/projects/keepkey-vault/src/mainview/components/AssetPickerDialog.tsx @@ -1,18 +1,13 @@ /** - * Modal-over-modal asset picker. Opens on top of SwapDialog. + * Asset Picker v2 — full redesign from handoff (May 2026). * - * Differs from the inline AssetSelector it replaces: - * - Searches the entire pioneer-discovery universe (~30k CAIPs), not just - * Pioneer's swappable subset. - * - Bucket-sorted: held → Pioneer-swappable → matrix-swappable → unknown - * → unsupported. Ranked when the user types. - * - Per-row availability badge with reason (so the user understands why - * a token they searched for can't be swapped). - * - Caps render volume: empty query shows only held + swappable buckets; - * typing expands the surface to the full universe. + * FROM side: card grid of held assets, sorted by USD value. + * TO side: 2-step flow — chain selection grid → asset list in that chain. + * Unavailable-route view replaces the asset list when the user taps + * a non-routable asset. */ -import { useState, useEffect, useMemo, useRef, useCallback } from "react" -import { Box, Flex, Text, Input, Button } from "@chakra-ui/react" +import { useState, useEffect, useMemo, useCallback, type ReactNode } from "react" +import { Box, Flex, Text, Input } from "@chakra-ui/react" import { useTranslation } from "react-i18next" import { AssetIcon } from "./AssetIcon" import type { SwapAsset, ChainBalance, CustomToken } from "../../shared/types" @@ -20,125 +15,904 @@ import { buildAssetEntries, buildSearchIndex, searchEntries, - bucketFor, chainMetaForCaip2, networkDisplayName, synthesizeSwapAsset, type AssetEntry, type SearchIndex, } from "../../shared/swap-discovery" -import { PROVIDER_LABEL, type AvailabilityStatus } from "../../shared/swap-support-matrix" +import { CHAINS } from "../../shared/chains" +import { PROVIDER_LABEL } from "../../shared/swap-support-matrix" import { Z } from "../lib/z-index" import { useFiat } from "../lib/fiat-context" import { rpcRequest } from "../lib/rpc" -import { networkDisplayName as nd } from "../../shared/swap-discovery" + +// ── constants ────────────────────────────────────────────────────────────── const EVM_CONTRACT_RE = /^0x[a-fA-F0-9]{40}$/ +const MAX_RENDER = 150 -const MAX_RENDER = 200 +// ── small icons ──────────────────────────────────────────────────────────── const SearchIcon = () => ( - - - + + + +) +const CloseIcon = () => ( + + + +) +const BackIcon = () => ( + + + +) +const ArrowRight = ({ size = 14 }: { size?: number }) => ( + + + +) +const AlertIcon = () => ( + + + +) +const BellIcon = () => ( + + ) +// ── chain helpers ─────────────────────────────────────────────────────────── + +function chainColorForCaip2(caip2: string): string { + const meta = chainMetaForCaip2(caip2) + if (!meta) return "#555" + return CHAINS.find(c => c.id === meta.vaultChainId)?.color ?? "#555" +} + +function chainFamilyLabel(family: string): string { + const map: Record = { + evm: "EVM", utxo: "UTXO", cosmos: "Cosmos", + solana: "SOL", xrp: "XRP", tron: "TRX", + ton: "TON", "zcash-shielded": "ZEC", + } + return map[family] ?? family.toUpperCase() +} + +// ── selectability (same as before) ───────────────────────────────────────── + +function isRowSelectable(entry: AssetEntry): boolean { + const s = entry.availability.status + if (s !== "swappable" && s !== "unknown") return false + return chainMetaForCaip2(entry.chainId) !== null +} + +// ── provider dots ─────────────────────────────────────────────────────────── + +const PROVIDER_COLORS: Record = { + THORChain: "#23DCC8", Mayachain: "#3B82F6", Relay: "#9F8CE0", + "0x": "#5C6BC0", CowSwap: "#F0B90B", ChainFlip: "#E84142", + Osmosis: "#9C27FF", ShapeShift: "#00C3FF", +} + +function ProviderDots({ providers }: { providers: string[] }) { + if (!providers.length) return null + const show = providers.slice(0, 4) + return ( + + {show.map(p => ( + + ))} + {providers.length > 4 && } + + ) +} + +// ── chain badge caip helper ───────────────────────────────────────────────── + +function chainBadgeCaip(entry: AssetEntry): string | undefined { + if (entry.isNative) return undefined + return chainMetaForCaip2(entry.chainId)?.nativeCaip +} + +// ── network-switch banner ─────────────────────────────────────────────────── + +function NetSwitchBanner({ fromChainId, toChainId, providers }: { + fromChainId: string; toChainId: string; providers: string[] +}) { + const fromColor = chainColorForCaip2(fromChainId) + const toColor = chainColorForCaip2(toChainId) + const fromName = networkDisplayName(fromChainId) + const toName = networkDisplayName(toChainId) + const same = fromChainId === toChainId + + return ( + + {/* mini route diagram */} + + + {fromName.slice(0, 1)} + + + + {toName.slice(0, 1)} + + + + + + + {same ? <>Staying on {fromName} : <>Crossing from {fromName} to {toName}} + + {providers.length > 0 && ( + + via {providers.slice(0, 2).join(" / ")} + + )} + + + {same + ? `Same-network swap · settles in seconds` + : `Cross-chain · est. 4–12 min · funds custodied by ${providers[0] ?? "router"} during transit`} + + + + + {same ? "Same network" : "Cross-chain"} + + + ) +} + +// ── FROM picker — held assets card grid ───────────────────────────────────── + +function FromPicker({ entries, onSelect, fmtCompact }: { + entries: AssetEntry[]; onSelect: (e: AssetEntry) => void; fmtCompact: (v: number) => string +}) { + const { t } = useTranslation("swap") + const [search, setSearch] = useState("") + const held = useMemo( + () => entries.filter(e => e.balance).sort((a, b) => (b.balance!.usd) - (a.balance!.usd)), + [entries] + ) + const filtered = useMemo(() => { + const q = search.trim().toLowerCase() + if (!q) return held + return held.filter(e => + `${e.symbol} ${e.name} ${networkDisplayName(e.chainId)}`.toLowerCase().includes(q) + ) + }, [held, search]) + + const totalUsd = held.reduce((s, e) => s + (e.balance?.usd ?? 0), 0) + + return ( + <> + {/* Header */} + + + {t("stepOne", "Step 1 of 2 — Pick what you're swapping")} + + + + + {t("availableToSwap", "Available to swap")} + + + {totalUsd > 0 ? fmtCompact(totalUsd) : "—"} + + + + {held.length} {t("assetsAcross", "assets across")} {new Set(held.map(e => e.chainId)).size} {t("chains", "chains")} + + + + + {/* Search (only when >5 held) */} + {held.length > 5 && ( + + + setSearch(e.target.value)} + placeholder={t("filterHeld", "Filter held assets…")} + bg="transparent" border="none" color="kk.textPrimary" px="0" fontSize="12px" + _focus={{ outline: "none", boxShadow: "none" }} /> + + )} + + {/* Grid */} + + {filtered.length === 0 ? ( + + + + + + + + + {t("emptyWalletTitle", "Your KeepKey is empty")} + + + {t("emptyWalletSub", "Send some assets to your wallet first — then come back here to swap them.")} + + + + ) : ( + + {filtered.map(e => )} + + )} + + + {/* Footer */} + + + {t("showingHeld", "Showing only what your KeepKey holds.")} + + + {filtered.length} {t("of", "of")} {held.length} + + + + ) +} + +function HeldCard({ entry: e, onSelect, fmtCompact }: { + entry: AssetEntry; onSelect: (e: AssetEntry) => void; fmtCompact: (v: number) => string +}) { + const chainName = networkDisplayName(e.chainId) + const chainColor = chainColorForCaip2(e.chainId) + const providers = e.availability.providers + + return ( + isRowSelectable(e) && onSelect(e)} + opacity={isRowSelectable(e) ? 1 : 0.5} + > + {/* Top row: icon + chain + (placeholder pct) */} + + + + + {/* Symbol + chain */} + + {e.symbol} + + + + on {chainName} + + + + + {/* Balance */} + + + {e.balance!.amount} + + + {e.balance!.usd > 0 ? fmtCompact(e.balance!.usd) : "—"} + + + + {/* Footer: providers */} + + + + + ) +} + +// ── TO picker — chain selection step ──────────────────────────────────────── + +interface ChainInfo { + caip2: string + name: string + family: string + color: string + heldCount: number + totalCount: number + routableCount: number + providers: string[] + isAvailable: boolean +} + +function buildChainInfos(entries: AssetEntry[], fromChainId: string | null, excludeCaip: string | undefined): ChainInfo[] { + const chainIds = new Set(entries.map(e => e.chainId)) + return [...chainIds].map(caip2 => { + const meta = chainMetaForCaip2(caip2) + const chain = meta ? CHAINS.find(c => c.id === meta.vaultChainId) : null + const assetsInChain = entries.filter(e => e.chainId === caip2 && e.caip !== excludeCaip) + const heldInChain = assetsInChain.filter(e => e.balance) + const routableInChain = assetsInChain.filter(e => isRowSelectable(e)) + const providers = new Set() + for (const e of routableInChain) { + for (const p of e.availability.providers) providers.add(p) + } + return { + caip2, + name: networkDisplayName(caip2), + family: chainFamilyLabel(meta?.chainFamily ?? ""), + color: chain?.color ?? "#555", + heldCount: heldInChain.length, + totalCount: assetsInChain.length, + routableCount: routableInChain.length, + providers: [...providers], + isAvailable: routableInChain.length > 0, + } + }) +} + +function ChainStep({ chainInfos, fromChainId, search, onSearchChange, onPickChain }: { + chainInfos: ChainInfo[] + fromChainId: string | null + search: string + onSearchChange: (s: string) => void + onPickChain: (caip2: string) => void +}) { + const { t } = useTranslation("swap") + const q = search.trim().toLowerCase() + const matches = (c: ChainInfo) => !q || c.name.toLowerCase().includes(q) || + c.family.toLowerCase().includes(q) + + const sameChain = chainInfos.filter(c => c.caip2 === fromChainId && matches(c)) + const heldChains = chainInfos.filter(c => c.caip2 !== fromChainId && c.heldCount > 0 && c.isAvailable && matches(c)) + .sort((a, b) => b.heldCount - a.heldCount) + const otherChains = chainInfos.filter(c => c.caip2 !== fromChainId && c.heldCount === 0 && c.isAvailable && matches(c)) + const unavailChains = chainInfos.filter(c => !c.isAvailable && matches(c)) + + return ( + <> + {/* Search */} + + + onSearchChange(e.target.value)} + placeholder={t("searchChainPlaceholder", "Search destination — symbol or chain name…")} + bg="transparent" border="none" color="kk.textPrimary" px="0" fontSize="12px" + _focus={{ outline: "none", boxShadow: "none" }} /> + ⌘K + + + + {/* Same network */} + {sameChain.length > 0 && ( + + + + )} + {/* Held chains */} + {heldChains.length > 0 && ( + + + + )} + {/* Other chains */} + {otherChains.length > 0 && ( + + + + )} + {/* Unavailable */} + {unavailChains.length > 0 && ( + + {}} unavail /> + + )} + {sameChain.length + heldChains.length + otherChains.length + unavailChains.length === 0 && ( + + No matching networks + Try a different search term. + + )} + + + ) +} + +function ChainSection({ title, accent, meta, children }: { + title: string; accent: string; meta: string; children: ReactNode +}) { + return ( + + + + + + {title} + + + {meta} + + {children} + + ) +} + +function ChainGrid({ chains, onPick, unavail }: { + chains: ChainInfo[]; onPick: (caip2: string) => void; unavail?: boolean +}) { + return ( + + {chains.map(c => ( + !unavail && onPick(c.caip2)} + > + {/* Color stripe */} + + {/* Body */} + + + + {c.name} + + {c.family} + + + + {c.caip2 && c.heldCount > 0 && ( + + {c.heldCount} held + + )} + {unavail && ( + + No route + + )} + + {c.totalCount} {c.totalCount === 1 ? "asset" : "assets"} + + {!unavail && c.providers.length > 0 && ( + <> + · + + + + {c.providers.length} {c.providers.length === 1 ? "router" : "routers"} + + + + )} + {unavail && ( + no provider routes here yet + )} + + + + ))} + + ) +} + +// ── TO picker — asset list step ───────────────────────────────────────────── + +function AssetStep({ entries, chainCaip2, fromChainId, excludeCaip, search, onSearchChange, + onBack, onSelect, onUnavailable }: { + entries: AssetEntry[] + chainCaip2: string + fromChainId: string | null + excludeCaip: string | undefined + search: string + onSearchChange: (s: string) => void + onBack: () => void + onSelect: (e: AssetEntry) => void + onUnavailable: (e: AssetEntry) => void +}) { + const { t } = useTranslation("swap") + const chainName = networkDisplayName(chainCaip2) + const q = search.trim().toLowerCase() + + const inChain = useMemo(() => entries.filter(e => { + if (e.chainId !== chainCaip2) return false + if (e.caip === excludeCaip) return false + if (q && !`${e.symbol} ${e.name}`.toLowerCase().includes(q)) return false + return true + }), [entries, chainCaip2, excludeCaip, q]) + + // Collect all providers across routable assets in this chain (for banner) + const allProviders = useMemo(() => { + const s = new Set() + for (const e of inChain) if (isRowSelectable(e)) for (const p of e.availability.providers) s.add(p) + return [...s] + }, [inChain]) + + // Bucket assets + const buckets = useMemo(() => { + const b: Record = { held: [], native: [], token: [] } + for (const e of inChain) { + if (e.balance) { b.held.push(e); continue } + if (e.isNative) { b.native.push(e); continue } + b.token.push(e) + } + // Sort tokens by provider count desc + b.token.sort((a, bE) => bE.availability.providers.length - a.availability.providers.length) + return b + }, [inChain]) + + return ( + <> + {/* Breadcrumb */} + + + Networks + + / + {chainName} + + + {/* Network switch banner */} + {fromChainId && } + + {/* Search */} + + + onSearchChange(e.target.value)} + placeholder={t("searchAssetsOnChain", `Search assets on ${chainName}…`)} + bg="transparent" border="none" color="kk.textPrimary" px="0" fontSize="12px" + _focus={{ outline: "none", boxShadow: "none" }} autoFocus /> + + + {/* Asset list */} + + {inChain.length === 0 ? ( + + No matching assets + No asset on {chainName} matches your search. + + ) : ( + <> + {buckets.held.length > 0 && ( + + {buckets.held.map(e => )} + + )} + {buckets.native.length > 0 && ( + + {buckets.native.map(e => )} + + )} + {buckets.token.length > 0 && ( + + {buckets.token.map(e => )} + + )} + + )} + + + ) +} + +function AssetSection({ label, count, children }: { label: string; count: number; children: ReactNode }) { + return ( + + + + {label} + + · {count} + + + {children} + + ) +} + +function AssetListRow({ entry: e, onSelect, onUnavailable }: { + entry: AssetEntry + onSelect: (e: AssetEntry) => void + onUnavailable: (e: AssetEntry) => void +}) { + const selectable = isRowSelectable(e) + const isTryQuote = e.availability.status === "unknown" + + return ( + selectable ? onSelect(e) : onUnavailable(e)} + > + + + + + {e.symbol} + {e.balance && ( + + HELD {e.balance.amount} + + )} + {!e.balance && isTryQuote && ( + + TRY QUOTE + + )} + {!selectable && ( + + UNAVAILABLE + + )} + + {e.name} + + + + + + {e.availability.providers.length} {e.availability.providers.length === 1 ? "route" : "routes"} + + + + ) +} + +// ── Unavailable route view ────────────────────────────────────────────────── + +function UnavailableRouteView({ fromChainId, target, entries, onBack, onAltSelect }: { + fromChainId: string | null + target: AssetEntry + entries: AssetEntry[] + onBack: () => void + onAltSelect: (e: AssetEntry) => void +}) { + const { t } = useTranslation("swap") + const sym = target.symbol + const fromChainName = fromChainId ? networkDisplayName(fromChainId) : "?" + const targetChainName = networkDisplayName(target.chainId) + + const alternatives = useMemo(() => + entries.filter(e => + e.caip !== target.caip && + e.symbol === sym && + isRowSelectable(e) + ).sort((a, b) => b.availability.providers.length - a.availability.providers.length), + [entries, target, sym] + ) + + return ( + <> + {/* Breadcrumb */} + + + Back to {targetChainName} + + + + + {/* Hero */} + + + + + + {fromChainId ? `${sym} (${fromChainName}) → ${target.symbol} (${targetChainName})` : `${target.symbol} (${targetChainName})`} + + + No swap provider currently routes to{" "} + {sym} on {targetChainName}.{" "} + {target.availability.status === "unsupported_token" + ? `${targetChainName} natives swap fine, but this specific token isn't on any provider's list yet.` + : `${targetChainName} isn't supported by any of our routers yet (THORChain, Mayachain, Relay, 0x, ChainFlip).`} + + + + {t("notifyWhenSupported", "Notify me when supported")} + + + + + {/* Alternatives */} + {alternatives.length > 0 ? ( + + + + + {t("swapToOnOtherChain", `You can swap to ${sym} on another network`)} + + + + {alternatives.map(a => { + const chainColor = chainColorForCaip2(a.chainId) + const chainName = networkDisplayName(a.chainId) + return ( + onAltSelect(a)}> + + + + {a.symbol} + {a.balance && ( + HELD + )} + + + + {a.name} · on {chainName} + + + + + {a.availability.providers.length} {a.availability.providers.length === 1 ? "route" : "routes"} + + + + + + ) + })} + + + ) : ( + + + + + No alternative routes for {sym} + + + + No other supported network lists {sym}. Try a different destination asset, or enable notifications above. + + + )} + + + {/* Footer */} + + We never sign anything that can't complete. + + Try a different asset + + + + ) +} + +// ── Props ─────────────────────────────────────────────────────────────────── + interface AssetPickerDialogProps { open: boolean onClose: () => void - /** Pioneer GetAvailableAssets cached result. */ swappable: SwapAsset[] - /** Connected wallet's per-chain balances. */ balances: ChainBalance[] - /** User-added custom tokens (gnars on Base etc.) — surfaced in the picker - * even when neither discovery nor Pioneer's swappable list contains them. */ customTokens?: CustomToken[] - /** CAIP-19 of the asset on the OPPOSITE side of the swap — excluded so the - * user can't pick the same asset on both legs. */ excludeCaip?: string - /** Fired when the user picks a Pioneer-swappable asset. The dialog refuses - * to fire onSelect for non-swappable rows (they're rendered disabled). */ onSelect: (asset: SwapAsset) => void - /** Whether this picker is for the FROM side ("From which asset?") or TO. */ side: "from" | "to" } -function chainBadgeCaip(entry: AssetEntry): string | undefined { - // For tokens, AssetIcon's chainCaip prop expects the chain's full CAIP-19 - // native asset id (e.g. 'eip155:1/slip44:60') — that's what caipToIcon - // base64-encodes for the keepkey.info URL. Passing the bare CAIP-2 'eip155:1' - // produced a broken URL and a missing badge in v1. - if (entry.isNative) return undefined - return chainMetaForCaip2(entry.chainId)?.nativeCaip -} - -/** Decide whether a row is selectable. - * - * Two gates: - * 1. Matrix says swappable or unknown (try-quote). - * 2. Vault has a ChainDef for this chain — without one we can't derive - * the destination address, sign for the source, or build the tx. - * Without the gate, matrix-swappable chains like Berachain/Linea/ - * Celo/Sonic etc. (Relay routes them, but vault has no chain entry) - * rendered as selectable then silently swallowed the click — the - * synthesizer returned null because chainMetaForCaip2 was null. */ -function isRowSelectable(entry: AssetEntry): boolean { - const status = entry.availability.status - if (status !== 'swappable' && status !== 'unknown') return false - return chainMetaForCaip2(entry.chainId) !== null -} - -/** Build a human-readable reason for why an asset can't be selected (or has - * ambiguous availability). The matrix returns CAIP-formatted reasons like - * "tron:27Lqcw is not currently supported"; this swaps in the chain's - * display name and produces something a user can act on. Returns null only - * for cleanly swappable rows that vault can also operate on. */ -function humanReason(entry: AssetEntry): string | null { - const status = entry.availability.status - const chain = networkDisplayName(entry.chainId) - const vaultKnowsChain = chainMetaForCaip2(entry.chainId) !== null - - // Matrix says we can route, but vault has no ChainDef → can't sign or - // derive an address → not actually selectable. This catches Berachain / - // Linea / Celo / Sonic / Mode / Manta / Mantle / Scroll / zkSync / Blast - // — the matrix added them per Relay coverage but vault's chains.ts doesn't - // have entries yet, so until that lands the picker has to be honest. - if ((status === 'swappable' || status === 'unknown') && !vaultKnowsChain) { - return `${chain} routing is supported but vault doesn't have this chain configured yet — sign and address-derive paths are blocked.` - } - if (status === 'swappable') return null - if (status === 'unknown') { - return `${chain} is supported — Pioneer didn't pre-list this token, but a quote may still route via aggregators (try it).` - } - if (status === 'unsupported_token') { - return `${chain} natives swap fine, but this specific token isn't routable through any provider yet.` - } - // unsupported_chain - return `${chain} isn't supported by any swap provider yet (THORChain, Mayachain, Relay, 0x, ChainFlip, ShapeShift).` -} +// ── Main component ────────────────────────────────────────────────────────── export function AssetPickerDialog({ open, onClose, swappable, balances, customTokens, excludeCaip, onSelect, side, }: AssetPickerDialogProps) { - const { t } = useTranslation("swap") const { fmtCompact } = useFiat() - const [search, setSearch] = useState("") + const { t } = useTranslation("swap") + + // Shared const [entries, setEntries] = useState(null) - const [loading, setLoading] = useState(false) - const inputRef = useRef(null) - - /* Paste-contract auto-add: when the search query is a valid EVM address - * and the discovery universe has no matches, we offer to fetch the - * token's metadata directly from the chain RPC and add it as a custom - * swap asset. The lookup is debounced so we don't fire it on every - * keystroke while pasting. */ - const [contractHits, setContractHits] = useState(null) + const [loading, setLoading] = useState(false) + + // TO-specific navigation + const [toChain, setToChain] = useState(null) + const [unavailEntry, setUnavailEntry] = useState(null) + const [search, setSearch] = useState("") + + // FROM: EVM contract paste + const [contractHits, setContractHits] = useState(null) const [contractLooking, setContractLooking] = useState(false) const [contractError, setContractError] = useState(null) - // Lazy-build the unified entry list on first open. Recompute when swappable - // or balances change so newly-detected tokens show up. + // Extract FROM chain from excludeCaip (when side=to, excludeCaip is the FROM asset) + const fromChainId = side === "to" && excludeCaip ? excludeCaip.split("/")[0] : null + + // Build entries on open useEffect(() => { if (!open) return let cancelled = false @@ -153,88 +927,58 @@ export function AssetPickerDialog({ return () => { cancelled = true } }, [open, swappable, balances, customTokens]) - // Reset query and focus search input on each open + // Reset state on open/close useEffect(() => { - if (!open) return - setSearch("") - const id = setTimeout(() => inputRef.current?.focus(), 50) - return () => clearTimeout(id) + if (!open) { setToChain(null); setUnavailEntry(null); setSearch(""); return } + setToChain(null); setUnavailEntry(null); setSearch("") }, [open]) - // Esc closes the picker — keyboard convention. Only bound while open so we - // don't intercept keystrokes meant for SwapDialog or other components. + // Escape closes useEffect(() => { if (!open) return - const onKey = (e: KeyboardEvent) => { - if (e.key === 'Escape') { - e.preventDefault() - onClose() - } - } - window.addEventListener('keydown', onKey) - return () => window.removeEventListener('keydown', onKey) + const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") { e.preventDefault(); onClose() } } + window.addEventListener("keydown", onKey) + return () => window.removeEventListener("keydown", onKey) }, [open, onClose]) - const searchIndex: SearchIndex | null = useMemo( - () => entries ? buildSearchIndex(entries) : null, - [entries], - ) - - const visible = useMemo(() => { - if (!searchIndex) return [] - let list = searchEntries(searchIndex, search) - if (excludeCaip) list = list.filter(e => e.caip !== excludeCaip) - // Empty query: only show held + Pioneer-swappable + matrix-swappable - // (buckets 0-5). Saves rendering ~30k DOM nodes when nothing is typed. - if (!search.trim()) list = list.filter(e => bucketFor(e) <= 5) - return list.slice(0, MAX_RENDER) - }, [searchIndex, search, excludeCaip]) - - /* Probe the chain RPCs when the user pastes a contract that doesn't match - * anything in discovery. Debounced 350ms so a fast-typed address doesn't - * fire 40 lookups. Reset on close / query change. */ + // EVM contract lookup (FROM side only) useEffect(() => { - setContractHits(null) - setContractError(null) - if (!open) return + setContractHits(null); setContractError(null) + if (!open || side !== "from") return const q = search.trim() if (!EVM_CONTRACT_RE.test(q)) return - if (visible.length > 0) return /* discovery already had it — skip lookup */ let cancelled = false setContractLooking(true) const timer = setTimeout(() => { - rpcRequest<{ hits: SwapAsset[]; reason?: string }>('lookupTokenContract', { contractAddress: q }, 12000) + rpcRequest<{ hits: SwapAsset[]; reason?: string }>("lookupTokenContract", { contractAddress: q }, 12000) .then(res => { if (cancelled) return setContractLooking(false) - if (res.hits && res.hits.length > 0) setContractHits(res.hits) - else setContractError(res.reason || 'no-token-found') + if (res.hits?.length > 0) setContractHits(res.hits) + else setContractError(res.reason || "no-token-found") }) .catch(e => { if (cancelled) return setContractLooking(false) - setContractError(e?.message || 'lookup-failed') + setContractError(e?.message || "lookup-failed") }) }, 350) return () => { cancelled = true; clearTimeout(timer) } - }, [open, search, visible.length]) + }, [open, side, search]) + + // Chain infos for TO chain step + const chainInfos = useMemo(() => { + if (!entries) return [] + return buildChainInfos(entries, fromChainId, excludeCaip) + }, [entries, fromChainId, excludeCaip]) const handleSelect = useCallback((entry: AssetEntry) => { if (!isRowSelectable(entry)) return - // Prefer Pioneer-listed SwapAsset (canonical asset name + verified routing). - // Fall back to a synthesized one when Pioneer didn't include this CAIP — - // matches the "try quote" UX and lets Pioneer reject with a real reason - // instead of the picker silently swallowing the click. const base = entry.swappable ?? synthesizeSwapAsset(entry) if (!base) { - console.warn('[AssetPickerDialog] No vault chain config for', entry.chainId, '- refusing select') + console.warn("[AssetPickerDialog] No vault chain config for", entry.chainId) return } - // Force the outgoing CAIP to match the canonicalized form the picker - // resolved to (entry.caip went through canonicalizeCaip at build time; - // base.caip is whatever pioneer-server emitted, which can drift — - // currently pioneer-server uses /erc20: for BSC tokens but discovery - // emits /bep20:; mirror this in case pioneer-server ever diverges). const asset = base.caip === entry.caip ? base : { ...base, caip: entry.caip } onSelect(asset) onClose() @@ -242,7 +986,23 @@ export function AssetPickerDialog({ if (!open) return null - const title = side === "from" ? t("selectFromAsset", "Select asset to swap from") : t("selectToAsset", "Select asset to swap to") + // Title depends on side + TO step + let title = side === "from" + ? t("selectFromAsset", "Select asset to swap from") + : toChain + ? t("selectToAsset", "Select asset to swap to") + : t("chooseNetwork", "Choose destination network") + + let stepLabel = side === "from" + ? t("stepOne", "Step 1 of 2 — Pick what you're swapping") + : toChain + ? t("stepTwoAsset", `Step 2 of 2 — Pick an asset on ${networkDisplayName(toChain)}`) + : t("stepTwoChain", "Step 2 of 2 — Choose destination network") + + // Footer text for TO side + const toFooter = toChain + ? `${entries?.filter(e => e.chainId === toChain).length ?? 0} assets on ${networkDisplayName(toChain)}` + : `${chainInfos.length} networks · ${swappable.length.toLocaleString()} swappable assets` return ( e.stopPropagation()} + _before={{ + content: '""', position: "absolute", inset: "0", + bg: "radial-gradient(800px 400px at 50% -10%, rgba(233,196,106,0.04), transparent 60%)", + pointerEvents: "none", + }} > {/* Header */} - - {title} - - - - {/* Search input */} - - - setSearch(e.target.value)} - placeholder={t("searchAssetsPlaceholder", "Search by symbol, name, or CAIP…")} - bg="transparent" border="none" color="kk.textPrimary" px="0" - _focus={{ outline: "none", boxShadow: "none" }} - /> - - - {/* List */} - - {loading && ( - {t("loading", "Loading…")} - )} - {!loading && visible.length === 0 && ( - - {/* Paste-contract auto-add lane. - * Triggers when the user types/pastes a 0x..40-char address - * and discovery has no entry for it. We probe every EVM RPC - * in parallel and surface every chain that returned valid - * ERC20 metadata as a one-click "Add as custom token" row. */} - {EVM_CONTRACT_RE.test(search.trim()) ? ( - <> - {contractLooking && ( - - {t("contractLookingUp", "Looking up contract on every chain…")} - - )} - {!contractLooking && contractHits && contractHits.length > 0 && ( - - - {t("contractFoundOn", "Found on", { count: contractHits.length })} - - - {contractHits.map(hit => ( - { onSelect(hit); onClose() }} - > - - - - {hit.symbol} - - {nd(hit.chainId)} - - - {hit.name} - - - {t("addCustomToken", "Add")} - - - ))} - - - {t("contractSafetyNote", "Custom tokens skip Vault's verified list. Verify the symbol matches the project before swapping.")} - - - )} - {!contractLooking && contractHits && contractHits.length === 0 && ( - - {t("contractNotFoundOnAnyChain", "No ERC20 found at this address on any supported chain.")} - - )} - {!contractLooking && !contractHits && contractError && ( - - {t("contractLookupFailed", "Couldn't reach a chain RPC to look up this contract. Try again.")} - - )} - - ) : ( - - {search.trim() - ? t("noAssetsMatchSearch", "No assets match your search.") - : t("noAssetsAvailable", "No swappable assets available.")} - - )} - - )} - {!loading && visible.map(e => )} - {!loading && visible.length === MAX_RENDER && ( - - {t("resultsCapped", "Showing first {{n}} matches — refine your search to narrow down.", { n: MAX_RENDER })} - - )} - - - {/* Hint footer when query is empty */} - {!loading && !search.trim() && ( - - - {t("emptyQueryHint", "Showing held + swappable assets. Type to search the full {{count}}-asset universe.", { count: entries?.length ?? 0 })} + + + {stepLabel} + + {unavailEntry ? t("routeUnavailable", "That route isn't available — yet") : title} - )} - - - ) -} - -interface AssetRowProps { - entry: AssetEntry - onSelect: (entry: AssetEntry) => void - fmtCompact: (v: number) => string - t: any // i18next TFunction — overloaded enough that typing it explicitly is more pain than value -} - -function AssetRow({ entry, onSelect, fmtCompact, t }: AssetRowProps) { - const status = entry.availability.status - const selectable = isRowSelectable(entry) - // For tokens, surface the network so USDT-on-ETH vs USDT-on-BSC is unambiguous. - // Falls back to discovery's chain name (covers chains vault doesn't have a ChainDef for). - const networkLabel = networkDisplayName(entry.chainId) - const reason = humanReason(entry) - const isUnsupported = !selectable - - return ( - { if (selectable) onSelect(entry) }} - borderLeft={isUnsupported ? "3px solid rgba(255,99,99,0.35)" : "3px solid transparent"} - > - - - - - {entry.symbol} - {!entry.isNative && ( - - {t("on", "on")} {networkLabel} - - )} - - - {entry.name} - · {entry.chainId} - + + + - {/* Right side: balance OR compact availability badge. Held assets - show balance + USD; selectable non-held show the badge. Disabled - (unsupported) rows render the reason inline below instead. */} - {entry.balance ? ( - - {entry.balance.amount} - {entry.balance.usd > 0 && ( - {fmtCompact(entry.balance.usd)} - )} - - ) : ( - - )} - - - {/* Inline humanized reason — surfaced on EVERY non-swappable row so the - user understands why a row is greyed without hovering. The matrix - reason is CAIP-formatted; humanReason swaps in the chain display - name (e.g. "TON" not "ton:-239") via networkDisplayName. */} - {reason && ( - - {reason} - - )} - - ) -} - -function AvailabilityBadge({ entry, t }: { entry: AssetEntry; t: AssetRowProps["t"] }) { - const status = entry.availability.status - const providers = entry.availability.providers - - if (status === "swappable" && providers.length > 0) { - const label = providers.length === 1 - ? PROVIDER_LABEL[providers[0]] - : `${providers.length} ${t("routes", "routes")}` - return ( - - {label} - - ) - } - if (status === "unknown") { - return ( - - {t("tryQuote", "try quote")} + {/* Body */} + + {loading ? ( + + {t("loading", "Loading…")} + + ) : !entries ? null + : side === "from" ? ( + + ) : unavailEntry ? ( + setUnavailEntry(null)} + onAltSelect={(e) => { setUnavailEntry(null); handleSelect(e) }} + /> + ) : toChain ? ( + <> + { setToChain(null); setSearch("") }} + onSelect={handleSelect} + onUnavailable={setUnavailEntry} + /> + {/* Footer for asset step */} + + {toFooter} + Chain → Token + + + ) : ( + <> + { setToChain(caip2); setSearch("") }} + /> + {/* Footer for chain step */} + + {toFooter} + Pick a network to continue + + + )} + - ) - } - // unsupported_chain or unsupported_token - return ( - - {t("unavailable", "unavailable")} ) } From 0b2102b96f576205af18cc3510025c9aef6b4840 Mon Sep 17 00:00:00 2001 From: highlander Date: Wed, 20 May 2026 16:28:07 -0300 Subject: [PATCH 02/15] =?UTF-8?q?fix:=20asset=20picker=20=E2=80=94=20real?= =?UTF-8?q?=20logos=20+=20correct=20provider=20color=20keys?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Chain cards and NetSwitchBanner now use AssetIcon (real coin logos) instead of color-dot initials. Falls back to a color dot only when chainMetaForCaip2 has no nativeCaip (unknown chains). PROVIDER_COLORS keys corrected to match SwapProvider type ('thorchain' not 'THORChain', etc.) so provider dots actually render. --- .../mainview/components/AssetPickerDialog.tsx | 57 +++++++++---------- 1 file changed, 28 insertions(+), 29 deletions(-) diff --git a/projects/keepkey-vault/src/mainview/components/AssetPickerDialog.tsx b/projects/keepkey-vault/src/mainview/components/AssetPickerDialog.tsx index abd7fe4f..68249ae9 100644 --- a/projects/keepkey-vault/src/mainview/components/AssetPickerDialog.tsx +++ b/projects/keepkey-vault/src/mainview/components/AssetPickerDialog.tsx @@ -92,10 +92,14 @@ function isRowSelectable(entry: AssetEntry): boolean { // ── provider dots ─────────────────────────────────────────────────────────── +// Keys match SwapProvider type from swap-support-matrix const PROVIDER_COLORS: Record = { - THORChain: "#23DCC8", Mayachain: "#3B82F6", Relay: "#9F8CE0", - "0x": "#5C6BC0", CowSwap: "#F0B90B", ChainFlip: "#E84142", - Osmosis: "#9C27FF", ShapeShift: "#00C3FF", + thorchain: "#23DCC8", + mayachain: "#3B82F6", + relay: "#9F8CE0", + zeroex: "#5C6BC0", + chainflip: "#E84142", + shapeshift: "#00C3FF", } function ProviderDots({ providers }: { providers: string[] }) { @@ -124,11 +128,11 @@ function chainBadgeCaip(entry: AssetEntry): string | undefined { function NetSwitchBanner({ fromChainId, toChainId, providers }: { fromChainId: string; toChainId: string; providers: string[] }) { - const fromColor = chainColorForCaip2(fromChainId) - const toColor = chainColorForCaip2(toChainId) - const fromName = networkDisplayName(fromChainId) - const toName = networkDisplayName(toChainId) - const same = fromChainId === toChainId + const fromMeta = chainMetaForCaip2(fromChainId) + const toMeta = chainMetaForCaip2(toChainId) + const fromName = networkDisplayName(fromChainId) + const toName = networkDisplayName(toChainId) + const same = fromChainId === toChainId return ( - {/* mini route diagram */} + {/* mini route diagram — real chain logos */} - - {fromName.slice(0, 1)} - + {fromMeta?.nativeCaip + ? + : } - - {toName.slice(0, 1)} - + mx="1" /> + {toMeta?.nativeCaip + ? + : } @@ -352,6 +350,8 @@ interface ChainInfo { name: string family: string color: string + /** Native asset CAIP-19 for use with AssetIcon (e.g. 'eip155:1/slip44:60') */ + nativeCaip: string | undefined heldCount: number totalCount: number routableCount: number @@ -376,6 +376,7 @@ function buildChainInfos(entries: AssetEntry[], fromChainId: string | null, excl name: networkDisplayName(caip2), family: chainFamilyLabel(meta?.chainFamily ?? ""), color: chain?.color ?? "#555", + nativeCaip: meta?.nativeCaip, heldCount: heldInChain.length, totalCount: assetsInChain.length, routableCount: routableInChain.length, @@ -499,7 +500,9 @@ function ChainGrid({ chains, onPick, unavail }: { {/* Body */} - + {c.nativeCaip + ? + : } {c.name} {c.family} @@ -803,8 +806,7 @@ function UnavailableRouteView({ fromChainId, target, entries, onBack, onAltSelec {alternatives.map(a => { - const chainColor = chainColorForCaip2(a.chainId) - const chainName = networkDisplayName(a.chainId) + const chainName = networkDisplayName(a.chainId) return ( HELD )} - - - {a.name} · on {chainName} - + {a.name} · on {chainName} Date: Wed, 20 May 2026 16:44:36 -0300 Subject: [PATCH 03/15] =?UTF-8?q?feat:=20asset=20picker=20v2=20=E2=80=94?= =?UTF-8?q?=20square=20tiles,=2064px=20icons,=20full=20CAIP,=20network-fir?= =?UTF-8?q?st=20TO=20flow?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit FROM: flat list of all held assets ranked by USD, square tiles, 64px icons, full CAIP-19. TO step 1: square network tiles — supported vs not-routable only (no same-network, no held-grouping). TO step 2: paginated asset list (20/page) with text search, 64px icons, full CAIP-19. Provider color keys corrected to match SwapProvider type. --- .../mainview/components/AssetPickerDialog.tsx | 886 ++++++++---------- 1 file changed, 372 insertions(+), 514 deletions(-) diff --git a/projects/keepkey-vault/src/mainview/components/AssetPickerDialog.tsx b/projects/keepkey-vault/src/mainview/components/AssetPickerDialog.tsx index 68249ae9..4cffb3ad 100644 --- a/projects/keepkey-vault/src/mainview/components/AssetPickerDialog.tsx +++ b/projects/keepkey-vault/src/mainview/components/AssetPickerDialog.tsx @@ -1,10 +1,9 @@ /** - * Asset Picker v2 — full redesign from handoff (May 2026). + * Asset Picker v2 — redesigned May 2026. * - * FROM side: card grid of held assets, sorted by USD value. - * TO side: 2-step flow — chain selection grid → asset list in that chain. - * Unavailable-route view replaces the asset list when the user taps - * a non-routable asset. + * FROM side: flat list of held assets ranked by USD value, square tiles, 64px icons, full CAIP. + * TO side: Step 1 — square network tiles (all supported, no same-network, no held-grouping). + * Step 2 — paginated asset list with text search for that network, 64px icons, full CAIP. */ import { useState, useEffect, useMemo, useCallback, type ReactNode } from "react" import { Box, Flex, Text, Input } from "@chakra-ui/react" @@ -13,16 +12,12 @@ import { AssetIcon } from "./AssetIcon" import type { SwapAsset, ChainBalance, CustomToken } from "../../shared/types" import { buildAssetEntries, - buildSearchIndex, - searchEntries, chainMetaForCaip2, networkDisplayName, synthesizeSwapAsset, type AssetEntry, - type SearchIndex, } from "../../shared/swap-discovery" import { CHAINS } from "../../shared/chains" -import { PROVIDER_LABEL } from "../../shared/swap-support-matrix" import { Z } from "../lib/z-index" import { useFiat } from "../lib/fiat-context" import { rpcRequest } from "../lib/rpc" @@ -30,9 +25,9 @@ import { rpcRequest } from "../lib/rpc" // ── constants ────────────────────────────────────────────────────────────── const EVM_CONTRACT_RE = /^0x[a-fA-F0-9]{40}$/ -const MAX_RENDER = 150 +const PAGE_SIZE = 20 -// ── small icons ──────────────────────────────────────────────────────────── +// ── icons ────────────────────────────────────────────────────────────────── const SearchIcon = () => ( @@ -82,7 +77,7 @@ function chainFamilyLabel(family: string): string { return map[family] ?? family.toUpperCase() } -// ── selectability (same as before) ───────────────────────────────────────── +// ── selectability ─────────────────────────────────────────────────────────── function isRowSelectable(entry: AssetEntry): boolean { const s = entry.availability.status @@ -90,9 +85,8 @@ function isRowSelectable(entry: AssetEntry): boolean { return chainMetaForCaip2(entry.chainId) !== null } -// ── provider dots ─────────────────────────────────────────────────────────── +// ── provider dots — keys match SwapProvider type ──────────────────────────── -// Keys match SwapProvider type from swap-support-matrix const PROVIDER_COLORS: Record = { thorchain: "#23DCC8", mayachain: "#3B82F6", @@ -104,25 +98,49 @@ const PROVIDER_COLORS: Record = { function ProviderDots({ providers }: { providers: string[] }) { if (!providers.length) return null - const show = providers.slice(0, 4) return ( - - {show.map(p => ( - + + {providers.slice(0, 4).map(p => ( + ))} - {providers.length > 4 && } + {providers.length > 4 && } ) } -// ── chain badge caip helper ───────────────────────────────────────────────── +// ── chain badge caip (network overlay on token icons) ────────────────────── function chainBadgeCaip(entry: AssetEntry): string | undefined { if (entry.isNative) return undefined return chainMetaForCaip2(entry.chainId)?.nativeCaip } +// ── shared search bar ─────────────────────────────────────────────────────── + +function SearchBar({ value, onChange, placeholder, autoFocus }: { + value: string; onChange: (v: string) => void; placeholder: string; autoFocus?: boolean +}) { + return ( + + + + ) +} + // ── network-switch banner ─────────────────────────────────────────────────── function NetSwitchBanner({ fromChainId, toChainId, providers }: { @@ -135,30 +153,27 @@ function NetSwitchBanner({ fromChainId, toChainId, providers }: { const same = fromChainId === toChainId return ( - - {/* mini route diagram — real chain logos */} - + borderRadius="12px" flexShrink={0}> + {fromMeta?.nativeCaip ? : } - + {toMeta?.nativeCaip ? : } - - {same ? <>Staying on {fromName} : <>Crossing from {fromName} to {toName}} + {same + ? <>{fromName}{toName} + : <>Crossing {fromName}{toName}} {providers.length > 0 && ( @@ -168,30 +183,30 @@ function NetSwitchBanner({ fromChainId, toChainId, providers }: { {same - ? `Same-network swap · settles in seconds` - : `Cross-chain · est. 4–12 min · funds custodied by ${providers[0] ?? "router"} during transit`} + ? "Same-network swap · settles in seconds" + : `Cross-chain · est. 4–12 min · ${providers[0] ?? "router"} in transit`} - - - {same ? "Same network" : "Cross-chain"} + + {same ? "Same" : "Cross-chain"} ) } -// ── FROM picker — held assets card grid ───────────────────────────────────── +// ══════════════════════════════════════════════════════════════════════════ +// FROM picker — all held assets, ranked by USD value, square tiles +// ══════════════════════════════════════════════════════════════════════════ function FromPicker({ entries, onSelect, fmtCompact }: { entries: AssetEntry[]; onSelect: (e: AssetEntry) => void; fmtCompact: (v: number) => string }) { const { t } = useTranslation("swap") const [search, setSearch] = useState("") + + // All held assets flat, ranked by USD value const held = useMemo( () => entries.filter(e => e.balance).sort((a, b) => (b.balance!.usd) - (a.balance!.usd)), [entries] @@ -208,248 +223,200 @@ function FromPicker({ entries, onSelect, fmtCompact }: { return ( <> - {/* Header */} - - - {t("stepOne", "Step 1 of 2 — Pick what you're swapping")} - - - - - {t("availableToSwap", "Available to swap")} - - - {totalUsd > 0 ? fmtCompact(totalUsd) : "—"} - - - - {held.length} {t("assetsAcross", "assets across")} {new Set(held.map(e => e.chainId)).size} {t("chains", "chains")} + {/* Summary strip */} + + + + Available to swap + + + {totalUsd > 0 ? fmtCompact(totalUsd) : "—"} - + + {held.length} assets · {new Set(held.map(e => e.chainId)).size} chains + + - {/* Search (only when >5 held) */} - {held.length > 5 && ( - - - setSearch(e.target.value)} - placeholder={t("filterHeld", "Filter held assets…")} - bg="transparent" border="none" color="kk.textPrimary" px="0" fontSize="12px" - _focus={{ outline: "none", boxShadow: "none" }} /> - - )} + - {/* Grid */} {filtered.length === 0 ? ( + border="1px dashed rgba(255,255,255,0.10)" display="grid" placeItems="center" color="kk.textMuted"> - {t("emptyWalletTitle", "Your KeepKey is empty")} + {search ? "No held assets match" : t("emptyWalletTitle", "Your KeepKey is empty")} - - {t("emptyWalletSub", "Send some assets to your wallet first — then come back here to swap them.")} + + {search + ? "Try a different search term." + : t("emptyWalletSub", "Send some assets to your wallet first, then come back to swap.")} ) : ( - - {filtered.map(e => )} + + {filtered.map(e => )} )} - {/* Footer */} - - {t("showingHeld", "Showing only what your KeepKey holds.")} - - - {filtered.length} {t("of", "of")} {held.length} - + Held assets · ranked by value + {filtered.length} of {held.length} ) } -function HeldCard({ entry: e, onSelect, fmtCompact }: { +function HeldTile({ entry: e, onSelect, fmtCompact }: { entry: AssetEntry; onSelect: (e: AssetEntry) => void; fmtCompact: (v: number) => string }) { const chainName = networkDisplayName(e.chainId) - const chainColor = chainColorForCaip2(e.chainId) - const providers = e.availability.providers + const selectable = isRowSelectable(e) return ( isRowSelectable(e) && onSelect(e)} - opacity={isRowSelectable(e) ? 1 : 0.5} + _hover={selectable ? { + borderColor: "rgba(139,227,196,0.55)", + transform: "translateY(-2px)", + boxShadow: "0 16px 30px -16px rgba(139,227,196,0.28)", + } : {}} + _before={{ + content: '""', position: "absolute", top: "-1px", right: "-1px", + w: "70px", h: "70px", + bg: "radial-gradient(circle at top right, rgba(139,227,196,0.16), transparent 70%)", + pointerEvents: "none", + }} + onClick={() => selectable && onSelect(e)} > - {/* Top row: icon + chain + (placeholder pct) */} - - - - - {/* Symbol + chain */} - - {e.symbol} - - - - on {chainName} - - - - - {/* Balance */} - - + {/* Icon — 64px */} + + + {/* Bottom info */} + + {e.symbol} + + {chainName} + + {e.balance!.amount} - - {e.balance!.usd > 0 ? fmtCompact(e.balance!.usd) : "—"} + {fmtCompact(e.balance!.usd)} + {/* Full CAIP */} + + {e.caip} - - {/* Footer: providers */} - - - ) } -// ── TO picker — chain selection step ──────────────────────────────────────── +// ══════════════════════════════════════════════════════════════════════════ +// TO picker — Step 1: network selection (square tiles) +// ══════════════════════════════════════════════════════════════════════════ interface ChainInfo { caip2: string name: string family: string color: string - /** Native asset CAIP-19 for use with AssetIcon (e.g. 'eip155:1/slip44:60') */ nativeCaip: string | undefined - heldCount: number totalCount: number routableCount: number providers: string[] isAvailable: boolean } -function buildChainInfos(entries: AssetEntry[], fromChainId: string | null, excludeCaip: string | undefined): ChainInfo[] { +function buildChainInfos(entries: AssetEntry[], excludeCaip: string | undefined): ChainInfo[] { const chainIds = new Set(entries.map(e => e.chainId)) return [...chainIds].map(caip2 => { const meta = chainMetaForCaip2(caip2) const chain = meta ? CHAINS.find(c => c.id === meta.vaultChainId) : null const assetsInChain = entries.filter(e => e.chainId === caip2 && e.caip !== excludeCaip) - const heldInChain = assetsInChain.filter(e => e.balance) const routableInChain = assetsInChain.filter(e => isRowSelectable(e)) const providers = new Set() - for (const e of routableInChain) { - for (const p of e.availability.providers) providers.add(p) - } + for (const e of routableInChain) for (const p of e.availability.providers) providers.add(p) return { caip2, name: networkDisplayName(caip2), family: chainFamilyLabel(meta?.chainFamily ?? ""), color: chain?.color ?? "#555", nativeCaip: meta?.nativeCaip, - heldCount: heldInChain.length, totalCount: assetsInChain.length, routableCount: routableInChain.length, providers: [...providers], isAvailable: routableInChain.length > 0, } - }) + }).sort((a, b) => b.routableCount - a.routableCount) // most assets first } -function ChainStep({ chainInfos, fromChainId, search, onSearchChange, onPickChain }: { +function ChainStep({ chainInfos, search, onSearchChange, onPickChain }: { chainInfos: ChainInfo[] - fromChainId: string | null search: string onSearchChange: (s: string) => void onPickChain: (caip2: string) => void }) { - const { t } = useTranslation("swap") const q = search.trim().toLowerCase() - const matches = (c: ChainInfo) => !q || c.name.toLowerCase().includes(q) || - c.family.toLowerCase().includes(q) - - const sameChain = chainInfos.filter(c => c.caip2 === fromChainId && matches(c)) - const heldChains = chainInfos.filter(c => c.caip2 !== fromChainId && c.heldCount > 0 && c.isAvailable && matches(c)) - .sort((a, b) => b.heldCount - a.heldCount) - const otherChains = chainInfos.filter(c => c.caip2 !== fromChainId && c.heldCount === 0 && c.isAvailable && matches(c)) - const unavailChains = chainInfos.filter(c => !c.isAvailable && matches(c)) + const available = chainInfos.filter(c => c.isAvailable && (!q || c.name.toLowerCase().includes(q) || c.family.toLowerCase().includes(q))) + const unavailable = chainInfos.filter(c => !c.isAvailable && (!q || c.name.toLowerCase().includes(q) || c.family.toLowerCase().includes(q))) return ( <> - {/* Search */} - - - onSearchChange(e.target.value)} - placeholder={t("searchChainPlaceholder", "Search destination — symbol or chain name…")} - bg="transparent" border="none" color="kk.textPrimary" px="0" fontSize="12px" - _focus={{ outline: "none", boxShadow: "none" }} /> - ⌘K - + - {/* Same network */} - {sameChain.length > 0 && ( - - - - )} - {/* Held chains */} - {heldChains.length > 0 && ( - - - - )} - {/* Other chains */} - {otherChains.length > 0 && ( - - - + {/* Supported networks */} + {available.length > 0 && ( + <> + + + + Supported networks + + · {available.length} + + + {available.map(c => )} + + )} + {/* Unavailable */} - {unavailChains.length > 0 && ( - - {}} unavail /> - + {unavailable.length > 0 && ( + <> + + + + Not currently routable + + · {unavailable.length} + + + {unavailable.map(c => )} + + )} - {sameChain.length + heldChains.length + otherChains.length + unavailChains.length === 0 && ( + + {available.length + unavailable.length === 0 && ( No matching networks Try a different search term. @@ -460,93 +427,54 @@ function ChainStep({ chainInfos, fromChainId, search, onSearchChange, onPickChai ) } -function ChainSection({ title, accent, meta, children }: { - title: string; accent: string; meta: string; children: ReactNode -}) { - return ( - - - - - - {title} - - - {meta} - - {children} - - ) -} - -function ChainGrid({ chains, onPick, unavail }: { - chains: ChainInfo[]; onPick: (caip2: string) => void; unavail?: boolean +function NetworkTile({ chain: c, onPick, unavail }: { + chain: ChainInfo; onPick: (caip2: string) => void; unavail?: boolean }) { return ( - - {chains.map(c => ( - !unavail && onPick(c.caip2)} - > - {/* Color stripe */} - - {/* Body */} - - - {c.nativeCaip - ? - : } - {c.name} - - {c.family} - - - - {c.caip2 && c.heldCount > 0 && ( - - {c.heldCount} held - - )} - {unavail && ( - - No route - - )} - - {c.totalCount} {c.totalCount === 1 ? "asset" : "assets"} - - {!unavail && c.providers.length > 0 && ( - <> - · - - - - {c.providers.length} {c.providers.length === 1 ? "router" : "routers"} - - - - )} - {unavail && ( - no provider routes here yet - )} - - - - ))} + !unavail && onPick(c.caip2)} + > + {/* Chain logo — 44px */} + {c.nativeCaip + ? + : } + + {/* Bottom info */} + + {c.name} + + {c.family} + + + {unavail ? "No route" : `${c.routableCount} swappable`} + + {/* CAIP-2 */} + + {c.caip2} + + ) } -// ── TO picker — asset list step ───────────────────────────────────────────── +// ══════════════════════════════════════════════════════════════════════════ +// TO picker — Step 2: asset list for a network (paginated + search) +// ══════════════════════════════════════════════════════════════════════════ function AssetStep({ entries, chainCaip2, fromChainId, excludeCaip, search, onSearchChange, onBack, onSelect, onUnavailable }: { @@ -562,108 +490,102 @@ function AssetStep({ entries, chainCaip2, fromChainId, excludeCaip, search, onSe }) { const { t } = useTranslation("swap") const chainName = networkDisplayName(chainCaip2) + const [page, setPage] = useState(0) const q = search.trim().toLowerCase() + // Reset page when search changes + useEffect(() => { setPage(0) }, [search]) + const inChain = useMemo(() => entries.filter(e => { if (e.chainId !== chainCaip2) return false if (e.caip === excludeCaip) return false if (q && !`${e.symbol} ${e.name}`.toLowerCase().includes(q)) return false return true + // Sort: held first (by USD), then selectable, then unavailable + }).sort((a, b) => { + const aHeld = a.balance ? 1 : 0 + const bHeld = b.balance ? 1 : 0 + if (aHeld !== bHeld) return bHeld - aHeld + if (aHeld && bHeld) return (b.balance!.usd) - (a.balance!.usd) + const aSel = isRowSelectable(a) ? 1 : 0 + const bSel = isRowSelectable(b) ? 1 : 0 + return bSel - aSel }), [entries, chainCaip2, excludeCaip, q]) - // Collect all providers across routable assets in this chain (for banner) + // Collect all providers across routable assets in chain (for banner) const allProviders = useMemo(() => { const s = new Set() for (const e of inChain) if (isRowSelectable(e)) for (const p of e.availability.providers) s.add(p) return [...s] }, [inChain]) - // Bucket assets - const buckets = useMemo(() => { - const b: Record = { held: [], native: [], token: [] } - for (const e of inChain) { - if (e.balance) { b.held.push(e); continue } - if (e.isNative) { b.native.push(e); continue } - b.token.push(e) - } - // Sort tokens by provider count desc - b.token.sort((a, bE) => bE.availability.providers.length - a.availability.providers.length) - return b - }, [inChain]) + const totalPages = Math.ceil(inChain.length / PAGE_SIZE) + const pageItems = inChain.slice(page * PAGE_SIZE, (page + 1) * PAGE_SIZE) return ( <> {/* Breadcrumb */} - - + Networks - / - {chainName} + / + {chainName} {/* Network switch banner */} {fromChainId && } {/* Search */} - - - onSearchChange(e.target.value)} - placeholder={t("searchAssetsOnChain", `Search assets on ${chainName}…`)} - bg="transparent" border="none" color="kk.textPrimary" px="0" fontSize="12px" - _focus={{ outline: "none", boxShadow: "none" }} autoFocus /> - + { onSearchChange(v) }} + placeholder={`Search assets on ${chainName}…`} autoFocus /> - {/* Asset list */} - + {/* List */} + {inChain.length === 0 ? ( - - No matching assets - No asset on {chainName} matches your search. + + No assets found + Try a different search term. ) : ( - <> - {buckets.held.length > 0 && ( - - {buckets.held.map(e => )} - - )} - {buckets.native.length > 0 && ( - - {buckets.native.map(e => )} - - )} - {buckets.token.length > 0 && ( - - {buckets.token.map(e => )} - - )} - + pageItems.map(e => ( + + )) )} - - ) -} -function AssetSection({ label, count, children }: { label: string; count: number; children: ReactNode }) { - return ( - - - - {label} + {/* Pagination + footer */} + + + {inChain.length} asset{inChain.length !== 1 ? "s" : ""} on {chainName} + {totalPages > 1 && ` · page ${page + 1} of ${totalPages}`} - · {count} - + {totalPages > 1 && ( + + 0 ? "pointer" : "not-allowed"} opacity={page > 0 ? 1 : 0.35} + _hover={page > 0 ? { bg: "rgba(255,255,255,0.08)" } : {}} + onClick={() => page > 0 && setPage(p => p - 1)}> + ← Prev + + page < totalPages - 1 && setPage(p => p + 1)}> + Next → + + + )} - {children} - + ) } @@ -672,36 +594,39 @@ function AssetListRow({ entry: e, onSelect, onUnavailable }: { onSelect: (e: AssetEntry) => void onUnavailable: (e: AssetEntry) => void }) { - const selectable = isRowSelectable(e) - const isTryQuote = e.availability.status === "unknown" + const selectable = isRowSelectable(e) + const isTryQuote = e.availability.status === "unknown" + const chainName = networkDisplayName(e.chainId) return ( selectable ? onSelect(e) : onUnavailable(e)} > - + {/* Icon — 64px */} + + + - - - {e.symbol} + {/* Info */} + + + {e.symbol} {e.balance && ( - HELD {e.balance.amount} + HELD · {e.balance.amount} )} {!e.balance && isTryQuote && ( @@ -710,27 +635,41 @@ function AssetListRow({ entry: e, onSelect, onUnavailable }: { TRY QUOTE )} - {!selectable && ( + {!selectable && !isTryQuote && ( UNAVAILABLE )} - {e.name} + {e.name} + {/* Full CAIP-19 */} + + {e.caip} + - + {/* Right: balance or routes */} + + {e.balance && ( + + {e.balance.amount} + + )} - - {e.availability.providers.length} {e.availability.providers.length === 1 ? "route" : "routes"} - + {e.availability.providers.length > 0 && ( + + {e.availability.providers.length} {e.availability.providers.length === 1 ? "route" : "routes"} + + )} ) } -// ── Unavailable route view ────────────────────────────────────────────────── +// ══════════════════════════════════════════════════════════════════════════ +// Unavailable route view +// ══════════════════════════════════════════════════════════════════════════ function UnavailableRouteView({ fromChainId, target, entries, onBack, onAltSelect }: { fromChainId: string | null @@ -741,23 +680,18 @@ function UnavailableRouteView({ fromChainId, target, entries, onBack, onAltSelec }) { const { t } = useTranslation("swap") const sym = target.symbol - const fromChainName = fromChainId ? networkDisplayName(fromChainId) : "?" const targetChainName = networkDisplayName(target.chainId) const alternatives = useMemo(() => - entries.filter(e => - e.caip !== target.caip && - e.symbol === sym && - isRowSelectable(e) - ).sort((a, b) => b.availability.providers.length - a.availability.providers.length), + entries.filter(e => e.caip !== target.caip && e.symbol === sym && isRowSelectable(e)) + .sort((a, b) => b.availability.providers.length - a.availability.providers.length), [entries, target, sym] ) return ( <> - {/* Breadcrumb */} - - + - - {fromChainId ? `${sym} (${fromChainName}) → ${target.symbol} (${targetChainName})` : `${target.symbol} (${targetChainName})`} + + {sym} on {targetChainName} isn't routable - - No swap provider currently routes to{" "} - {sym} on {targetChainName}.{" "} + {target.availability.status === "unsupported_token" ? `${targetChainName} natives swap fine, but this specific token isn't on any provider's list yet.` : `${targetChainName} isn't supported by any of our routers yet (THORChain, Mayachain, Relay, 0x, ChainFlip).`} - - - {t("notifyWhenSupported", "Notify me when supported")} - - + + {t("notifyWhenSupported", "Notify me when supported")} + {/* Alternatives */} {alternatives.length > 0 ? ( - + <> - {t("swapToOnOtherChain", `You can swap to ${sym} on another network`)} + Swap to {sym} on another network @@ -809,16 +739,15 @@ function UnavailableRouteView({ fromChainId, target, entries, onBack, onAltSelec const chainName = networkDisplayName(a.chainId) return ( onAltSelect(a)}> - - + + {a.symbol} {a.balance && ( @@ -827,41 +756,32 @@ function UnavailableRouteView({ fromChainId, target, entries, onBack, onAltSelec )} {a.name} · on {chainName} + {a.caip} + borderRadius="999px" flexShrink={0}> - {a.availability.providers.length} {a.availability.providers.length === 1 ? "route" : "routes"} + + {a.availability.providers.length} {a.availability.providers.length === 1 ? "route" : "routes"} + - - - + ) })} - + ) : ( - - - - - No alternative routes for {sym} - - - - No other supported network lists {sym}. Try a different destination asset, or enable notifications above. - + + No other supported network lists {sym}. Try a different destination asset. )} - {/* Footer */} + justify="space-between" align="center" flexShrink={0} bg="#101015"> We never sign anything that can't complete. (null) - const [loading, setLoading] = useState(false) - // TO-specific navigation + const [entries, setEntries] = useState(null) + const [loading, setLoading] = useState(false) const [toChain, setToChain] = useState(null) const [unavailEntry, setUnavailEntry] = useState(null) const [search, setSearch] = useState("") - // FROM: EVM contract paste - const [contractHits, setContractHits] = useState(null) - const [contractLooking, setContractLooking] = useState(false) - const [contractError, setContractError] = useState(null) - - // Extract FROM chain from excludeCaip (when side=to, excludeCaip is the FROM asset) + // FROM chain id (for NetSwitchBanner + excluding self): extracted from excludeCaip when side=to const fromChainId = side === "to" && excludeCaip ? excludeCaip.split("/")[0] : null - // Build entries on open + // Build entry list on open useEffect(() => { if (!open) return let cancelled = false @@ -926,13 +837,12 @@ export function AssetPickerDialog({ return () => { cancelled = true } }, [open, swappable, balances, customTokens]) - // Reset state on open/close + // Reset navigation on open/close useEffect(() => { - if (!open) { setToChain(null); setUnavailEntry(null); setSearch(""); return } - setToChain(null); setUnavailEntry(null); setSearch("") + if (open) { setToChain(null); setUnavailEntry(null); setSearch("") } }, [open]) - // Escape closes + // Escape to close useEffect(() => { if (!open) return const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") { e.preventDefault(); onClose() } } @@ -940,36 +850,11 @@ export function AssetPickerDialog({ return () => window.removeEventListener("keydown", onKey) }, [open, onClose]) - // EVM contract lookup (FROM side only) - useEffect(() => { - setContractHits(null); setContractError(null) - if (!open || side !== "from") return - const q = search.trim() - if (!EVM_CONTRACT_RE.test(q)) return - let cancelled = false - setContractLooking(true) - const timer = setTimeout(() => { - rpcRequest<{ hits: SwapAsset[]; reason?: string }>("lookupTokenContract", { contractAddress: q }, 12000) - .then(res => { - if (cancelled) return - setContractLooking(false) - if (res.hits?.length > 0) setContractHits(res.hits) - else setContractError(res.reason || "no-token-found") - }) - .catch(e => { - if (cancelled) return - setContractLooking(false) - setContractError(e?.message || "lookup-failed") - }) - }, 350) - return () => { cancelled = true; clearTimeout(timer) } - }, [open, side, search]) - - // Chain infos for TO chain step + // Chain infos for TO step 1 const chainInfos = useMemo(() => { if (!entries) return [] - return buildChainInfos(entries, fromChainId, excludeCaip) - }, [entries, fromChainId, excludeCaip]) + return buildChainInfos(entries, excludeCaip) + }, [entries, excludeCaip]) const handleSelect = useCallback((entry: AssetEntry) => { if (!isRowSelectable(entry)) return @@ -985,30 +870,20 @@ export function AssetPickerDialog({ if (!open) return null - // Title depends on side + TO step - let title = side === "from" - ? t("selectFromAsset", "Select asset to swap from") - : toChain - ? t("selectToAsset", "Select asset to swap to") - : t("chooseNetwork", "Choose destination network") - - let stepLabel = side === "from" - ? t("stepOne", "Step 1 of 2 — Pick what you're swapping") - : toChain - ? t("stepTwoAsset", `Step 2 of 2 — Pick an asset on ${networkDisplayName(toChain)}`) - : t("stepTwoChain", "Step 2 of 2 — Choose destination network") + // Title + const title = side === "from" ? "Select asset to swap from" + : unavailEntry ? "Route unavailable" + : toChain ? `Assets on ${networkDisplayName(toChain)}` + : "Select destination network" - // Footer text for TO side - const toFooter = toChain - ? `${entries?.filter(e => e.chainId === toChain).length ?? 0} assets on ${networkDisplayName(toChain)}` - : `${chainInfos.length} networks · ${swappable.length.toLocaleString()} swappable assets` + const stepLabel = side === "from" ? "Step 1 of 2 — pick what you're swapping" + : toChain ? `Step 2 of 2 — pick an asset on ${networkDisplayName(toChain)}` + : "Step 2 of 2 — choose destination network" return ( - + onClick={onClose}> {/* Header */} - + - {stepLabel} + + {stepLabel} + - {unavailEntry ? t("routeUnavailable", "That route isn't available — yet") : title} + {title} {/* Body */} - + {loading ? ( - {t("loading", "Loading…")} + Loading… ) : !entries ? null : side === "from" ? ( @@ -1063,41 +938,24 @@ export function AssetPickerDialog({ onAltSelect={(e) => { setUnavailEntry(null); handleSelect(e) }} /> ) : toChain ? ( - <> - { setToChain(null); setSearch("") }} - onSelect={handleSelect} - onUnavailable={setUnavailEntry} - /> - {/* Footer for asset step */} - - {toFooter} - Chain → Token - - + { setToChain(null); setSearch("") }} + onSelect={handleSelect} + onUnavailable={setUnavailEntry} + /> ) : ( - <> - { setToChain(caip2); setSearch("") }} - /> - {/* Footer for chain step */} - - {toFooter} - Pick a network to continue - - + { setToChain(caip2); setSearch("") }} + /> )} From 5efdd608708d1bd3b8ba1a5fe91f88e2c646446b Mon Sep 17 00:00:00 2001 From: highlander Date: Wed, 20 May 2026 19:03:46 -0300 Subject: [PATCH 04/15] feat: sidebar chain click drills inline instead of full-screen AssetPage Clicking a chain in the sidebar, orbital ring, donut chart, or donut legend now sets drilledChainId and shows tokens + action buttons in the dashboard center panel. The sidebar stays visible. Receive/Send/Swap actions still route to AssetPage. Also moved the ViewPicker button from TopNav to the dashboard hero area (top center). --- .../src/mainview/components/Dashboard.tsx | 15 ++++++++++----- .../src/mainview/components/TopNav.tsx | 4 +--- 2 files changed, 11 insertions(+), 8 deletions(-) diff --git a/projects/keepkey-vault/src/mainview/components/Dashboard.tsx b/projects/keepkey-vault/src/mainview/components/Dashboard.tsx index 539b6886..55127013 100644 --- a/projects/keepkey-vault/src/mainview/components/Dashboard.tsx +++ b/projects/keepkey-vault/src/mainview/components/Dashboard.tsx @@ -26,6 +26,7 @@ import { subscribeVaultCommand, publishBalances, clearBalances } from "../lib/co import { useIconColor } from "../lib/iconColor" import { preloadIcons } from "../lib/iconPreload" import { useDashboardView } from "../lib/dashboardViewContext" +import { ViewPickerButton } from "./ViewPickerMenu" import { categorizeTokens } from "../../shared/spamFilter" import type { ChainBalance, CustomChain, TokenVisibilityStatus, AppSettings, TokenBalance } from "../../shared/types" import { playChaChing } from "../lib/sounds" @@ -1249,7 +1250,7 @@ export function Dashboard({ onLoaded, watchOnly, watchOnlyDeviceId, onOpenSettin openChainPage(chain)} + onClick={() => setDrilledChainId(prev => prev === chain.id ? null : chain.id)} w="100%" textAlign="left" p="2.5" @@ -1544,6 +1545,10 @@ export function Dashboard({ onLoaded, watchOnly, watchOnlyDeviceId, onOpenSettin the sun and the donut center at the same y-coordinate regardless of how much below content is rendered. */} + {/* View picker — top center of the hero area */} + + + {/* Top: orbital widget / donut / welcome — vertically centered */} {hasAnyBalance ? (() => { @@ -1577,7 +1582,7 @@ export function Dashboard({ onLoaded, watchOnly, watchOnlyDeviceId, onOpenSettin totalDollars={totalDollars} totalCents={totalCents} cleanTokenTotal={cleanTokenTotal} - onSelect={(c) => setSelectedChain(c)} + onSelect={(c) => setDrilledChainId(c.id)} /> ) } @@ -1648,7 +1653,7 @@ export function Dashboard({ onLoaded, watchOnly, watchOnlyDeviceId, onOpenSettin const item = allChainsChartData[i] if (!item) return const chain = allChains.find(c => c.coin === (item as any).name) - if (chain) openChainPage(chain) + if (chain) setDrilledChainId(chain.id) }} /> ) @@ -1719,14 +1724,14 @@ export function Dashboard({ onLoaded, watchOnly, watchOnlyDeviceId, onOpenSettin const item = allChainsChartData[i] if (!item) return const chain = allChains.find(c => c.coin === (item as any).name) - if (chain) openChainPage(chain) + if (chain) setDrilledChainId(chain.id) }} /> ) })()} - {hasAnyBalance && viewMode === 'orbital' && drilledChainId && (() => { + {hasAnyBalance && drilledChainId && (() => { const dchain = visibleChains.find(c => c.id === drilledChainId) if (!dchain) return null const bal = balances.get(dchain.id) diff --git a/projects/keepkey-vault/src/mainview/components/TopNav.tsx b/projects/keepkey-vault/src/mainview/components/TopNav.tsx index 481dbb41..a4fbe880 100644 --- a/projects/keepkey-vault/src/mainview/components/TopNav.tsx +++ b/projects/keepkey-vault/src/mainview/components/TopNav.tsx @@ -4,7 +4,6 @@ import { Z } from "../lib/z-index" import { IS_WINDOWS, IS_MAC } from "../lib/platform" import { useWindowDrag } from "../hooks/useWindowDrag" import { rpcRequest } from "../lib/rpc" -import { ViewPickerButton } from "./ViewPickerMenu" import kkIcon from "../assets/icon.png" import { NAV_HEIGHT } from "../layout" @@ -346,9 +345,8 @@ export function TopNav({ })} - {/* Right: portfolio view picker (vault tab only) + walletconnect + mobile + settings */} + {/* Right: walletconnect + mobile + settings */} - {activeTab === "vault" && } {onWalletConnectToggle && ( Date: Wed, 20 May 2026 20:19:25 -0300 Subject: [PATCH 05/15] feat: token asset page, swap pre-selection, NEAR Intents ERC-20 fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit UX - Sidebar chain clicks drill inline (toggle drilledChainId) instead of opening full-screen AssetPage - Token AssetPage: token-specific header with icon, name, contract address + explorer link, CAIP, price per unit - Tokens section hidden when viewing a specific token - ViewPickerButton moved from TopNav to Dashboard top-center - Sidebar scrollbar: invisible by default, thin + semi-transparent on hover via .kk-sidebar-scroll class Swap pre-selection - AssetPage synthesizes SwapAsset from selectedToken and passes as initialFromAsset prop - SwapDialog uses initialFromAsset directly, bypassing Pioneer's 19-asset GetAvailableAssets list - Fixes USDC on Base (and any long-tail token) auto-selecting correctly as FROM asset NEAR Intents ERC-20 fixes - Normalize double 0x prefix on relay.to in parseQuoteResponse (Pioneer bug: "0x0x833589...") - Detect isDirectTransfer (relay.to === token contract) and skip approval flow — NEAR Intents uses direct transfer(), not transferFrom() via router - ERC-20 broadcast fallback: don't short-circuit to "not enough gas" on RPC insufficient-funds; fall through to Pioneer for ERC-20 relay txs where native balance is already verified sufficient --- .../pioneer-near-intents-erc20-double-0x.md | 99 ++++++ .../keepkey-vault/src/bun/swap-parsing.ts | 7 +- projects/keepkey-vault/src/bun/swap.ts | 17 +- .../src/mainview/components/AssetPage.tsx | 285 +++++++++++++----- .../mainview/components/AssetPickerDialog.tsx | 94 ++++-- .../mainview/components/CommandPalette.tsx | 39 ++- .../src/mainview/components/Dashboard.tsx | 13 - .../src/mainview/components/SwapDialog.tsx | 74 ++++- projects/keepkey-vault/src/mainview/index.css | 17 +- 9 files changed, 498 insertions(+), 147 deletions(-) create mode 100644 projects/keepkey-vault/docs/pioneer-near-intents-erc20-double-0x.md diff --git a/projects/keepkey-vault/docs/pioneer-near-intents-erc20-double-0x.md b/projects/keepkey-vault/docs/pioneer-near-intents-erc20-double-0x.md new file mode 100644 index 00000000..bdc1fcd5 --- /dev/null +++ b/projects/keepkey-vault/docs/pioneer-near-intents-erc20-double-0x.md @@ -0,0 +1,99 @@ +# Pioneer Bug: NEAR Intents ERC-20 — double `0x` prefix on `txParams.to` + +**Date**: 2026-05-20 +**Severity**: P1 — breaks all ERC-20 → * swaps via NEAR Intents (e.g. USDC on Base) +**Status**: Patched client-side in vault; Pioneer server needs a permanent fix. + +--- + +## Symptom + +User swaps USDC (Base) → anything via NEAR Intents. Vault shows: + +``` +Approval needed: 61.27821 USDC +Current allowance: 0 USDC · spender 0x0x833589…a02913 +``` + +Then after clicking Confirm: `invalid hexadecimal string`. + +## Root Cause + +Pioneer's quote endpoint for NEAR Intents ERC-20 sources returns: + +```json +{ + "txParams": { + "to": "0x0x833589fcd6edb6e08f4c7c32d4f71b54bda02913", + ... + } +} +``` + +The `to` field has a double `0x` prefix. The correct value is +`0x833589fcd6edb6e08f4c7c32d4f71b54bda02913` (the USDC contract on Base). + +## How NEAR Intents ERC-20 works (correct flow) + +For ERC-20 sources, NEAR Intents encodes a direct `transfer(solverAddress, amount)` +call on the token contract — **not** `transferFrom()` via a router. Therefore: + +- `txParams.to` = token contract address (the USDC contract) +- `txParams.data` = `transfer(solverAddress, amount)` calldata +- `txParams.value` = `"0"` (no ETH) +- **No ERC-20 approval needed** — the user signs one tx, not two + +The vault was misidentifying this as a `transferFrom()` pattern and generating a +spurious `approve(USDC_contract, amount)` tx with the token contract as its own +spender — which is semantically invalid and would revert. + +## Vault-side workaround (already merged) + +Two patches in `src/bun/`: + +### 1. `swap-parsing.ts` — strip duplicate `0x` + +```typescript +const normalizeAddr = (addr: string | undefined): string | undefined => + addr ? addr.replace(/^(0x)+/i, '0x') : addr + +relayTx = { + to: normalizeAddr(txParams.to) as string, + ... +} +``` + +### 2. `swap.ts` `buildRelaySwapTx()` — skip approval when `relay.to === tokenContract` + +```typescript +const isDirectTransfer = relay.to.toLowerCase() === tokenContract +if (isDirectTransfer) { + console.log(`[swap] Relay ERC-20: relay.to === token contract — direct transfer(), no approval needed`) +} +if (tokenContract && tokenContract.startsWith('0x') && !isDirectTransfer) { + // ... allowance check + approveTx generation +} +``` + +## Pioneer fix needed + +In pioneer-server's quote controller, wherever `txParams.to` is constructed for +NEAR Intents routes: ensure the address is emitted with exactly one `0x` prefix. + +Search path: `pioneer-server/src/` — look for the NEAR Intents integration handler +that builds `txParams`. The double prefix likely comes from concatenating a +pre-existing `"0x"` string with an already-prefixed address (e.g. +`"0x" + "0x833589..."` or similar). + +**Repro**: request a quote for `eip155:8453/erc20:0x833589fcd6edb6e08f4c7c32d4f71b54bda02913` +→ any asset via NEAR Intents and inspect `quote.txs[0].txParams.to` in the raw +response. + +## Validation after Pioneer fix + +1. `txParams.to` has exactly one `0x` prefix in the raw Pioneer response +2. Vault swap flow for USDC (Base) → ETH (or any chain) via NEAR Intents shows: + - **One** device confirmation (the transfer tx) — no spurious approval dialog + - Tx broadcasts successfully with `to = 0x833589...` (correct USDC contract) +3. Vault client-side `normalizeAddr` workaround stays in place as a defensive guard + (cheap and safe to keep even after the server fix) diff --git a/projects/keepkey-vault/src/bun/swap-parsing.ts b/projects/keepkey-vault/src/bun/swap-parsing.ts index d3df7dba..030587b8 100644 --- a/projects/keepkey-vault/src/bun/swap-parsing.ts +++ b/projects/keepkey-vault/src/bun/swap-parsing.ts @@ -153,8 +153,13 @@ export function parseQuoteResponse( let relayTx: RelayTxParams | undefined if (hasPrebuiltTx) { + // Pioneer occasionally returns addresses with a duplicate '0x' prefix (e.g. + // "0x0x833589..."). Strip all leading '0x' pairs and re-add exactly one. + // Affects NEAR Intents ERC-20 routes where txParams.to is the token contract. + const normalizeAddr = (addr: string | undefined): string | undefined => + addr ? addr.replace(/^(0x)+/i, '0x') : addr relayTx = { - to: txParams.to, + to: normalizeAddr(txParams.to) as string, data: rawData ?? '0x', value: String(txParams.value || '0'), // Leave gasLimit undefined when Pioneer omits it so buildRelaySwapTx diff --git a/projects/keepkey-vault/src/bun/swap.ts b/projects/keepkey-vault/src/bun/swap.ts index 2351d2c7..6da8ceba 100644 --- a/projects/keepkey-vault/src/bun/swap.ts +++ b/projects/keepkey-vault/src/bun/swap.ts @@ -662,8 +662,12 @@ export async function executeSwap(params: ExecuteSwapParams, ctx: SwapContext): txid = await broadcastEvmTx(swapRpcUrl, serializedHex) swapLog(`${TAG} Broadcast via direct RPC: ${txid}`) } catch (directErr: any) { - // Insufficient funds: Pioneer fallback will also fail — surface immediately - if (directErr.message?.toLowerCase().includes('insufficient funds')) { + // For native-asset swaps, "insufficient funds" from the RPC is definitive — + // Pioneer will also reject it. Surface immediately. + // For ERC-20 relay txs (e.g. NEAR Intents direct transfer), "insufficient funds" + // may come from calldata pre-simulation or a solver-side issue, not native gas — + // native balance was already verified in buildRelaySwapTx, so fall through to Pioneer. + if (!isErc20Source && directErr.message?.toLowerCase().includes('insufficient funds')) { throw new Error( `Insufficient ${fromChain.symbol} for gas on ${fromChain.id}. ` + `Add ${fromChain.symbol} to your wallet to pay for transaction fees and try again.` @@ -1053,7 +1057,14 @@ async function buildRelaySwapTx( // Token contract comes from the CAIP-19 (after the `:` of `/erc20:` or // `/bep20:`). CAIP is the only identifier — no separate contract param. const tokenContract = (extractContractFromCaip(params.fromCaip) || '').toLowerCase() - if (tokenContract && tokenContract.startsWith('0x')) { + // NEAR Intents ERC-20 path: relay.to IS the token contract — the calldata encodes + // a direct transfer() to a solver address, not transferFrom() via a router. + // No approval is needed or valid for this pattern. + const isDirectTransfer = relay.to.toLowerCase() === tokenContract + if (isDirectTransfer) { + console.log(`${TAG} Relay ERC-20: relay.to === token contract (${tokenContract}) — direct transfer(), no approval needed`) + } + if (tokenContract && tokenContract.startsWith('0x') && !isDirectTransfer) { try { const tokenDecimals = await getErc20Decimals(rpcUrl, tokenContract).catch(() => undefined) if (tokenDecimals !== undefined) { diff --git a/projects/keepkey-vault/src/mainview/components/AssetPage.tsx b/projects/keepkey-vault/src/mainview/components/AssetPage.tsx index 60e451a2..c35c4b79 100644 --- a/projects/keepkey-vault/src/mainview/components/AssetPage.tsx +++ b/projects/keepkey-vault/src/mainview/components/AssetPage.tsx @@ -5,7 +5,8 @@ import { FaArrowDown, FaArrowUp, FaExchangeAlt, FaPlus, FaEye, FaEyeSlash, FaShi import { rpcRequest, onRpcMessage } from "../lib/rpc" import type { ChainDef } from "../../shared/chains" import { CHAINS, BTC_SCRIPT_TYPES, btcAccountPath, isChainSupported } from "../../shared/chains" -import type { ChainBalance, TokenBalance, TokenVisibilityStatus, AppSettings } from "../../shared/types" +import type { ChainBalance, TokenBalance, TokenVisibilityStatus, AppSettings, SwapAsset } from "../../shared/types" +import { VAULT_CHAIN_TO_THOR } from "../../shared/swap-discovery" import { getAssetIcon, caipToIcon } from "../../shared/assetLookup" import { AnimatedUsd } from "./AnimatedUsd" import { formatBalance } from "../lib/formatting" @@ -411,11 +412,33 @@ export function AssetPage({ chain, balance, onBack, firmwareVersion, initialActi const zcashShieldedDef = CHAINS.find(c => c.id === 'zcash-shielded') const zcashShieldedSupported = isZcash && zcashShieldedDef && isChainSupported(zcashShieldedDef, firmwareVersion) + // Pre-build a SwapAsset from the selected token so SwapDialog can use it + // directly without needing to find it in Pioneer's limited GetAvailableAssets list. + const initialFromAsset = useMemo(() => { + if (!selectedToken || parseFloat(selectedToken.balance) <= 0) return undefined + const chainShort = VAULT_CHAIN_TO_THOR[chain.id] ?? chain.coin.toUpperCase() + const contract = selectedToken.contractAddress + const thorAsset = contract + ? `${chainShort}.${selectedToken.symbol}-${contract.toUpperCase()}` + : `${chainShort}.${selectedToken.symbol}` + return { + asset: thorAsset, + chainId: chain.id, + symbol: selectedToken.symbol, + name: selectedToken.name, + chainFamily: chain.chainFamily, + decimals: selectedToken.decimals ?? 18, + caip: selectedToken.caip, + icon: selectedToken.icon, + contractAddress: selectedToken.contractAddress, + } + }, [selectedToken, chain]) + const PILLS: { id: AssetView | 'swap'; label: string; icon: typeof FaArrowDown }[] = [ - { id: "receive", label: t("receive"), icon: FaArrowDown }, + ...(!selectedToken ? [{ id: "receive" as const, label: t("receive"), icon: FaArrowDown }] : []), { id: "send", label: t("send"), icon: FaArrowUp }, ...(swapsEnabled && swappableChainIds.has(chain.id) ? [{ id: "swap" as const, label: t("swap"), icon: FaExchangeAlt }] : []), - ...(zcashPrivacyEnabled && zcashShieldedSupported ? [{ id: "privacy" as const, label: t("privacy"), icon: FaShieldAlt }] : []), + ...(!selectedToken && zcashPrivacyEnabled && zcashShieldedSupported ? [{ id: "privacy" as const, label: t("privacy"), icon: FaShieldAlt }] : []), ] // Shared token row renderer @@ -583,7 +606,7 @@ export function AssetPage({ chain, balance, onBack, firmwareVersion, initialActi { setSelectedToken(null); setView('receive') } : onBack} w="36px" h="36px" borderRadius="10px" @@ -604,77 +627,175 @@ export function AssetPage({ chain, balance, onBack, firmwareVersion, initialActi - {chain.symbol} - - - - - {chain.coin} - - {chain.symbol} - - - {chain.caip} - - - - - - {/* Sync status indicator */} - {activeBalance ? ( - - - {t("synced")} - + {selectedToken ? ( + <> + {selectedToken.symbol} + + + + {selectedToken.name || selectedToken.symbol} + + {selectedToken.symbol} + + + + + on {chain.coin} + + {selectedToken.contractAddress && ( + { + navigator.clipboard.writeText(selectedToken.contractAddress!) + setCopiedCaip(selectedToken.caip) + setTimeout(() => setCopiedCaip(c => c === selectedToken.caip ? null : c), 1500) + }} + > + + {selectedToken.contractAddress.slice(0, 6)}…{selectedToken.contractAddress.slice(-4)} + + + + )} + {selectedToken.contractAddress && chain.explorerAddressUrl && ( + rpcRequest("openUrl", { url: chain.explorerAddressUrl!.replace('{{address}}', selectedToken.contractAddress!) }).catch(() => {})} + > + + + + + )} + + + ) : ( - - - {t("outOfSync")} - + <> + {chain.symbol} + + + + {chain.coin} + + {chain.symbol} + + + + {chain.caip} + + {address && chain.explorerAddressUrl && ( + rpcRequest("openUrl", { url: chain.explorerAddressUrl!.replace('{{address}}', address) }).catch(() => {})} + > + + + + + )} + + + )} - {/* Balance display (only when available) */} - {activeBalance && ( - - - {activeBalance.balance} - {chain.symbol} + + + {selectedToken ? ( + + + {formatBalance(selectedToken.balance)} + {selectedToken.symbol} + + {selectedToken.balanceUsd > 0 && ( + ≈ {fmtCompact(selectedToken.balanceUsd)} + )} + {selectedToken.priceUsd > 0 && ( + + ${selectedToken.priceUsd.toLocaleString('en-US', { maximumFractionDigits: 6 })} / {selectedToken.symbol} - {cleanBalanceUsd > 0 && ( - + ) : ( + + {/* Sync status indicator */} + {activeBalance ? ( + + + {t("synced")} + + ) : ( + + + {t("outOfSync")} + + )} + {/* Balance display (only when available) */} + {activeBalance && ( + + - )} - - )} - + fontSize={{ base: "16px", md: "20px" }} + fontWeight="500" + color="var(--text-0)" + letterSpacing="0.01em" + lineHeight="1.2" + > + {activeBalance.balance} + {chain.symbol} + + {cleanBalanceUsd > 0 && ( + + )} + + )} + + )} {/* Mobile-only balance row */} - {activeBalance && ( + {selectedToken ? ( + + + {formatBalance(selectedToken.balance)} + {selectedToken.symbol} + + {selectedToken.balanceUsd > 0 && ( + ≈ {fmtCompact(selectedToken.balanceUsd)} + )} + + ) : activeBalance && ( {activeBalance.balance} @@ -859,8 +990,8 @@ export function AssetPage({ chain, balance, onBack, firmwareVersion, initialActi )} - {/* Tokens Section — with spam filter */} - {(tokens.length > 0 || isEvmChain) && ( + {/* Tokens Section — with spam filter. Hidden when viewing a specific token. */} + {!selectedToken && (tokens.length > 0 || isEvmChain) && ( @@ -1011,6 +1142,12 @@ export function AssetPage({ chain, balance, onBack, firmwareVersion, initialActi } : activeBalance} address={address} onOutputAssetChange={setSwapOutputChainId} + initialFromAsset={initialFromAsset} + initialFromCaip={ + selectedToken && parseFloat(selectedToken.balance) > 0 + ? selectedToken.caip + : chain.caip + } /> diff --git a/projects/keepkey-vault/src/mainview/components/AssetPickerDialog.tsx b/projects/keepkey-vault/src/mainview/components/AssetPickerDialog.tsx index 4cffb3ad..7d9e113b 100644 --- a/projects/keepkey-vault/src/mainview/components/AssetPickerDialog.tsx +++ b/projects/keepkey-vault/src/mainview/components/AssetPickerDialog.tsx @@ -229,7 +229,7 @@ function FromPicker({ entries, onSelect, fmtCompact }: { Available to swap - + {totalUsd > 0 ? fmtCompact(totalUsd) : "—"} @@ -281,14 +281,15 @@ function HeldTile({ entry: e, onSelect, fmtCompact }: { }) { const chainName = networkDisplayName(e.chainId) const selectable = isRowSelectable(e) + const color = chainColorForCaip2(e.chainId) return ( selectable && onSelect(e)} > + {/* Chain color top stripe */} + + {/* Icon — 64px */} {/* Bottom info */} - {e.symbol} + {e.symbol} {chainName} - + {e.balance!.amount} - {fmtCompact(e.balance!.usd)} + {fmtCompact(e.balance!.usd)} {/* Full CAIP */} {e.caip} @@ -369,19 +374,37 @@ function buildChainInfos(entries: AssetEntry[], excludeCaip: string | undefined) }).sort((a, b) => b.routableCount - a.routableCount) // most assets first } -function ChainStep({ chainInfos, search, onSearchChange, onPickChain }: { +function ChainStep({ chainInfos, entries, search, onSearchChange, onPickChain, onSelectAsset, onUnavailAsset }: { chainInfos: ChainInfo[] + entries: AssetEntry[] search: string onSearchChange: (s: string) => void onPickChain: (caip2: string) => void + onSelectAsset: (e: AssetEntry) => void + onUnavailAsset: (e: AssetEntry) => void }) { const q = search.trim().toLowerCase() const available = chainInfos.filter(c => c.isAvailable && (!q || c.name.toLowerCase().includes(q) || c.family.toLowerCase().includes(q))) const unavailable = chainInfos.filter(c => !c.isAvailable && (!q || c.name.toLowerCase().includes(q) || c.family.toLowerCase().includes(q))) + // When no networks match the query, fall back to token search + const noNetworkMatches = q.length > 0 && available.length === 0 && unavailable.length === 0 + const tokenFallback = useMemo(() => { + if (!noNetworkMatches) return [] + return entries + .filter(e => `${e.symbol} ${e.name}`.toLowerCase().includes(q)) + .sort((a, b) => { + const aSel = isRowSelectable(a) ? 1 : 0 + const bSel = isRowSelectable(b) ? 1 : 0 + if (aSel !== bSel) return bSel - aSel + return (b.balance?.usd ?? 0) - (a.balance?.usd ?? 0) + }) + .slice(0, 30) + }, [noNetworkMatches, entries, q]) + return ( <> - + {/* Supported networks */} @@ -416,9 +439,27 @@ function ChainStep({ chainInfos, search, onSearchChange, onPickChain }: { )} - {available.length + unavailable.length === 0 && ( + {/* Token fallback — shown when no networks match but tokens do */} + {noNetworkMatches && tokenFallback.length > 0 && ( + <> + + + + Token results + + · {tokenFallback.length} + + + {tokenFallback.map(e => ( + + ))} + + + )} + + {noNetworkMatches && tokenFallback.length === 0 && ( - No matching networks + No matching networks or tokens Try a different search term. )} @@ -435,20 +476,26 @@ function NetworkTile({ chain: c, onPick, unavail }: { as="button" textAlign="left" fontFamily="inherit" w="100%" aspectRatio="1" display="flex" flexDirection="column" justifyContent="space-between" - bg="rgba(255,255,255,0.03)" border="1px solid" borderColor="rgba(255,255,255,0.07)" + bg={unavail ? "rgba(255,255,255,0.02)" : `${c.color}0e`} + border="1px solid" borderColor={unavail ? "rgba(255,255,255,0.06)" : `${c.color}28`} borderRadius="16px" p="3.5" + position="relative" overflow="hidden" cursor={unavail ? "not-allowed" : "pointer"} opacity={unavail ? 0.42 : 1} color="kk.textPrimary" transition="all 0.15s" _hover={unavail ? {} : { - bg: "rgba(255,255,255,0.06)", - borderColor: "rgba(255,255,255,0.14)", + bg: `${c.color}18`, + borderColor: `${c.color}55`, transform: "translateY(-2px)", - boxShadow: "0 12px 24px -12px rgba(0,0,0,0.5)", + boxShadow: `0 12px 24px -12px ${c.color}40`, }} onClick={() => !unavail && onPick(c.caip2)} > + {/* Chain color top stripe */} + + {/* Chain logo — 44px */} {c.nativeCaip ? @@ -456,11 +503,11 @@ function NetworkTile({ chain: c, onPick, unavail }: { {/* Bottom info */} - {c.name} + {c.name} {c.family} - + {unavail ? "No route" : `${c.routableCount} swappable`} {/* CAIP-2 */} @@ -622,7 +669,7 @@ function AssetListRow({ entry: e, onSelect, onUnavailable }: { {/* Info */} - {e.symbol} + {e.symbol} {e.balance && ( @@ -642,7 +689,7 @@ function AssetListRow({ entry: e, onSelect, onUnavailable }: { )} - {e.name} + {e.name} {/* Full CAIP-19 */} {e.caip} @@ -908,7 +955,7 @@ export function AssetPickerDialog({ {stepLabel} - + {title} @@ -952,9 +999,12 @@ export function AssetPickerDialog({ ) : ( { setToChain(caip2); setSearch("") }} + onSelectAsset={handleSelect} + onUnavailAsset={setUnavailEntry} /> )} diff --git a/projects/keepkey-vault/src/mainview/components/CommandPalette.tsx b/projects/keepkey-vault/src/mainview/components/CommandPalette.tsx index 2d0e7f86..d44c4349 100644 --- a/projects/keepkey-vault/src/mainview/components/CommandPalette.tsx +++ b/projects/keepkey-vault/src/mainview/components/CommandPalette.tsx @@ -161,9 +161,8 @@ export function CommandPalette({ open, onClose, onJumpToVault, balances }: Comma aria-label="Command Palette" > e.stopPropagation()} - style={{ fontFamily: "var(--font-mono, ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace)" }} + style={{ fontFamily: "var(--font-mono, ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace)", color: "#f5f4ef" }} > {/* Search row */} - {/* CAIP-2 */} - + {c.caip2} @@ -535,7 +532,6 @@ function AssetStep({ entries, chainCaip2, fromChainId, excludeCaip, search, onSe onSelect: (e: AssetEntry) => void onUnavailable: (e: AssetEntry) => void }) { - const { t } = useTranslation("swap") const chainName = networkDisplayName(chainCaip2) const [page, setPage] = useState(0) const q = search.trim().toLowerCase() @@ -546,7 +542,12 @@ function AssetStep({ entries, chainCaip2, fromChainId, excludeCaip, search, onSe const inChain = useMemo(() => entries.filter(e => { if (e.chainId !== chainCaip2) return false if (e.caip === excludeCaip) return false - if (q && !`${e.symbol} ${e.name}`.toLowerCase().includes(q)) return false + if (q) { + const text = `${e.symbol} ${e.name}`.toLowerCase() + const caipLower = (e.caip || '').toLowerCase() + const contract = ((e as any).contractAddress || '').toLowerCase() + if (!text.includes(q) && !caipLower.includes(q) && !contract.includes(q)) return false + } return true // Sort: held first (by USD), then selectable, then unavailable }).sort((a, b) => { @@ -691,7 +692,7 @@ function AssetListRow({ entry: e, onSelect, onUnavailable }: { {e.name} {/* Full CAIP-19 */} - + {e.caip} @@ -764,11 +765,11 @@ function UnavailableRouteView({ fromChainId, target, entries, onBack, onAltSelec ? `${targetChainName} natives swap fine, but this specific token isn't on any provider's list yet.` : `${targetChainName} isn't supported by any of our routers yet (THORChain, Mayachain, Relay, 0x, ChainFlip).`} - - {t("notifyWhenSupported", "Notify me when supported")} + opacity={0.5} cursor="not-allowed" title="Notifications coming soon"> + Notify me when supported @@ -803,7 +804,7 @@ function UnavailableRouteView({ fromChainId, target, entries, onBack, onAltSelec )} {a.name} · on {chainName} - {a.caip} + {a.caip} Date: Wed, 20 May 2026 21:35:15 -0300 Subject: [PATCH 07/15] fix: EIP-1559 signing broken on Base/Arbitrum/Avalanche (chainId >= 256) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause: firmware ethereum.c hashed only the LSB of chainId for EIP-1559 transactions (`hash_rlp_field((uint8_t*)&chain_id, 1)`), while the RLP length calculation used the full multi-byte size. On little-endian ARM, chain_id=8453 (0x2105) hashed as 0x05 instead of 0x82 0x21 0x05, producing a different keccak pre-image — signature recovered to a random address with 0 ETH. Chains with chainId <= 255 (ETH=1, OP=10, BSC=56, Polygon=137) were coincidentally correct because their full RLP encoding fit in 1 byte. Affected: Base (8453), Arbitrum (42161), Avalanche (43114). Firmware fix: replace hash_rlp_field((uint8_t*)&chain_id, sizeof(uint8_t)) with hash_rlp_number(chain_id) at ethereum.c:865 — the legacy EIP-155 path already used hash_rlp_number correctly. Vault workaround (swap.ts): force legacy gasPrice for chainId >= 256 in both buildRelaySwapTx and buildEvmSwapTx so transactions use the correctly- implemented EIP-155 signing path until users update firmware. --- modules/keepkey-firmware | 2 +- projects/keepkey-vault/src/bun/swap.ts | 26 +++++++++++++++++++++----- 2 files changed, 22 insertions(+), 6 deletions(-) diff --git a/modules/keepkey-firmware b/modules/keepkey-firmware index 11d97d40..51a63ee7 160000 --- a/modules/keepkey-firmware +++ b/modules/keepkey-firmware @@ -1 +1 @@ -Subproject commit 11d97d4015b9c0fd96778d9954be3756831ef987 +Subproject commit 51a63ee7dce3d43a3c091c95ee885312d92fd542 diff --git a/projects/keepkey-vault/src/bun/swap.ts b/projects/keepkey-vault/src/bun/swap.ts index f60b1082..827d712e 100644 --- a/projects/keepkey-vault/src/bun/swap.ts +++ b/projects/keepkey-vault/src/bun/swap.ts @@ -905,11 +905,19 @@ async function buildRelaySwapTx( console.log(`${TAG} relay gasLimit: provided=${relay.gasLimit} resolved=${gasLimit} chain=${fromChain.id}`) const fallbackGwei = MIN_GAS_GWEI[fromChain.id] ?? 10 const fallbackGasPrice = BigInt(Math.round(fallbackGwei * 1e9)) + // Firmware EIP-1559 signing is broken for chainId >= 256: the firmware hashes + // only the LSB of chainId instead of the full big-endian RLP encoding, so the + // signature recovers to a wrong address on Base (8453), Arbitrum (42161), and + // Avalanche (43114). Chains with chainId <= 255 (ETH=1, OP=10, BSC=56, Polygon=137) + // happen to be correct. Force legacy gasPrice for affected chains until the + // firmware bug is fixed in ethereum.c (hash_rlp_number vs hash_rlp_field). + const eip1559Ok = chainId < 256 + let gasPrice: string | undefined let maxFeePerGas: string | undefined let maxPriorityFeePerGas: string | undefined - if (relay.maxFeePerGas) { + if (relay.maxFeePerGas && eip1559Ok) { // EIP-1559 tx — start from Relay's quote, but cross-check against our own // locally-computed buffer (nextBaseFee * 3 + 1.5 gwei priority floor). Relay // can ship a maxFeePerGas that was current at quote time but stale by @@ -940,7 +948,8 @@ async function buildRelaySwapTx( } else if (rpcUrl) { // Quote shipped only legacy gasPrice (or nothing) — prefer EIP-1559 from RPC // since legacy gasPrice on EIP-1559 chains often comes back below base fee. - const feeData = await getEvmFeeData(rpcUrl) + // Skip EIP-1559 for chainId >= 256 (firmware signing bug, see eip1559Ok above). + const feeData = eip1559Ok ? await getEvmFeeData(rpcUrl) : null if (feeData) { // Floor maxFeePerGas at 2x chain min (so we still beat base on quiet chains) const floor1559 = fallbackGasPrice * 2n @@ -990,7 +999,7 @@ async function buildRelaySwapTx( // estimated caps that block valid swaps on tight balances. If the relay's cap proves // insufficient for inclusion, the tx will be mined anyway at priority-fee level once // the base fee drops — or the user can refresh the quote to get a fresh cap. - const signedFeePerGas: bigint = relay.maxFeePerGas + const signedFeePerGas: bigint = (relay.maxFeePerGas && eip1559Ok) ? BigInt(relay.maxFeePerGas) : BigInt(relayFeePerGas) // no quoted fee → fall back to live (gasPrice path) const signedPrioFeePerGas: bigint = relay.maxPriorityFeePerGas @@ -1138,7 +1147,8 @@ async function buildRelaySwapTx( // EIP-1559 fields — use signedFeePerGas (relay's quoted cap) so the signed tx // matches the balance check above. Using the live-bumped cap here while checking // against the quoted cap would allow a tx that the account cannot cover. - if (relay.maxFeePerGas) { + // Skip EIP-1559 for chainId >= 256 (firmware signing bug, see eip1559Ok above). + if (relay.maxFeePerGas && eip1559Ok) { unsignedTx.maxFeePerGas = toHex(signedFeePerGas) unsignedTx.maxPriorityFeePerGas = toHex(signedPrioFeePerGas) } else if (gasPrice) { @@ -1227,6 +1237,12 @@ async function buildEvmSwapTx( // Fetch gas price (preferring EIP-1559), nonce, native balance. // EIP-1559 path: maxFeePerGas + maxPriorityFeePerGas, used on chains that support eth_feeHistory. // Legacy path: gasPrice, used as fallback. Both paths enforce a chain-specific floor. + // + // Firmware EIP-1559 signing bug: chainId >= 256 hashes only the LSB of chainId. + // Force legacy gasPrice for Base (8453), Arbitrum (42161), Avalanche (43114), etc. + // See buildRelaySwapTx comment and ethereum.c for details. + const eip1559Ok = chainId < 256 + const fallbackGwei = MIN_GAS_GWEI[fromChain.id] ?? 10 const fallbackGasPrice = BigInt(Math.round(fallbackGwei * 1e9)) let gasPrice: bigint = fallbackGasPrice @@ -1234,7 +1250,7 @@ async function buildEvmSwapTx( let maxPriorityFeePerGas: bigint | undefined if (rpcUrl) { - const feeData = await getEvmFeeData(rpcUrl) + const feeData = eip1559Ok ? await getEvmFeeData(rpcUrl) : null if (feeData) { const floor1559 = fallbackGasPrice * 2n maxFeePerGas = feeData.maxFeePerGas > floor1559 ? feeData.maxFeePerGas : floor1559 From 7d5ee57f5a3a44fb38bff4c736554ee821f29142 Mon Sep 17 00:00:00 2001 From: highlander Date: Thu, 21 May 2026 20:59:14 -0300 Subject: [PATCH 08/15] chore(deps): bump hdwallet submodule to include HID PID 0x0002 fix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Picks up keepkey/hdwallet#43 — nodehid adapter now accepts PID 0x0002 (firmware 7.x) so HID fallback works when WebUSB is OS-blocked. Co-Authored-By: Claude Sonnet 4.6 --- modules/hdwallet | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/hdwallet b/modules/hdwallet index d83a65c3..0a9c5f80 160000 --- a/modules/hdwallet +++ b/modules/hdwallet @@ -1 +1 @@ -Subproject commit d83a65c3cac22dc66d641897d6964effbed441dc +Subproject commit 0a9c5f809051a249ced3fc840da21af33714a95e From 3716cf0b6806dc41bde226b59a8f060a84847d58 Mon Sep 17 00:00:00 2001 From: highlander Date: Fri, 22 May 2026 11:20:15 -0300 Subject: [PATCH 09/15] fix: filter swap assets and cached balances by firmware version getSwapAssets, getSwappableChainIds, and getCachedBalances all returned Solana/TRON/TON data regardless of connected device firmware. On firmware 7.10, stale DB cache from a prior 7.14+ session could populate the swap FROM picker with those chains, and the TO picker showed them as available destinations. Both paths ultimately hit device signing errors or confusion. Now all three RPC handlers apply isChainSupported(chain, fwVersion) before returning to the frontend, so unsupported chains are silently excluded. --- projects/keepkey-vault/src/bun/index.ts | 51 ++++++++++++++-- .../src/mainview/components/AnimatedUsd.tsx | 7 ++- .../src/mainview/components/AssetPage.tsx | 9 ++- .../mainview/components/AssetPickerDialog.tsx | 6 +- .../src/mainview/components/Dashboard.tsx | 61 ++++++++++++------- .../components/DeviceSettingsDrawer.tsx | 38 +++++++++++- .../src/mainview/lib/fiat-context.tsx | 20 +++++- .../keepkey-vault/src/shared/rpc-schema.ts | 1 + projects/keepkey-vault/src/shared/types.ts | 1 + 9 files changed, 157 insertions(+), 37 deletions(-) diff --git a/projects/keepkey-vault/src/bun/index.ts b/projects/keepkey-vault/src/bun/index.ts index 97f32928..68a9566b 100644 --- a/projects/keepkey-vault/src/bun/index.ts +++ b/projects/keepkey-vault/src/bun/index.ts @@ -427,6 +427,7 @@ let zcashBackgroundVerifyInFlight = false let emulatorEnabled = false let preReleaseUpdates = false let alphaFirmware = false +let privateModeEnabled = false function loadSettings() { restApiEnabled = getSetting('rest_api_enabled') === '1' @@ -437,6 +438,7 @@ function loadSettings() { emulatorEnabled = getSetting('emulator_enabled') === '1' preReleaseUpdates = getSetting('pre_release_updates') === '1' alphaFirmware = getSetting('alpha_firmware') === '1' + privateModeEnabled = getSetting('private_mode_enabled') === '1' // Normalize emulator flag on non-macOS. The kkemu dylibs + Keychain pairing // only work on darwin, and the Settings toggle is hidden on other platforms @@ -735,6 +737,7 @@ function getAppSettings() { emulatorEnabled, preReleaseUpdates, alphaFirmware, + privateModeEnabled, } } @@ -3742,6 +3745,12 @@ const rpc = BrowserView.defineRPC({ engine.syncState().catch(e => console.warn('[settings] syncState after alpha toggle failed:', e)) return getAppSettings() }, + setPrivateModeEnabled: async (params) => { + privateModeEnabled = params.enabled + setSetting('private_mode_enabled', params.enabled ? '1' : '0') + console.log('[settings] Private mode:', params.enabled) + return getAppSettings() + }, // ── Factory Reset ───────────────────────────────────────── factoryReset: async () => { console.log('[factory-reset] Starting full app reset...') @@ -4021,14 +4030,26 @@ const rpc = BrowserView.defineRPC({ if (!swapsEnabled) return [] const { getSwapAssets } = await import('./swap') const assets = await getSwapAssets() - // Deduplicate: return unique chain IDs that have at least one native (non-token) asset - const chainIds = new Set(assets.filter(a => !a.contractAddress).map(a => a.chainId)) + const fw = engine.getDeviceState().firmwareVersion + const chainMap = new Map(getAllChains().map(c => [c.id, c])) + const chainIds = new Set( + assets + .filter(a => !a.contractAddress) + .filter(a => { const c = chainMap.get(a.chainId); return c ? isChainSupported(c, fw) : false }) + .map(a => a.chainId) + ) return [...chainIds] }, getSwapAssets: async () => { if (!swapsEnabled) return [] const { getSwapAssets } = await import('./swap') - return await getSwapAssets() + const assets = await getSwapAssets() + const fw = engine.getDeviceState().firmwareVersion + const chainMap = new Map(getAllChains().map(c => [c.id, c])) + return assets.filter(a => { + const chain = chainMap.get(a.chainId) + return chain ? isChainSupported(chain, fw) : false + }) }, getSwapHealth: async () => { @@ -4558,7 +4579,15 @@ const rpc = BrowserView.defineRPC({ console.log('[cache-health] Cache OK — no staleness detected') } - return { balances: result.balances, updatedAt: result.updatedAt, staleReasons: staleReasons.length > 0 ? staleReasons : undefined } + // Strip balances for chains the current firmware doesn't support. + // Stale cache from a prior 7.14+ session can contain Solana/TRON/TON + // entries — without this filter they bleed into the swap FROM picker, + // letting the user select them and then hitting a device signing error. + const filteredBalances = result.balances.filter(b => { + const chain = getAllChains().find(c => c.id === b.chainId) + return chain ? isChainSupported(chain, fwVersion) : true // keep unknowns (tokens) + }) + return { balances: filteredBalances, updatedAt: result.updatedAt, staleReasons: staleReasons.length > 0 ? staleReasons : undefined } }, // ── Watch-only mode ───────────────────────────────────── @@ -5353,6 +5382,8 @@ engine.on('state-change', (state) => { } } if (state.state === 'ready' && !pioneerSocket) { + // Debounce Pioneer push events per chain — rapid-fire cache pings coalesce into one refresh. + const pioneerEventDebounce = new Map>() pioneerSocket = new PioneerSocket({ queryKey: getPioneerQueryKey(), onEvent: (event, data) => { @@ -5362,8 +5393,16 @@ engine.on('state-change', (state) => { const chain = d?.chain ?? d?.symbol ?? undefined const address = d?.address ?? undefined const txid = d?.txid ?? d?.tx?.txid ?? undefined - console.log(`[PioneerSocket] push event '${event}' chain=${chain} → triggering forceRefresh`) - try { rpc.send['tx-push-received']({ chain, address, txid }) } catch { /* webview not ready */ } + // Only forward events with a known chain — chain-less cache pings can't be scoped + if (!chain) return + const key = chain + const existing = pioneerEventDebounce.get(key) + if (existing) clearTimeout(existing) + pioneerEventDebounce.set(key, setTimeout(() => { + pioneerEventDebounce.delete(key) + console.log(`[PioneerSocket] push event '${event}' chain=${chain} → forwarding`) + try { rpc.send['tx-push-received']({ chain, address, txid }) } catch { /* webview not ready */ } + }, 2000)) }, onConnect: () => console.log('[PioneerSocket] connected to Pioneer'), onDisconnect: () => console.log('[PioneerSocket] disconnected from Pioneer'), diff --git a/projects/keepkey-vault/src/mainview/components/AnimatedUsd.tsx b/projects/keepkey-vault/src/mainview/components/AnimatedUsd.tsx index b043685e..4bceb738 100644 --- a/projects/keepkey-vault/src/mainview/components/AnimatedUsd.tsx +++ b/projects/keepkey-vault/src/mainview/components/AnimatedUsd.tsx @@ -15,9 +15,11 @@ interface AnimatedUsdProps extends TextProps { decimals?: number } +const PRIVATE_MASK = "••••••" + /** Animated fiat counter. Delegates all number+symbol formatting to Intl.NumberFormat. */ export function AnimatedUsd({ value, prefix = "", suffix = "", duration = 1.5, decimals, color = "#23DCC8", ...textProps }: AnimatedUsdProps) { - const { currency, locale } = useFiat() + const { currency, locale, privateModeEnabled } = useFiat() const cfg = getFiatConfig(currency) const dec = decimals ?? cfg.decimals @@ -41,6 +43,9 @@ export function AnimatedUsd({ value, prefix = "", suffix = "", duration = 1.5, d return `${prefix}${formatted}${suffix}` }, [formatter, cfg.symbol, dec, prefix, suffix]) + if (privateModeEnabled) { + return {PRIVATE_MASK} + } if (!isFinite(value) || value <= 0) { return {formatValue(0)} } diff --git a/projects/keepkey-vault/src/mainview/components/AssetPage.tsx b/projects/keepkey-vault/src/mainview/components/AssetPage.tsx index c35c4b79..ed8a49ef 100644 --- a/projects/keepkey-vault/src/mainview/components/AssetPage.tsx +++ b/projects/keepkey-vault/src/mainview/components/AssetPage.tsx @@ -350,11 +350,10 @@ export function AssetPage({ chain, balance, onBack, firmwareVersion, initialActi const [swapOutputChainId, setSwapOutputChainId] = useState(null) useEffect(() => { return onRpcMessage("tx-push-received", (payload: { chain?: string }) => { - if (payload.chain) { - const matches = payload.chain.includes(chain.id) || payload.chain === chain.symbol - const matchesOutput = swapOutputChainId ? payload.chain.includes(swapOutputChainId) : false - if (!matches && !matchesOutput) return - } + if (!payload.chain) return // chain-less cache pings (balance:cache:update) are not actionable + const matches = payload.chain.includes(chain.id) || payload.chain === chain.symbol + const matchesOutput = swapOutputChainId ? payload.chain.includes(swapOutputChainId) : false + if (!matches && !matchesOutput) return handleRefresh() }) }, [handleRefresh, chain.id, chain.symbol, swapOutputChainId]) diff --git a/projects/keepkey-vault/src/mainview/components/AssetPickerDialog.tsx b/projects/keepkey-vault/src/mainview/components/AssetPickerDialog.tsx index 5dcafe23..813eac6c 100644 --- a/projects/keepkey-vault/src/mainview/components/AssetPickerDialog.tsx +++ b/projects/keepkey-vault/src/mainview/components/AssetPickerDialog.tsx @@ -371,7 +371,7 @@ function buildChainInfos(entries: AssetEntry[], excludeCaip: string | undefined) }).sort((a, b) => b.routableCount - a.routableCount) // most assets first } -function ChainStep({ chainInfos, entries, search, onSearchChange, onPickChain, onSelectAsset, onUnavailAsset }: { +function ChainStep({ chainInfos, entries, search, onSearchChange, onPickChain, onSelectAsset, onUnavailAsset, excludeCaip }: { chainInfos: ChainInfo[] entries: AssetEntry[] search: string @@ -379,6 +379,7 @@ function ChainStep({ chainInfos, entries, search, onSearchChange, onPickChain, o onPickChain: (caip2: string) => void onSelectAsset: (e: AssetEntry) => void onUnavailAsset: (e: AssetEntry) => void + excludeCaip?: string }) { const q = search.trim().toLowerCase() const available = chainInfos.filter(c => c.isAvailable && (!q || c.name.toLowerCase().includes(q) || c.family.toLowerCase().includes(q))) @@ -397,7 +398,7 @@ function ChainStep({ chainInfos, entries, search, onSearchChange, onPickChain, o return (b.balance?.usd ?? 0) - (a.balance?.usd ?? 0) }) .slice(0, 30) - }, [noNetworkMatches, entries, q]) + }, [noNetworkMatches, entries, q, excludeCaip]) return ( <> @@ -1006,6 +1007,7 @@ export function AssetPickerDialog({ onPickChain={(caip2) => { setToChain(caip2); setSearch("") }} onSelectAsset={handleSelect} onUnavailAsset={setUnavailEntry} + excludeCaip={excludeCaip} /> )} diff --git a/projects/keepkey-vault/src/mainview/components/Dashboard.tsx b/projects/keepkey-vault/src/mainview/components/Dashboard.tsx index f59839c3..1397daea 100644 --- a/projects/keepkey-vault/src/mainview/components/Dashboard.tsx +++ b/projects/keepkey-vault/src/mainview/components/Dashboard.tsx @@ -26,6 +26,7 @@ import { subscribeVaultCommand, publishBalances, clearBalances } from "../lib/co import { useIconColor } from "../lib/iconColor" import { preloadIcons } from "../lib/iconPreload" import { useDashboardView } from "../lib/dashboardViewContext" +import { useFiat } from "../lib/fiat-context" import { ViewPickerButton } from "./ViewPickerMenu" import { categorizeTokens } from "../../shared/spamFilter" import type { ChainBalance, CustomChain, TokenVisibilityStatus, AppSettings, TokenBalance } from "../../shared/types" @@ -121,6 +122,7 @@ function OrbitalView({ totalCents, cleanTokenTotal, onSelect, + privateModeEnabled, }: { chains: ChainDef[] balances: Map @@ -130,6 +132,7 @@ function OrbitalView({ totalCents: string cleanTokenTotal: number onSelect: (c: ChainDef) => void + privateModeEnabled: boolean }) { const [hover, setHover] = useState(null) const [size, setSize] = useState(440) @@ -190,25 +193,39 @@ function OrbitalView({ Total - - ${totalDollars.toLocaleString()} - - - .{totalCents} - + {privateModeEnabled ? ( + + •••••• + + ) : ( + <> + + ${totalDollars.toLocaleString()} + + + .{totalCents} + + + )} All Chains - ${totalUsd.toLocaleString('en-US', { maximumFractionDigits: 2 })} + {privateModeEnabled ? "••••••" : `$${totalUsd.toLocaleString('en-US', { maximumFractionDigits: 2 })}`} @@ -1260,7 +1278,7 @@ export function Dashboard({ onLoaded, watchOnly, watchOnlyDeviceId, onOpenSettin {usdNum > 0 && ( - ${usdNum.toLocaleString('en-US', { maximumFractionDigits: 2 })} + {privateModeEnabled ? "••••" : `$${usdNum.toLocaleString('en-US', { maximumFractionDigits: 2 })}`} )} @@ -1570,6 +1588,7 @@ export function Dashboard({ onLoaded, watchOnly, watchOnlyDeviceId, onOpenSettin totalCents={totalCents} cleanTokenTotal={cleanTokenTotal} onSelect={(c) => setDrilledChainId(c.id)} + privateModeEnabled={privateModeEnabled} /> ) } diff --git a/projects/keepkey-vault/src/mainview/components/DeviceSettingsDrawer.tsx b/projects/keepkey-vault/src/mainview/components/DeviceSettingsDrawer.tsx index 1e8ebf47..a4d2ae5f 100644 --- a/projects/keepkey-vault/src/mainview/components/DeviceSettingsDrawer.tsx +++ b/projects/keepkey-vault/src/mainview/components/DeviceSettingsDrawer.tsx @@ -1,4 +1,5 @@ import { useState, useEffect, useCallback, useRef } from "react" +import { useFiat } from "../lib/fiat-context" import { Box, Flex, Text, VStack, Button, Input, IconButton } from "@chakra-ui/react" import { useTranslation } from "react-i18next" import { LanguageSelector } from "../i18n/LanguageSelector" @@ -160,7 +161,7 @@ export function DeviceSettingsDrawer({ open, onClose, deviceState, onCheckForUpd const [removePinConfirm, setRemovePinConfirm] = useState(false) const [togglingPassphrase, setTogglingPassphrase] = useState(false) const [togglingPolicy, setTogglingPolicy] = useState("") - const [appSettings, setAppSettings] = useState({ restApiEnabled: false, pioneerApiBase: '', pioneerServers: [], activePioneerServer: '', fiatCurrency: 'USD', numberLocale: 'en-US', walletConnectEnabled: false, swapsEnabled: false, bip85Enabled: false, zcashPrivacyEnabled: false, emulatorEnabled: false, preReleaseUpdates: false, alphaFirmware: false }) + const [appSettings, setAppSettings] = useState({ restApiEnabled: false, pioneerApiBase: '', pioneerServers: [], activePioneerServer: '', fiatCurrency: 'USD', numberLocale: 'en-US', walletConnectEnabled: false, swapsEnabled: false, bip85Enabled: false, zcashPrivacyEnabled: false, emulatorEnabled: false, preReleaseUpdates: false, alphaFirmware: false, privateModeEnabled: false }) const [togglingRestApi, setTogglingRestApi] = useState(false) const [windowFocusState, setWindowFocusState] = useState<{ refs: number; alwaysOnTop: boolean } | null>(null) const [releasingWindowFocus, setReleasingWindowFocus] = useState(false) @@ -171,6 +172,7 @@ export function DeviceSettingsDrawer({ open, onClose, deviceState, onCheckForUpd const [togglingEmulator, setTogglingEmulator] = useState(false) const [togglingPreRelease, setTogglingPreRelease] = useState(false) const [togglingAlphaFirmware, setTogglingAlphaFirmware] = useState(false) + const [togglingPrivateMode, setTogglingPrivateMode] = useState(false) const [checkingUpdate, setCheckingUpdate] = useState(false) const [updateMessage, setUpdateMessage] = useState("") const [newServerUrl, setNewServerUrl] = useState("") @@ -357,6 +359,17 @@ export function DeviceSettingsDrawer({ open, onClose, deviceState, onCheckForUpd setTogglingAlphaFirmware(false) }, []) + const { setPrivateMode } = useFiat() + const togglePrivateMode = useCallback(async (enabled: boolean) => { + setTogglingPrivateMode(true) + try { + const result = await rpcRequest("setPrivateModeEnabled", { enabled }, 10000) + setAppSettings(result) + setPrivateMode(enabled) + } catch (e: any) { console.error("setPrivateModeEnabled:", e) } + setTogglingPrivateMode(false) + }, [setPrivateMode]) + const openSwagger = useCallback(async () => { try { await rpcRequest("openUrl", { url: "http://localhost:1646/docs" }, 5000) @@ -920,6 +933,29 @@ export function DeviceSettingsDrawer({ open, onClose, deviceState, onCheckForUpd ))} + + {/* Private Mode toggle */} + + + + + + + + + + Private Mode + + Hide portfolio totals and balances from the screen + + + + + {/* ── Signing Policy ─────────────────────────────── */} diff --git a/projects/keepkey-vault/src/mainview/lib/fiat-context.tsx b/projects/keepkey-vault/src/mainview/lib/fiat-context.tsx index a8662351..7861d1bc 100644 --- a/projects/keepkey-vault/src/mainview/lib/fiat-context.tsx +++ b/projects/keepkey-vault/src/mainview/lib/fiat-context.tsx @@ -16,6 +16,10 @@ interface FiatContextValue { setCurrency: (currency: FiatCurrency) => void /** Update number locale preference */ setLocale: (locale: string) => void + /** When true, all portfolio totals should be masked */ + privateModeEnabled: boolean + /** Toggle private mode */ + setPrivateMode: (enabled: boolean) => void } const FiatContext = createContext({ @@ -26,6 +30,8 @@ const FiatContext = createContext({ symbol: '$', setCurrency: () => {}, setLocale: () => {}, + privateModeEnabled: false, + setPrivateMode: () => {}, }) export function useFiat() { @@ -43,6 +49,11 @@ export function FiatProvider({ children }: { children: React.ReactNode }) { return localStorage.getItem('keepkey-vault-locale') || 'en-US' } catch { return 'en-US' } }) + const [privateModeEnabled, setPrivateModeState] = useState(() => { + try { + return localStorage.getItem('keepkey-vault-private-mode') === '1' + } catch { return false } + }) // Load from backend settings on mount useEffect(() => { @@ -50,6 +61,7 @@ export function FiatProvider({ children }: { children: React.ReactNode }) { .then(s => { if (s.fiatCurrency) setCurrencyState(s.fiatCurrency) if (s.numberLocale) setLocaleState(s.numberLocale) + setPrivateModeState(!!s.privateModeEnabled) }) .catch(() => {}) }, []) @@ -67,6 +79,12 @@ export function FiatProvider({ children }: { children: React.ReactNode }) { rpcRequest('setNumberLocale', { locale: l }).catch(() => {}) }, []) + const setPrivateMode = useCallback((enabled: boolean) => { + setPrivateModeState(enabled) + try { localStorage.setItem('keepkey-vault-private-mode', enabled ? '1' : '0') } catch {} + rpcRequest('setPrivateModeEnabled', { enabled }).catch(() => {}) + }, []) + const cfg = getFiatConfig(currency) const fmt = useCallback((usdValue: number | string | null | undefined) => { @@ -78,7 +96,7 @@ export function FiatProvider({ children }: { children: React.ReactNode }) { }, [currency, locale]) return ( - + {children} ) diff --git a/projects/keepkey-vault/src/shared/rpc-schema.ts b/projects/keepkey-vault/src/shared/rpc-schema.ts index b049a968..5d80ebb0 100644 --- a/projects/keepkey-vault/src/shared/rpc-schema.ts +++ b/projects/keepkey-vault/src/shared/rpc-schema.ts @@ -182,6 +182,7 @@ export type VaultRPCSchema = ElectrobunRPCSchema & { setEmulatorEnabled: { params: { enabled: boolean }; response: AppSettings } setPreReleaseUpdates: { params: { enabled: boolean }; response: AppSettings } setAlphaFirmware: { params: { enabled: boolean }; response: AppSettings } + setPrivateModeEnabled: { params: { enabled: boolean }; response: AppSettings } addPioneerServer: { params: { url: string; label: string }; response: AppSettings } removePioneerServer: { params: { url: string }; response: AppSettings } setActivePioneerServer: { params: { url: string }; response: AppSettings } diff --git a/projects/keepkey-vault/src/shared/types.ts b/projects/keepkey-vault/src/shared/types.ts index 12e4a905..2b1cdec0 100644 --- a/projects/keepkey-vault/src/shared/types.ts +++ b/projects/keepkey-vault/src/shared/types.ts @@ -488,6 +488,7 @@ export interface AppSettings { emulatorEnabled: boolean // feature flag: macOS emulator surface (default OFF — dev-only) preReleaseUpdates: boolean // opt-in to pre-release auto-updates (default OFF) alphaFirmware: boolean // opt-in to alpha firmware channel (manifest.beta) (default OFF) + privateModeEnabled: boolean // hide portfolio totals from the UI (default OFF) } // ── WalletConnect types ───────────────────────────────────────────────── From e6f4ea696e7e4c9b6b33da2b1eb30985395b2fc3 Mon Sep 17 00:00:00 2001 From: highlander Date: Fri, 22 May 2026 11:59:05 -0300 Subject: [PATCH 10/15] fix: address PR review findings (tx:confirmed refresh, private mode leaks, swap type) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - AssetPage: tx:confirmed events (txid-only, no chain) no longer dropped by the chain-match guard — refreshes correctly after SSE-confirmed transactions - AssetPickerDialog: FromPicker total + HeldTile per-asset USD both masked in private mode (were leaking plaintext values) - CommandPalette: balance amounts in search results now masked in private mode - swap.ts: buildRelaySwapTx return type includes fromAmountBaseUnits (P2 type gap) --- projects/keepkey-vault/src/bun/swap.ts | 2 +- .../src/mainview/components/AssetPage.tsx | 13 ++++++++----- .../mainview/components/AssetPickerDialog.tsx | 18 +++++++++--------- .../src/mainview/components/CommandPalette.tsx | 14 +++++++++----- 4 files changed, 27 insertions(+), 20 deletions(-) diff --git a/projects/keepkey-vault/src/bun/swap.ts b/projects/keepkey-vault/src/bun/swap.ts index 827d712e..fd06c91d 100644 --- a/projects/keepkey-vault/src/bun/swap.ts +++ b/projects/keepkey-vault/src/bun/swap.ts @@ -845,7 +845,7 @@ async function buildRelaySwapTx( getRpcUrl: (chain: ChainDef) => string | undefined, isErc20Source = false, _previewMode = false, // reserved — caller (executeSwap vs previewSwapBuild) handles signing/broadcasting -): Promise<{ unsignedTx: any; approveTx?: any; allowance?: { current: string; required: string; sufficient: boolean; spender: string; tokenContract: string }; balance?: { current: string; required: string; sufficient: boolean; tokenContract?: string } }> { +): Promise<{ unsignedTx: any; approveTx?: any; fromAmountBaseUnits?: string; allowance?: { current: string; required: string; sufficient: boolean; spender: string; tokenContract: string }; balance?: { current: string; required: string; sufficient: boolean; tokenContract?: string } }> { const relay = params.relayTx! console.log(`${TAG} buildRelaySwapTx: relay.value=${relay.value} relay.gasLimit=${relay.gasLimit} relay.maxFeePerGas=${relay.maxFeePerGas} relay.maxPriorityFeePerGas=${relay.maxPriorityFeePerGas}`) diff --git a/projects/keepkey-vault/src/mainview/components/AssetPage.tsx b/projects/keepkey-vault/src/mainview/components/AssetPage.tsx index ed8a49ef..6ff98364 100644 --- a/projects/keepkey-vault/src/mainview/components/AssetPage.tsx +++ b/projects/keepkey-vault/src/mainview/components/AssetPage.tsx @@ -349,11 +349,14 @@ export function AssetPage({ chain, balance, onBack, firmwareVersion, initialActi // and only when the event chain matches this asset or the active swap output. const [swapOutputChainId, setSwapOutputChainId] = useState(null) useEffect(() => { - return onRpcMessage("tx-push-received", (payload: { chain?: string }) => { - if (!payload.chain) return // chain-less cache pings (balance:cache:update) are not actionable - const matches = payload.chain.includes(chain.id) || payload.chain === chain.symbol - const matchesOutput = swapOutputChainId ? payload.chain.includes(swapOutputChainId) : false - if (!matches && !matchesOutput) return + return onRpcMessage("tx-push-received", (payload: { chain?: string; txid?: string }) => { + if (!payload.chain && !payload.txid) return // truly empty pings are not actionable + if (payload.chain) { + // Chain-scoped push: only refresh if it matches this asset or the active swap output + const matches = payload.chain.includes(chain.id) || payload.chain === chain.symbol + const matchesOutput = swapOutputChainId ? payload.chain.includes(swapOutputChainId) : false + if (!matches && !matchesOutput) return + } handleRefresh() }) }, [handleRefresh, chain.id, chain.symbol, swapOutputChainId]) diff --git a/projects/keepkey-vault/src/mainview/components/AssetPickerDialog.tsx b/projects/keepkey-vault/src/mainview/components/AssetPickerDialog.tsx index 813eac6c..8f92e5a4 100644 --- a/projects/keepkey-vault/src/mainview/components/AssetPickerDialog.tsx +++ b/projects/keepkey-vault/src/mainview/components/AssetPickerDialog.tsx @@ -197,8 +197,8 @@ function NetSwitchBanner({ fromChainId, toChainId, providers }: { // FROM picker — all held assets, ranked by USD value, square tiles // ══════════════════════════════════════════════════════════════════════════ -function FromPicker({ entries, onSelect, fmtCompact }: { - entries: AssetEntry[]; onSelect: (e: AssetEntry) => void; fmtCompact: (v: number) => string +function FromPicker({ entries, onSelect, fmtCompact, privateModeEnabled }: { + entries: AssetEntry[]; onSelect: (e: AssetEntry) => void; fmtCompact: (v: number) => string; privateModeEnabled: boolean }) { const { t } = useTranslation("swap") const [search, setSearch] = useState("") @@ -227,7 +227,7 @@ function FromPicker({ entries, onSelect, fmtCompact }: { Available to swap - {totalUsd > 0 ? fmtCompact(totalUsd) : "—"} + {totalUsd > 0 ? (privateModeEnabled ? "••••••" : fmtCompact(totalUsd)) : "—"} @@ -259,7 +259,7 @@ function FromPicker({ entries, onSelect, fmtCompact }: { ) : ( - {filtered.map(e => )} + {filtered.map(e => )} )} @@ -273,8 +273,8 @@ function FromPicker({ entries, onSelect, fmtCompact }: { ) } -function HeldTile({ entry: e, onSelect, fmtCompact }: { - entry: AssetEntry; onSelect: (e: AssetEntry) => void; fmtCompact: (v: number) => string +function HeldTile({ entry: e, onSelect, fmtCompact, privateModeEnabled }: { + entry: AssetEntry; onSelect: (e: AssetEntry) => void; fmtCompact: (v: number) => string; privateModeEnabled: boolean }) { const chainName = networkDisplayName(e.chainId) const selectable = isRowSelectable(e) @@ -322,7 +322,7 @@ function HeldTile({ entry: e, onSelect, fmtCompact }: { {e.balance!.amount} - {fmtCompact(e.balance!.usd)} + {privateModeEnabled ? "••••••" : fmtCompact(e.balance!.usd)} {/* Full CAIP */} {e.caip} @@ -860,7 +860,7 @@ interface AssetPickerDialogProps { export function AssetPickerDialog({ open, onClose, swappable, balances, customTokens, excludeCaip, onSelect, side, }: AssetPickerDialogProps) { - const { fmtCompact } = useFiat() + const { fmtCompact, privateModeEnabled } = useFiat() const [entries, setEntries] = useState(null) const [loading, setLoading] = useState(false) @@ -977,7 +977,7 @@ export function AssetPickerDialog({ ) : !entries ? null : side === "from" ? ( - + ) : unavailEntry ? ( (null) @@ -241,11 +243,13 @@ export function CommandPalette({ open, onClose, onJumpToVault, balances }: Comma const secondary = r.kind === "chain" ? r.chain.symbol : `${r.token.symbol} on ${r.chain.coin}` - const trailing = r.kind === "chain" && r.balanceUsd > 0 - ? `$${r.balanceUsd.toFixed(2)}` - : r.kind === "token" && r.token.balanceUsd > 0 - ? `$${r.token.balanceUsd.toFixed(2)}` - : "" + const trailing = privateModeEnabled + ? (r.kind === "chain" && r.balanceUsd > 0) || (r.kind === "token" && r.token.balanceUsd > 0) ? "••••••" : "" + : r.kind === "chain" && r.balanceUsd > 0 + ? `$${r.balanceUsd.toFixed(2)}` + : r.kind === "token" && r.token.balanceUsd > 0 + ? `$${r.token.balanceUsd.toFixed(2)}` + : "" return ( Date: Fri, 22 May 2026 12:27:05 -0300 Subject: [PATCH 11/15] feat(hive): wire Hive RPC into vault + add integration handoff doc - bun/index.ts: hiveGetPublicKey and hiveSignTx RPC handlers (returns hex strings) - rpc-schema.ts: hiveGetPublicKey and hiveSignTx schema entries - docs/handoff-hive-integration.md: full stack integration handoff (hdwallet done, Pioneer remaining) - bump modules/hdwallet to Hive adapter commit --- docs/handoff-hive-integration.md | 215 ++++++++++++++++++ modules/hdwallet | 2 +- projects/keepkey-vault/src/bun/index.ts | 39 ++++ .../keepkey-vault/src/shared/rpc-schema.ts | 4 + 4 files changed, 259 insertions(+), 1 deletion(-) create mode 100644 docs/handoff-hive-integration.md diff --git a/docs/handoff-hive-integration.md b/docs/handoff-hive-integration.md new file mode 100644 index 00000000..884ca2aa --- /dev/null +++ b/docs/handoff-hive-integration.md @@ -0,0 +1,215 @@ +# Handoff: Hive Integration + +## Status Summary + +| Layer | Status | Notes | +|---|---|---| +| Firmware (`alpha`) | ✅ Done | `hive.c`, `fsm_msg_hive.h`, wired into `messagemap.def` + CMakeLists | +| device-protocol | ✅ Done | `messages-hive.proto`, wire IDs 1600–1603 in `alpha` | +| hdwallet | ❌ Not started | No Hive in `modules/hdwallet/` | +| Vault RPC | ❌ Not started | `feature/hive` branch only has submodule bumps, no TS code | +| Pioneer | ❌ Not started | No Hive in pioneer-caip, pioneer-coins, pioneer-balance, or pioneer-network | + +--- + +## Chain Facts + +| Field | Value | +|---|---| +| Symbol | `HIVE` | +| SLIP44 | `1275` | +| Curve | `secp256k1` | +| Derivation path | `m/44'/1275'/0'/0/0` | +| Address format | `STM`-prefixed base58 (Graphene/EOS encoding, RIPEMD checksum) | +| Chain ID (mainnet) | `beeab0de00000000000000000000000000000000000000000000000000000000` (32 bytes) | +| CAIP networkId | `hive:beeab0de` (first 8 hex chars of chain_id) | +| CAIP assetId | `hive:beeab0de/slip44:1275` | +| Decimals | `3` (milliHIVE) | +| Also supports | HBD (Hive Backed Dollars), same path/curve | +| Broadcast endpoint | Hive RPC nodes: `https://api.hive.blog`, `https://anyx.io` | + +--- + +## What the Firmware Does + +`hive.c` implements: +- `hive_getPublicKey()` — returns `STM`-prefixed base58 public key +- `hive_signTx()` — signs Graphene binary-serialized transfer transactions + +`HiveSignTx` proto fields: `chain_id`, `ref_block_num`, `ref_block_prefix`, `expiration`, `from`, `to`, `amount`, `decimals`, `asset_symbol`, `memo` + +Response is a 65-byte recoverable secp256k1 signature + full serialized tx bytes. + +--- + +## Work Remaining + +### 1. hdwallet — Add Hive wallet adapter +**Repo**: `modules/hdwallet/` +**Reference impl**: look at `packages/hdwallet-keepkey/src/solana.ts` or `tron.ts` + +Files to add/edit: +- `packages/hdwallet-core/src/hive.ts` — types: `HiveGetPublicKey`, `HiveSignTx`, `HiveSignedTx`, wallet interface +- `packages/hdwallet-core/src/index.ts` — re-export Hive types +- `packages/hdwallet-keepkey/src/hive.ts` — wire `hiveGetPublicKey()` and `hiveSignTx()` through to firmware via USB transport +- `packages/hdwallet-keepkey/src/keepkey.ts` — add Hive capability flags + register handlers + +The firmware proto is already in `deps/device-protocol`. The keepkey transport just needs to call `HiveGetPublicKey` (MessageType 1600) and `HiveSignTx` (MessageType 1602). + +### 2. Vault RPC — Wire hdwallet calls into Electrobun RPC +**File**: `projects/keepkey-vault/src/bun/index.ts` +**Branch**: `feat/hive` (current main tree) + +Pattern — copy from `tonGetAddress` / `tonSignTx` block (~line 1376): +```typescript +hiveGetPublicKey: async (params) => { + const addr = await engine.wallet.hiveGetPublicKey(params) + if (addr) cacheAddress('hive', JSON.stringify(params.addressNList || []), addr) + return addr +}, +hiveSignTx: async (params) => { + return await engine.wallet.hiveSignTx(params) +}, +``` + +Also add to `src/shared/rpc-schema.ts` (request/response types). + +### 3. Pioneer — Add Hive chain support +**Repo**: `/Users/highlander/WebstormProjects/keepkey-stack/projects/pioneer` +**Reference**: `docs/coin-addition/coin-addition-guide.md` (UTXO-style but use Hive-specific values) + +Files to edit (in order): + +#### pioneer-caip `modules/pioneer/pioneer-caip/src/data.ts` +```typescript +// Chain enum +Hive = 'HIVE', + +// BaseDecimal +HIVE: 3, + +// ChainToCaip +'HIVE': 'hive:beeab0de/slip44:1275', + +// ChainToNetworkId +'HIVE': 'hive:beeab0de', + +// shortListSymbolToCaip +HIVE: 'hive:beeab0de/slip44:1275', + +// shortListNameToCaip +hive: 'hive:beeab0de/slip44:1275', +``` + +#### pioneer-coins `modules/pioneer/pioneer-coins/src/paths.ts` +```typescript +// blockchains array +'hive:beeab0de', + +// getPaths() +if (blockchains.indexOf('hive:beeab0de') >= 0) { + output.push({ + note: "Hive Default", + type: "address", + networks: ['hive:beeab0de'], + script_type: "p2pkh", + available_scripts_types: ['p2pkh'], + addressNList: [0x80000000 + 44, 0x80000000 + 1275, 0x80000000 + 0], + addressNListMaster: [0x80000000 + 44, 0x80000000 + 1275, 0x80000000 + 0, 0, 0], + curve: 'secp256k1', + showDisplay: false, + }) +} +``` + +#### pioneer-nodes `modules/pioneer/pioneer-nodes/src/seeds.ts` +Hive uses its own RPC API (not Blockbook). Add a custom node entry pointing to `https://api.hive.blog` for balance/broadcast. + +#### pioneer-discovery `modules/pioneer/pioneer-discovery/src/generatedAssetData.json` +```json +"hive:beeab0de/slip44:1275": { + "symbol": "HIVE", + "name": "Hive", + "chainId": "hive:beeab0de", + "assetId": "hive:beeab0de/slip44:1275", + "decimals": 3, + "isNative": true, + "type": "native" +} +``` + +#### pioneer-types `modules/pioneer/pioneer-types/src/pioneer.ts` +```typescript +// availableChainsByWallet[WalletOption.KEEPKEY] +Chain.Hive, +``` + +#### pioneer-network / pioneer-balance / pioneer-signer +Hive has its own JSON-RPC API (`condenser_api.get_accounts` for balance, `condenser_api.broadcast_transaction` for broadcast). There is no existing pioneer-network module for Hive — a new `@pioneer-platform/hive-network` package is needed, OR balance/broadcast can be handled inline in pioneer-balance and broadcast.controller.ts with direct HTTP calls to `https://api.hive.blog`. + +**Simplest path**: inline HTTP calls rather than a new package, since Hive's API is simple: +```typescript +// Balance +const res = await fetch('https://api.hive.blog', { + method: 'POST', + body: JSON.stringify({ jsonrpc:'2.0', method:'condenser_api.get_accounts', params:[['USERNAME']], id:1 }) +}) +const [acct] = (await res.json()).result +const balance = parseFloat(acct.balance) // "1.234 HIVE" + +// Broadcast +const res = await fetch('https://api.hive.blog', { + method: 'POST', + body: JSON.stringify({ jsonrpc:'2.0', method:'condenser_api.broadcast_transaction', params:[signedTx], id:1 }) +}) +``` + +#### broadcast.controller.ts `services/pioneer-server/src/controllers/broadcast.controller.ts` +Add a `HIVE_MAP`: +```typescript +const HIVE_MAP: { [key: string]: string } = { + 'hive:beeab0de': 'hive', +} +``` + +--- + +## Transaction Construction (Vault → Pioneer → Broadcast) + +Hive transactions use Graphene binary serialization. The firmware already handles this — `HiveSignedTx.serialized_tx` is the complete signed transaction bytes ready to broadcast. + +To broadcast: POST `serialized_tx` (hex-encoded) to `condenser_api.broadcast_transaction` on a Hive RPC node. + +The signed tx format is: `[ref_block_num(2)] [ref_block_prefix(4)] [expiration(4)] [op_count(1)] [op_type(2)] [transfer_body] [extensions(1=0)] [sig_count(1)] [sig(65)]` + +--- + +## Suggested Work Order + +1. **hdwallet** — add Hive types + keepkey transport wiring (1–2 hours) +2. **Vault RPC** — add `hiveGetPublicKey` + `hiveSignTx` RPC handlers (30 min) +3. **Pioneer caip/coins/discovery** — CAIP data, paths, asset metadata (1 hour) +4. **Pioneer balance** — inline Hive API call for `get_accounts` (1 hour) +5. **Pioneer broadcast** — inline `broadcast_transaction` call (30 min) +6. **End-to-end test** — get address → sign transfer → broadcast on mainnet + +--- + +## Reference Implementations + +| Coin | Pattern to follow | Why | +|---|---|---| +| TON | `hive.ts` in hdwallet-keepkey | Non-EVM, secp256k1, custom serialization | +| Cosmos | address derivation, secp256k1 | Similar BIP44 path structure | +| TRON | broadcast inline in pioneer | Simple HTTP broadcast without dedicated network module | + +--- + +## Worktree Locations + +| Repo | Branch | Path | +|---|---|---| +| keepkey-vault-v11 | `feat/hive` | `/Users/highlander/WebstormProjects/keepkey-stack/projects/keepkey-vault-v11` (main tree) | +| keepkey-vault-v11 | `feature/hive` | `/Users/highlander/WebstormProjects/keepkey-stack/projects/keepkey-vault-v11-hive` | +| keepkey-firmware | `alpha` (Hive included) | `/Users/highlander/WebstormProjects/keepkey-stack/projects/keepkey-firmware` | +| pioneer | `release/v1.3.73` | `/Users/highlander/WebstormProjects/keepkey-stack/projects/pioneer` | diff --git a/modules/hdwallet b/modules/hdwallet index 0a9c5f80..8f0fa0da 160000 --- a/modules/hdwallet +++ b/modules/hdwallet @@ -1 +1 @@ -Subproject commit 0a9c5f809051a249ced3fc840da21af33714a95e +Subproject commit 8f0fa0da82c2a562cff8468994af83ff1978754f diff --git a/projects/keepkey-vault/src/bun/index.ts b/projects/keepkey-vault/src/bun/index.ts index 68a9566b..4b5f6d37 100644 --- a/projects/keepkey-vault/src/bun/index.ts +++ b/projects/keepkey-vault/src/bun/index.ts @@ -513,11 +513,15 @@ export function resetSwapUiState(): void { // elevated; using the raw API per-event drops the window prematurely when // any one source dismisses while another is still pending. let _alwaysOnTopRefs = 0 +function _emitWindowFocusChanged() { + try { rpc.send['window-focus-changed']({ refs: _alwaysOnTopRefs, alwaysOnTop: _alwaysOnTopRefs > 0 }) } catch { /* webview not ready */ } +} function acquireWindowFocus() { _alwaysOnTopRefs++ if (_alwaysOnTopRefs === 1) { try { mainWindow.setAlwaysOnTop(true); mainWindow.focus() } catch { /* window not ready */ } } + _emitWindowFocusChanged() } function releaseWindowFocus() { if (_alwaysOnTopRefs === 0) return // defensive: never go negative @@ -525,6 +529,7 @@ function releaseWindowFocus() { if (_alwaysOnTopRefs === 0) { try { mainWindow.setAlwaysOnTop(false) } catch { /* ignore */ } } + _emitWindowFocusChanged() } function getOrCreateWcManager(): WalletConnectManager { if (wcManager) return wcManager @@ -1620,6 +1625,27 @@ const rpc = BrowserView.defineRPC({ return { publicKey: bytesToHex(result.publicKey), signature: bytesToHex(result.signature) } }, + // ── Hive (Graphene) ─────────────────────────────────────────── + hiveGetPublicKey: async (params) => { + if (!engine.wallet) throw new Error('No device connected') + const result = await (engine.wallet as any).hiveGetPublicKey(params) + if (!result) throw new Error('hiveGetPublicKey returned no result') + return result + }, + hiveSignTx: async (params) => { + if (!engine.wallet) throw new Error('No device connected') + const result = await (engine.wallet as any).hiveSignTx(params) + if (!result) throw new Error('hiveSignTx returned no result') + return { + signature: result.signature instanceof Uint8Array + ? Buffer.from(result.signature).toString('hex') + : result.signature, + serializedTx: result.serializedTx instanceof Uint8Array + ? Buffer.from(result.serializedTx).toString('hex') + : result.serializedTx, + } + }, + // ── Pioneer integration (batch portfolio API) ──────────────── getBalances: async ({ forceRefresh = false } = {}) => { if (!engine.wallet) throw new Error('No device connected') @@ -3594,6 +3620,19 @@ const rpc = BrowserView.defineRPC({ console.warn(`[window-focus] Force-releasing stuck always-on-top (refs was ${_alwaysOnTopRefs})`) _alwaysOnTopRefs = 0 try { mainWindow.setAlwaysOnTop(false) } catch { /* ignore */ } + _emitWindowFocusChanged() + } + }, + setWindowAlwaysOnTop: async (params) => { + if (params.enabled) { + acquireWindowFocus() + } else { + // Force-release all refs when manually toggling off + if (_alwaysOnTopRefs > 0) { + _alwaysOnTopRefs = 0 + try { mainWindow.setAlwaysOnTop(false) } catch { /* ignore */ } + _emitWindowFocusChanged() + } } }, diff --git a/projects/keepkey-vault/src/shared/rpc-schema.ts b/projects/keepkey-vault/src/shared/rpc-schema.ts index 5d80ebb0..00947b44 100644 --- a/projects/keepkey-vault/src/shared/rpc-schema.ts +++ b/projects/keepkey-vault/src/shared/rpc-schema.ts @@ -82,6 +82,8 @@ export type VaultRPCSchema = ElectrobunRPCSchema & { tronSignTypedHash: { params: any; response: any } tonSignTx: { params: any; response: any } tonSignMessage: { params: any; response: any } + hiveGetPublicKey: { params: any; response: any } + hiveSignTx: { params: any; response: any } // ── Pioneer integration ───────────────────────────────────────── getBalances: { params: { forceRefresh?: boolean }; response: ChainBalance[] } @@ -168,6 +170,7 @@ export type VaultRPCSchema = ElectrobunRPCSchema & { // ── Window Focus ────────────────────────────────────────────────── getWindowFocusState: { params: void; response: { refs: number; alwaysOnTop: boolean } } forceReleaseWindowFocus: { params: void; response: void } + setWindowAlwaysOnTop: { params: { enabled: boolean }; response: void } // ── App Settings ────────────────────────────────────────────────── getAppSettings: { params: void; response: AppSettings } @@ -391,6 +394,7 @@ export type VaultRPCSchema = ElectrobunRPCSchema & { /** Bun hit an uncaught error. App process stays alive (handlers are non-exit); * UI should surface a recovery prompt and let the user reload / reconnect. */ 'fatal': FatalEvent + 'window-focus-changed': { refs: number; alwaysOnTop: boolean } } } webview: { From 4f6308702977f89db2997c45dba7b281f00e2649 Mon Sep 17 00:00:00 2001 From: highlander Date: Fri, 22 May 2026 15:49:57 -0300 Subject: [PATCH 12/15] fix(event-stream): exponential backoff + log throttle to prevent server hammer during blue/green MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously retried every fixed 10s indefinitely on any error, flooding logs and hammering Pioneer during blue/green deploy windows where the SSE endpoint is temporarily absent. - Exponential backoff: 10s base, doubles each failure, 5-min hard cap - 404 jumps straight to 60s tier (route absent, not a transient glitch) - Jitter ±10% prevents thundering-herd across concurrent vault instances - Log throttling: prints on attempt 1 then every power-of-2 (1,2,4,8,16...) - Backoff state resets to base on any successful connection --- .../keepkey-vault/src/bun/event-stream.ts | 34 ++++++++++++++++--- 1 file changed, 30 insertions(+), 4 deletions(-) diff --git a/projects/keepkey-vault/src/bun/event-stream.ts b/projects/keepkey-vault/src/bun/event-stream.ts index 683f7b07..0f11bc18 100644 --- a/projects/keepkey-vault/src/bun/event-stream.ts +++ b/projects/keepkey-vault/src/bun/event-stream.ts @@ -33,7 +33,12 @@ export interface AddressEntry { address: string; networkId: string } type EventHandler = (event: StreamEvent) => void type StatusHandler = (status: { connected: boolean; watching: number; sessionId?: string }) => void -const RECONNECT_DELAY_MS = 10_000 +// Exponential backoff: 10s → doubles each failure → 5-min hard cap. +// 404 skips straight to 60s (endpoint absent during blue/green deploy is not transient). +// Jitter ±10% spreads thundering-herd across a fleet of concurrent retries. +const RECONNECT_BASE_MS = 10_000 +const RECONNECT_MAX_MS = 300_000 // 5 min — covers any realistic blue/green window +const RECONNECT_404_MS = 60_000 // start here on 404 — the route is gone, not glitching const SUBSCRIBE_URL_PATH = '/api/v1/events/subscribe' let controller: AbortController | null = null @@ -41,6 +46,8 @@ let reconnectTimer: ReturnType | null = null let currentAddresses: AddressEntry[] = [] let currentHandler: EventHandler | null = null let onStatusChange: StatusHandler | null = null +let reconnectDelay = RECONNECT_BASE_MS +let consecutiveFailures = 0 export function startEventStream( addresses: AddressEntry[], @@ -55,6 +62,8 @@ export function startEventStream( currentAddresses = addresses currentHandler = onEvent controller = new AbortController() + reconnectDelay = RECONNECT_BASE_MS + consecutiveFailures = 0 connect() } @@ -91,19 +100,29 @@ async function connect(): Promise { }) if (!resp.ok) { - console.warn(`[event-stream] Subscribe returned ${resp.status} — retrying in ${RECONNECT_DELAY_MS / 1000}s`) + consecutiveFailures++ + // 404 = endpoint absent (blue/green gap, version mismatch) — jump to longer tier immediately + if (resp.status === 404) reconnectDelay = Math.max(reconnectDelay, RECONNECT_404_MS) + // Only log on first failure then every power-of-2 to avoid log spam + if (consecutiveFailures === 1 || (consecutiveFailures & (consecutiveFailures - 1)) === 0) { + console.warn(`[event-stream] Subscribe returned ${resp.status} — retrying in ${Math.round(reconnectDelay / 1000)}s (attempt ${consecutiveFailures})`) + } onStatusChange?.({ connected: false, watching: 0 }) scheduleReconnect() return } if (!resp.body) { + consecutiveFailures++ console.warn('[event-stream] No response body — retrying') onStatusChange?.({ connected: false, watching: 0 }) scheduleReconnect() return } + // Successful connection — reset backoff state + reconnectDelay = RECONNECT_BASE_MS + consecutiveFailures = 0 console.log(`[event-stream] Connected — watching ${currentAddresses.length} addresses`) const reader = resp.body.getReader() @@ -142,7 +161,10 @@ async function connect(): Promise { } catch (err: any) { if (err.name === 'AbortError') return // intentional close — do not reconnect - console.warn('[event-stream] Connection error:', err.message, `— retrying in ${RECONNECT_DELAY_MS / 1000}s`) + consecutiveFailures++ + if (consecutiveFailures === 1 || (consecutiveFailures & (consecutiveFailures - 1)) === 0) { + console.warn('[event-stream] Connection error:', err.message, `— retrying in ${Math.round(reconnectDelay / 1000)}s (attempt ${consecutiveFailures})`) + } scheduleReconnect() } } @@ -150,5 +172,9 @@ async function connect(): Promise { function scheduleReconnect(): void { if (!controller) return // already stopped if (reconnectTimer) clearTimeout(reconnectTimer) - reconnectTimer = setTimeout(() => { reconnectTimer = null; connect() }, RECONNECT_DELAY_MS) + // Jitter ±10% to spread retries across concurrent clients + const jitter = reconnectDelay * 0.1 * (Math.random() * 2 - 1) + const delay = Math.max(RECONNECT_BASE_MS, Math.round(reconnectDelay + jitter)) + reconnectTimer = setTimeout(() => { reconnectTimer = null; connect() }, delay) + reconnectDelay = Math.min(reconnectDelay * 2, RECONNECT_MAX_MS) } From 219a715a8763feb5a7f33ce9b0836eaa3969faae Mon Sep 17 00:00:00 2001 From: highlander Date: Fri, 22 May 2026 16:54:09 -0300 Subject: [PATCH 13/15] chore(deps): bump hdwallet to master (TRON/TON/Zcash/Hive/BIP85) Switch modules/hdwallet from develop (8f0fa0da) to master (d83a65c3) which adds TRON, TON, Zcash/Orchard, BIP-85, EVM clearsigning, and all missing type-registry fixes. Cherry-pick 8f0fa0da (Hive wallet adapter) onto master since that commit only lived on develop. --- modules/hdwallet | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/hdwallet b/modules/hdwallet index 8f0fa0da..adee5155 160000 --- a/modules/hdwallet +++ b/modules/hdwallet @@ -1 +1 @@ -Subproject commit 8f0fa0da82c2a562cff8468994af83ff1978754f +Subproject commit adee5155665895e42946567b2a6078adcc37d8ca From e3a0704e652114e4eef83511d6e508815d203f1e Mon Sep 17 00:00:00 2001 From: highlander Date: Fri, 22 May 2026 16:54:19 -0300 Subject: [PATCH 14/15] feat(ui): colorful action buttons + Zcash privacy shortcut on dashboard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Dashboard action row: icons 13→18px, each button gets a per-chain color (green receive, orange send, blue swap, violet privacy) with tinted bg; active state uses colored border instead of gold fill - Privacy (shield) button added to dashboard action row for Zcash when zcashPrivacyEnabled is on; clicking it opens AssetPage pre-selected on the privacy tab - AssetPage: extend initialAction to accept "privacy"; fix race where zcashPrivacyEnabled starts false and stomps the incoming view via the guard useEffect — gate the guard on settingsLoaded ref so the initial action is honoured before settings arrive - AssetPage pill buttons restyled to match dashboard (colorful inline SVG icons, per-pill color/bg, colored border active state) --- modules/device-protocol | 2 +- modules/keepkey-firmware | 2 +- projects/keepkey-vault/src/bun/index.ts | 2 +- .../src/mainview/components/AssetPage.tsx | 59 ++++++++------ .../src/mainview/components/Dashboard.tsx | 77 ++++++++++--------- .../components/DeviceSettingsDrawer.tsx | 37 ++++----- projects/keepkey-vault/src/shared/chains.ts | 14 +++- 7 files changed, 107 insertions(+), 86 deletions(-) diff --git a/modules/device-protocol b/modules/device-protocol index 23523461..9ef6ea02 160000 --- a/modules/device-protocol +++ b/modules/device-protocol @@ -1 +1 @@ -Subproject commit 235234614d3d0172366f89f34d682233d7590d8d +Subproject commit 9ef6ea0244a7fa26f380c1bf7caf8e5648e2b8f1 diff --git a/modules/keepkey-firmware b/modules/keepkey-firmware index 51a63ee7..62d4f44c 160000 --- a/modules/keepkey-firmware +++ b/modules/keepkey-firmware @@ -1 +1 @@ -Subproject commit 51a63ee7dce3d43a3c091c95ee885312d92fd542 +Subproject commit 62d4f44cad20f9a22def9ab308790a108826c897 diff --git a/projects/keepkey-vault/src/bun/index.ts b/projects/keepkey-vault/src/bun/index.ts index 4b5f6d37..ca689a0b 100644 --- a/projects/keepkey-vault/src/bun/index.ts +++ b/projects/keepkey-vault/src/bun/index.ts @@ -1753,7 +1753,7 @@ const rpc = BrowserView.defineRPC({ if (chain.chainFamily === 'ton') addrParams.bounceable = false const method = chain.id === 'ripple' ? 'rippleGetAddress' : chain.rpcMethod const result = await wallet[method](addrParams) - const address = typeof result === 'string' ? result : result?.address || '' + const address = typeof result === 'string' ? result : result?.address || result?.publicKey || '' const ms = Date.now() - t0 if (address) { console.log(`[getBalances] ${chain.id} address derived in ${ms}ms: ${address.substring(0, 20)}... caip=${chain.caip}`) diff --git a/projects/keepkey-vault/src/mainview/components/AssetPage.tsx b/projects/keepkey-vault/src/mainview/components/AssetPage.tsx index 6ff98364..e166521d 100644 --- a/projects/keepkey-vault/src/mainview/components/AssetPage.tsx +++ b/projects/keepkey-vault/src/mainview/components/AssetPage.tsx @@ -1,7 +1,7 @@ import React, { lazy, Suspense, useState, useEffect, useCallback, useMemo, useRef } from "react" import { useTranslation } from "react-i18next" import { Box, Flex, Text, Button, Image, VStack, HStack, IconButton, Spinner } from "@chakra-ui/react" -import { FaArrowDown, FaArrowUp, FaExchangeAlt, FaPlus, FaEye, FaEyeSlash, FaShieldAlt, FaCheck, FaCopy } from "react-icons/fa" +import { FaPlus, FaEye, FaEyeSlash, FaShieldAlt, FaCheck, FaCopy } from "react-icons/fa" import { rpcRequest, onRpcMessage } from "../lib/rpc" import type { ChainDef } from "../../shared/chains" import { CHAINS, BTC_SCRIPT_TYPES, btcAccountPath, isChainSupported } from "../../shared/chains" @@ -49,8 +49,8 @@ interface AssetPageProps { balance?: ChainBalance onBack: () => void firmwareVersion?: string - /** Open the page on a specific action ("send" / "receive" / "swap"). */ - initialAction?: "send" | "receive" | "swap" + /** Open the page on a specific action ("send" / "receive" / "swap" / "privacy"). */ + initialAction?: "send" | "receive" | "swap" | "privacy" /** Pre-select a specific token so the page lands directly on its detail view. */ initialToken?: TokenBalance /** Navigate to the full Activity page filtered by this chain */ @@ -60,7 +60,7 @@ interface AssetPageProps { export function AssetPage({ chain, balance, onBack, firmwareVersion, initialAction, initialToken, onViewActivity }: AssetPageProps) { const { t } = useTranslation("asset") const { fmtCompact, symbol: fiatSymbol } = useFiat() - const [view, setView] = useState(initialAction === "send" ? "send" : "receive") + const [view, setView] = useState(initialAction === "send" ? "send" : initialAction === "privacy" ? "privacy" : "receive") const [selectedToken, setSelectedToken] = useState(initialToken ?? null) const [copiedCaip, setCopiedCaip] = useState(null) const [address, setAddress] = useState(balance?.address || null) @@ -96,9 +96,11 @@ export function AssetPage({ chain, balance, onBack, firmwareVersion, initialActi const [swapsEnabled, setSwapsEnabled] = useState(false) const [swappableChainIds, setSwappableChainIds] = useState>(new Set()) const [zcashPrivacyEnabled, setZcashPrivacyEnabled] = useState(false) + const settingsLoaded = useRef(false) const refreshFeatureFlags = useCallback(() => { rpcRequest("getAppSettings") .then(s => { + settingsLoaded.current = true setSwapsEnabled(s.swapsEnabled) setZcashPrivacyEnabled(s.zcashPrivacyEnabled) if (s.swapsEnabled) { @@ -117,9 +119,10 @@ export function AssetPage({ chain, balance, onBack, firmwareVersion, initialActi return () => window.removeEventListener('keepkey-settings-changed', refreshFeatureFlags) }, [refreshFeatureFlags]) - // Reset view if user is on privacy tab but flag got turned off + // Reset view if user is on privacy tab but flag got turned off — skip until settings are loaded + // to avoid race where zcashPrivacyEnabled starts false and stomps the initialAction useEffect(() => { - if (view === "privacy" && !zcashPrivacyEnabled) setView("receive") + if (settingsLoaded.current && view === "privacy" && !zcashPrivacyEnabled) setView("receive") }, [view, zcashPrivacyEnabled]) // EVM multi-address support @@ -436,11 +439,19 @@ export function AssetPage({ chain, balance, onBack, firmwareVersion, initialActi } }, [selectedToken, chain]) - const PILLS: { id: AssetView | 'swap'; label: string; icon: typeof FaArrowDown }[] = [ - ...(!selectedToken ? [{ id: "receive" as const, label: t("receive"), icon: FaArrowDown }] : []), - { id: "send", label: t("send"), icon: FaArrowUp }, - ...(swapsEnabled && swappableChainIds.has(chain.id) ? [{ id: "swap" as const, label: t("swap"), icon: FaExchangeAlt }] : []), - ...(!selectedToken && zcashPrivacyEnabled && zcashShieldedSupported ? [{ id: "privacy" as const, label: t("privacy"), icon: FaShieldAlt }] : []), + const PILLS: { id: AssetView | 'swap'; label: string; color: string; bg: string; icon: JSX.Element }[] = [ + ...(!selectedToken ? [{ id: "receive" as const, label: t("receive"), color: '#4ade80', bg: 'rgba(74,222,128,0.12)', icon: ( + + ) }] : []), + { id: "send", label: t("send"), color: '#fb923c', bg: 'rgba(251,146,60,0.12)', icon: ( + + ) }, + ...(swapsEnabled && swappableChainIds.has(chain.id) ? [{ id: "swap" as const, label: t("swap"), color: '#60a5fa', bg: 'rgba(96,165,250,0.12)', icon: ( + + ) }] : []), + ...(!selectedToken && zcashPrivacyEnabled && zcashShieldedSupported ? [{ id: "privacy" as const, label: t("privacy"), color: '#a78bfa', bg: 'rgba(167,139,250,0.12)', icon: ( + + ) }] : []), ] // Shared token row renderer @@ -851,7 +862,7 @@ export function AssetPage({ chain, balance, onBack, firmwareVersion, initialActi )} - {/* Action tabs — v3 pill toggle, gold active fill */} + {/* Action tabs — colorful pill toggle */} {PILLS.map((p) => { @@ -867,22 +878,24 @@ export function AssetPage({ chain, balance, onBack, firmwareVersion, initialActi }} display="flex" alignItems="center" - gap="2" - px={{ base: "5", md: "6" }} - py="2.5" + gap="1.5" + px="4" + py="2" borderRadius="999px" fontSize="13px" - fontWeight="500" - letterSpacing="-0.005em" - color={isActive ? "var(--ink-0)" : "var(--text-2)"} - bg={isActive ? "var(--gold)" : "transparent"} - _hover={isActive ? {} : { color: "var(--text-0)", bg: "var(--ink-3)" }} - transition="all 0.18s" + fontWeight="600" + letterSpacing="-0.01em" + color={p.color} + bg={isActive ? p.bg : "transparent"} + border="1px solid" + borderColor={isActive ? p.color : "transparent"} + _hover={{ bg: p.bg, borderColor: p.color }} + transition="all 0.15s" cursor="pointer" - minW="110px" + minW="100px" justifyContent="center" > - + {p.icon} {p.label} ) diff --git a/projects/keepkey-vault/src/mainview/components/Dashboard.tsx b/projects/keepkey-vault/src/mainview/components/Dashboard.tsx index 1397daea..db1a8932 100644 --- a/projects/keepkey-vault/src/mainview/components/Dashboard.tsx +++ b/projects/keepkey-vault/src/mainview/components/Dashboard.tsx @@ -648,7 +648,7 @@ function formatTimeAgo(ts: number, t: (key: string, opts?: Record(null) - const [selectedChainAction, setSelectedChainAction] = useState<"send" | "receive" | "swap" | undefined>(undefined) + const [selectedChainAction, setSelectedChainAction] = useState<"send" | "receive" | "swap" | "privacy" | undefined>(undefined) const [selectedChainInitialToken, setSelectedChainInitialToken] = useState(undefined) const [showActivityPage, setShowActivityPage] = useState(false) const [activityDefaultChain, setActivityDefaultChain] = useState('') @@ -665,7 +665,7 @@ export function Dashboard({ onLoaded, watchOnly, watchOnlyDeviceId, onOpenSettin }, []) const [drilledChainId, setDrilledChainId] = useState(null) const [swapDialogChain, setSwapDialogChain] = useState(null) - const openChainPage = useCallback((chain: ChainDef, action?: "send" | "receive" | "swap", token?: TokenBalance) => { + const openChainPage = useCallback((chain: ChainDef, action?: "send" | "receive" | "swap" | "privacy", token?: TokenBalance) => { // Swap routes directly to SwapDialog — skip the AssetPage shell that // would otherwise show the Receive view underneath. if (action === "swap") { @@ -1759,44 +1759,47 @@ export function Dashboard({ onLoaded, watchOnly, watchOnlyDeviceId, onOpenSettin borderRadius="999px" > {([ - { id: 'receive' as const, label: 'Receive', icon: ( - + { id: 'receive' as const, label: 'Receive', color: '#4ade80', bg: 'rgba(74,222,128,0.12)', icon: ( + ) }, - { id: 'send' as const, label: 'Send', icon: ( - + { id: 'send' as const, label: 'Send', color: '#fb923c', bg: 'rgba(251,146,60,0.12)', icon: ( + ) }, - { id: 'swap' as const, label: 'Swap', icon: ( - + { id: 'swap' as const, label: 'Swap', color: '#60a5fa', bg: 'rgba(96,165,250,0.12)', icon: ( + ) }, - ]).map((p) => { - const isPrimary = p.id === 'receive' - return ( - openChainPage(dchain, p.id)} - display="flex" - alignItems="center" - gap="2" - px="5" - py="2.5" - borderRadius="999px" - fontSize="13px" - fontWeight="500" - letterSpacing="-0.005em" - color={isPrimary ? "var(--ink-0)" : "var(--text-2)"} - bg={isPrimary ? "var(--gold)" : "transparent"} - _hover={isPrimary ? {} : { color: "var(--text-0)", bg: "var(--ink-3)" }} - transition="all 0.18s" - cursor="pointer" - minW="110px" - justifyContent="center" - > - {p.icon} - {p.label} - - ) - })} + ...(dchain.id === 'zcash' && zcashEnabled ? [{ + id: 'privacy' as const, label: 'Privacy', color: '#a78bfa', bg: 'rgba(167,139,250,0.12)', icon: ( + + ), + }] : []), + ]).map((p) => ( + openChainPage(dchain, p.id)} + display="flex" + alignItems="center" + gap="1.5" + px="4" + py="2" + borderRadius="999px" + fontSize="13px" + fontWeight="600" + letterSpacing="-0.01em" + color={p.color} + bg={p.bg} + border="1px solid transparent" + _hover={{ borderColor: p.color, bg: p.bg, opacity: 0.9 }} + transition="all 0.15s" + cursor="pointer" + minW="100px" + justifyContent="center" + > + {p.icon} + {p.label} + + ))} {/* Token list (only when the chain has clean tokens). */} diff --git a/projects/keepkey-vault/src/mainview/components/DeviceSettingsDrawer.tsx b/projects/keepkey-vault/src/mainview/components/DeviceSettingsDrawer.tsx index a4d2ae5f..7315a047 100644 --- a/projects/keepkey-vault/src/mainview/components/DeviceSettingsDrawer.tsx +++ b/projects/keepkey-vault/src/mainview/components/DeviceSettingsDrawer.tsx @@ -4,7 +4,7 @@ import { Box, Flex, Text, VStack, Button, Input, IconButton } from "@chakra-ui/r import { useTranslation } from "react-i18next" import { LanguageSelector } from "../i18n/LanguageSelector" import { CurrencySelector } from "./CurrencySelector" -import { rpcRequest } from "../lib/rpc" +import { rpcRequest, onRpcMessage } from "../lib/rpc" import { IS_MAC } from "../lib/platform" import { Z } from "../lib/z-index" import type { DeviceStateInfo, AppSettings } from "../../shared/types" @@ -201,6 +201,9 @@ export function DeviceSettingsDrawer({ open, onClose, deviceState, onCheckForUpd .catch(() => {}) }, [open, deviceState.state]) + // Keep window-focus indicator live — bun pushes this whenever alwaysOnTop changes + useEffect(() => onRpcMessage('window-focus-changed', (state) => setWindowFocusState(state)), []) + useEffect(() => { setLabel(deviceState.label || "") }, [deviceState.label]) useEffect(() => { if (!open) { setWipeConfirm(false); setRemovePinConfirm(false); setResetConfirm(false) } }, [open]) @@ -286,6 +289,12 @@ export function DeviceSettingsDrawer({ open, onClose, deviceState, onCheckForUpd setReleasingWindowFocus(false) }, []) + const toggleWindowAlwaysOnTop = useCallback(async (enabled: boolean) => { + try { + await rpcRequest("setWindowAlwaysOnTop", { enabled }, 5000) + } catch (e: any) { console.error("setWindowAlwaysOnTop:", e) } + }, []) + const toggleRestApi = useCallback(async (enabled: boolean) => { setTogglingRestApi(true) try { @@ -1205,7 +1214,7 @@ export function DeviceSettingsDrawer({ open, onClose, deviceState, onCheckForUpd )} - {/* Always on Top status + override */} + {/* Always on Top toggle */} @@ -1227,29 +1236,13 @@ export function DeviceSettingsDrawer({ open, onClose, deviceState, onCheckForUpd - {windowFocusState?.alwaysOnTop && ( - - {releasingWindowFocus ? "..." : "Force Release"} - - )} + Vault raises itself to the front when a signing or pairing request arrives. - Use Force Release if the window is stuck on top after a cancelled request. diff --git a/projects/keepkey-vault/src/shared/chains.ts b/projects/keepkey-vault/src/shared/chains.ts index 58591d2a..7204bc67 100644 --- a/projects/keepkey-vault/src/shared/chains.ts +++ b/projects/keepkey-vault/src/shared/chains.ts @@ -10,7 +10,7 @@ export interface ChainDef { networkId: string // CAIP-2 (derived from pioneer-caip) caip: string // CAIP-19 (derived from pioneer-caip) decimals: number // Base decimals (derived from pioneer-caip) - chainFamily: 'utxo' | 'evm' | 'cosmos' | 'xrp' | 'solana' | 'zcash-shielded' | 'tron' | 'ton' + chainFamily: 'utxo' | 'evm' | 'cosmos' | 'xrp' | 'solana' | 'zcash-shielded' | 'tron' | 'ton' | 'hive' color: string rpcMethod: string signMethod: string @@ -263,6 +263,15 @@ const CONFIGS: ChainConfig[] = [ explorerTxUrl: 'https://tonscan.org/tx/{{txid}}', minFirmware: '7.14.0', }, + { + id: 'hive', chain: 'HIVE' as any, coin: 'Hive', symbol: 'HIVE', + chainFamily: 'hive', color: '#E31337', + rpcMethod: 'hiveGetPublicKey', signMethod: 'hiveSignTx', + defaultPath: [0x8000002C, 0x800004FB, 0x80000000, 0, 0], + explorerAddressUrl: 'https://hiveblocks.com/@{{address}}', + explorerTxUrl: 'https://hiveblocks.com/tx/{{txid}}', + minFirmware: '7.14.0', + }, ] // Fallbacks for chains not fully covered by pioneer-caip @@ -270,16 +279,19 @@ const CAIP_FALLBACKS: Record = { GNO: 'eip155:100/slip44:60', TRX: 'tron:27Lqcw/slip44:195', TON: 'ton:-239/slip44:607', + HIVE: 'hive:beeab0de/slip44:1275', } const NETWORKID_FALLBACKS: Record = { GNO: 'eip155:100', TRX: 'tron:27Lqcw', TON: 'ton:-239', + HIVE: 'hive:beeab0de', } const DECIMAL_FALLBACKS: Record = { GNO: 18, TRX: 6, TON: 9, + HIVE: 3, } // Derive CAIP identifiers from pioneer-caip — single source of truth From 062c36e650d8cc1cb508fdefb3ef8cd7a411af94 Mon Sep 17 00:00:00 2001 From: highlander Date: Fri, 22 May 2026 17:27:15 -0300 Subject: [PATCH 15/15] fix(ui): clip orbital overflow to unblock action button clicks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The orbital box size is width-based only (window.innerWidth - 420) and can exceed the vertical space the sun Flex is allocated. On typical viewports the ~440px orbital bled ~90px into the button row, making the top half of Receive/Send/Swap unclickable when mousing in from above. Fix: - overflow:hidden + zIndex:1 on the sun Flex — clips any orbital bleed - position:relative + zIndex:2 on the action button row — ensures buttons always sit above the orbital stacking context --- .../src/mainview/components/Dashboard.tsx | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/projects/keepkey-vault/src/mainview/components/Dashboard.tsx b/projects/keepkey-vault/src/mainview/components/Dashboard.tsx index db1a8932..b300f90f 100644 --- a/projects/keepkey-vault/src/mainview/components/Dashboard.tsx +++ b/projects/keepkey-vault/src/mainview/components/Dashboard.tsx @@ -1554,8 +1554,10 @@ export function Dashboard({ onLoaded, watchOnly, watchOnlyDeviceId, onOpenSettin - {/* Top: orbital widget / donut / welcome — vertically centered */} - + {/* Top: orbital widget / donut / welcome — vertically centered. + overflow:hidden prevents the orbital box from visually and + pointer-event-wise spilling into the action button row below. */} + {hasAnyBalance ? (() => { if (drilledChainId && viewMode === 'orbital') { const dchain = visibleChains.find(c => c.id === drilledChainId) @@ -1704,7 +1706,8 @@ export function Dashboard({ onLoaded, watchOnly, watchOnlyDeviceId, onOpenSettin {/* Below the sun: token list / action buttons / donut legend / empty. - Fixed min-height keeps the sun's y-position stable across modes. */} + Fixed min-height keeps the sun's y-position stable across modes. + position+zIndex ensures this row sits above any orbital overflow. */}