diff --git a/docs/handoff-near-intents-btc-eth.md b/docs/handoff-near-intents-btc-eth.md deleted file mode 100644 index 08248811..00000000 --- a/docs/handoff-near-intents-btc-eth.md +++ /dev/null @@ -1,205 +0,0 @@ -# Handoff: BTC→ETH via ShapeShift NEAR Intents - -**Status**: Pioneer (blue) deployed ✅ — Vault changes committed locally, needs PR -**Branch**: `portfolio-debug` in keepkey-vault-v11 -**Pioneer branch**: `release/dedup-scam-filter` → api-blue.keepkey.info - ---- - -## What was fixed - -THORChain is globally halted (all 43 pools, `trading_halted: true`). MayaChain is down (502). -ShapeShift routes BTC→ETH via **NEAR Intents** — a memo-less deposit: just send BTC to a -deposit address, no OP_RETURN memo required. Three layers needed fixing: - -### 1. Pioneer: `shapeshift-swap` client (`modules/intergrations/shapeshift-swap/src/index.ts`) - -ShapeShift's NEAR Intents response puts the BTC deposit address in `step.allowanceContract`, -not `step.transactionData.to` (which is `{}`). The transfer branch now reads: - -``` -depositAddress = txData.to || step.allowanceContract || originalQuote.recipientAddress -txParams.to = depositAddress -txParams.recipientAddress = depositAddress -txParams.swapper = step.source // 'NEAR Intents' -``` - -### 2. Pioneer: router (`modules/pioneer/pioneer-router/src/index.ts`) - -- `hasInstructions` now accepts `transfer`-type txs that have a destination address (no memo required). -- `MEMOLESS_SUPPORT` now includes `shapeshiftSwap: true` so `memoless: true` requests route via NEAR Intents instead of returning "No quotes available". - -### 3. Vault: `swap-parsing.ts` + `swap.ts` - -Two memo-required guards each needed a NEAR Intents exemption: - -```ts -const isMemolessTransfer = !!inboundAddress && swapper === 'NEAR Intents' -``` - -Files changed: -- `projects/keepkey-vault/src/bun/swap-parsing.ts` — line ~185 -- `projects/keepkey-vault/src/bun/swap.ts` — lines ~401 and ~683 - ---- - -## How to test locally - -### Prerequisites - -- Pioneer running locally (`make start` in pioneer monorepo, port 9001) -- Vault running against local Pioneer (or use `--blue` flag in the e2e test to hit blue) -- BTC in the wallet (need ~0.001+ BTC + fees) -- KeepKey connected - -### 1. Verify Pioneer returns a NEAR Intents quote - -```bash -curl -s -X POST http://localhost:9001/api/v1/quote \ - -H "Content-Type: application/json" \ - -d '{ - "sellAsset": "bip122:000000000019d6689c085ae165831e93/slip44:0", - "sellAmount": "0.001", - "buyAsset": "eip155:1/slip44:60", - "recipientAddress": "0xYOUR_ETH_ADDRESS", - "senderAddress": "YOUR_BTC_ADDRESS" - }' | python3 -m json.tool -``` - -**Expected response shape**: -```json -[{ - "integration": "shapeshiftSwap", - "quote": { - "swapper": "NEAR Intents", - "txs": [{ - "type": "transfer", - "txParams": { - "to": "bc1q...", ← BTC deposit address (from allowanceContract) - "recipientAddress": "bc1q...", - "memo": "", ← intentionally empty - "swapper": "NEAR Intents" - } - }] - } -}] -``` - -Key things to confirm: -- `txs[0].type` is `"transfer"` (not `"EVM"`) -- `txParams.to` is a valid `bc1q...` bech32 address -- `txParams.memo` is empty/absent -- `quote.swapper` is `"NEAR Intents"` - -### 2. Check swap health (verify THORChain is still halted) - -```bash -curl -s http://localhost:9001/api/v1/swap/health | python3 -m json.tool -# or against blue: -curl -s https://api-blue.keepkey.info/api/v1/swap/health | python3 -m json.tool -``` - -THORChain should show `"status": "degraded"` with BTC in `haltedPools`. -ShapeShift and Relay should show `"status": "ok"`. - -### 3. Apply vault changes - -The vault changes are on `portfolio-debug` in keepkey-vault-v11. Cherry-pick or apply manually: - -**`src/bun/swap-parsing.ts`** — around the `!memo && !hasPrebuiltTx` check (~line 185): - -```diff -+ // For memo-less UTXO swaps (e.g. NEAR Intents via ShapeShift), the deposit -+ // address IS the only instruction — no memo or calldata needed. -+ const isMemolessTransfer = !!inboundAddress && swapper === 'NEAR Intents' - if (!memo && !hasPrebuiltTx && !isNativeDeposit) { -+ if (!memo && !hasPrebuiltTx && !isNativeDeposit && !isMemolessTransfer) { - throw new Error('Quote returned no swap instructions ...') - } -``` - -**`src/bun/swap.ts`** — two places, same pattern: - -```diff - const hasPrebuiltTx = !!params.relayTx - const isNativeDeposit = isNativeDepositCaip(params.fromCaip) -+ const isMemolessTransfer = !!params.inboundAddress && params.swapper === 'NEAR Intents' - if (!params.inboundAddress && !isNativeDeposit && !hasPrebuiltTx) throw new Error(...) -- if (!params.memo && !hasPrebuiltTx) throw new Error('Missing swap memo from quote') -+ if (!params.memo && !hasPrebuiltTx && !isMemolessTransfer) throw new Error('Missing swap memo from quote') -``` - -(Occurs at ~line 400 in `executeSwap` and ~line 683 in `previewSwap`.) - -### 4. Run the e2e swap test - -```bash -cd e2e/swaps/e2e-swap-suite - -# Against blue Pioneer + local vault (typical local dev setup): -bun run simple-swap \ - "bip122:000000000019d6689c085ae165831e93/slip44:0" \ - "eip155:1/slip44:60" \ - "0.001" \ - --blue - -# Against local Pioneer: -bun run simple-swap \ - "bip122:000000000019d6689c085ae165831e93/slip44:0" \ - "eip155:1/slip44:60" \ - "0.001" \ - --local -``` - -**Expected flow**: -1. Quote fetched — `shapeshiftSwap / NEAR Intents` selected -2. Preview builds — plain BTC send to `bc1q...` deposit address, no memo -3. KeepKey shows "Send 0.001 BTC to bc1q..." (no OP_RETURN output) -4. User confirms on device -5. BTC tx broadcasts -6. ~13 minutes later ETH arrives at recipient address - -### 5. What "success" looks like in the UI - -When THORChain is halted, the swap dialog should: -- Show amber/red dot on THORChain in the provider health bar (if implemented) -- Fall through to ShapeShift NEAR Intents automatically -- Show no memo field in the preview (NEAR Intents is memo-less) -- Show estimated time ~13 minutes (812s from ShapeShift API) - ---- - -## Why the UTXO tx builder works without changes - -`createUnsignedUtxoTx` already handles empty memo correctly: - -```ts -const memoRaw = memo && memo.trim() ? memo.trim() : undefined -// ... -...(memoRaw ? { opReturnData: memoRaw } : {}), // skipped when empty -``` - -An empty memo produces a plain BTC send to `params.inboundAddress` (the deposit address) -with no OP_RETURN output — exactly what NEAR Intents requires. - ---- - -## Pairs that work via NEAR Intents - -NEAR Intents routes any UTXO→EVM pair ShapeShift supports. Confirmed working: -- BTC → ETH -- BTC → USDC (eip155:1) -- BTC → any EVM token ShapeShift lists - -UTXO→UTXO (BTC→LTC) and EVM→UTXO are not supported by NEAR Intents. - ---- - -## Current network status (as of 2026-05-15) - -| Integration | Status | Notes | -|---|---|---| -| THORChain | ❌ All pools halted | `trading_halted: true` on all 43 pools | -| MayaChain | ❌ Down | 502 from midgard | -| ShapeShift NEAR Intents | ✅ Working | BTC→ETH confirmed | -| Relay | ✅ Working | EVM↔EVM only | diff --git a/docs/handoffs/balance-no-walk-backwards.md b/docs/handoffs/balance-no-walk-backwards.md new file mode 100644 index 00000000..2ab85609 --- /dev/null +++ b/docs/handoffs/balance-no-walk-backwards.md @@ -0,0 +1,133 @@ +# Balance Cache: No-Walk-Backwards Audit & Fix + +**Date:** 2026-05-17 +**Branch:** `swapping-cleanup` +**Symptom:** Fresh wallet shows 0 balances; Pioneer cold-cache responses can overwrite real cached balances with 0 + +--- + +## The Rule + +> A balance we have **seen before and saved in SQLite** must never be replaced by a lower (including zero) value from a transient Pioneer response. Show stale-but-real data with a timestamp rather than wiping to 0. + +--- + +## Root Causes Found + +### 1. `setCachedBalances` — blind `INSERT OR REPLACE` + +**Before:** `INSERT OR REPLACE INTO balances ...` — SQLite `OR REPLACE` deletes the old row and inserts a new one. A Pioneer response of `balanceUsd: 0` for any chain overwrote the cached value, even if the user had 1 BTC cached. + +**Affects:** Full portfolio refresh (`getBalances` RPC). If Pioneer returns 0 for a chain (cold cache, timeout partial result), that 0 is written to SQLite and shown on next load. + +### 2. `updateCachedBalance` — same blind `INSERT OR REPLACE` + +**Affects:** Single-chain refresh (`getBalance` RPC, also BTC aggregate sync after btc-accounts-update). Same problem. + +### 3. `saveCachedPubkey` — defaults balance to `'0'` + +**Before:** `balance || '0'` — if the per-xpub balance from Pioneer was missing/undefined, `'0'` was stored. Combined with `INSERT OR REPLACE`, this erased real cached per-xpub balances. + +**Affects:** BTC per-account cache; read back during swap routing and `getCachedPubkeys` fallback. + +### 4. UI `refreshBalances` — replaced entire map with live result + +**Before:** +```typescript +const map = new Map() +for (const b of result) map.set(b.chainId, b) +setBalances(map) +``` + +When a Pioneer chunk fails, its chains are absent from `result`. The old `Map` constructor discarded the current in-memory balances, so failed-chunk chains disappeared from the UI for the session (even though they were preserved in DB by the fix above). + +--- + +## Fixes Applied + +### `db.ts` — No-walk-backwards upsert (all three write paths) + +Changed all three functions from `INSERT OR REPLACE` to `INSERT ... ON CONFLICT DO UPDATE` with conditional column updates: + +```sql +INSERT INTO balances (...) VALUES (...) +ON CONFLICT(device_id, chain_id) DO UPDATE SET + symbol = excluded.symbol, + address = CASE WHEN excluded.address != '' THEN excluded.address ELSE address END, + balance = CASE WHEN CAST(excluded.balance_usd AS REAL) > 0 THEN excluded.balance ELSE balance END, + balance_usd = CASE WHEN CAST(excluded.balance_usd AS REAL) > 0 THEN excluded.balance_usd ELSE balance_usd END, + tokens_json = CASE WHEN CAST(excluded.balance_usd AS REAL) > 0 THEN excluded.tokens_json ELSE tokens_json END, + updated_at = CASE WHEN CAST(excluded.balance_usd AS REAL) > 0 THEN excluded.updated_at ELSE updated_at END +``` + +**Effect:** +- New non-zero balance → row is updated normally (balance, tokens, `updated_at` all advance). +- Pioneer returns 0 but DB has non-zero → only `symbol` and `address` are updated; balance/tokens/timestamp are preserved. +- Row doesn't exist yet → inserted as-is (standard INSERT path, no conflict). +- `updated_at` only advances when the balance is confirmed non-zero → the Dashboard's "X ago · Refresh" accurately shows when the balance was last confirmed, not just when Pioneer was last called. + +Applied identically to: +- `setCachedBalances` (full portfolio write, `balances` table) +- `updateCachedBalance` (single-chain upsert, `balances` table) +- `saveCachedPubkey` (per-xpub write, `cached_pubkeys` table, conflict key is `(device_id, chain_id, path)`) + +### `types.ts` — `ChainBalance.updatedAt` + +Added `updatedAt?: number` to `ChainBalance`. Set by `getCachedBalances` from the `updated_at` DB column. This is the timestamp of the last confirmed non-zero balance for that chain. + +### `Dashboard.tsx` — UI-level no-walk-backwards merge + +Changed `refreshBalances` to start from the current in-memory `balances` map and merge live results into it, rather than replacing the entire map: + +```typescript +const map = new Map(balances) // preserve existing +for (const b of result) { + const prev = map.get(b.chainId) + if (!prev || b.balanceUsd > 0 || parseFloat(b.balance || '0') > 0) { + map.set(b.chainId, b) + } else { + console.log(`[Dashboard] Preserving prior ${b.chainId} balance — Pioneer returned 0`) + } +} +setBalances(map) +``` + +**Effect:** Chains from failed Pioneer chunks remain visible in the UI during the session (using the last-displayed value). They'll be refreshed on the next successful call or app restart (where DB has the correct preserved value). + +--- + +## What Still Clears to Zero (Intentional) + +These paths bypass the no-walk-backwards rule and are correct: + +| Path | Why intentional | +|------|----------------| +| `clearBalances(deviceId)` | Triggered on seed change — the old seed's balances are wrong for the new seed | +| `deleteDeviceSnapshot(deviceId)` | Device forgotten by user — full wipe expected | +| Factory reset | User explicitly erasing all data | +| `clearCachedPubkeys(deviceId)` | Paired with seed change; same rationale | + +--- + +## How "Last Date in UI" Works + +`getCachedBalances` computes `maxUpdatedAt` = max of `updated_at` across all cached chains. +Dashboard shows this as `cacheUpdatedAt` in the "Refresh" button: +- **Teal**: < 1 hour old +- **Gold**: 1h–24h old +- **Rose**: > 24h old + +With the no-walk-backwards fix, `updated_at` only advances when a chain's balance is confirmed non-zero. If Pioneer returns 0 for a chain (cold cache), `updated_at` stays at the last good confirmation time. So the UI will show "from 3 days ago" if that's when we last confirmed a real balance — which is accurate. + +Per-chain `updatedAt` is now available via `ChainBalance.updatedAt` for future UI use (e.g., per-chain stale indicators in the chain list). + +--- + +## Files Changed + +| File | Change | +|------|--------| +| `src/bun/db.ts` | `setCachedBalances`, `updateCachedBalance`, `saveCachedPubkey` — no-walk-backwards upsert SQL | +| `src/bun/db.ts` | `getCachedBalances` — include `updatedAt` per chain in returned `ChainBalance` | +| `src/shared/types.ts` | `ChainBalance.updatedAt?: number` | +| `src/mainview/components/Dashboard.tsx` | `refreshBalances` — merge into existing map instead of replacing | diff --git a/docs/handoffs/balance-portfolio-diagnostic-1.2.17-to-current.md b/docs/handoffs/balance-portfolio-diagnostic-1.2.17-to-current.md new file mode 100644 index 00000000..aee3c999 --- /dev/null +++ b/docs/handoffs/balance-portfolio-diagnostic-1.2.17-to-current.md @@ -0,0 +1,339 @@ +# Balance & Portfolio Diagnostic: v1.2.17 → current + +**Branch:** `swapping-cleanup` +**Symptom:** Fresh wallet shows 0 balances across all chains +**Date:** 2026-05-17 + +--- + +## Executive Summary + +Five distinct changes since 1.2.17 can each independently produce 0 balances on a fresh wallet. +The most critical is the **error handling inversion** (`efadfdb5`) — what used to silently fall back +to showing 0s now throws and surfaces a Pioneer error banner. Combined with the new chunked Pioneer +call architecture, a single network hiccup during the cold-start Pioneer call drops the entire +refresh cycle and leaves the UI in an error state with no balance data. + +--- + +## Change Index (chronological, balance-relevant only) + +| Commit | Description | +|--------|-------------| +| `efadfdb5` | **Stabilize balances and swap pricing** — silent 0-fallback replaced with hard throw | +| `01c86844` | **Switch EVM asset balances by address** — `updateAddressBalance` → `setAddressChainBalance` + `recalculateBalanceUsd` | +| `f5880924` | **Improve Windows Pioneer balance refresh** — sequential chunking introduced (8 pubkeys/chunk, 20s each) | +| `1fdbd581` | **Balance refresh fallback handling** — skip zeroing chains from failed chunks; partial > nothing | +| `232d81d9` | **Fix incomplete portfolio refresh handling** — sequential loop → concurrent `mapWithConcurrency` (4 parallel) + 45s total timeout | +| `74120bd6` | **Portfolio debug, token dedup** — total timeout 90s; `seenByOwnerCaip` dedup; `evmTokensByOwner` dedup | +| `7708deee` | **Sync BTC DB cache with aggregate total** — `updateCachedBalance(bitcoin)` after every `btc-accounts-update` | +| `b11b63e9` | **Fix dashboard token warning for empty wallets** — removed the token-warning banner (was a diagnostic signal) | + +--- + +## Change 1: Silent fallback → hard throw (`efadfdb5`) + +### Before (1.2.17) + +```typescript +} catch (e: any) { + console.warn('[getBalances] Portfolio API failed:', e.message) + const seen = new Set() + for (const entry of pubkeys) { + if (seen.has(entry.chainId)) continue + seen.add(entry.chainId) + results.push({ chainId: entry.chainId, symbol: entry.symbol, balance: '0', balanceUsd: 0, address: entry.pubkey }) + } +} +``` + +On Pioneer failure, the old code returned 0-balance entries for every chain. The UI loaded +normally and showed zero. No error banner, no indication anything failed. + +### After (current) + +```typescript +} catch (e: any) { + const message = getPioneerPortfolioErrorMessage(e) + console.warn('[getBalances] Portfolio API failed:', message) + try { rpc.send['pioneer-error']({ message, url: getPioneerApiBase() }) } catch {} + throw new Error(`Balance server error: ${message}`) +} +``` + +On Pioneer failure, the RPC handler now throws. The `getBalances` RPC call rejects. +Dashboard catches it and shows the Pioneer error banner with a "change server" prompt. + +**Fresh wallet impact:** A fresh wallet has cold Pioneer cache (never looked up these addresses). +Pioneer may be slow or time out on first lookup. Under the old code this returned 0s silently. +Under the new code this surfaces as an error — which is correct behavior, but means users +see an error instead of "loading" on first launch. + +--- + +## Change 2: Single API call → chunked parallel calls (`f5880924`, `232d81d9`, `74120bd6`) + +### Before (1.2.17) + +```typescript +const resp = await withTimeout( + pioneer.GetPortfolioBalances( + { pubkeys: pubkeys.map(p => ({ caip: p.caip, pubkey: p.pubkey })) }, + { forceRefresh: true } + ), + PIONEER_TIMEOUT_MS, // 60,000ms + 'GetPortfolioBalances' +) +``` + +One call, all pubkeys (typically 30–50 depending on chains), 60s total timeout. + +### After (current) + +```typescript +const PIONEER_PORTFOLIO_CHUNK_SIZE = 8 +const PIONEER_PORTFOLIO_CHUNK_TIMEOUT_MS = 20_000 +const PIONEER_PORTFOLIO_MAX_CONCURRENCY = 4 +const PIONEER_PORTFOLIO_TOTAL_TIMEOUT_MS = 90_000 + +const pubkeyChunks = chunkArray(pubkeys, PIONEER_PORTFOLIO_CHUNK_SIZE) +const chunkResults = await withTimeout( + mapWithConcurrency(pubkeyChunks, PIONEER_PORTFOLIO_MAX_CONCURRENCY, async (chunk, i) => { + // ... each chunk: 20s timeout, retry without extraContracts on schema error + return { entries: [...], error: null } // or { entries: [], error: 'msg' } + }), + PIONEER_PORTFOLIO_TOTAL_TIMEOUT_MS, // 90s outer + 'GetPortfolioBalances chunks' +) +``` + +30–50 pubkeys → 4–7 chunks, up to 4 running in parallel, 20s per chunk, 90s outer wall clock. + +**Fresh wallet impact (critical path):** +1. A fresh wallet sends all pubkeys to Pioneer for the first time (cold cache). +2. Pioneer must index/scan all addresses — can be slow (15–25s per chain on first hit). +3. If any chunk hits the 20s per-chunk timeout, that chunk's chains show 0. +4. If ALL chunks fail (Pioneer down / network timeout), the outer throw fires → error banner → 0 balances. +5. Partial failure (some chunks succeed, some fail): those chains show 0 but no error is surfaced + (only a console warning), so users see mixed balances with no explanation. + +**What triggers "all 0":** total Pioneer timeout (outer 90s), or Pioneer completely unreachable. +**What triggers "partial 0":** individual chunk timeouts (20s), or chunk-level 4xx/5xx from Pioneer. + +--- + +## Change 3: EVM balance aggregation rewrite (`01c86844`) + +### Before (1.2.17) + +```typescript +if (usd > 0) evmAddresses.updateAddressBalance(entry.pubkey, usd) +// evmChainAgg.set(chainId, { balance, usd, address, symbol }) +``` + +`updateAddressBalance` added `usd` directly to `EvmTrackedAddress.balanceUsd`. + +### After (current) + +```typescript +const entryTokens = evmTokensByOwner.get(`${entry.chainId}:${entry.pubkey.toLowerCase()}`) || [] +const entryTokenUsd = entryTokens.reduce((sum, t) => sum + t.balanceUsd, 0) +evmAddresses.setAddressChainBalance(entry.pubkey, entry.chainId, { + balance: bal > 0 ? bal.toFixed(18).replace(/0+$/, '').replace(/\.$/, '') : '0', + balanceUsd: usd + entryTokenUsd, + nativeBalanceUsd: usd, +}) +// recalculateBalanceUsd sums address.chainBalances[*].balanceUsd +``` + +Each address now stores per-chain balances in `chainBalances: Record`. +`recalculateBalanceUsd()` sums all chain balances to get `address.balanceUsd`. + +**Fresh wallet impact:** +- Before: if Pioneer returned any usd > 0, it was added immediately. +- After: if Pioneer returns 0 for a chain (fresh wallet, unindexed), `chainBalances[chainId].balanceUsd = 0`. + `recalculateBalanceUsd` sums zero → `address.balanceUsd = 0`. The address shows 0. +- This is correct behavior, not a regression — but the chain/token UI in AssetPage now + depends on `evmTokensByOwner` being populated. If it isn't (fresh wallet, no tokens), the + per-address token view is empty. + +**resetBalances change:** + +```typescript +// Before +resetBalances(): void { for (const a of this.addresses) a.balanceUsd = 0 } + +// After — accepts chainId to reset one chain before single-chain refresh +resetBalances(chainId?: string): void { + for (const a of this.addresses) { + if (chainId) { + if (a.chainBalances) delete a.chainBalances[chainId] + this.recalculateBalanceUsd(a) + } else { + a.balanceUsd = 0 + a.chainBalances = {} + } + } +} +``` + +On full `getBalances`, `resetBalances()` (no chainId) zeroes everything. +On single-chain `getBalance`, `resetBalances(chain.id)` only removes that chain — +preserving other chains' contributions to the address USD total. + +--- + +## Change 4: Cache write guard change (`1fdbd581` → `232d81d9` → `74120bd6`) + +### 1.2.17 + +```typescript +if (results.length > 0 && !engine.isPassphraseWallet) setCachedBalances(deviceId, results) +``` + +### After `1fdbd581` (interim) + +```typescript +// Only skip cache on partial/chunk failures — was blocking cache writes on any failure +``` + +### Final state (current) + +```typescript +// Cache balances (fire-and-forget). +// Write partial results even on chunk failures — chains from failed chunks simply +// won't be in results, so the next getCachedBalances staleness check will flag +// them as missing and trigger another refresh. Partial is always better than nothing. +if (results.length > 0 && !engine.isPassphraseWallet) setCachedBalances(deviceId, results) +``` + +**Fresh wallet impact:** On the very first successful balance fetch, the cache is written. +On total failure (throw), the cache is never written. On partial failure (some chunks failed), +the partial results ARE written. Next refresh will re-fetch the missing chains. + +--- + +## Change 5: BTC DB cache sync (`7708deee`) + +### Problem + +`getCachedBalances(deviceId)` was returning the last single-xpub Bitcoin value rather than +the multi-account aggregate. SwapDialog and REST endpoints that read from cache saw stale BTC. + +### Fix + +After every `btc-accounts-update` push (both in `getBalances` and `getBalance`), now also +calls `updateCachedBalance(devId, { chainId: 'bitcoin', balance: totalBalance, ... })`. + +```typescript +const btcSet = btcAccounts.toAccountSet() +try { rpc.send['btc-accounts-update'](btcSet) } catch {} +// NEW: sync DB cache +updateCachedBalance(devId, { + chainId: 'bitcoin', symbol: 'BTC', + balance: btcSet.totalBalance, + balanceUsd: btcSet.totalBalanceUsd, + nativeBalanceUsd: btcSet.totalBalanceUsd, + address: btcAccounts.getSelectedXpub()?.xpub || '', +}) +``` + +**Fresh wallet impact:** Fresh wallet → BTC account manager just initialized → `totalBalance = '0'` → +`updateCachedBalance` writes 0 to DB. This is correct, not a regression. + +--- + +## Diagnostic Checklist for "0 Balances on Fresh Wallet" + +Run with vault console open. Look for these log lines: + +### Step 1: Did Pioneer initialize? + +``` +[getBalances] Pioneer init failed (will return zero balances): +``` + +If this appears → Pioneer URL is unreachable or auth failed. Check Pioneer server URL in +Settings → Advanced → Pioneer Servers. + +``` +[getBalances] pubkeys ( BTC xpubs) → chunked GetPortfolioBalances calls +``` + +N should be > 20 (all chains). M should be ≥ 1 (at least one BTC xpub). If M = 0, BTC +account manager failed to initialize. If N < 15, some chain derivation failed. + +### Step 2: Did chunks succeed? + +``` +[getBalances] Portfolio chunk X/Y failed (caip1, caip2): +``` + +Each line = one chunk of 8 chains that timed out or got an API error. +If you see this for all chunks → Pioneer is down or addresses have no cached data yet. + +``` +[getBalances] Partial portfolio response: X/Y chunks succeeded — failed chains will show 0 +``` + +Partial success. Failed chains will be missing from results. + +``` +[getBalances] All Y portfolio chunks failed +``` + +Total failure → error thrown → Pioneer error banner shown → 0 balances. + +### Step 3: Did entries come back? + +``` +[getBalances] GetPortfolioBalances response: entries +[getBalances] After classification: natives, tokens +``` + +N = 0 on a fresh wallet with no Pioneer cache yet. This is expected on first launch — +Pioneer must index the addresses before it can return balances. + +### Step 4: Did the cache write succeed? + +``` +[getBalances] FINAL: chains, tokens, $ +``` + +If USD = 0 but you have funds, the balance server did not return your data. Try: +1. Wait 30–60s and hit refresh (Pioneer may be warming up cache for new addresses). +2. Change Pioneer server to a different endpoint. +3. Check whether the device ID / seed changed (factory reset clears cached pubkeys). + +--- + +## Root Cause Assessment: Fresh Wallet Shows 0 + +The most likely cause is **Pioneer cold-cache + per-chunk timeout**: + +1. Fresh wallet → addresses never seen by Pioneer. +2. Pioneer must scan on-chain for each address (Ethereum, Cosmos, BTC gap-limit scan, etc.). +3. This takes 10–30s per address on first lookup. +4. With `PIONEER_PORTFOLIO_CHUNK_TIMEOUT_MS = 20_000` (20s), Pioneer can't finish + scanning a brand-new BTC address (gap-limit scan) in time. +5. Chunk fails → chains in that chunk show 0 → no error surfaced to user → silent zeros. + +**In 1.2.17**, the same cold-start took up to 60s (single call) but was more likely to complete +because Pioneer could process all pubkeys at once and return partial results in one response. +Under chunking, a 20s timeout per chunk is much tighter than a 60s timeout for everything. + +**Fix candidates:** +- Increase `PIONEER_PORTFOLIO_CHUNK_TIMEOUT_MS` to 45_000 (match original 60s / ~1.3 chunks). +- Add a "first refresh" path that disables chunking and uses the original single-call with 60s. +- Show a "scanning new wallet..." banner when `allEntries.length === 0` after a successful call. + +--- + +## Files Changed (Balance/Portfolio Surface Area) + +| File | Lines changed | Role | +|------|--------------|------| +| `src/bun/index.ts` | ~1,100 | `getBalances` + `getBalance` RPC handlers, all Pioneer calls | +| `src/bun/evm-addresses.ts` | ~40 | `setAddressChainBalance`, `recalculateBalanceUsd`, `resetBalances(chainId)` | +| `src/bun/db.ts` | ~360 | `updateCachedBalance` (BTC aggregate sync), API log scoping | +| `src/mainview/components/Dashboard.tsx` | ~80 | Removed token-warning banner; `hasEverRefreshed` state | diff --git a/docs/handoffs/handoff-pioneer-btc-multikey-verified.md b/docs/handoffs/handoff-pioneer-btc-multikey-verified.md new file mode 100644 index 00000000..e26f9c07 --- /dev/null +++ b/docs/handoffs/handoff-pioneer-btc-multikey-verified.md @@ -0,0 +1,141 @@ +# Handoff: Pioneer BTC Multi-Xpub — Claim Disproved + +**Date**: 2026-05-16T22:38:59 UTC +**Branch**: `release/near-intents-refund-fix` (HEAD: `028ce2810`) +**Tested by**: Claude (follow-up session to `handoff-pioneer-btc-multikey.md`) + +--- + +## Claim Being Disputed + +The previous handoff (`handoff-pioneer-btc-multikey.md`) stated: + +> **Layer 1: Pioneer `GetPortfolioBalances` doesn't echo back pubkey on each response entry for all 3 BTC xpubs (xpub/ypub/zpub). Only the zpub matched.** + +This claim is **false** as of 2026-05-16. Pioneer correctly returns all 3 xpubs on both green and blue. + +--- + +## Proof: Live API Responses + +### Command used (same as handoff's reproduce curl, corrected endpoint) + +```bash +curl -X POST https://api.keepkey.info/api/v1/portfolio \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer test-key" \ + -d '{ + "pubkeys": [ + {"caip":"bip122:000000000019d6689c085ae165831e93/slip44:0","pubkey":"xpub6CXwXedtSY8ng1P8XULzYzFTJFujJamcKXaXDnBJzGYB7P56W1bkZLxeHmrAs9bmVgb9pbV9STMoiE6oyxs8DpvFuuYNKnxR4MrSM5aqgTp"}, + {"caip":"bip122:000000000019d6689c085ae165831e93/slip44:0","pubkey":"ypub6XKTae3tTEUCB6WaWVuHxJYYxoAw7uMthRfRrYSnnSvkFjksZfjG4igK1tvQRvejWVPjSrvU8DTPqkED6LRnxCwNkf77WPTD9xACU9REzDh"}, + {"caip":"bip122:000000000019d6689c085ae165831e93/slip44:0","pubkey":"zpub6rXHd37fxCQN5Rn5cVtP64gg1xosZ5KA3jomifTN7dWKQCY64PSbDXT3ehyG5kWbQfVCmb5ZhjyvSDUYx1jvDrxtb2B7wvSjdgPsjWKYnwB"} + ] + }' +``` + +> **Note**: The original handoff used `/api/v1/portfolio/balances` which returns 404. Correct path is `/api/v1/portfolio`. The OperationId `GetPortfolioBalances` is on `POST /portfolio`. + +--- + +### Green — `api.keepkey.info` — 2026-05-16T22:34:34 UTC + +| pubkey prefix | balance | valueUsd | isStale | fetchedAtISO | +|---|---|---|---|---| +| `xpub6CXwXedt…` | `0.00000000` | `$0.00` | false | 2026-05-16T22:34:34.863Z | +| `ypub6XKTae3t…` | `0.00063913` | `$49.99` | false | 2026-05-16T22:34:34.878Z | +| `zpub6rXHd37f…` | `0.00017924` | `$14.02` | false | 2026-05-16T22:34:34.874Z | + +**All 3 entries present. Each has `pubkey` field echoing back the submitted xpub string.** + +Raw response (truncated for readability): +```json +{ + "balances": [ + { + "caip": "bip122:000000000019d6689c085ae165831e93/slip44:0", + "pubkey": "xpub6CXwXedtSY8ng1P8XULzYzFTJFujJamcKXaXDnBJzGYB7P56W1bkZLxeHmrAs9bmVgb9pbV9STMoiE6oyxs8DpvFuuYNKnxR4MrSM5aqgTp", + "balance": "0.00000000", + "valueUsd": "0.00", + "isStale": false + }, + { + "caip": "bip122:000000000019d6689c085ae165831e93/slip44:0", + "pubkey": "ypub6XKTae3tTEUCB6WaWVuHxJYYxoAw7uMthRfRrYSnnSvkFjksZfjG4igK1tvQRvejWVPjSrvU8DTPqkED6LRnxCwNkf77WPTD9xACU9REzDh", + "balance": "0.00063913", + "valueUsd": "49.99", + "isStale": false + }, + { + "caip": "bip122:000000000019d6689c085ae165831e93/slip44:0", + "pubkey": "zpub6rXHd37fxCQN5Rn5cVtP64gg1xosZ5KA3jomifTN7dWKQCY64PSbDXT3ehyG5kWbQfVCmb5ZhjyvSDUYx1jvDrxtb2B7wvSjdgPsjWKYnwB", + "balance": "0.00017924", + "valueUsd": "14.02", + "isStale": false + } + ] +} +``` + +--- + +### Blue — `api-blue.keepkey.info` — 2026-05-16T22:38:59 UTC + +**Identical response** — all 3 entries, same balances, `isStale: false`. + +| pubkey prefix | balance | valueUsd | +|---|---|---| +| `xpub6CXwXedt…` | `0.00000000` | `$0.00` | +| `ypub6XKTae3t…` | `0.00063913` | `$49.99` | +| `zpub6rXHd37f…` | `0.00017924` | `$14.02` | + +--- + +## Why the Previous Session Saw "Only zpub Matched" + +The Pioneer server itself was **never broken**. Two things confused the previous session: + +### 1. Wrong endpoint in the curl +The handoff's reproduce command hit `/api/v1/portfolio/balances` which returns: +``` +Cannot POST /api/v1/portfolio/balances +``` +The actual endpoint is `/api/v1/portfolio`. The previous session never successfully called Pioneer — it hit a 404 and may have assumed Pioneer was the problem. + +### 2. Vault-side matching, not Pioneer +The log line `[getBalances] BTC match for zpub...: balance=0.00017924` comes from `keepkey-vault-v11/src/bun/index.ts` (the vault code), not from Pioneer. The vault was iterating its local `effectivePubkeys` and looking them up in the Pioneer response via `pureNatives.find(d => d.pubkey === entry.pubkey)`. If the vault's `effectivePubkeys` list was incomplete (missing xpub/ypub), or if those entries hadn't been populated in the vault's local state yet, only zpub would log a match — even with a perfect Pioneer response. + +The root cause was **vault-side**, not Pioneer-side. + +--- + +## Actual Root Cause (Corrected) + +| Layer | Location | Description | Status | +|---|---|---|---| +| 1 (claimed) | Pioneer server | Doesn't echo back all 3 pubkeys | **DOES NOT EXIST** — Pioneer works correctly | +| 2 (real) | `keepkey-vault-v11/src/bun/index.ts:2166` | `setCachedBalances` gated by `!hadChunkFailures` — a Pioneer timeout blocks cache write | **Real bug, vault-side** | +| 3 (real) | `keepkey-vault-v11/src/bun/index.ts` (AssetPage / swap panel) | BTC "AVAILABLE" showed single-xpub balance, not sum of all 3 | **Fixed in `swapping-cleanup` branch** | + +--- + +## Vault Fixes (Already in `swapping-cleanup`) + +These are the real fixes that address the symptom: + +| Fix | File | Status | +|---|---|---| +| Show aggregate BTC balance in swap/send panels | `AssetPage.tsx` | ✅ `swapping-cleanup` | +| Timeout-protect all Pioneer API calls in swap-tracker | `swap-tracker.ts` | ✅ `swapping-cleanup` | +| `localRefundOverridesPioneer` | `swap-tracker.ts` | ✅ `swapping-cleanup` | +| NEAR Intents min amount guard | `swap.ts`, `swap-parsing.ts` | ✅ `swapping-cleanup` | +| `buyAsset.address` populated | `swap-tracker.ts` | ✅ `swapping-cleanup` | + +--- + +## Definition of Done (Updated) + +- [x] Pioneer `GetPortfolioBalances` returns all 3 BTC xpubs with `pubkey` echoed — **verified live, both green and blue** +- [ ] Vault `swapping-cleanup` deployed to blue — vault shows `~0.00081837 BTC` as AVAILABLE +- [ ] `api/debug/portfolio` shows `bitcoin.balance ≈ 0.00081837` after fresh `getBalances` RPC + +**Next action**: Deploy `keepkey-vault-v11 swapping-cleanup` branch to blue (vault Vercel preview), verify swap panel shows `0.00081837 BTC`. diff --git a/docs/handoffs/handoff-pioneer-btc-multikey.md b/docs/handoffs/handoff-pioneer-btc-multikey.md new file mode 100644 index 00000000..c6cc1c22 --- /dev/null +++ b/docs/handoffs/handoff-pioneer-btc-multikey.md @@ -0,0 +1,158 @@ +# Handoff: Pioneer — BTC Multi-Xpub Balance Missing + +**Date**: 2026-05-16 +**Branch**: `swapping-cleanup` (vault fixes already committed) +**Vault path**: `/Users/highlander/WebstormProjects/keepkey-stack/projects/keepkey-vault-v11/projects/keepkey-vault/` +**Pioneer path**: `/Users/highlander/WebstormProjects/keepkey-stack/projects/pioneer/` + +--- + +## The Problem + +The vault's swap UI shows AVAILABLE BTC as one account's balance instead of the sum across all three account types (Legacy/SegWit/NativeSegWit). The cache in SQLite: + +``` +chain_id=bitcoin | balance=0.00020865 | address=zpub6rXHd37… +``` + +Expected sum from `cached_pubkeys` table: +| xpub prefix | balance | +|---|---| +| xpub (Legacy p2pkh) | 0.00000000 | +| ypub (SegWit p2sh-p2wpkh) | 0.00063913 | +| zpub (NativeSegWit p2wpkh) | 0.00017924 | +| **Total** | **0.00081837** | + +--- + +## Root Cause — Two Layers + +### Layer 1: Pioneer `GetPortfolioBalances` doesn't echo back all 3 pubkeys + +The vault sends all 3 xpubs to `GetPortfolioBalances`: +```json +{ "pubkeys": [ + { "caip": "bip122:…/slip44:0", "pubkey": "xpub6CXwX…" }, + { "caip": "bip122:…/slip44:0", "pubkey": "ypub6XKTa…" }, + { "caip": "bip122:…/slip44:0", "pubkey": "zpub6rXHd…" } +]} +``` + +Vault match logic (`index.ts` line ~2006): +```typescript +const match = pureNatives.find((d: any) => d.pubkey === entry.pubkey) + || pureNatives.find((d: any) => d.caip === entry.caip && d.address === entry.pubkey) +``` + +From the log, **only the zpub matched**: +``` +[getBalances] BTC match for zpub6rXHd37fxCQN5Rn5...: balance=0.00017924, usd=14.03 +``` + +No match lines for xpub or ypub, meaning Pioneer's response did not include entries with `pubkey` = the submitted xpub/ypub strings. + +**Either Pioneer is:** +1. Only returning a balance entry for the LAST submitted xpub (order-dependent bug), or +2. Aggregating all 3 into a single entry keyed by one pubkey (probably the last/first), or +3. Not echoing back the `pubkey` field on entries for xpub/ypub prefix types + +### Layer 2: Vault cache update blocked by Pioneer timeouts + +In `index.ts` line 2166: +```typescript +if (results.length > 0 && !hadChunkFailures && !engine.isPassphraseWallet) + setCachedBalances(deviceId, results) +``` + +A Pioneer timeout (observed in logs — `[ERROR] | Client | Operation error: Request timed out`) sets `hadChunkFailures = true`, so the cache is NEVER written even when partial data arrived. The UI then reads the stale 2-hour-old single-xpub value from DB. + +--- + +## What to Investigate in Pioneer + +### 1. `GetPortfolioBalances` endpoint +File: likely `pioneer-server/src/routes/portfolio.ts` or similar +Method: `POST /portfolio/balances` +Swagger op: `GetPortfolioBalances` + +**Questions:** +- When 3 xpubs with the SAME caip are submitted, does the server return 3 separate response entries each with `pubkey` echoed back? +- Or does it query a shared address set and return aggregated/deduplicated results? +- Does it return `pubkey` in each response entry? If not, the vault can't match. + +**Expected response shape** (what vault needs): +```json +[ + { "pubkey": "xpub6CXwX…", "caip": "bip122:…", "balance": "0", "valueUsd": 0 }, + { "pubkey": "ypub6XKTa…", "caip": "bip122:…", "balance": "0.00063913", "valueUsd": 49.97 }, + { "pubkey": "zpub6rXHd…", "caip": "bip122:…", "balance": "0.00017924", "valueUsd": 14.01 } +] +``` + +Each entry must echo back the submitted `pubkey` so the vault can match and aggregate. + +### 2. Reproduce the mismatch +```bash +# Hit Pioneer directly with the 3 xpubs +curl -X POST https://api.keepkey.info/api/v1/portfolio/balances \ + -H "Content-Type: application/json" \ + -d '{ + "pubkeys": [ + {"caip":"bip122:000000000019d6689c085ae165831e93/slip44:0","pubkey":"xpub6CXwXedtSY8ng1P8XULzYzFTJFujJamcKXaXDnBJzGYB7P56W1bkZLxeHmrAs9bmVgb9pbV9STMoiE6oyxs8DpvFuuYNKnxR4MrSM5aqgTp"}, + {"caip":"bip122:000000000019d6689c085ae165831e93/slip44:0","pubkey":"ypub6XKTae3tTEUCB6WaWVuHxJYYxoAw7uMthRfRrYSnnSvkFjksZfjG4igK1tvQRvejWVPjSrvU8DTPqkED6LRnxCwNkf77WPTD9xACU9REzDh"}, + {"caip":"bip122:000000000019d6689c085ae165831e93/slip44:0","pubkey":"zpub6rXHd37fxCQN5Rn5cVtP64gg1xosZ5KA3jomifTN7dWKQCY64PSbDXT3ehyG5kWbQfVCmb5ZhjyvSDUYx1jvDrxtb2B7wvSjdgPsjWKYnwB"} + ] + }' +``` + +**Verify:** Does the response contain 3 entries, each with `pubkey` field matching the submitted value? + +### 3. If Pioneer aggregates BTC xpubs +Pioneer may normalize all 3 xpubs to the same underlying address set (BIP32 derivation path is the same, just different output encoding xpub/ypub/zpub). Fix options: + - **Server-side**: Echo back the submitted `pubkey` on each response entry + - **Server-side**: Return one entry per submitted pubkey (even if same underlying path), with the specific script-type balance + +--- + +## Vault-Side Fallback (Already Available, Not Yet Used) + +As a defensive measure independent of the Pioneer fix, the vault's matching could fall back to `cached_pubkeys` DB balances when Pioneer doesn't return a match for a submitted xpub. The `cached_pubkeys` table stores per-xpub balances updated on each successful Pioneer response: + +``` +bitcoin | ypub6XKTae3t… | balance=0.00063913 | balance_usd=49.97 +bitcoin | zpub6rXHd37fx… | balance=0.00017924 | balance_usd=14.01 +``` + +If the vault sees `match === undefined` for an xpub, it could use `getCachedPubkeys(devId).find(p => p.xpub === entry.pubkey)` as fallback. This is a vault-side mitigation — the Pioneer fix is still needed for accuracy. + +--- + +## Vault Fixes Already in `swapping-cleanup` Branch + +| Fix | File | Description | +|---|---|---| +| `buyAsset.address` populated | `swap-tracker.ts` | ETH address from walletId sent to Pioneer | +| `localRefundOverridesPioneer` | `swap-tracker.ts` | Pioneer "completed" can't override local "refunded" | +| NEAR Intents min amount guard | `swap.ts`, `swap-parsing.ts` | Throws before signing if amount < minAmountIn | +| Pioneer timeout protection | `swap-tracker.ts` | 30s timeout on all GetPendingSwap/CreatePendingSwap calls | +| Aggregate BTC balance in UI | `AssetPage.tsx` | Swap+send panels now receive btcAccounts.totalBalance | + +--- + +## Wallet Under Test + +- Device: `343737340F4736331F003B00` +- BTC xpubs (all account 0): + - Legacy: `xpub6CXwXedtSY8ng1P8XULzYzFTJFujJamcKXaXDnBJzGYB7P56W1bkZLxeHmrAs9bmVgb9pbV9STMoiE6oyxs8DpvFuuYNKnxR4MrSM5aqgTp` + - SegWit: `ypub6XKTae3tTEUCB6WaWVuHxJYYxoAw7uMthRrRrYSnnSvkFjksZfjG4igK1tvQRvejWVPjSrvU8DTPqkED6LRnxCwNkf77WPTD9xACU9REzDh` + - NativeSegWit: `zpub6rXHd37fxCQN5Rn5cVtP64gg1xosZ5KA3jomifTN7dWKQCY64PSbDXT3ehyG5kWbQfVCmb5ZhjyvSDUYx1jvDrxtb2B7wvSjdgPsjWKYnwB` +- Vault DB: `/Users/highlander/Library/Application Support/com.keepkey.vault/dev/vault.db` +- Pioneer server: `https://api.keepkey.info` (reset from blue; use `make start` in pioneer dir) + +--- + +## Definition of Done + +- [ ] Pioneer `GetPortfolioBalances` returns a separate response entry for each submitted BTC xpub, with `pubkey` echoed back +- [ ] Vault's "AVAILABLE" in swap dialog shows ~0.00081837 BTC (sum of all 3 accounts) +- [ ] `api/debug/portfolio` endpoint shows `bitcoin.balance ≈ 0.00081837` after a fresh `getBalances` RPC call diff --git a/docs/handoffs/handoff-pioneer-cosmos-xrp-hyperliquid-timeout.md b/docs/handoffs/handoff-pioneer-cosmos-xrp-hyperliquid-timeout.md new file mode 100644 index 00000000..462aa1f9 --- /dev/null +++ b/docs/handoffs/handoff-pioneer-cosmos-xrp-hyperliquid-timeout.md @@ -0,0 +1,163 @@ +# Handoff: Pioneer — Cosmos / XRP / Hyperliquid GetPortfolioBalances timeout + +Repo: `/Users/highlander/WebstormProjects/keepkey-stack/projects/pioneer` +Related vault PR: https://github.com/keepkey/keepkey-vault/pull/178 +Priority: P1 — cosmos, thorchain, maya, osmosis, xrp, hyperliquid all show $0 on every load + +--- + +## Observed symptom (vault side) + +Vault builds pubkeys in this order: UTXO → EVM → nonEVM (cosmos/xrp) → BTC. +With chunk size 8 and 10 EVM chains, chunk 3 looks like: + +``` +[eip155:2868/slip44:60 : 0xETH] ← Hyperliquid +[cosmos:cosmoshub-4/... : cosmos1...] +[cosmos:thorchain-mainnet-v1/... : thor1...] +[cosmos:mayachain-mainnet-v1/... : maya1...] +[cosmos:osmosis-1/... : osmo1...] +[ripple:4109c.../... : rXXX...] +[BTC xpub1] +[BTC xpub2] +``` + +This chunk times out at 45 s on every load. The vault log confirms: + +``` +[getBalances] Partial portfolio response: 2/3 chunks succeeded — failed chains will show 0 +[getBalances] Chunk 3 failed — excluded chains: hyperliquid, cosmos, thorchain, mayachain, osmosis, ripple, bitcoin, bitcoin +``` + +The result: all 6 chains appear with balance=0 in the vault (vault-side workaround +in PR #178 ensures they at least show up rather than vanishing, but balance is always 0). + +--- + +## Root cause hypothesis + +Pioneer's `GetPortfolioBalances` handler is slow or erroring for one or more of these CAIPs: + +| Chain | CAIP sent to Pioneer | +|---|---| +| Hyperliquid | `eip155:2868/slip44:60` | +| Cosmos | `cosmos:cosmoshub-4/slip44:118` | +| THORChain | `cosmos:thorchain-mainnet-v1/slip44:931` | +| Mayachain | `cosmos:mayachain-mainnet-v1/slip44:931` | +| Osmosis | `cosmos:osmosis-1/slip44:118` | +| XRP | `ripple:4109c6f2045fc7eff4cde8f9905d19c2/slip44:144` | + +**Most likely culprits:** + +1. **Hyperliquid (`eip155:2868`)** — new chain, Pioneer may not have a registered RPC or + balance fetcher for it. A single unknown CAIP can cause the entire batch to hang if + Pioneer awaits a fetch that never returns. + +2. **Cosmos-family chains** — `pioneer-cache` fetches via external RPCs + (`api.cosmos.shapeshift.com`, `lcd-osmosis.keplr.app`, `thornode.ninerealms.com`). + These endpoints may be slow, rate-limited, or stale since the ninerealms migration. + See also: `handoff-pioneer-fetch-error-zero.md` — failed fetches silently return `balance:'0'` + instead of propagating the error. + +3. **XRP** — `xrplcluster.com` may be timing out. + +--- + +## How to diagnose + +### 1. Call GetPortfolioBalances directly + +```bash +curl -X POST https://api.keepkey.info/portfolio \ + -H "Content-Type: application/json" \ + -H "Authorization: key:public-test" \ + -d '{ + "pubkeys": [ + { "caip": "cosmos:cosmoshub-4/slip44:118", "pubkey": "cosmos1YOURADDRESS" }, + { "caip": "cosmos:thorchain-mainnet-v1/slip44:931", "pubkey": "thor1YOURADDRESS" }, + { "caip": "cosmos:osmosis-1/slip44:118", "pubkey": "osmo1YOURADDRESS" }, + { "caip": "ripple:4109c6f2045fc7eff4cde8f9905d19c2/slip44:144", "pubkey": "rXXX" }, + { "caip": "eip155:2868/slip44:60", "pubkey": "0xETH" } + ] + }' \ + --max-time 60 +``` + +Time the response. If it exceeds 10s, the culprit RPC is slow. If it errors immediately, +the CAIP is unregistered. + +### 2. Bisect — send each chain individually + +Send each pubkey as a single-element batch and time each separately. The one that +hangs or errors is the culprit poisoning the whole chunk. + +### 3. Check pioneer-cache logs + +In the pioneer repo, `pioneer-cache/src/stores/balance-cache.ts` logs warn-level +messages when `fetchFresh` fails. Run with `DEBUG=true` and watch for which chain +blocks. + +--- + +## Fixes needed in Pioneer + +### A. Hyperliquid — register or no-op + +If `eip155:2868` has no registered balance fetcher in pioneer-cache, `fetchFresh` +likely returns null/undefined → the `getBatchBalances` catch block returns `{balance:'0'}` +(see `handoff-pioneer-fetch-error-zero.md`). + +**Fix options:** +- Add an EVM RPC for Hyperliquid and register it in the chain config +- OR explicitly return `null` for unregistered chains so the vault omits them cleanly + +### B. Cosmos-family RPC health + +Check `pioneer-cache/src/stores/balance-cache.ts` and the RPCUrl config: + +``` +RPCUrl["Cosmos"] = "https://api.cosmos.shapeshift.com" ← may be down +RPCUrl["Osmosis"] = "https://lcd-osmosis.keplr.app" +RPCUrl["THORChain"] = "https://thornode.ninerealms.com" ← ninerealms migration risk +RPCUrl["Mayachain"] = "https://mayanode.mayachain.info" +RPCUrl["Ripple"] = "https://xrplcluster.com" +``` + +Verify each is reachable and returns in <5s. Replace dead/slow ones with working mirrors. + +### C. Per-CAIP timeout in getBatchBalances + +Currently one slow CAIP blocks the entire batch. Add a per-entry timeout inside +`getBatchBalances` so a single hanging RPC only zeroes that chain, not the whole chunk: + +```typescript +const balanceInfo = await Promise.race([ + fetchFresh(item), + new Promise((_, reject) => setTimeout(() => reject(new Error('per-entry timeout')), 10_000)) +]) +``` + +### D. Return null on failure (from existing handoff) + +`handoff-pioneer-fetch-error-zero.md` covers this in detail. The same catch blocks that +return `{balance:'0'}` on RPC failure should return `null` so the vault knows the entry +was an RPC error vs a genuine zero balance. This is complementary to fix C. + +--- + +## Files + +| File | Change | +|---|---| +| `pioneer-cache/src/stores/balance-cache.ts` | Per-entry timeout in getBatchBalances; return null on failure | +| `pioneer-cache/src/config/chains.ts` (or equivalent) | Fix/replace dead Cosmos/XRP RPC URLs | +| Chain registry | Register Hyperliquid (eip155:2868) or return null for unknown CAIPs | + +--- + +## Test plan + +After fixes, call `GetPortfolioBalances` with the 6-chain batch above and verify: +- Response time < 10s +- All 6 chains return a non-null balance entry (or null if no data, not a 60s hang) +- Vault FINAL count = 23 chains, none of the 6 showing 0 due to timeout diff --git a/docs/handoffs/handoff-pioneer-fetch-error-zero.md b/docs/handoffs/handoff-pioneer-fetch-error-zero.md new file mode 100644 index 00000000..b0b296f4 --- /dev/null +++ b/docs/handoffs/handoff-pioneer-fetch-error-zero.md @@ -0,0 +1,162 @@ +# Handoff: Pioneer — fetch failures silently return balance="0" + +Repo: `/Users/highlander/WebstormProjects/keepkey-stack/projects/pioneer` +Related vault PR: https://github.com/keepkey/keepkey-vault/pull/178 +Priority: P1 — vault cannot distinguish "user spent to zero" from "RPC was down" + +--- + +## Root cause (confirmed in source) + +`fetchFresh` in `pioneer-cache/src/stores/balance-cache.ts` **correctly** throws when a chain's +RPC is unreachable (line 158-160): + +```typescript +if (balanceInfo?.error || balanceInfo === null || balanceInfo === undefined) { + log.warn(tag, `Balance fetch failed ...`); + throw new Error(`Failed to fetch balance for ${caip}: ${errorMsg}`); +} +``` + +But `getBatchBalances` (same file) catches that thrown error in **two places** and silently +returns `{ balance: '0' }` instead of propagating the failure: + +**Lines 330-339** (forceRefresh / waitForFresh path): +```typescript +} catch (error) { + log.error(tag, `Failed to fetch fresh ${item.caip}/${item.pubkey}:`, error); + const now = Date.now(); + return { + caip: item.caip, + pubkey: item.pubkey, + balance: '0', // ← RPC failure silently becomes "zero balance" + fetchedAt: now, + fetchedAtISO: new Date(now).toISOString() + }; +} +``` + +**Lines 440-449** (cache-miss fetch path): +```typescript +} catch (error) { + log.error(tag, `Failed to fetch ${item.caip}/${item.pubkey}:`, error); + const now = Date.now(); + results[item.index] = { + caip: item.caip, + pubkey: item.pubkey, + balance: '0', // ← same silent zero + fetchedAt: now, + fetchedAtISO: new Date(now).toISOString() + }; +} +``` + +The `isStale` flag (computed in `balance.controller.ts:732`) is based only on `fetchedAt` +timestamp — since both catch blocks set `fetchedAt: now`, the response arrives at the vault +with `isStale: false, balance: "0"` and is indistinguishable from a genuine empty address. + +--- + +## The fix + +**Option A (recommended): omit failed entries from the response** + +On fetch failure, return/set `null` and filter nulls before returning from `getBatchBalances`. +The vault already treats "missing from response" correctly (preserves old cached value). +No vault-side changes needed beyond the ones already in PR #178. + +**Lines 330-339** — change return value: +```typescript +} catch (error) { + log.error(tag, `Failed to fetch fresh ${item.caip}/${item.pubkey}:`, error); + return null; // omit — vault will preserve last-known balance +} +``` + +Then filter at the call site (line 343): +```typescript +const results = (await Promise.all(fetchPromises)).filter(r => r !== null); +``` + +**Lines 440-449** — change result assignment: +```typescript +} catch (error) { + log.error(tag, `Failed to fetch ${item.caip}/${item.pubkey}:`, error); + // Leave results[item.index] as the placeholder — but mark it as fetchError + results[item.index] = null as any; +} +``` + +Then before `return results` at line 467, filter: +```typescript +return results.filter(r => r !== null); +``` + +**Option B (if omission breaks existing consumers): add `fetchError: true` flag** + +```typescript +return { + caip: item.caip, + pubkey: item.pubkey, + balance: '0', + fetchedAt: now, + fetchedAtISO: new Date(now).toISOString(), + fetchError: true, // signals: do not cache this zero, RPC was unreachable +}; +``` + +Vault would then skip caching entries with `fetchError: true`. +Requires threading `fetchError` through `BalanceData` type → controller response shape → vault parsing. + +--- + +## What the vault does after this fix + +With Option A in place, the vault-side fix in `db.ts` becomes a simple unconditional upsert: + +```sql +-- All entries in results are real Pioneer values (even if zero) +-- Missing entries (RPC failure) were never included in results +ON CONFLICT(device_id, chain_id) DO UPDATE SET + balance = excluded.balance, + balance_usd = excluded.balance_usd, + tokens_json = excluded.tokens_json, + updated_at = excluded.updated_at +``` + +And `Dashboard.tsx` merge becomes: +```typescript +const map = new Map(balances) // preserve all current +for (const b of result) map.set(b.chainId, b) // overwrite only returned chains +setBalances(map) +``` + +Chains whose RPC was down are absent from `result` → old value stays. Chains that returned +zero balance (genuine empty address) ARE in `result` → zero is written. Clean separation. + +--- + +## Test plan + +| Scenario | Before fix | After fix | +|---|---|---| +| Chain RPC down, `forceRefresh=true` | Returns `{balance:"0", isStale:false}` | Entry omitted from response | +| Chain RPC down, cache miss | Returns `{balance:"0", isStale:false}` | Entry omitted from response | +| Chain genuinely empty (fresh wallet) | Returns `{balance:"0", isStale:false}` | Returns `{balance:"0", isStale:false}` ✓ | +| Chain has balance, RPC up | Returns `{balance:"X", isStale:false}` | Returns `{balance:"X", isStale:false}` ✓ | + +Concrete test: take a chain (e.g. Optimism) whose RPC is unreachable, call +`GetPortfolioBalances` with `forceRefresh=true`. With the fix, that chain should not +appear in the response array. Without the fix, it appears with `balance="0.00000000"`. + +--- + +## Files + +| File | Change | +|---|---| +| `modules/pioneer/pioneer-cache/src/stores/balance-cache.ts:330-339` | Return null on fetch failure (waitForFresh path) | +| `modules/pioneer/pioneer-cache/src/stores/balance-cache.ts:440-449` | Set null on fetch failure (cache-miss path) | +| `modules/pioneer/pioneer-cache/src/stores/balance-cache.ts:343` | Filter nulls from results array | +| `modules/pioneer/pioneer-cache/src/stores/balance-cache.ts:467` | Filter nulls before return | +| `modules/pioneer/pioneer-cache/src/types.ts` (if exists) | Make `getBatchBalances` return `(BalanceData | null)[]` then filter | diff --git a/docs/handoffs/handoff-pioneer-swap-health-cache.md b/docs/handoffs/handoff-pioneer-swap-health-cache.md new file mode 100644 index 00000000..3d95cdfd --- /dev/null +++ b/docs/handoffs/handoff-pioneer-swap-health-cache.md @@ -0,0 +1,84 @@ +# Handoff: Pioneer — Cache Swap Health in Redis + +**Date**: 2026-05-17 +**Priority**: Medium — causes vault swap UI to show all providers "offline" when endpoint hangs + +--- + +## Problem + +`GET /api/v1/swap/health` makes live requests to THORNode, Mayachain, ShapeShift, Relay on **every call**. + +- THORNode is flaky — when it hangs (no response), the whole health endpoint hangs with it +- Vault calls this endpoint every 60s (SwapDialog open) with an 8s client timeout +- When Pioneer hangs > 8s, vault falls back to showing all providers as "offline" — false negative + +Observed: endpoint returns in < 1s when THORNode responds, times out at 15s when it doesn't. + +--- + +## Fix: Redis-cached background poller + +### Pattern +``` +Background job (every 30s): + → hit THORNode, Mayachain, ShapeShift, Relay in parallel (each with 10s timeout) + → write result to Redis: SET swap:health EX 120 + +GET /api/v1/swap/health: + → read from Redis (instant) + → if key missing (cold start): return { status: 'unknown' } for each integration + → never make live external requests inline +``` + +### Redis key +``` +swap:health +``` + +### Response shape (no change needed) +```json +{ + "fetchedAt": 1779018528488, + "integrations": [ + { "key": "thorchain", "label": "THORChain", "status": "ok" | "degraded" | "offline" | "unknown" }, + { "key": "mayachain", "label": "Mayachain", "status": "ok" }, + { "key": "shapeshift", "label": "ShapeShift", "status": "ok" }, + { "key": "relay", "label": "Relay", "status": "ok" }, + { "key": "chainflip", "label": "Chainflip", "status": "ok" } + ] +} +``` + +`"unknown"` is a valid status (already in vault's `SwapProviderStatus` type) — use it on cold start or when a check is still pending. + +### Background job behavior +- Run immediately on server start, then every 30s +- Each integration check: 10s timeout, catch → `{ status: 'offline', detail: }` +- Write the full payload to Redis with TTL 120s (so stale data auto-expires if poller dies) +- Log a warning if any check exceeds 5s (early warning for THORNode issues) + +--- + +## Vault-side (no change needed) + +Vault already handles the `'unknown'` status correctly in the UI. Once Pioneer returns fast, the 8s client timeout stops firing. No vault changes required. + +--- + +## Why This Matters + +Every open SwapDialog polls every 60s. With multiple users, Pioneer's health endpoint was being called constantly and making N×(integrations) outbound requests per second. Redis caching means: +- Response time: 1-2ms (Redis read) instead of 1-15s (live check) +- External load: 1 check per integration per 30s regardless of how many clients are polling +- No false "offline" banners when THORNode is just slow + +--- + +## Files to Change in Pioneer + +Look for the existing health handler — likely in: +- `src/routes/swap.ts` or `src/controllers/swap-health.ts` +- The current handler that makes live THORNode/etc requests + +Add a background scheduler (Bull, node-cron, or setInterval at startup) that does the polling and writes to Redis. The route handler becomes a simple Redis `GET`. diff --git a/docs/handoffs/handoff-pr178-review-fixes.md b/docs/handoffs/handoff-pr178-review-fixes.md new file mode 100644 index 00000000..946e90c7 --- /dev/null +++ b/docs/handoffs/handoff-pr178-review-fixes.md @@ -0,0 +1,256 @@ +# Handoff: PR #178 Review Fixes — Balance Zero / BTC Send / Timeouts / Audit CAIP + +Branch: `swapping-cleanup` +PR: https://github.com/keepkey/keepkey-vault/pull/178 +Reviewer findings: 2× P1, 2× P2 + +--- + +## P1-A: Real zero balances can never replace stale non-zero balances + +### What's wrong + +`db.ts setCachedBalances` and `updateCachedBalance` use a conditional upsert that only +overwrites stored balance when `balance_usd > 0`: + +```sql +balance = CASE WHEN CAST(excluded.balance_usd AS REAL) > 0 + THEN excluded.balance ELSE balance END +``` + +Same guard is in `Dashboard.tsx` merge: +```typescript +if (!prev || b.balanceUsd > 0 || parseFloat(b.balance || '0') > 0) { + map.set(b.chainId, b) // only updates on non-zero +} +``` + +**Result**: if a user sends all ETH out, Pioneer returns `balance="0", valueUsd=0` on the next +refresh. Both the DB and the UI ignore that zero and keep showing the old balance forever. + +### Why the guard was added (context) + +The guard was meant to handle a different case: Pioneer's cold-cache or chunk-timeout returning +0 for a chain that still has funds. That case is already handled upstream — the +`effectivePubkeys` / `results` array in `getBalances` only contains chains from **successful** +chunks. Chains from failed chunks are simply absent from `results`. So by the time `setCachedBalances` +is called, every entry it receives represents a genuinely-returned Pioneer value. + +### The fix (two locations) + +**`src/bun/db.ts`** — revert to simple unconditional upsert in both `setCachedBalances` and +`updateCachedBalance`. The conditional guard is redundant and harmful here: + +```sql +-- Remove the CASE WHEN guards: +ON CONFLICT(device_id, chain_id) DO UPDATE SET + symbol = excluded.symbol, + address = CASE WHEN excluded.address != '' THEN excluded.address ELSE address END, + balance = excluded.balance, + balance_usd = excluded.balance_usd, + tokens_json = excluded.tokens_json, + updated_at = excluded.updated_at +``` + +(Keep the `address` guard — an empty string address should not overwrite a real address.) + +**`src/mainview/components/Dashboard.tsx:595-606`** — always write chains present in `result`, +only preserve chains **absent** from `result` (those came from failed chunks): + +```typescript +// Correct merge: result contains only chains from successful chunks. +// Chains absent from result (failed chunks) stay in map with prior value. +const map = new Map(balances) +const returnedChains = new Set(result.map(b => b.chainId)) +for (const b of result) { + map.set(b.chainId, b) // always update — zero here means genuinely zero +} +// Chains not in returnedChains survive in map from the spread above (no action needed) +setBalances(map) +``` + +### Pioneer API question — does a zero ever mean "stale / unavailable"? + +The `GetPortfolioBalances` response has an `isStale` field. Currently the vault ignores it. +If Pioneer can return `balance="0", isStale=true` for a temporarily-unavailable chain, the +simple unconditional write above would still incorrectly zero a real balance. + +**Investigation needed before shipping:** +1. Trigger a scenario where Pioneer's data source for one chain is temporarily down. + Does it return `balance="0", isStale=true` or omit the entry entirely? +2. Trigger a real-zero scenario (fresh wallet or after draining an account). + Does it return `balance="0", isStale=false`? + +**If Pioneer uses `isStale` correctly**, the DB upsert can be: +```sql +balance = CASE WHEN excluded.is_stale = 0 OR CAST(excluded.balance_usd AS REAL) > 0 + THEN excluded.balance ELSE balance END +``` +But this requires threading `isStale` through `ChainBalance` → `setCachedBalances` → SQL. + +**If Pioneer omits entries for unavailable chains** (rather than returning zero), no Pioneer +changes are needed — the fix above is sufficient. + +### Test plan + +| Scenario | Expected DB | Expected UI | +|---|---|---| +| Chain queried, Pioneer returns 0, `isStale=false` | Writes 0 | Shows 0 | +| Chain queried, Pioneer returns 0, `isStale=true` | Preserves old | Shows old | +| Chain absent from response (chunk failed) | Preserves old | Shows old | +| Chain present, Pioneer returns real balance | Writes balance | Shows balance | + +Use `GET /api/debug/pioneer-audit` to inspect per-chunk Pioneer responses and confirm +`isStale` behavior before deciding on the Pioneer-side contract. + +--- + +## P1-B: BTC send shows aggregate balance but spends only selected xpub + +### What's wrong + +`AssetPage.tsx:770-775` and `AssetPage.tsx:909-914` both pass `btcAccounts.totalBalance` +(sum across all accounts) into `SendForm` and `SwapDialog`. But: +- `SendForm` passes `xpubOverride={btcSelected?.xpubData?.xpub}` (selected xpub only) +- `index.ts buildTx` builds from that single xpub +- `index.ts getBalance` (singular) also looks up only the selected xpub + +So validation MAX and the "balance" label show aggregate BTC (~X.XX), but signing can only +spend the selected xpub's UTXOs. + +### The fix + +Scope the balance passed to `SendForm` to the selected xpub only. `btcSelected.xpubData` +(`BtcXpub`) already has `.balance: string` and `.balanceUsd: number`: + +```typescript +// AssetPage.tsx line 770-775 — SendForm: +balance={isBtc && btcAccounts.accounts.length > 0 ? { + ...activeBalance!, + balance: btcSelected?.xpubData?.balance ?? '0', + balanceUsd: btcSelected?.xpubData?.balanceUsd ?? 0, + nativeBalanceUsd: btcSelected?.xpubData?.balanceUsd ?? 0, +} : activeBalance} + +// AssetPage.tsx line 909-914 — SwapDialog: +balance={isBtc && btcAccounts.accounts.length > 0 ? { + ...activeBalance!, + balance: btcSelected?.xpubData?.balance ?? '0', + balanceUsd: btcSelected?.xpubData?.balanceUsd ?? 0, + nativeBalanceUsd: btcSelected?.xpubData?.balanceUsd ?? 0, +} : activeBalance} +``` + +### Longer-term option: multi-xpub spend + +If the goal is "spend from all funded xpubs in one send", `buildTx` needs to: +1. Accept multiple xpubs sorted by balance desc +2. UTXO-select across all of them +3. Sign each input group with the correct xpub path + +This requires Pioneer or the PSBT builder to support multi-account UTXOs. Defer until +explicitly requested. The fix above is the correct short-term behavior. + +--- + +## P2-A: Backend portfolio timeout exceeds frontend RPC timeout + +### What's wrong + +- `PIONEER_PORTFOLIO_TOTAL_TIMEOUT_MS = 180_000` (index.ts:131) +- `rpcRequest('getBalances', undefined, 120000)` (Dashboard.tsx:581) + +With 90s chunk timeout and concurrency 2: worst case is 180s backend, but frontend cuts +the RPC at 120s and throws "Balance server error" before the backend can return partial results. + +### The fix + +Increase the frontend RPC timeout to 200s (20s buffer above the 180s backend max): + +```typescript +// Dashboard.tsx:581 +const result = await rpcRequest('getBalances', undefined, 200000) +``` + +Or reduce backend total to 110s (safer, but risks real timeout on slow Pioneer days). +The 200s frontend approach is simpler and avoids changing tuned backend constants. + +--- + +## P2-B: `/api/debug/pioneer-audit` uses `pk.caip` which doesn't exist in `getCachedPubkeys()` + +### What's wrong + +`getCachedPubkeys()` (`db.ts:1264`) queries: +```sql +SELECT chain_id, path, xpub, address, script_type, balance, balance_usd FROM cached_pubkeys +``` + +No `caip` column. `rest-api.ts:2830` reads `pk.caip || ''` → always `''` for BTC/non-EVM entries, +making the audit endpoint send empty CAIPs and giving false chunk diagnostics. + +### The fix + +Build CAIP from `chainId` in the endpoint using a static lookup map: + +```typescript +// In /api/debug/pioneer-audit handler, replace the pk.caip reference: +const CHAIN_ID_TO_CAIP: Record = { + bitcoin: 'bip122:000000000019d6689c085ae165831e93/slip44:0', + dogecoin: 'bip122:00000000001a91e3dace36e2be3bf030/slip44:3', + litecoin: 'bip122:12a765e31ffd4059bada1e25190f6e98/slip44:2', + cosmos: 'cosmos:cosmoshub-4/slip44:118', + thorchain: 'cosmos:thorchain-mainnet-v1/slip44:931', + mayachain: 'cosmos:mayachain-mainnet-v1/slip44:931', + osmosis: 'cosmos:osmosis-1/slip44:118', + ripple: 'ripple:4109c6f2045fc7eff4cde8f9905d19c2/slip44:144', +} + +for (const pk of cachedPks) { + const caip = CHAIN_ID_TO_CAIP[pk.chainId] || '' + if (pk.xpub) pubkeys.push({ caip, pubkey: pk.xpub, label: `${pk.chainId}:xpub` }) + else if (pk.address) pubkeys.push({ caip, pubkey: pk.address, label: `${pk.chainId}:addr` }) +} +``` + +The EVM section already builds CAIPs correctly from `evmCaips[]` — only the UTXO/Cosmos/XRP +section needed this fix. + +--- + +## File map + +| File | Change | +|---|---| +| `src/bun/db.ts:403-412` | Remove conditional balance guards in `setCachedBalances` | +| `src/bun/db.ts:431-440` | Same for `updateCachedBalance` | +| `src/mainview/components/Dashboard.tsx:595-606` | Always-write merge for `result` entries | +| `src/mainview/components/Dashboard.tsx:581` | `120000` → `200000` | +| `src/mainview/components/AssetPage.tsx:770-775` | `totalBalance` → `xpubData.balance` | +| `src/mainview/components/AssetPage.tsx:909-914` | Same for SwapDialog | +| `src/bun/rest-api.ts:2829-2831` | Build CAIP from chainId map, not `pk.caip` | + +--- + +## Pioneer investigation before shipping P1-A + +Open questions that determine whether the simple unconditional upsert is safe: + +1. **Does Pioneer omit chain entries when a data source is down?** + Test: kill a chain's RPC behind Pioneer, call `GetPortfolioBalances` for that chain. + → If entry is omitted: no Pioneer changes needed, simple upsert is correct. + → If entry is included with `balance="0", isStale=true`: need to thread `isStale` through. + +2. **Does `forceRefresh: true` bypass stale-cache and always return fresh data?** + Currently all vault calls use `forceRefresh: true`. If this guarantees the response + reflects the latest on-chain state (even if balance=0), then `isStale` can be ignored + for vault's purposes. + +3. **Cold-cache scenario on startup:** + First call after Pioneer restart — does Pioneer return `isStale=true` with last-known + balance, or `balance="0"`? The vault's `setCachedBalances` is the source of truth during + Pioneer cold start — important to know what Pioneer sends. + +Pioneer repo: `/Users/highlander/WebstormProjects/keepkey-stack/projects/pioneer` +Relevant endpoint: `GetPortfolioBalances` in pioneer-server +Look for: `isStale` flag population logic, what happens when a chain's data source is unreachable diff --git a/docs/handoffs/pioneer-btc-tx-history.md b/docs/handoffs/pioneer-btc-tx-history.md new file mode 100644 index 00000000..0ee04f56 --- /dev/null +++ b/docs/handoffs/pioneer-btc-tx-history.md @@ -0,0 +1,107 @@ +# Handoff: Pioneer BTC Transaction History — Cold Cache Bug + +**Date:** 2026-05-18 +**Diagnosed by:** vault team +**Target:** Pioneer server (`keepkey/pioneer`) + +--- + +## Problem + +When a user selects BTC and hits "Refresh History" in the vault, no transactions appear — even for wallets with real on-chain history. The vault's activity panel stays empty. + +## Root Cause: Pioneer Side (confirmed) + +Pioneer's `POST /api/v1/tx/history` only returns transactions when the xpub is **already in Pioneer's cache**. For a cold (first-time or cache-expired) xpub it returns: + +```json +{"success":true,"histories":[{"transactions":[],"fresh":false,"cached":false,...}]} +``` + +…even when the xpub has real history on-chain. + +### Proof + +Test xpub with known real history (~$60, 8 txs): +``` +zpub6rXHd37fxCQN5Rn5cVtP64gg1xosZ5KA3jomifTN7dWKQCY64PSbDXT3ehyG5kWbQfVCmb5ZhjyvSDUYx1jvDrxtb2B7wvSjdgPsjWKYnwB +``` +CAIP: `bip122:000000000019d6689c085ae165831e93/slip44:0` + +**Cold call (first-time xpub):** +```bash +curl -X POST "https://api.keepkey.info/api/v1/tx/history" \ + -H "Content-Type: application/json" \ + -d '{"queries":[{"pubkey":"zpub6rXH...nwB","caip":"bip122:000000000019d6689c085ae165831e93/slip44:0"}]}' +# → transactions: [], fresh: false, cached: false +``` + +**After cache is warm:** +```bash +# Same request → transactions: [8 txs], fresh: true, cached: true +``` + +**With `forceRefresh=true`:** +```bash +curl -X POST "https://api.keepkey.info/api/v1/tx/history?forceRefresh=true" ... +# → transactions: [], fresh: false, cached: false ← ALSO BROKEN +``` + +## Two Bugs to Fix in Pioneer + +### Bug 1 (P0): Cold cache returns empty instead of fetching +`GET /api/v1/tx/history` with an uncached xpub should trigger a fetch from the blockchain indexer (Blockbook/Electrum/etc.) before responding — not return empty. Currently it only serves from Redis; if the key is missing it returns `[]`. + +**Fix:** In the `GetTransactionHistory` handler, if `cached:false`, fetch from the UTXO indexer synchronously before responding (or trigger + await the cache population). + +### Bug 2 (P1): `forceRefresh=true` returns empty for zpubs +Passing `?forceRefresh=true` should bypass cache and fetch live from the indexer. Instead it returns `fresh:false, cached:false, transactions:[]`. This suggests the forceRefresh path fails silently for zpub-format keys (possibly the indexer call errors out and the handler swallows the error and returns empty). + +**Fix:** Add error logging in the `forceRefresh` path. Check if the UTXO indexer supports zpub format natively or needs conversion to the underlying scriptPubKey set first. + +## Vault Side: No Change Needed + +The vault correctly calls `pioneer.GetTransactionHistory({ queries: [{pubkey: xpub, caip}] })` for each BTC script type (native segwit zpub, p2sh ypub, legacy xpub). The response parsing in `unwrapHistoryTransactions()` correctly handles `histories[].transactions`. The vault code is not the problem. + +## How to Test the Fix + +```bash +# 1. Clear Pioneer's cache for this xpub first: +curl -X DELETE "https://api.keepkey.info/api/v1/tx/history/cache/clear" ... + +# 2. Cold call — should now return 8 txs: +curl -X POST "https://api.keepkey.info/api/v1/tx/history" \ + -H "Content-Type: application/json" \ + -d '{"queries":[{"pubkey":"zpub6rXHd37fxCQN5Rn5cVtP64gg1xosZ5KA3jomifTN7dWKQCY64PSbDXT3ehyG5kWbQfVCmb5ZhjyvSDUYx1jvDrxtb2B7wvSjdgPsjWKYnwB","caip":"bip122:000000000019d6689c085ae165831e93/slip44:0"}]}' +# Expected: transactions.length > 0, fresh: true, cached: false + +# 3. forceRefresh=true — should return same txs: +curl -X POST "https://api.keepkey.info/api/v1/tx/history?forceRefresh=true" \ + -H "Content-Type: application/json" \ + -d '{"queries":[{"pubkey":"zpub6rXHd37fxCQN5Rn5cVtP64gg1xosZ5KA3jomifTN7dWKQCY64PSbDXT3ehyG5kWbQfVCmb5ZhjyvSDUYx1jvDrxtb2B7wvSjdgPsjWKYnwB","caip":"bip122:000000000019d6689c085ae165831e93/slip44:0"}]}' +# Expected: transactions.length > 0, fresh: true + +# 4. Repeat with ypub and xpub formats for same wallet (p2sh and legacy accounts) +``` + +## Transaction Shape (for reference) + +Pioneer returns transactions in this format — vault's `normalizeMeta()` and `normalizeActivityType()` handle it correctly: + +```json +{ + "txid": "292ddbb5...", + "caip": "bip122:000000000019d6689c085ae165831e93/slip44:0", + "direction": "sent", + "type": "transfer", + "status": "confirmed", + "blockHeight": 949962, + "timestamp": 1779120668, + "confirmations": 3, + "value": "75654", + "fee": "2496", + "from": ["bc1qq7..."], + "to": ["bc1qpn..."], + "swapMetadata": { ... } // present on swap txs +} +``` diff --git a/docs/handoffs/pioneer-cache-block-on-miss-bug.md b/docs/handoffs/pioneer-cache-block-on-miss-bug.md new file mode 100644 index 00000000..0059d0f8 --- /dev/null +++ b/docs/handoffs/pioneer-cache-block-on-miss-bug.md @@ -0,0 +1,135 @@ +# Pioneer Cache: `blockOnMiss: true` Bug + +**Status:** Root cause confirmed. One-line fix identified. + +--- + +## The Core Question + +> Does Pioneer's portfolio API return 0 immediately from cache when a pubkey is unknown, then sync in the background? + +**Answer: NO.** Pioneer blocks the HTTP response on every cache miss and waits for a real on-chain lookup to complete. + +--- + +## Root Cause + +**File:** `modules/pioneer/pioneer-cache/src/stores/balance-cache.ts:89` + +```typescript +blockOnMiss: true, // Wait for fresh data on first request - users need real balances! +``` + +With `blockOnMiss: true`, the execution path for an unknown pubkey is: + +``` +POST /api/v1/portfolio + → getBatchBalances(pubkeys, false, false) + → Redis MGET (fast, <5ms) + → cache miss detected + → shouldBlock = this.config.blockOnMiss // true + → await Promise.all(fetchPromises) // BLOCKS HERE + → fetchFresh() → fetchFromSource() + → balanceModule.getBalance() // real on-chain lookup + → Blockbook (BTC/UTXO) — gap-limit scan, can take 20-60s on cold addresses + → JSON-RPC (EVM) — one call, usually 2-5s + → LCD (Cosmos) — one call, usually 3-10s + → HTTP response finally sent (after all on-chain calls complete or timeout) +``` + +The background refresh queue (`cache-refresh`) exists and works — but it only activates when `blockOnMiss: false`. With `true`, background refresh is never triggered. + +--- + +## The Fix + +**One line change** in `modules/pioneer/pioneer-cache/src/stores/balance-cache.ts:89`: + +```typescript +// BEFORE +blockOnMiss: true, // Wait for fresh data on first request - users need real balances! + +// AFTER +blockOnMiss: false, // Return 0 immediately, queue background refresh for real data +``` + +### What changes after the fix + +| Scenario | Before (blockOnMiss: true) | After (blockOnMiss: false) | +|---|---|---| +| Unknown pubkey, cold cache | Blocks HTTP for 15-60s per chain | Returns `balance: '0'` in <5ms | +| Cache miss | Blocks, awaits on-chain lookup | Queues high-priority job to `cache-refresh` queue | +| Next request (30-60s later) | Cached, fast | Cached (worker populated it) | +| Stale cache (>5 min old) | Returns stale + queues refresh | Same (already correct) | +| `forceRefresh=true` | Bypasses cache, blocks | Bypasses cache, blocks (unchanged) | + +### Why `blockOnMiss: false` is correct + +1. **Background worker already exists** — `pioneer-cache-worker` processes the `cache-refresh` Redis queue continuously, processing high-priority jobs first. +2. **`triggerAsyncRefresh()` already implemented** — lines 455-460 of balance-cache.ts queue jobs when `!shouldBlock`. The infrastructure is there, just never reached. +3. **Users see 0, then real balances** — on second request (seconds/minutes later), cache is populated. This is standard stale-while-revalidate behavior. +4. **`forceRefresh` still works** — vault can explicitly request blocking fetch when it needs real data immediately (e.g., post-swap). + +--- + +## Core Files + +| File | Role | Key Lines | +|---|---|---| +| `modules/pioneer/pioneer-cache/src/stores/balance-cache.ts` | Cache config + `getBatchBalances()` | L89 (`blockOnMiss`), L416 (`shouldBlock`), L418-454 (blocking path), L455-460 (background path) | +| `modules/pioneer/pioneer-cache/src/stores/balance-cache.ts` | `fetchFromSource()` | L133-210 — calls `balanceModule.getBalance()`, real on-chain | +| `modules/pioneer/pioneer-cache/src/core/base-cache.ts` | `fetchFresh()` + `triggerAsyncRefresh()` | L473 (`fetchFresh`), L372 (`triggerAsyncRefresh`) | +| `modules/pioneer/pioneer-cache/src/core/base-cache.ts` | Queue push | L451 — `await this.redisQueue.createWork(this.config.queueName, job)` | +| `services/pioneer-server/src/controllers/balance.controller.ts` | Portfolio endpoint | L426 — `getBatchBalances(normalizedPubkeys, forceRefresh || false, false)` | +| `services/pioneer-cache-worker/src/index.ts` | Background worker | L58-63 — `initializeCacheManager()` with `RefreshWorker` | +| `modules/pioneer/pioneer-cache/src/core/cache-manager.ts` | Worker + queue processing | L266 — `'cache-refresh'` unified queue | + +--- + +## Secondary Config Parameters (for context) + +```typescript +// balance-cache.ts defaults +staleThreshold: 5 * 60 * 1000, // 5 min — triggers background refresh on stale hits +queueName: 'cache-refresh', // Redis queue for background jobs +enableQueue: true, // Queue is enabled +apiTimeout: 15000, // 15s per individual on-chain fetch +maxRetries: 3, +``` + +With `blockOnMiss: false`: +- Cache miss → `triggerAsyncRefresh(item, 'high')` → `redisQueue.createWork('cache-refresh', job)` +- Worker processes job, fetches on-chain, writes to Redis +- Next HTTP request hits Redis and returns real balance + +--- + +## Vault-Side Context + +The vault chunks pubkeys (8 per chunk) with a 20s chunk timeout. With `blockOnMiss: true` and a fresh wallet: +- Each BTC/UTXO pubkey can take 30-60s (gap-limit scan) +- Each chunk of 8 misses blocks for `min(slowest_pubkey, 20s)` → 20s → timeout +- Vault logs: `Portfolio chunk X/Y failed` — silent zero for those chains + +After the fix, all chunks return in <100ms. Worker populates cache. Vault retries (or user refreshes) and gets real balances. + +--- + +## Testing + +After applying the fix: + +1. Clear Redis cache: `redis-cli FLUSHDB` (or restart with fresh Redis) +2. Call `POST /api/v1/portfolio` with a known pubkey +3. Verify response time is <500ms (not 15-60s) +4. Verify `balance: '0'` for unknown pubkeys +5. Wait 30-60s, call again — verify balance is now populated by background worker +6. Check cache-worker logs for `✅ Fetched fresh data` messages + +--- + +## Risk + +Low. The comment on line 89 says "Wait for fresh data on first request — users need real balances!" This is a reasonable intent but the wrong mechanism — blocking a 60-pubkey batch request for 60s is not "giving users real balances," it's causing timeouts and showing 0s. + +The correct mechanism is what the infrastructure already provides: return 0 fast, populate cache in background, real data appears on next request. diff --git a/projects/keepkey-vault/__tests__/swap-parsing.test.ts b/projects/keepkey-vault/__tests__/swap-parsing.test.ts index 9de6c032..4cdbbb6d 100644 --- a/projects/keepkey-vault/__tests__/swap-parsing.test.ts +++ b/projects/keepkey-vault/__tests__/swap-parsing.test.ts @@ -361,7 +361,7 @@ describe('parseQuoteResponse', () => { expect(result.swapper).toBe('Chainflip') }) - test('NEAR Intents ETH→BTC: data="0x" also flagged as deposit-channel', () => { + test('NEAR Intents ETH→BTC: filtered out — throws no-swap-instructions', () => { const btcCaip = 'bip122:000000000019d6689c085ae165831e93/slip44:0' const ethCaip = 'eip155:1/slip44:60' const resp = { @@ -374,8 +374,8 @@ describe('parseQuoteResponse', () => { }, }], } - const result = parseQuoteResponse(resp, { fromCaip: ethCaip, toCaip: btcCaip, slippageBps: 300 }) - expect(result.relayTx?.isDepositChannel).toBe(true) + expect(() => parseQuoteResponse(resp, { fromCaip: ethCaip, toCaip: btcCaip, slippageBps: 300 })) + .toThrow(/no swap instructions|no quotes/i) }) test('Relay with data="0x" is NOT a deposit channel — no relayTx created', () => { @@ -402,9 +402,7 @@ describe('parseQuoteResponse', () => { expect(result.memo).toBe('MEMO') }) - test('NEAR Intents BTC→ETH (UTXO source, no memo) — isMemolessTransfer fires', () => { - // The canonical case: BTC → ETH via NEAR Intents. Pioneer provides a BTC - // deposit address; no memo or calldata needed. Should parse successfully. + test('NEAR Intents BTC→ETH (UTXO source, no memo) — filtered out, throws no-swap-instructions', () => { const btcCaip = 'bip122:000000000019d6689c085ae165831e93/slip44:0' const ethCaip = 'eip155:1/slip44:60' const resp = { @@ -414,16 +412,36 @@ describe('parseQuoteResponse', () => { swapper: 'NEAR Intents', buyAmount: '0.05', inbound_address: 'bc1qnearintentsdeposit', - // No memo, no calldata — only deposit address (UTXO side) txs: [{ txParams: { to: 'bc1qnearintentsdeposit' } }], }, }], } - const result = parseQuoteResponse(resp, { fromCaip: btcCaip, toCaip: ethCaip, slippageBps: 300 }) - expect(result.inboundAddress).toBe('bc1qnearintentsdeposit') - expect(result.memo).toBe('') - expect(result.relayTx).toBeUndefined() - expect(result.swapper).toBe('NEAR Intents') + expect(() => parseQuoteResponse(resp, { fromCaip: btcCaip, toCaip: ethCaip, slippageBps: 300 })) + .toThrow(/no swap instructions|no quotes/i) + }) + + test('NEAR Intents first in list — fallback to Chainflip route selected', () => { + const btcCaip = 'bip122:000000000019d6689c085ae165831e93/slip44:0' + const ethCaip = 'eip155:1/slip44:60' + const resp = { + data: [ + { + integration: 'shapeshift', + quote: { swapper: 'NEAR Intents', buyAmount: '0.0001', txs: [{ txParams: { data: '0x', to: '0xnear' } }] }, + }, + { + integration: 'shapeshiftSwap', + quote: { + swapper: 'Chainflip', + buyAmount: '0.0002685', + txs: [{ txParams: { to: '0xchainflip_deposit', data: '0x', value: '10000000000000000' } }], + }, + }, + ], + } + const result = parseQuoteResponse(resp, { fromCaip: ethCaip, toCaip: btcCaip, slippageBps: 300 }) + expect(result.swapper).toBe('Chainflip') + expect(result.relayTx?.isDepositChannel).toBe(true) }) test('relayTx with real calldata to EVM destination is still accepted', () => { diff --git a/projects/keepkey-vault/src/bun/index.ts b/projects/keepkey-vault/src/bun/index.ts index 33d0b24a..5dc92169 100644 --- a/projects/keepkey-vault/src/bun/index.ts +++ b/projects/keepkey-vault/src/bun/index.ts @@ -4123,7 +4123,43 @@ const rpc = BrowserView.defineRPC({ throw new Error(`Could not resolve destination address for ${params.toCaip} — device may be locked or disconnected`) } - const quote = await getSwapQuote(params) + let quote = await getSwapQuote(params) + + // NEAR Intents sendMax BTC fix: the first quote commits NEAR Intents to + // receiving `params.amount` (full balance), but the UTXO tx only delivers + // `balance - miner_fee`. NEAR Intents hard-fails (PARTIAL_DEPOSIT refund) + // on any shortfall. Fix: re-quote with the actual net delivery amount. + if ( + quote.swapper === 'NEAR Intents' + && params.isMax + && params.fromCaip.startsWith('bip122:') + && engine.wallet + ) { + try { + const { estimateUtxoFee } = await import('./txbuilder/utxo') + const { getPioneer: getPio } = await import('./pioneer') + const pio = await getPio() + const btcChain = getAllChains().find(c => c.id === 'bitcoin') + const xpubs = btcAccounts.isInitialized ? btcAccounts.getFundedXpubs() : [] + if (btcChain && xpubs.length > 0) { + const est = await estimateUtxoFee(pio, btcChain, { + to: quote.inboundAddress || params.fromAddress, + amount: params.amount, + isMax: true, + feeLevel: params.feeLevel, + allXpubs: xpubs, + }) + if (est && est.feeSat > 0) { + const netBtc = (est.netSat / 1e8).toFixed(8) + console.log(`[swap] NEAR Intents sendMax: re-quoting with net amount ${netBtc} BTC (fee=${est.feeSat} sat)`) + quote = await getSwapQuote({ ...params, amount: netBtc, isMax: false }) + } + } + } catch (e: any) { + console.warn(`[swap] NEAR Intents fee estimation failed, using original quote: ${e.message}`) + } + } + // Cache quote so executeSwap can pass real data to the tracker const cacheKey = `${params.fromCaip}-${params.toCaip}-${params.amount}-${params.slippageBps || 300}-${params.fromAddress}-${params.toAddress}` swapQuoteCache.delete(cacheKey) // delete+set for LRU ordering diff --git a/projects/keepkey-vault/src/bun/swap-parsing.ts b/projects/keepkey-vault/src/bun/swap-parsing.ts index 7339e8bb..8ce84ade 100644 --- a/projects/keepkey-vault/src/bun/swap-parsing.ts +++ b/projects/keepkey-vault/src/bun/swap-parsing.ts @@ -83,8 +83,14 @@ export function parseQuoteResponse( const quotes: any[] = Array.isArray(qInner) ? qInner : [qInner] if (quotes.length === 0) throw new Error('No quotes available for this pair') - // Select first (best) quote - const best = quotes[0] + // Drop unsupported swappers so a valid fallback route is used when available. + // Without this, Pioneer returning NEAR Intents first blocks a supported second route. + const UNSUPPORTED_SWAPPER = /^near/i + const supported = quotes.filter(q => { + const sw = ((q.quote || q).swapper || '').replace(/\s/g, '') + return !UNSUPPORTED_SWAPPER.test(sw) + }) + const best = (supported.length > 0 ? supported : quotes)[0] const integration = best.integration || 'thorchain' const quote = best.quote || best // Pioneer wraps THORNode data in quote.raw and tx details in quote.txs[] @@ -132,10 +138,10 @@ export function parseQuoteResponse( // // Two sub-cases: // A) Real calldata (data.length ≥ 10): encodes the swap instruction (Relay, 0x, …) - // B) Deposit-channel (data = '0x' / empty): Chainflip and NEAR Intents EVM-side - // use a plain ETH transfer to a protocol-controlled address; the swap destination - // was registered off-chain when the quote/channel was created. `data` is empty - // intentionally — do NOT conflate with a malformed Relay quote. + // B) Deposit-channel (data = '0x' / empty): Chainflip uses a plain ETH transfer to a + // protocol-controlled address; the swap destination was registered off-chain when + // the quote/channel was created. `data` is empty intentionally — do NOT conflate + // with a malformed Relay quote. const rawData: string | undefined = txParams.data const hasRealCalldata = !!rawData && rawData !== '0x' && rawData !== '0x0' && rawData.length >= 10 @@ -144,11 +150,8 @@ export function parseQuoteResponse( // Allowed list is narrow and explicit — unknown swappers with empty calldata // are rejected by buildRelaySwapTx's ERC-20 guard if applicable, and warned // by the pre-existing cross-chain guard. - // Deposit-channel only applies when source is EVM. For UTXO sources (BTC→ETH via - // NEAR Intents), the txParams.to is a Bitcoin address and we use the inboundAddress - // path instead (isMemolessTransfer below). const fromIsUtxo = params.fromCaip.startsWith('bip122:') - const DEPOSIT_CHANNEL_SWAPPERS = new Set(['Chainflip', 'NEAR Intents']) + const DEPOSIT_CHANNEL_SWAPPERS = new Set(['Chainflip']) const isDepositChannel = !hasRealCalldata && !fromIsUtxo && !!txParams.to && DEPOSIT_CHANNEL_SWAPPERS.has(swapper ?? '') const hasPrebuiltTx = hasRealCalldata || isDepositChannel let relayTx: RelayTxParams | undefined @@ -189,16 +192,12 @@ export function parseQuoteResponse( } // Guard: UTXO sources must send to a chain-native address, not an EVM address. - // NEAR Intents BTC→ETH falls back to the user's ETH recipientAddress when Pioneer - // can't surface a BTC deposit address from step.allowanceContract — that ETH - // address would be passed to the firmware as a Bitcoin output and cause - // "Failed to compile output" (code 9). Fail loudly here instead. if (fromIsUtxo && inboundAddress && inboundAddress.startsWith('0x')) { - console.error(`${TAG} NEAR Intents BTC deposit address is missing — Pioneer returned EVM address ${inboundAddress} as inbound address for a UTXO source. Dumping quote:`) + console.error(`${TAG} Pioneer returned EVM address ${inboundAddress} as inbound address for a UTXO source. Dumping quote:`) console.error(`${TAG} txParams keys: ${Object.keys(txParams).join(', ')}`) console.error(`${TAG} txParams: ${JSON.stringify(txParams, null, 2).slice(0, 2000)}`) console.error(`${TAG} best keys: ${Object.keys(best).join(', ')}`) - throw new Error('Swap quote did not provide a valid BTC deposit address — NEAR Intents deposit channel may be unavailable for this pair. Try refreshing the quote.') + throw new Error('Swap quote did not provide a valid deposit address for this chain. Try a different pair or refresh the quote.') } // Expiry for depositWithExpiry @@ -219,11 +218,7 @@ export function parseQuoteResponse( console.error(`${TAG} full best: ${JSON.stringify(best, null, 2).slice(0, 2000)}`) throw new Error('Quote response missing inbound address') } - // For memo-less UTXO swaps (NEAR Intents BTC→ETH): the deposit address IS the - // only instruction — no memo or calldata needed. fromIsUtxo guards direction: - // EVM→BTC with no calldata has no way to encode the BTC destination. - const isMemolessTransfer = fromIsUtxo && !!inboundAddress && swapper === 'NEAR Intents' - if (!memo && !hasPrebuiltTx && !isNativeDeposit && !isMemolessTransfer) { + if (!memo && !hasPrebuiltTx && !isNativeDeposit) { // A quote with neither memo nor prebuilt calldata has no swap instructions — // it cannot be executed. Throw now so the UI surfaces a clear error at // quote-fetch time rather than a cryptic "Missing swap memo" at preview time. diff --git a/projects/keepkey-vault/src/bun/swap-tracker.ts b/projects/keepkey-vault/src/bun/swap-tracker.ts index 241f26b2..82334ba7 100644 --- a/projects/keepkey-vault/src/bun/swap-tracker.ts +++ b/projects/keepkey-vault/src/bun/swap-tracker.ts @@ -433,7 +433,6 @@ async function registerWithPioneer(swap: PendingSwap): Promise { if (swap.relayRequestId) { body.relayData = { requestId: swap.relayRequestId } } - swapLog(`${TAG} CreatePendingSwap request:`, JSON.stringify({ txHash: body.txHash, sellCaip: body.sellAsset.caip, buyCaip: body.buyAsset.caip, integration: body.integration, swapper: body.swapper })) const resp = await withTimeout(pioneer.CreatePendingSwap(body), PIONEER_SWAP_TIMEOUT_MS, 'CreatePendingSwap') @@ -777,6 +776,7 @@ export async function refreshSwap(txid: string, deviceId?: string, walletId?: st const relayStatus = await fetchRelayExecutionStatus(swap) applyRelayExecutionStatus(swap, relayStatus) } + } catch (e: any) { if (e.status === 404 || e.statusCode === 404 || e.message?.includes('404')) { swapLog(`${TAG} refreshSwap ${txid.slice(0, 10)}...: not indexed yet (404)`) diff --git a/projects/keepkey-vault/src/bun/swap.ts b/projects/keepkey-vault/src/bun/swap.ts index d95fbdd5..f99baeca 100644 --- a/projects/keepkey-vault/src/bun/swap.ts +++ b/projects/keepkey-vault/src/bun/swap.ts @@ -288,29 +288,6 @@ export async function getSwapQuote(params: SwapQuoteParams): Promise : (result.integration || 'unknown') swapLog(`${TAG} Quote: ${result.expectedOutput} (${route}), memo=${result.memo || 'NONE'}, router=${result.router || 'NONE'}, expiry=${result.expiry}`) - // NEAR Intents BTC→EVM: competitive solver network that fronts ETH then claims BTC. - // Solvers need 2 BTC confirmations (20-40 min) and must cover ETH gas (~$2-5). - // Amounts below ~$50 USD are systematically refunded — no solver finds it profitable. - if (result.swapper === 'NEAR Intents' && params.fromCaip.startsWith('bip122:')) { - // Block amounts below the protocol's stated minimum - if (result.minAmountIn && parseFloat(params.amount) < parseFloat(result.minAmountIn)) { - throw new Error( - `Amount too small for NEAR Intents — minimum ${result.minAmountIn} BTC required. ` + - `Solvers must front ETH and wait for BTC confirmations; smaller amounts are unprofitable and will be refunded.` - ) - } - // Warn if quote deadline is too short for BTC's 2-confirmation requirement (~20-40 min). - // A 30-min deadline may expire before BTC even confirms, causing automatic refund. - const nowSec = Math.floor(Date.now() / 1000) - const minutesUntilExpiry = result.expiry ? (result.expiry - nowSec) / 60 : 0 - if (result.expiry && minutesUntilExpiry < 60) { - console.warn( - `${TAG} NEAR Intents BTC→EVM quote expires in ${minutesUntilExpiry.toFixed(0)} min — ` + - `BTC requires 2 confirmations (20-40 min). If BTC doesn't confirm in time, swap will be refunded.` - ) - } - } - return result } @@ -421,10 +398,6 @@ export async function executeSwap(params: ExecuteSwapParams, ctx: SwapContext): const hasPrebuiltTx = !!params.relayTx // Native THORChain/Maya deposits (RUNE, CACAO) use MsgDeposit — no inbound vault needed const isNativeDeposit = isNativeDepositCaip(params.fromCaip) - // NEAR Intents (memo-less UTXO → EVM): deposit address is the only instruction. - // Detected by fromCaip chain family — `swapper` is not in ExecuteSwapParams. - // Direction guard: only valid for UTXO sources; EVM → BTC with no calldata - // has no way to encode the BTC destination. const fromIsUtxo = params.fromCaip.startsWith('bip122:') const isMemolessTransfer = fromIsUtxo && !!params.inboundAddress && !params.memo if (!params.inboundAddress && !isNativeDeposit && !hasPrebuiltTx) throw new Error('Missing inbound vault address from quote') @@ -716,7 +689,6 @@ export async function previewSwapBuild( const hasPrebuiltTx = !!params.relayTx const isNativeDeposit = isNativeDepositCaip(params.fromCaip) - // NEAR Intents (memo-less UTXO → EVM): CAIP-based detection (swapper not in ExecuteSwapParams) const fromIsUtxoPreview = params.fromCaip.startsWith('bip122:') const isMemolessTransfer = fromIsUtxoPreview && !!params.inboundAddress && !params.memo if (!params.inboundAddress && !isNativeDeposit && !hasPrebuiltTx) throw new Error('Missing inbound vault address from quote') @@ -992,17 +964,28 @@ async function buildRelaySwapTx( if (nativeBalance === undefined) { throw new Error(`Unable to verify ${fromChain.symbol} balance for Relay transaction — refusing to sign. Try refreshing the quote.`) } - console.log(`${TAG} relay gas check: value=${formatWei(relayValue, fromChain.decimals)} gasReserve=${formatWei(relayGasReserve, fromChain.decimals)} required=${formatWei(relayNativeRequired, fromChain.decimals)} balance=${formatWei(nativeBalance, fromChain.decimals)} ${fromChain.symbol}`) - if (nativeBalance < relayNativeRequired) { + // Deposit-channel sendMax fix: quote was generated with the full balance as the + // send amount, but gas must also come from that same balance. Reduce the deposit + // value by the gas reserve so the tx fits without requiring a re-quote. + // Only applies to native-asset deposit channels (Chainflip) — ERC-20 + // sources never have value > 0, and standard Relay calldata txs carry exact amounts. + let effectiveRelayValue = relayValue + if (params.isMax && relay.isDepositChannel && !isErc20Source && nativeBalance > relayGasReserve) { + effectiveRelayValue = nativeBalance - relayGasReserve + console.log(`${TAG} deposit channel sendMax: adjusted relay value ${formatWei(relayValue, fromChain.decimals)} → ${formatWei(effectiveRelayValue, fromChain.decimals)} ${fromChain.symbol} (gas: ${formatWei(relayGasReserve, fromChain.decimals)})`) + } + const effectiveRelayRequired = effectiveRelayValue + relayGasReserve + approveGasReserve + console.log(`${TAG} relay gas check: value=${formatWei(effectiveRelayValue, fromChain.decimals)} gasReserve=${formatWei(relayGasReserve, fromChain.decimals)} required=${formatWei(effectiveRelayRequired, fromChain.decimals)} balance=${formatWei(nativeBalance, fromChain.decimals)} ${fromChain.symbol}`) + if (nativeBalance < effectiveRelayRequired) { if (params.isMax && !isErc20Source) { throw new Error( - `Relay quote is stale: updated gas fees require ${formatWei(relayNativeRequired, fromChain.decimals)} ${fromChain.symbol} ` + + `Relay quote is stale: updated gas fees require ${formatWei(effectiveRelayRequired, fromChain.decimals)} ${fromChain.symbol} ` + `but the wallet has ${formatWei(nativeBalance, fromChain.decimals)}. Refresh the quote so the max send amount can reserve gas before signing.` ) } throw new Error( - `Insufficient ${fromChain.symbol} for Relay transaction: need ${formatWei(relayNativeRequired, fromChain.decimals)} ` + - `(${formatWei(relayValue, fromChain.decimals)} value + ${formatWei(relayGasReserve, fromChain.decimals)} gas), ` + + `Insufficient ${fromChain.symbol} for Relay transaction: need ${formatWei(effectiveRelayRequired, fromChain.decimals)} ` + + `(${formatWei(effectiveRelayValue, fromChain.decimals)} value + ${formatWei(relayGasReserve, fromChain.decimals)} gas), ` + `have ${formatWei(nativeBalance, fromChain.decimals)}.` ) } @@ -1093,7 +1076,7 @@ async function buildRelaySwapTx( nonce: toHex(BigInt(nonce)), gasLimit: toHex(BigInt(gasLimit)), to: relay.to, - value: toHex(relayValue), + value: toHex(effectiveRelayValue), data: relay.data, } @@ -1118,8 +1101,8 @@ async function buildRelaySwapTx( // Historical incidents: // - USDT→BTC (Maya, txid 0x8426ca…) — ERC-20 source, dust transfer to non-vault EOA // - // Cross-chain native-asset swaps (ETH→BTC) via deposit-channel protocols - // (Chainflip, NEAR Intents) legitimately use `data = '0x'` — the swap + // Cross-chain native-asset swaps via deposit-channel protocols + // (Chainflip) legitimately use `data = '0x'` — the swap // destination was registered off-chain when the quote/channel was created. // These are flagged `relay.isDepositChannel = true` by parseQuoteResponse // so we can distinguish them from truly malformed quotes. diff --git a/projects/keepkey-vault/src/bun/txbuilder/utxo.ts b/projects/keepkey-vault/src/bun/txbuilder/utxo.ts index 6fd5dd9e..8c4b5262 100644 --- a/projects/keepkey-vault/src/bun/txbuilder/utxo.ts +++ b/projects/keepkey-vault/src/bun/txbuilder/utxo.ts @@ -176,6 +176,68 @@ async function fetchUtxosForXpub( return utxos } +/** Estimate the miner fee for a UTXO send without building the full tx. + * Runs steps 1-3 of buildUtxoTx (fetch UTXOs + fee rate + coin selection) and + * returns the fee in satoshis and the net spendable amount. Returns null on any + * error so callers can safely degrade to no-estimate. */ +export async function estimateUtxoFee( + pioneer: any, + chain: ChainDef, + params: Pick, +): Promise<{ feeSat: number; netSat: number } | null> { + try { + const { to, feeLevel = 5, isMax = false, xpub, allXpubs, accountPath } = params + const primaryXpub = xpub || allXpubs?.[0]?.xpub + if (!primaryXpub) return null + const scriptType = getScriptTypeFromXpub(primaryXpub) || chain.scriptType || 'p2pkh' + + let utxos: any[] + if (allXpubs && allXpubs.length > 0) { + const settled = await Promise.allSettled( + allXpubs.map(x => fetchUtxosForXpub(pioneer, chain.networkId, x.xpub, x.scriptType, x.accountPath)) + ) + utxos = settled.flatMap(r => r.status === 'fulfilled' ? r.value : []) + } else { + utxos = await fetchUtxosForXpub(pioneer, chain.networkId, primaryXpub, scriptType, accountPath) + } + if (!utxos.length) return null + + let feeRates: { slow: number; average: number; fast: number } + if (HARDCODED_FEES[chain.networkId]) { + feeRates = HARDCODED_FEES[chain.networkId] + } else { + try { + const feeResp = pioneer.GetFeeRateByNetwork + ? await pioneer.GetFeeRateByNetwork({ networkId: chain.networkId }) + : await pioneer.GetFeeRate({ networkId: chain.networkId }) + const data = feeResp?.data || {} + const vals = [data.slow, data.average, data.fast, data.fastest].filter(Boolean) + const needsConversion = vals.some((v: number) => v > 500) + feeRates = { + slow: (data.slow || data.average || 5) / (needsConversion ? 1000 : 1), + average: (data.average || data.fast || 10) / (needsConversion ? 1000 : 1), + fast: (data.fastest || data.fast || data.average || 15) / (needsConversion ? 1000 : 1), + } + } catch { + feeRates = DEFAULT_FEES[chain.networkId] || { slow: 3, average: 5, fast: 15 } + } + } + const effectiveFeeRate = Math.max(3, Math.ceil(feeLevel <= 2 ? feeRates.slow : feeLevel <= 4 ? feeRates.average : feeRates.fast)) + + const satoshis = parseDecimalToInt(params.amount, chain.decimals) + const result = isMax + ? coinSelectSplit(utxos, [{ address: to }], effectiveFeeRate) + : coinSelect(utxos, [{ address: to, value: satoshis }], effectiveFeeRate) + + if (!result?.inputs || result.fee == null) return null + const totalIn = result.inputs.reduce((s: number, i: any) => s + i.value, 0) + const feeSat: number = result.fee + return { feeSat, netSat: totalIn - feeSat } + } catch { + return null + } +} + export async function buildUtxoTx( pioneer: any, chain: ChainDef, diff --git a/projects/keepkey-vault/src/mainview/App.tsx b/projects/keepkey-vault/src/mainview/App.tsx index a3382f84..32b0d291 100644 --- a/projects/keepkey-vault/src/mainview/App.tsx +++ b/projects/keepkey-vault/src/mainview/App.tsx @@ -21,6 +21,8 @@ import { TopNav, SplashNav } from "./components/TopNav" import { WindowResizeHandles } from "./components/WindowResizeHandles" import type { NavTab } from "./components/TopNav" import { Dashboard } from "./components/Dashboard" +import { CommandPalette } from "./components/CommandPalette" +import { useLatestBalances } from "./lib/commandBus" import { AppStore } from "./components/AppStore" import { DeviceSettingsDrawer } from "./components/DeviceSettingsDrawer" import { UpdateBanner } from "./components/UpdateBanner" @@ -53,6 +55,8 @@ function App() { const [gridReady, setGridReady] = useState(false) const [settingsOpen, setSettingsOpen] = useState(false) const [activeTab, setActiveTab] = useState("vault") + const [paletteOpen, setPaletteOpen] = useState(false) + const paletteBalances = useLatestBalances() const [updateDismissed, setUpdateDismissed] = useState(false) const [appVersion, setAppVersion] = useState<{ version: string; channel: string } | null>(null) const [restApiEnabled, setRestApiEnabled] = useState(false) @@ -132,6 +136,25 @@ function App() { } }, [update.phase]) + // ── Command Palette (⌘K / Ctrl+K) ─────────────────────────────── + // Global toggle. Ignore presses while the user is typing in an input or + // textarea so we don't hijack search fields elsewhere in the app. + useEffect(() => { + const onKey = (e: KeyboardEvent) => { + if (!((e.metaKey || e.ctrlKey) && (e.key === "k" || e.key === "K"))) return + const target = e.target + // Allow toggle from inside the palette's own input — only ignore other inputs. + if (target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement) { + const insidePalette = (target as HTMLElement).closest('[aria-label="Command Palette"]') + if (!insidePalette) return + } + e.preventDefault() + setPaletteOpen((o) => !o) + } + window.addEventListener("keydown", onKey) + return () => window.removeEventListener("keydown", onKey) + }, []) + // ── PIN overlay ───────────────────────────────────────────────── const [pinRequestType, setPinRequestType] = useState(null) const [pinDismissed, setPinDismissed] = useState(false) @@ -735,6 +758,12 @@ function App() { updatePhase={update.phase} updateVersion={update.info?.version} /> + setPaletteOpen(false)} + onJumpToVault={() => setActiveTab("vault")} + balances={paletteBalances} + /> ) } @@ -803,9 +832,19 @@ function App() { {!portfolioLoaded && activeTab === "vault" && ( )} - + {/* Full-screen ambient radial glow — replaces the per-card glow inside the orbital view. */} + + setPaletteOpen(false)} + onJumpToVault={() => setActiveTab("vault")} + balances={paletteBalances} + /> {/* Top-level swap dialog mount for REST-driven /api/v2/swap/open. */} {/* Enable API Bridge dialog — shown when user tries to launch an app with REST disabled */} diff --git a/projects/keepkey-vault/src/mainview/assets/doge.png b/projects/keepkey-vault/src/mainview/assets/doge.png new file mode 100644 index 00000000..e6f6affd Binary files /dev/null and b/projects/keepkey-vault/src/mainview/assets/doge.png differ diff --git a/projects/keepkey-vault/src/mainview/components/ActivityPage.tsx b/projects/keepkey-vault/src/mainview/components/ActivityPage.tsx new file mode 100644 index 00000000..2b74b7d4 --- /dev/null +++ b/projects/keepkey-vault/src/mainview/components/ActivityPage.tsx @@ -0,0 +1,654 @@ +import { useState, useEffect, useCallback, useMemo, useRef } from "react" +import { Box, Flex, Text, VStack, HStack, Image, Spinner } from "@chakra-ui/react" +import { rpcRequest, onRpcMessage } from "../lib/rpc" +import { CHAINS } from "../../shared/chains" +import { caipToIcon } from "../../shared/assetLookup" +import type { RecentActivity, PendingSwap, ChainBalance, SwapStatusUpdate, ApiLogEntry } from "../../shared/types" +import { + ActivityRow, SwapRow, TxDetailDialog, + recentFirst, nativePriceByChain, + type TxDetail, type ActivityTimelineItem, +} from "./ActivityPanel" +import { ReportDialog } from "./ReportDialog" + +interface ActivityPageProps { + defaultChainId?: string + onBack?: () => void + onResumeSwap?: (swap: PendingSwap) => void +} + +const TYPE_COLORS: Record = { + send: 'var(--rose)', + receive: 'var(--teal)', + swap: 'var(--gold)', + sign: 'var(--violet)', + approve: 'var(--amber, #e9a86a)', + message: 'var(--violet)', +} + +function bucketByDate(items: ActivityTimelineItem[]): Array<{ label: string; items: ActivityTimelineItem[] }> { + const today = new Date() + const yesterday = new Date(today) + yesterday.setDate(yesterday.getDate() - 1) + + const groups = new Map() + for (const item of items) { + const d = new Date(item.createdAt) + let label: string + if (d.toDateString() === today.toDateString()) label = 'Today' + else if (d.toDateString() === yesterday.toDateString()) label = 'Yesterday' + else { + label = d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) + } + if (!groups.has(label)) groups.set(label, []) + groups.get(label)!.push(item) + } + return Array.from(groups.entries()).map(([label, items]) => ({ label, items })) +} + +function StatCard({ label, value, sub, accent }: { label: string; value: string | number; sub?: string; accent?: string }) { + return ( + + + {label} + + + {value} + + {sub && {sub}} + + ) +} + +function ChainFilterDropdown({ + chains, + selected, + onSelect, +}: { + chains: Array<{ id: string; symbol: string; coin: string; caip: string; color: string }> + selected: string + onSelect: (id: string) => void +}) { + const [open, setOpen] = useState(false) + const ref = useRef(null) + useEffect(() => { + const handler = (e: MouseEvent) => { + if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false) + } + window.addEventListener('mousedown', handler) + return () => window.removeEventListener('mousedown', handler) + }, []) + + const selectedDef = chains.find(c => c.id === selected) + + return ( + + setOpen(o => !o)} + className="electrobun-webkit-app-region-no-drag" + > + {selectedDef ? ( + <> + } /> + {selectedDef.symbol} + + ) : ( + <> + + + + All Networks + + )} + + + + + + {open && ( + <> + setOpen(false)} /> + + { onSelect(''); setOpen(false) }} + > + + * + + All Networks + + {chains.map(c => ( + { onSelect(c.id); setOpen(false) }} + > + } /> + {c.symbol} + {c.coin} + + ))} + + + )} + + ) +} + +export function ActivityPage({ defaultChainId, onBack, onResumeSwap }: ActivityPageProps) { + const [activities, setActivities] = useState([]) + const [pendingSwaps, setPendingSwaps] = useState([]) + const [availableChains, setAvailableChains] = useState([]) + const [loading, setLoading] = useState(true) + const [scanning, setScanning] = useState(false) + const [scanResult, setScanResult] = useState(null) + const [selectedDetail, setSelectedDetail] = useState(null) + const [showReports, setShowReports] = useState(false) + + // Filters + const [chainFilter, setChainFilter] = useState(defaultChainId || '') + const [typeFilters, setTypeFilters] = useState>(new Set()) + const [search, setSearch] = useState('') + const [sort, setSort] = useState<'recent' | 'oldest'>('recent') + + const fetchActivities = useCallback(() => { + rpcRequest('getRecentActivity', { limit: 200 }, 10000) + .then(r => { if (r) setActivities(r) }) + .catch(() => {}) + .finally(() => setLoading(false)) + }, []) + + const fetchSwaps = useCallback(() => { + rpcRequest('getPendingSwaps', undefined, 5000) + .then(r => { if (r) setPendingSwaps(r) }) + .catch(() => {}) + }, []) + + const fetchChains = useCallback(() => { + rpcRequest<{ balances: ChainBalance[] } | null>('getCachedBalances') + .then(r => { if (r?.balances) setAvailableChains(r.balances) }) + .catch(() => {}) + }, []) + + useEffect(() => { + fetchActivities() + fetchSwaps() + fetchChains() + }, [fetchActivities, fetchSwaps, fetchChains]) + + // Listen for new activity + useEffect(() => { + const u1 = onRpcMessage('api-log', (entry: ApiLogEntry) => { + if (entry.activityType) fetchActivities() + }) + const u2 = onRpcMessage('swap-update', (_u: SwapStatusUpdate) => { fetchSwaps() }) + const u3 = onRpcMessage('swap-complete', () => { fetchSwaps(); fetchActivities() }) + return () => { u1(); u2(); u3() } + }, [fetchActivities, fetchSwaps]) + + const nativePrices = useMemo(() => nativePriceByChain(availableChains), [availableChains]) + + // Chain options for filter dropdown — all supported chains, sorted by symbol + const chainOptions = useMemo(() => { + return [...CHAINS].sort((a, b) => a.symbol.localeCompare(b.symbol)) + }, []) + + // Merge + filter timeline + const filteredTimeline = useMemo(() => { + const swapTxids = new Set(pendingSwaps.map(s => s.txid)) + const activeSwaps = pendingSwaps.filter( + s => s.status !== 'completed' && s.status !== 'failed' && s.status !== 'refunded' + ) + + // Filter activities + let filteredActs = activities.filter(a => { + if (typeFilters.size > 0 && !typeFilters.has(a.type)) return false + if (chainFilter) { + const chainDef = CHAINS.find(c => c.id === chainFilter) + if (chainDef && !(a.chainId === chainDef.id || a.chain === chainDef.symbol || a.chain === chainDef.id)) return false + } + // Don't show activities that are also in activeSwaps (avoid double rendering) + if (a.type === 'swap' && a.txid && swapTxids.has(a.txid)) return false + if (search) { + const q = search.toLowerCase() + const hay = [a.txid, a.to, a.appName, a.asset, a.chain, a.chainId].filter(Boolean).join(' ').toLowerCase() + if (!hay.includes(q)) return false + } + return true + }) + + // Filter swaps (only include if type filter includes 'swap' or no type filter) + let filteredSwaps: PendingSwap[] = [] + if (typeFilters.size === 0 || typeFilters.has('swap')) { + filteredSwaps = activeSwaps.filter(s => { + if (chainFilter) { + const chainDef = CHAINS.find(c => c.id === chainFilter) + if (chainDef && !(s.fromChainId === chainDef.id || s.toChainId === chainDef.id || s.fromSymbol === chainDef.symbol || s.toSymbol === chainDef.symbol)) return false + } + if (search) { + const q = search.toLowerCase() + const hay = [s.txid, s.fromSymbol, s.toSymbol, s.integration].filter(Boolean).join(' ').toLowerCase() + if (!hay.includes(q)) return false + } + return true + }) + } + + const merged: ActivityTimelineItem[] = [ + ...filteredSwaps.map(s => ({ kind: 'swap' as const, id: `swap-${s.txid}`, createdAt: s.createdAt, swap: s })), + ...filteredActs.map(a => ({ kind: 'activity' as const, id: `act-${a.id}`, createdAt: a.createdAt, activity: a })), + ] + + return sort === 'oldest' + ? [...merged].sort((a, b) => a.createdAt - b.createdAt) + : recentFirst(merged) + }, [activities, pendingSwaps, typeFilters, chainFilter, search, sort]) + + // Stats + const stats = useMemo(() => { + const total = activities.length + pendingSwaps.filter(s => s.status !== 'completed' && s.status !== 'failed' && s.status !== 'refunded').length + const sent = activities.filter(a => a.type === 'send').length + const received = activities.filter(a => a.type === 'receive').length + const swaps = pendingSwaps.length + return { total, sent, received, swaps } + }, [activities, pendingSwaps]) + + const dateGroups = useMemo(() => bucketByDate(filteredTimeline), [filteredTimeline]) + + const toggleType = (t: string) => { + setTypeFilters(prev => { + const next = new Set(prev) + if (next.has(t)) next.delete(t); else next.add(t) + return next + }) + } + + const scanningRef = useRef(false) + const handleRescan = useCallback(async () => { + if (scanningRef.current) return + scanningRef.current = true + setScanning(true) + setScanResult(null) + try { + const chainsToScan = chainFilter ? [chainFilter] : CHAINS.map(c => c.id) + let total = 0 + for (const chainId of chainsToScan) { + try { + const result = await rpcRequest<{ count: number }>('scanChainHistory', { chainId }, 60000) + total += result?.count || 0 + } catch { /* skip failing chains */ } + } + setScanResult(total > 0 ? `+${total} tx${total > 1 ? 's' : ''}` : 'Up to date') + fetchActivities() + } catch (e: any) { + setScanResult(e.message || 'Failed') + } finally { + scanningRef.current = false + setScanning(false) + } + }, [chainFilter, fetchActivities]) + + useEffect(() => { setScanResult(null) }, [chainFilter]) + + const fetchingSwapRef = useRef(false) + const handleSelectActivity = useCallback((a: RecentActivity) => { + if (a.type === 'swap' && a.txid && onResumeSwap) { + if (fetchingSwapRef.current) return + fetchingSwapRef.current = true + rpcRequest('getSwapByTxid', { txid: a.txid }) + .then(swap => { + if (swap) onResumeSwap(swap) + else setSelectedDetail({ kind: 'activity', activity: a }) + }) + .catch(() => setSelectedDetail({ kind: 'activity', activity: a })) + .finally(() => { fetchingSwapRef.current = false }) + } else { + setSelectedDetail({ kind: 'activity', activity: a }) + } + }, [onResumeSwap]) + + const TYPE_PILLS = [ + { id: 'send', label: 'Sent' }, + { id: 'receive', label: 'Received' }, + { id: 'swap', label: 'Swaps' }, + { id: 'sign', label: 'Signs' }, + ] + + const pending = useMemo(() => + pendingSwaps.filter(s => s.status === 'pending' || s.status === 'confirming' || s.status === 'signing' || s.status === 'output_confirming'), + [pendingSwaps] + ) + + return ( + + + + {/* Header */} + + + {onBack && ( + + + + + + )} + + + Activity + + + Every signature & on-chain move + + + + + + setShowReports(true)} + px="3" py="2" borderRadius="8px" + bg="var(--ink-2)" border="1px solid var(--line)" + fontSize="11px" fontWeight="500" color="var(--text-2)" + cursor="pointer" display="flex" alignItems="center" gap="6px" + _hover={{ bg: 'var(--ink-3)', color: 'var(--text-0)', borderColor: 'var(--line-2)' }} + transition="all 0.15s" + className="electrobun-webkit-app-region-no-drag" + > + + + + Reports + + + + + + {scanning ? 'Scanning…' : 'Rescan'} + + + + + {/* Pending banner */} + {pending.length > 0 && ( + + + + {pending.length} transaction{pending.length === 1 ? '' : 's'} broadcasting + + + {pending.map(s => `${s.fromSymbol} → ${s.toSymbol}`).join(' · ')} + + + )} + + {/* Scan result message */} + {scanResult && ( + + {scanResult} + + )} + + {/* Stat cards */} + + + 0 ? `${stats.sent} tx${stats.sent > 1 ? 's' : ''}` : 'None'} accent="var(--rose)" /> + 0 ? `${stats.received} tx${stats.received > 1 ? 's' : ''}` : 'None'} accent="var(--teal)" /> + 0 ? `${stats.swaps} total` : 'None'} accent="var(--gold)" /> + + + {/* Filter toolbar */} + + {/* Search */} + + + + + ) => setSearch(e.target.value)} + sx={{ '::placeholder': { color: 'var(--text-3)' } }} + /> + {search && ( + setSearch('')} color="var(--text-3)" _hover={{ color: 'var(--text-1)' }} lineHeight={1}> + × + + )} + + + {/* Type pills */} + {TYPE_PILLS.map(p => ( + toggleType(p.id)} + className="electrobun-webkit-app-region-no-drag" + > + {p.label} + + ))} + + {/* Chain filter */} + ({ id: c.id, symbol: c.symbol, coin: c.coin, caip: c.caip, color: c.color }))} + selected={chainFilter} + onSelect={setChainFilter} + /> + + {/* Sort toggle */} + setSort(s => s === 'recent' ? 'oldest' : 'recent')} + className="electrobun-webkit-app-region-no-drag" + > + + + + + {sort === 'recent' ? 'Newest' : 'Oldest'} + + + {/* Result count */} + + {filteredTimeline.length} of {activities.length + pendingSwaps.filter(s => s.status !== 'completed' && s.status !== 'failed' && s.status !== 'refunded').length} + + + + {/* Timeline */} + {loading ? ( + + + Loading activity… + + ) : filteredTimeline.length === 0 ? ( + + + + {typeFilters.size > 0 || chainFilter || search + ? 'No activity matches your filters' + : 'No activity yet — click Rescan to load history.'} + + {!chainFilter && !search && typeFilters.size === 0 && ( + + Scan all networks + + )} + + ) : ( + + {dateGroups.map(group => ( + + + {group.label} + + + {group.items.map(item => + item.kind === 'swap' ? ( + { + if (onResumeSwap) onResumeSwap(s) + else setSelectedDetail({ kind: 'swap', swap: s }) + }} + /> + ) : ( + + ) + )} + + + ))} + + )} + + + {/* TX Detail dialog */} + {selectedDetail && ( + setSelectedDetail(null)} + /> + )} + + {showReports && setShowReports(false)} />} + + ) +} diff --git a/projects/keepkey-vault/src/mainview/components/ActivityPanel.tsx b/projects/keepkey-vault/src/mainview/components/ActivityPanel.tsx index 70edfff9..ed00052a 100644 --- a/projects/keepkey-vault/src/mainview/components/ActivityPanel.tsx +++ b/projects/keepkey-vault/src/mainview/components/ActivityPanel.tsx @@ -19,6 +19,7 @@ interface ActivityPanelProps { pendingSwaps: PendingSwap[] onRefresh: () => void onResumeSwap?: (swap: PendingSwap) => void + onOpenFullPage?: () => void } const CHAIN_COLORS: Record = {} @@ -99,12 +100,12 @@ function getExplorerUrl(chainSymbol: string, txid: string): string | null { return chain.explorerTxUrl.replace('{{txid}}', normalizedTxid) } -function truncateTxid(txid: string): string { +export function truncateTxid(txid: string): string { if (txid.length <= 16) return txid return txid.slice(0, 8) + '...' + txid.slice(-8) } -function timeAgo(ts: number): string { +export function timeAgo(ts: number): string { const diff = Date.now() - ts if (diff < 60_000) return 'just now' if (diff < 3600_000) return `${Math.floor(diff / 60_000)}m ago` @@ -117,7 +118,7 @@ function recentTimestamp(item: { createdAt: number }): number { return Number.isFinite(timestamp) ? timestamp : 0 } -function recentFirst(items: T[]): T[] { +export function recentFirst(items: T[]): T[] { return [...items].sort((a, b) => recentTimestamp(b) - recentTimestamp(a)) } @@ -174,7 +175,7 @@ function formatNativeValue(raw: string | undefined, chainDef: { decimals: number return { amount, usd } } -function nativePriceByChain(balances: ChainBalance[]): Record { +export function nativePriceByChain(balances: ChainBalance[]): Record { const prices: Record = {} for (const b of balances) { const balance = Number(b.balance) @@ -188,7 +189,7 @@ function nativePriceByChain(balances: ChainBalance[]): Record { } // ── Detail types for the TX detail dialog ─────────────────────────── -type TxDetail = { +export type TxDetail = { kind: 'activity' activity: RecentActivity } | { @@ -196,11 +197,11 @@ type TxDetail = { swap: PendingSwap } -type ActivityTimelineItem = +export type ActivityTimelineItem = | { kind: 'activity'; id: string; createdAt: number; activity: RecentActivity } | { kind: 'swap'; id: string; createdAt: number; swap: PendingSwap } -function formatFullDate(ts: number): string { +export function formatFullDate(ts: number): string { const d = new Date(ts) return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) + ' ' + d.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false }) @@ -243,7 +244,7 @@ function CopyableRow({ label, value, explorerUrl }: { label: string; value: stri ) } -function TxDetailDialog({ detail, onClose, nativePrices }: { detail: TxDetail; onClose: () => void; nativePrices: Record }) { +export function TxDetailDialog({ detail, onClose, nativePrices }: { detail: TxDetail; onClose: () => void; nativePrices: Record }) { if (detail.kind === 'activity') { const a = detail.activity const typeConf = TYPE_CONFIG[a.type] || TYPE_CONFIG.sign @@ -443,7 +444,7 @@ function TxDetailDialog({ detail, onClose, nativePrices }: { detail: TxDetail; o ) } -function ActivityRow({ activity, onSelect, nativePrices }: { activity: RecentActivity; onSelect: (a: RecentActivity) => void; nativePrices: Record }) { +export function ActivityRow({ activity, onSelect, nativePrices }: { activity: RecentActivity; onSelect: (a: RecentActivity) => void; nativePrices: Record }) { const [copied, setCopied] = useState(false) const typeConf = TYPE_CONFIG[activity.type] || TYPE_CONFIG.sign const chainDef = CHAINS.find(c => activity.chainId && c.id === activity.chainId) || CHAINS.find(c => c.symbol === activity.chain || c.id === activity.chain) @@ -525,7 +526,7 @@ function ActivityRow({ activity, onSelect, nativePrices }: { activity: RecentAct ) } -function SwapRow({ swap, onSelect }: { swap: PendingSwap; onSelect: (s: PendingSwap) => void }) { +export function SwapRow({ swap, onSelect }: { swap: PendingSwap; onSelect: (s: PendingSwap) => void }) { const [copied, setCopied] = useState(false) const handleCopy = (text: string) => { navigator.clipboard.writeText(text); setCopied(true); setTimeout(() => setCopied(false), 1500) } const explorerUrl = getExplorerUrl(swap.fromSymbol, swap.txid) @@ -695,7 +696,7 @@ function NetworkSelector({ chainOptions, selectedChain, selectedDef, scanning, s ) } -export function ActivityPanel({ open, onClose, activities, pendingSwaps, onRefresh, onResumeSwap }: ActivityPanelProps) { +export function ActivityPanel({ open, onClose, activities, pendingSwaps, onRefresh, onResumeSwap, onOpenFullPage }: ActivityPanelProps) { const [tab, setTab] = useState<'activity' | 'swaps'>('activity') const [selectedChain, setSelectedChain] = useState('') const [scanning, setScanning] = useState(false) @@ -796,15 +797,30 @@ export function ActivityPanel({ open, onClose, activities, pendingSwaps, onRefre {/* Header */} Recent Activity - × + + {onOpenFullPage && ( + { onClose(); onOpenFullPage() }} + display="flex" alignItems="center" gap="1" + > + Full page + + + + + )} + × + {/* Tabs */} diff --git a/projects/keepkey-vault/src/mainview/components/ActivityTracker.tsx b/projects/keepkey-vault/src/mainview/components/ActivityTracker.tsx index b9d0579b..fe311ceb 100644 --- a/projects/keepkey-vault/src/mainview/components/ActivityTracker.tsx +++ b/projects/keepkey-vault/src/mainview/components/ActivityTracker.tsx @@ -188,7 +188,7 @@ export function ActivityTracker() { {/* Floating bubble — always visible */} - + { fetchActivities(); fetchSwaps() }} onResumeSwap={(swap) => { setPanelOpen(false); setResumeSwap(swap) }} + onOpenFullPage={() => window.dispatchEvent(new CustomEvent('keepkey-open-activity'))} /> {/* Resume swap dialog — opened from activity panel swap click */} diff --git a/projects/keepkey-vault/src/mainview/components/AppStore.tsx b/projects/keepkey-vault/src/mainview/components/AppStore.tsx index 372d6f09..19e41a3f 100644 --- a/projects/keepkey-vault/src/mainview/components/AppStore.tsx +++ b/projects/keepkey-vault/src/mainview/components/AppStore.tsx @@ -70,10 +70,8 @@ export function AppStore({ onOpenApp, onOpenKeepKey }: AppStoreProps) { import("./ZcashPrivacyTab").then(m => ({ defa const StakingPanel = lazy(() => import("./StakingPanel").then(m => ({ default: m.StakingPanel }))) import { SweepDialog } from "./SweepDialog" +import { ActivityRow, TxDetailDialog, recentFirst, nativePriceByChain, type TxDetail } from "./ActivityPanel" +import type { RecentActivity } from "../../shared/types" import { BtcXpubSelector } from "./BtcXpubSelector" import { EvmAddressSelector } from "./EvmAddressSelector" import { useBtcAccounts } from "../hooks/useBtcAccounts" @@ -46,13 +48,19 @@ interface AssetPageProps { balance?: ChainBalance onBack: () => void firmwareVersion?: string + /** Open the page on a specific action ("send" / "receive" / "swap"). */ + initialAction?: "send" | "receive" | "swap" + /** 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 */ + onViewActivity?: (chainId: string) => void } -export function AssetPage({ chain, balance, onBack, firmwareVersion }: 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("receive") - const [selectedToken, setSelectedToken] = useState(null) + const [view, setView] = useState(initialAction === "send" ? "send" : "receive") + const [selectedToken, setSelectedToken] = useState(initialToken ?? null) const [copiedCaip, setCopiedCaip] = useState(null) const [address, setAddress] = useState(balance?.address || null) const [loading, setLoading] = useState(false) @@ -332,9 +340,28 @@ export function AssetPage({ chain, balance, onBack, firmwareVersion }: AssetPage const cleanBalanceUsd = (activeBalance?.balanceUsd || 0) - spamTotalUsd const [showAddToken, setShowAddToken] = useState(false) - const [showSwapDialog, setShowSwapDialog] = useState(false) + const [showSwapDialog, setShowSwapDialog] = useState(initialAction === "swap") const [showSweep, setShowSweep] = useState(false) useEffect(() => { if (!swapsEnabled) setShowSwapDialog(false) }, [swapsEnabled]) + + // Activity preview + const [previewActivities, setPreviewActivities] = useState([]) + const [previewPrices, setPreviewPrices] = useState>({}) + const [activityDetail, setActivityDetail] = useState(null) + useEffect(() => { + rpcRequest('getRecentActivity', { limit: 100 }, 10000) + .then(result => { + if (!result) return + const filtered = recentFirst(result.filter(a => + a.chainId === chain.id || a.chain === chain.symbol || a.chain === chain.id + )).slice(0, 5) + setPreviewActivities(filtered) + }) + .catch(() => {}) + rpcRequest<{ balances: ChainBalance[] } | null>('getCachedBalances') + .then(r => { if (r?.balances) setPreviewPrices(nativePriceByChain(r.balances)) }) + .catch(() => {}) + }, [chain.id, chain.symbol]) const isEvmChain = chain.chainFamily === 'evm' // Toggle token visibility via RPC @@ -577,11 +604,9 @@ export function AssetPage({ chain, balance, onBack, firmwareVersion }: AssetPage setShowAddToken(false)} /> )} + + {/* Activity preview */} + + + + Recent Activity{previewActivities.length > 0 && ` · ${previewActivities.length}`} + + {onViewActivity && ( + onViewActivity(chain.id)} + className="electrobun-webkit-app-region-no-drag" + > + View all + + + + + )} + + + {previewActivities.length === 0 ? ( + + No indexed activity yet + {onViewActivity && ( + onViewActivity(chain.id)} + className="electrobun-webkit-app-region-no-drag" + > + Scan history → + + )} + + ) : ( + + {previewActivities.map(a => ( + setActivityDetail({ kind: 'activity', activity: act })} + /> + ))} + + )} + {/* SwapDialog rendered outside overflow container so position:fixed works */} {swapsEnabled && showSwapDialog && ( @@ -964,6 +1052,14 @@ export function AssetPage({ chain, balance, onBack, firmwareVersion }: AssetPage refreshAccounts={refreshBtcAccounts} /> )} + + {activityDetail && ( + setActivityDetail(null)} + /> + )} ) } diff --git a/projects/keepkey-vault/src/mainview/components/CommandPalette.tsx b/projects/keepkey-vault/src/mainview/components/CommandPalette.tsx new file mode 100644 index 00000000..2d0e7f86 --- /dev/null +++ b/projects/keepkey-vault/src/mainview/components/CommandPalette.tsx @@ -0,0 +1,315 @@ +import { useEffect, useMemo, useRef, useState } from "react" +import { Box, Flex, Text, Image, Input } from "@chakra-ui/react" +import { CHAINS, type ChainDef } from "../../shared/chains" +import { getAssetIcon } from "../../shared/assetLookup" +import type { ChainBalance, TokenBalance } from "../../shared/types" +import { Z } from "../lib/z-index" +import { dispatchVaultCommand } from "../lib/commandBus" + +interface CommandPaletteProps { + open: boolean + onClose: () => void + /** Called before dispatching commands so App can switch to the vault tab. */ + onJumpToVault: () => void + /** Latest balances snapshot (bridged out of Dashboard). Empty Map is fine — + * results just fall back to chains-only. */ + balances: Map +} + +type ChainResult = { kind: "chain"; chain: ChainDef; balanceUsd: number } +type TokenResult = { kind: "token"; chain: ChainDef; token: TokenBalance } +type Result = ChainResult | TokenResult + +const MAX_RESULTS = 60 + +function scoreMatch(query: string, fields: { symbol?: string; coin?: string; name?: string; caip?: string }): number { + if (!query) return 1 + const q = query.toLowerCase() + const sym = (fields.symbol || "").toLowerCase() + const coin = (fields.coin || "").toLowerCase() + const name = (fields.name || "").toLowerCase() + const caip = (fields.caip || "").toLowerCase() + + // Exact symbol match wins (e.g. typing "BTC") + if (sym === q) return 1000 + // Symbol starts-with (e.g. "et" -> ETH) + if (sym.startsWith(q)) return 600 + // Coin / name starts-with + if (coin.startsWith(q) || name.startsWith(q)) return 500 + // CAIP starts-with (e.g. "eip155:1" -> Ethereum) + if (caip.startsWith(q)) return 400 + // Substring contains + if (sym.includes(q) || coin.includes(q) || name.includes(q)) return 200 + if (caip.includes(q)) return 100 + return 0 +} + +export function CommandPalette({ open, onClose, onJumpToVault, balances }: CommandPaletteProps) { + const [query, setQuery] = useState("") + const [activeIdx, setActiveIdx] = useState(0) + const inputRef = useRef(null) + const listRef = useRef(null) + + // Reset on open + useEffect(() => { + if (open) { + setQuery("") + setActiveIdx(0) + // Auto-focus the input on next frame to win against any other focus moves. + const id = requestAnimationFrame(() => inputRef.current?.focus()) + return () => cancelAnimationFrame(id) + } + }, [open]) + + const results = useMemo(() => { + const chains = CHAINS.filter(c => !c.hidden) + const trimmed = query.trim() + + if (!trimmed) { + // Empty query: chains only, top-balance first, then alphabetical + return chains + .map(chain => ({ kind: "chain" as const, chain, balanceUsd: balances.get(chain.id)?.balanceUsd ?? 0 })) + .sort((a, b) => { + if (b.balanceUsd !== a.balanceUsd) return b.balanceUsd - a.balanceUsd + return a.chain.coin.localeCompare(b.chain.coin) + }) + .slice(0, MAX_RESULTS) + } + + const scored: Array<{ r: Result; score: number }> = [] + for (const chain of chains) { + const s = scoreMatch(trimmed, { symbol: chain.symbol, coin: chain.coin, caip: chain.caip }) + if (s > 0) scored.push({ r: { kind: "chain", chain, balanceUsd: balances.get(chain.id)?.balanceUsd ?? 0 }, score: s + 50 }) + } + // Token results across all balances + for (const bal of balances.values()) { + const chain = chains.find(c => c.id === bal.chainId) + if (!chain) continue + for (const token of bal.tokens || []) { + const s = scoreMatch(trimmed, { symbol: token.symbol, name: token.name, caip: token.caip }) + if (s > 0) scored.push({ r: { kind: "token", chain, token }, score: s }) + } + } + scored.sort((a, b) => b.score - a.score) + return scored.slice(0, MAX_RESULTS).map(s => s.r) + }, [query, balances]) + + // Clamp active index when results change + useEffect(() => { + if (activeIdx >= results.length) setActiveIdx(Math.max(0, results.length - 1)) + }, [results.length, activeIdx]) + + // Scroll active row into view + useEffect(() => { + const list = listRef.current + if (!list) return + const row = list.querySelector(`[data-cp-row="${activeIdx}"]`) + if (row) row.scrollIntoView({ block: "nearest" }) + }, [activeIdx]) + + const select = (r: Result) => { + onJumpToVault() + if (r.kind === "chain") { + dispatchVaultCommand({ type: "open-chain", chainId: r.chain.id }) + } else { + dispatchVaultCommand({ type: "open-token", chainId: r.chain.id, tokenCaip: r.token.caip }) + } + onClose() + } + + const onKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Escape") { + e.preventDefault() + onClose() + return + } + if (e.key === "ArrowDown") { + e.preventDefault() + setActiveIdx(i => Math.min(results.length - 1, i + 1)) + return + } + if (e.key === "ArrowUp") { + e.preventDefault() + setActiveIdx(i => Math.max(0, i - 1)) + return + } + if (e.key === "Enter") { + e.preventDefault() + const r = results[activeIdx] + if (r) select(r) + return + } + } + + if (!open) return null + + return ( + { if (e.target === e.currentTarget) onClose() }} + onKeyDown={onKeyDown} + role="dialog" + aria-modal="true" + aria-label="Command Palette" + > + e.stopPropagation()} + style={{ fontFamily: "var(--font-mono, ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace)" }} + > + {/* Search row */} + + + { setQuery(e.target.value); setActiveIdx(0) }} + placeholder="Search chains and tokens — name, symbol, CAIP" + variant="unstyled" + border="0" + outline="none" + color="var(--text-0)" + fontSize="14px" + lineHeight="1.4" + py="0" + minH="0" + h="auto" + _placeholder={{ color: "var(--text-3)" }} + _focus={{ outline: "none", boxShadow: "none", borderColor: "transparent" }} + _focusVisible={{ outline: "none", boxShadow: "none", borderColor: "transparent" }} + spellCheck={false} + autoComplete="off" + style={{ fontFamily: "inherit" }} + /> + + esc + + + + {/* Result list */} + + {results.length === 0 ? ( + + No matches + + ) : ( + results.map((r, idx) => { + const isActive = idx === activeIdx + const caip = r.kind === "chain" ? r.chain.caip : r.token.caip + const icon = getAssetIcon(caip) + const primary = r.kind === "chain" ? r.chain.coin : r.token.name + 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)}` + : "" + return ( + setActiveIdx(idx)} + onClick={() => select(r)} + > + + ) => { e.currentTarget.style.opacity = "0" }} + /> + + + + {primary} + + + {secondary} + + + {trailing && ( + + {trailing} + + )} + + ) + }) + )} + + + {/* Footer hint row */} + + + ↑↓ navigate + ↵ open + + {results.length} result{results.length === 1 ? "" : "s"} + + + + ) +} diff --git a/projects/keepkey-vault/src/mainview/components/Dashboard.tsx b/projects/keepkey-vault/src/mainview/components/Dashboard.tsx index 90e2d43e..d1b04684 100644 --- a/projects/keepkey-vault/src/mainview/components/Dashboard.tsx +++ b/projects/keepkey-vault/src/mainview/components/Dashboard.tsx @@ -1,4 +1,4 @@ -import { Component, useState, useEffect, useCallback, useMemo, useRef, type ReactNode, type ErrorInfo } from "react" +import { Component, lazy, Suspense, useState, useEffect, useCallback, useMemo, useRef, type ReactNode, type ErrorInfo } from "react" import { Box, Flex, Text, Spinner, Image, SimpleGrid, Button } from "@chakra-ui/react" import { useTranslation } from "react-i18next" import { CHAINS, customChainToChainDef, isChainSupported, type ChainDef } from "../../shared/chains" @@ -7,14 +7,27 @@ import { formatBalance } from "../lib/formatting" import { AnimatedUsd } from "./AnimatedUsd" import { getAssetIcon, registerCustomAsset } from "../../shared/assetLookup" import { AssetPage } from "./AssetPage" +import { ActivityPage } from "./ActivityPage" import { DonutChart, ChartLegend, type DonutChartItem } from "./DonutChart" import { AddChainDialog } from "./AddChainDialog" import { ReportDialog } from "./ReportDialog" import { Bip85VaultDialog } from "./Bip85VaultDialog" +import { DogeEasterEgg } from "./DogeEasterEgg" +import { HeatmapView, buildAllChainsTiles, buildChainDetailTiles } from "./HeatmapView" +import { StackedBarView, type StackedBarItem } from "./StackedBarView" + +// SwapDialog is heavy (loads swapper providers) — lazy so it doesn't enter the +// initial Dashboard chunk. Used to open Swap directly from the action row +// without routing through AssetPage. +const LazySwapDialog = lazy(() => import("./SwapDialog").then(m => ({ default: m.SwapDialog }))) import { rpcRequest, onRpcMessage } from "../lib/rpc" +import { subscribeVaultCommand, publishBalances, clearBalances } from "../lib/commandBus" +import { useIconColor } from "../lib/iconColor" +import { preloadIcons } from "../lib/iconPreload" +import { useDashboardView } from "../lib/dashboardViewContext" import { categorizeTokens } from "../../shared/spamFilter" -import type { ChainBalance, CustomChain, TokenVisibilityStatus, AppSettings } from "../../shared/types" +import type { ChainBalance, CustomChain, TokenVisibilityStatus, AppSettings, TokenBalance } from "../../shared/types" import { playChaChing } from "../lib/sounds" /** Error boundary wrapping AssetPage — ensures user can always go back to Dashboard */ @@ -136,14 +149,6 @@ function OrbitalView({ return ( - - - - - - - - 0 ? (usd / totalUsd) * 100 : 0 + // Flip the hover tip below the satellite when it's in the upper + // half of the orbit so the TopNav doesn't clip it. + const tipBelow = y < cy return ( {chain.coin} @@ -307,6 +316,279 @@ function OrbitalView({ ) } +/** Single token satellite — separate component so each call site gets its own + * hook stack (useIconColor) keyed by the token icon. */ +function TokenSatellite({ + tok, + x, + y, + sat, + isHover, + tipBelow, + fallbackColor, + onEnter, + onLeave, + onClick, +}: { + tok: import("../../shared/types").TokenBalance + x: number + y: number + sat: number + isHover: boolean + tipBelow: boolean + fallbackColor: string + onEnter: () => void + onLeave: () => void + onClick: () => void +}) { + const iconUrl = tok.icon || getAssetIcon(tok.caip) + const glow = useIconColor(iconUrl, fallbackColor) + const usd = tok.balanceUsd ?? 0 + return ( + + + {isHover && ( + + + {tok.symbol} + + + {formatBalance(tok.balance)} · ${usd.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} + + + )} + + ) +} + +/** Heatmap canvas wrapper — measures its own top in the viewport on mount + * and on resize, and sets an explicit pixel height so the squarified + * treemap inside fills exactly the visible area (no tiles below the fold). */ +function HeatmapHost({ tiles }: { tiles: Parameters[0]["tiles"] }) { + const ref = useRef(null) + const [height, setHeight] = useState(0) + + useEffect(() => { + const measure = () => { + const el = ref.current + if (!el) return + const rect = el.getBoundingClientRect() + const margin = 12 // breathing room above the window's bottom edge + const available = window.innerHeight - rect.top - margin + setHeight(Math.max(220, Math.floor(available))) + } + measure() + window.addEventListener("resize", measure) + // Re-measure on next paint in case parent flex/banner state shifts + const raf = requestAnimationFrame(measure) + return () => { + window.removeEventListener("resize", measure) + cancelAnimationFrame(raf) + } + }, []) + + return ( + + + + ) +} + +/** Chain-detail orbital — chain icon as the sun, tokens orbiting as satellites. + * Shown when the user picks a chain from the sidebar list. */ +function ChainDetailOrbital({ + chain, + balance, + nativeBalanceUsd, + cleanTokens, + onSelectChain, + onSelectToken, +}: { + chain: ChainDef + balance?: ChainBalance + nativeBalanceUsd: number + cleanTokens: import("../../shared/types").TokenBalance[] + onSelectChain: () => void + onSelectToken: (tok: import("../../shared/types").TokenBalance) => void +}) { + const [hover, setHover] = useState(null) + const [size, setSize] = useState(440) + + useEffect(() => { + const compute = () => setSize(Math.min(520, Math.max(320, window.innerWidth - 420))) + compute() + window.addEventListener('resize', compute) + return () => window.removeEventListener('resize', compute) + }, []) + + const cx = size / 2 + const cy = size / 2 + const orbitR = size * 0.42 + const ringR = size * 0.46 + + const tokenSats = cleanTokens + .slice() + .sort((a, b) => (b.balanceUsd ?? 0) - (a.balanceUsd ?? 0)) + .slice(0, 8) + + const nativeBal = balance?.balance || '0' + + return ( + + + + + + {/* Center sun — chain icon + native balance */} + + + + + + {chain.coin} + + + {formatBalance(nativeBal)} {chain.symbol} + + {nativeBalanceUsd > 0 && ( + + ≈ ${nativeBalanceUsd.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} + + )} + + + {/* Token satellites — each pulls its glow color from its own icon. */} + {tokenSats.map((tok, i) => { + const angle = (Math.PI * 2 * i) / tokenSats.length - Math.PI / 2 + const x = cx + Math.cos(angle) * orbitR + const y = cy + Math.sin(angle) * orbitR + const usd = tok.balanceUsd ?? 0 + const sat = Math.max(40, Math.min(64, 32 + Math.sqrt(usd) * 1.2)) + const isHover = hover === tok.caip + const tipBelow = y < cy + return ( + setHover(tok.caip)} + onLeave={() => setHover(null)} + onClick={() => onSelectToken(tok)} + /> + ) + })} + + ) +} + interface PioneerError { message: string url: string @@ -344,6 +626,34 @@ function formatTimeAgo(ts: number, t: (key: string, opts?: Record(null) + const [selectedChainAction, setSelectedChainAction] = useState<"send" | "receive" | "swap" | undefined>(undefined) + const [selectedChainInitialToken, setSelectedChainInitialToken] = useState(undefined) + const [showActivityPage, setShowActivityPage] = useState(false) + const [activityDefaultChain, setActivityDefaultChain] = useState('') + const [activityResumeSwap, setActivityResumeSwap] = useState(null) + const handleViewActivity = useCallback((chainId: string) => { + setActivityDefaultChain(chainId) + setShowActivityPage(true) + }, []) + // Listen for the global event dispatched by ActivityTracker's panel + useEffect(() => { + const handler = () => { setActivityDefaultChain(''); setShowActivityPage(true) } + window.addEventListener('keepkey-open-activity', handler) + return () => window.removeEventListener('keepkey-open-activity', handler) + }, []) + const [drilledChainId, setDrilledChainId] = useState(null) + const [swapDialogChain, setSwapDialogChain] = useState(null) + const openChainPage = useCallback((chain: ChainDef, action?: "send" | "receive" | "swap", token?: TokenBalance) => { + // Swap routes directly to SwapDialog — skip the AssetPage shell that + // would otherwise show the Receive view underneath. + if (action === "swap") { + setSwapDialogChain(chain) + return + } + setSelectedChainAction(action) + setSelectedChainInitialToken(token) + setSelectedChain(chain) + }, []) const [balances, setBalances] = useState>(new Map()) const [loadingBalances, setLoadingBalances] = useState(false) const [initialLoaded, setInitialLoaded] = useState(false) @@ -401,6 +711,37 @@ export function Dashboard({ onLoaded, watchOnly, watchOnlyDeviceId, onOpenSettin } }, []) + // Publish balances to the command bus so out-of-tree consumers + // (CommandPalette) can render token results without us having to lift + // balances state up to App. Clear on unmount so stale balances from a + // previous wallet session don't persist after disconnect. + useEffect(() => { + publishBalances(balances) + }, [balances]) + useEffect(() => { + return () => { clearBalances() } + }, []) + + // Subscribe to imperative vault commands from CommandPalette (⌘K). These + // drive the existing drill/open behavior without exposing Dashboard's + // internal state to App. + useEffect(() => { + return subscribeVaultCommand((cmd) => { + if (cmd.type === "open-chain") { + setDrilledChainId(cmd.chainId) + return + } + if (cmd.type === "open-token") { + const chain = [...CHAINS, ...customChainDefs].find(c => c.id === cmd.chainId) + if (!chain) return + setDrilledChainId(cmd.chainId) + const bal = balances.get(cmd.chainId) + const token = bal?.tokens?.find(tk => tk.caip === cmd.tokenCaip) + if (token) openChainPage(chain, undefined, token) + } + }) + }, [customChainDefs, balances, openChainPage]) + // Load token visibility overrides (for spam filtering). Refetch on // `token-visibility-changed` push so a "mark as scam" action in // AssetPage immediately removes the spam USD from the dashboard total @@ -696,12 +1037,25 @@ export function Dashboard({ onLoaded, watchOnly, watchOnlyDeviceId, onOpenSettin const allChains = useMemo(() => [...CHAINS, ...customChainDefs], [customChainDefs]) + // Warm the browser-level image cache + hold live Image references so chain + // and token logos don't visibly re-fetch when the user switches between + // chains in the sidebar. + useEffect(() => { + const urls: (string | undefined)[] = [] + for (const chain of allChains) urls.push(getAssetIcon(chain.caip)) + for (const bal of balances.values()) { + if (!bal.tokens) continue + for (const tok of bal.tokens) urls.push(tok.icon || getAssetIcon(tok.caip)) + } + preloadIcons(urls) + }, [allChains, balances]) + const existingChainIds = useMemo(() => [ ...CHAINS.filter(c => c.chainFamily === 'evm' && c.chainId).map(c => Number(c.chainId)), ...customChainDefs.filter(c => c.chainId).map(c => Number(c.chainId)), ], [customChainDefs]) - const chartData = useMemo(() => allChains + const allChainsChartData = useMemo(() => allChains .map((chain) => { const clean = cleanBalanceUsd.get(chain.id) return { name: chain.coin, value: clean?.usd || 0, color: chain.color, chainId: chain.id } @@ -709,15 +1063,39 @@ export function Dashboard({ onLoaded, watchOnly, watchOnlyDeviceId, onOpenSettin .filter((d) => d.value > 0) .sort((a, b) => b.value - a.value), [allChains, cleanBalanceUsd]) - const hasAnyBalance = chartData.length > 0 + // Token palette used for the drilled-chain donut so each slice reads as a + // distinct color even though tokens don't carry a brand color of their own. + const TOKEN_PALETTE = ['#e9c46a', '#8be3c4', '#6c7be8', '#e08c7b', '#9f8ce0', '#f0a85c', '#4eb591', '#4f7fc8'] + + const drilledChainTokensChartData = useMemo(() => { + if (!drilledChainId) return [] + const chain = allChains.find(c => c.id === drilledChainId) + const bal = balances.get(drilledChainId) + if (!chain || !bal) return [] + const overrides = new Map(Object.entries(visibilityMap).map(([k, v]) => [k.toLowerCase(), v] as const)) + const cleanTokens = bal.tokens ? categorizeTokens(bal.tokens, overrides).clean : [] + const nativeUsd = bal.nativeBalanceUsd ?? Math.max(0, (bal.balanceUsd || 0) - cleanTokens.reduce((s, t) => s + (t.balanceUsd || 0), 0)) + const out: DonutChartItem[] = [] + if (nativeUsd > 0) { + out.push({ name: chain.symbol, value: nativeUsd, color: chain.color }) + } + cleanTokens + .slice() + .sort((a, b) => (b.balanceUsd ?? 0) - (a.balanceUsd ?? 0)) + .forEach((tok, i) => { + out.push({ name: tok.symbol, value: tok.balanceUsd ?? 0, color: TOKEN_PALETTE[i % TOKEN_PALETTE.length] }) + }) + return out.filter(d => d.value > 0) + }, [drilledChainId, allChains, balances, visibilityMap]) - /* Portfolio view mode — orbital is the new default per design handoff, - * donut is preserved as a toggle so power users can still get the - * percentage-bar legend. Persisted to localStorage. */ - const [viewMode, setViewMode] = useState(readSavedView) - useEffect(() => { - try { localStorage.setItem(DASHBOARD_VIEW_KEY, viewMode) } catch { /* private mode etc. */ } - }, [viewMode]) + const chartData = drilledChainId ? drilledChainTokensChartData : allChainsChartData + + const hasAnyBalance = allChainsChartData.length > 0 + + /* Portfolio view mode — pulled from a shared context so the toggle living + * in the TopNav can drive Dashboard's rendering. Persistence happens in + * the provider. */ + const { viewMode } = useDashboardView() /* Splits totalUsd into dollars + cents so the orbital can render the * cents in a smaller weight (matches handoff layout). */ @@ -758,19 +1136,253 @@ export function Dashboard({ onLoaded, watchOnly, watchOnlyDeviceId, onOpenSettin !cacheUpdatedAt || (Date.now() - cacheUpdatedAt > 86_400_000) ) + if (showActivityPage) { + return ( + <> + setShowActivityPage(false)} + onResumeSwap={(swap) => setActivityResumeSwap(swap)} + /> + + setActivityResumeSwap(null)} + resumeSwap={activityResumeSwap} + /> + + + ) + } + if (selectedChain) { const bal = balances.get(selectedChain.id) return ( setSelectedChain(null)} chainName={selectedChain.coin}> - setSelectedChain(null)} firmwareVersion={firmwareVersion} /> + { setSelectedChain(null); setSelectedChainAction(undefined); setSelectedChainInitialToken(undefined) }} firmwareVersion={firmwareVersion} initialAction={selectedChainAction} initialToken={selectedChainInitialToken} onViewActivity={handleViewActivity} /> ) } return ( - + + {/* ── Sidebar: chains list (replaces the cards grid) ───────────── */} + {hasUsableBalanceSnapshot && ( + + {/* "All Chains" reset row */} + setDrilledChainId(null)} + w="100%" + textAlign="left" + p="2.5" + mb="2" + borderRadius="lg" + bg={drilledChainId === null ? "kk.cardBgHover" : "transparent"} + border="1px solid" + borderColor={drilledChainId === null ? "kk.border" : "transparent"} + _hover={{ bg: "kk.cardBg" }} + cursor="pointer" + transition="all 0.15s" + > + + + + + + + + + All Chains + + ${totalUsd.toLocaleString('en-US', { maximumFractionDigits: 2 })} + + + + + + {sortedChains.map((chain) => { + const bal = balances.get(chain.id) + const clean = cleanBalanceUsd.get(chain.id) + const balNum = parseFloat(bal?.balance || '0') + const usdNum = clean?.usd || 0 + const hasBalance = balNum > 0 || usdNum > 0 + const tokenCount = clean?.cleanTokenCount || 0 + const isActive = drilledChainId === chain.id + return ( + openChainPage(chain)} + w="100%" + textAlign="left" + p="2.5" + mb="1.5" + borderRadius="lg" + bg={isActive ? "kk.cardBgHover" : "transparent"} + border="1px solid" + borderColor={isActive ? `${chain.color}80` : "transparent"} + _hover={{ bg: "kk.cardBg", borderColor: `${chain.color}50` }} + cursor="pointer" + transition="all 0.15s" + opacity={hasBalance ? 1 : 0.55} + > + + + + + + {chain.coin} + + {usdNum > 0 && ( + + ${usdNum.toLocaleString('en-US', { maximumFractionDigits: 2 })} + + )} + + + + {hasBalance ? `${formatBalance(bal?.balance || '0')} ${chain.symbol}` : t("noBalance")} + + {tokenCount > 0 && ( + + +{tokenCount} + + )} + + + + + ) + })} + + {/* Add Chain row */} + {!watchOnly && ( + setShowAddChain(true)} + w="100%" + mt="2" + p="2.5" + borderRadius="lg" + bg="transparent" + border="1px dashed" + borderColor="kk.border" + _hover={{ borderColor: "kk.gold", bg: "rgba(233,196,106,0.05)" }} + cursor="pointer" + transition="all 0.15s" + > + + + + {t("addChain")} + + + )} + + )} + + + + {/* Top-right utility row: Reports + Refresh (sits above all main content) */} + {!watchOnly && ( + + {!isHiddenWallet && setShowReports(true)} + > + + + + + + + + + {t("reports")} + + } + refreshBalances(true)} + css={isStale && !loadingBalances ? { animation: "pulseGold 2s ease-in-out infinite" } : undefined} + > + + {loadingBalances ? ( + + ) : ( + + + + + )} + {loadingBalances + ? t("refreshing") + : cacheUpdatedAt + ? <> + { + const age = Date.now() - cacheUpdatedAt + if (age < 3_600_000) return "var(--teal)" + if (age < 86_400_000) return "var(--gold)" + return "var(--rose)" + })()}> + {formatTimeAgo(cacheUpdatedAt, t)} + + {" · "}{t("refreshBalances")} + + : t("refreshPrompt")} + + + + )} + {/* Watch-only banner */} {watchOnly && ( )} - {/* Portfolio view — orbital (default) or donut, switchable via the - pill toggle in the top-right of the card. */} - {hasAnyBalance ? ( - - {/* View toggle — orbital / donut. Two-state pill with icon glyphs. */} - - setViewMode('orbital')} - w="26px" h="22px" - borderRadius="999px" - display="flex" alignItems="center" justifyContent="center" - bg={viewMode === 'orbital' ? 'rgba(233,196,106,0.18)' : 'transparent'} - color={viewMode === 'orbital' ? 'var(--gold)' : 'var(--text-3)'} - _hover={{ color: 'var(--text-1)' }} - transition="all 0.15s" - cursor="pointer" - title="Orbital view" - > - - - - - - - - setViewMode('donut')} - w="26px" h="22px" - borderRadius="999px" - display="flex" alignItems="center" justifyContent="center" - bg={viewMode === 'donut' ? 'rgba(233,196,106,0.18)' : 'transparent'} - color={viewMode === 'donut' ? 'var(--gold)' : 'var(--text-3)'} - _hover={{ color: 'var(--text-1)' }} - transition="all 0.15s" - cursor="pointer" - title="Donut view" - > - - - - - - - - {viewMode === 'orbital' ? ( - setSelectedChain(c)} - /> - ) : ( - + {/* Centered hero region — split into a flex-1 "sun" area (vertically + centered) and a fixed-min-height "below" area. The split anchors + the sun and the donut center at the same y-coordinate regardless + of how much below content is rendered. */} + + {/* Top: orbital widget / donut / welcome — vertically centered */} + + {hasAnyBalance ? (() => { + if (drilledChainId && viewMode === 'orbital') { + const dchain = visibleChains.find(c => c.id === drilledChainId) + if (!dchain) return null + const bal = balances.get(dchain.id) + const overrides = new Map( + Object.entries(visibilityMap).map(([k, v]) => [k.toLowerCase(), v] as const), + ) + const cleanTokens = bal?.tokens ? categorizeTokens(bal.tokens, overrides).clean : [] + const nativeUsd = bal?.nativeBalanceUsd ?? bal?.balanceUsd ?? 0 + return ( + openChainPage(dchain)} + onSelectToken={(tok) => openChainPage(dchain, undefined, tok)} + /> + ) + } + if (viewMode === 'orbital') { + return ( + setSelectedChain(c)} + /> + ) + } + if (viewMode === 'heatmap') { + const tiles = drilledChainId + ? (() => { + const dchain = visibleChains.find(c => c.id === drilledChainId) + if (!dchain) return [] + const bal = balances.get(dchain.id) + const overrides = new Map(Object.entries(visibilityMap).map(([k, v]) => [k.toLowerCase(), v] as const)) + const cleanTokens = bal?.tokens ? categorizeTokens(bal.tokens, overrides).clean : [] + const cleanTokensUsd = cleanTokens.reduce((s, t) => s + (t.balanceUsd ?? 0), 0) + const nativeUsd = bal?.nativeBalanceUsd ?? Math.max(0, (bal?.balanceUsd ?? 0) - cleanTokensUsd) + return buildChainDetailTiles(dchain, bal, cleanTokens, nativeUsd, (tok) => openChainPage(dchain, undefined, tok)) + })() + : buildAllChainsTiles(visibleChains, cleanBalanceUsd, (chainId) => setDrilledChainId(chainId)) + // Full-canvas: dynamically measure where the heatmap container + // starts in the viewport and stretch it down to the bottom edge. + // Static `calc(100vh - )` got the offset wrong because of + // banners / utility row variability. + return + } + if (viewMode === 'stack') { + const items: StackedBarItem[] = drilledChainId + ? (() => { + const dchain = visibleChains.find(c => c.id === drilledChainId) + if (!dchain) return [] + const bal = balances.get(dchain.id) + const overrides = new Map(Object.entries(visibilityMap).map(([k, v]) => [k.toLowerCase(), v] as const)) + const cleanTokens = bal?.tokens ? categorizeTokens(bal.tokens, overrides).clean : [] + const cleanTokensUsd = cleanTokens.reduce((s, t) => s + (t.balanceUsd ?? 0), 0) + const nativeUsd = bal?.nativeBalanceUsd ?? Math.max(0, (bal?.balanceUsd ?? 0) - cleanTokensUsd) + const tokenPalette = ['#e9c46a', '#8be3c4', '#6c7be8', '#e08c7b', '#9f8ce0', '#f0a85c', '#4eb591', '#4f7fc8'] + const arr: StackedBarItem[] = [] + if (nativeUsd > 0) arr.push({ id: `${dchain.id}:native`, label: dchain.symbol, color: dchain.color, value: nativeUsd, onSelect: () => openChainPage(dchain) }) + cleanTokens + .slice() + .sort((a, b) => (b.balanceUsd ?? 0) - (a.balanceUsd ?? 0)) + .forEach((tok, i) => arr.push({ + id: tok.caip, + label: tok.symbol, + color: tokenPalette[i % tokenPalette.length]!, + value: tok.balanceUsd ?? 0, + onSelect: () => openChainPage(dchain, undefined, tok), + })) + return arr + })() + : visibleChains.map(chain => ({ + id: chain.id, + label: chain.coin, + color: chain.color, + value: cleanBalanceUsd.get(chain.id)?.usd ?? 0, + onSelect: () => setDrilledChainId(chain.id), + })).filter(it => it.value > 0) + const stackTotal = drilledChainId + ? items.reduce((s, it) => s + it.value, 0) + : totalUsd + return + } + const safeIndex = activeSliceIndex !== null && activeSliceIndex < chartData.length ? activeSliceIndex : (chartData.length > 0 ? 0 : null) + return ( setActiveSliceIndex(i === null ? 0 : i)} /> - + ) + })() : !loadingBalances && initialLoaded && !pioneerError ? ( + + + + + + + + + + {t("welcomeTitle")} + + + Pick a chain from the list on the left to get your deposit address. + + + {visibleChains.length > 0 && ( + openChainPage(visibleChains[0]!)} + px="5" py="2.5" + bg="var(--gold)" color="var(--ink-0)" + borderRadius="999px" + fontSize="13px" fontWeight="600" + cursor="pointer" + _hover={{ bg: 'var(--gold-2)' }} + transition="all 0.15s" + className="electrobun-webkit-app-region-no-drag" + > + Get {visibleChains[0]!.symbol} address → + + )} + + ) : null} + + + {/* Below the sun: token list / action buttons / donut legend / empty. + Fixed min-height keeps the sun's y-position stable across modes. */} + + {hasAnyBalance && viewMode === 'donut' && chartData.length > 0 && (() => { + const donutTotal = drilledChainId + ? chartData.reduce((s, d) => s + d.value, 0) + : totalUsd + const safeIndex = activeSliceIndex !== null && activeSliceIndex < chartData.length ? activeSliceIndex : 0 + return ( + setActiveSliceIndex(i === null ? 0 : i)} /> - - )} - - ) : !loadingBalances && initialLoaded && !pioneerError && ( - - - {/* Shield / vault icon */} - - - - - - - - - - {t("welcomeTitle")} - - - {t("welcomeSubtitle")} - - - - - - 1. - - {t("welcomeTip1")} - - - - 2. - - {t("welcomeTip2")} - - - - - - )} + ) + })()} + + {hasAnyBalance && viewMode === 'orbital' && drilledChainId && (() => { + const dchain = visibleChains.find(c => c.id === drilledChainId) + if (!dchain) return null + const bal = balances.get(dchain.id) + const overrides = new Map( + Object.entries(visibilityMap).map(([k, v]) => [k.toLowerCase(), v] as const), + ) + const cleanTokens = bal?.tokens ? categorizeTokens(bal.tokens, overrides).clean : [] + + return ( + <> + {/* Always-on action row: Receive / Send / Swap. Sits in the same + slot whether or not the chain has tokens. */} + + {([ + { id: 'receive' as const, label: 'Receive', icon: ( + + ) }, + { id: 'send' as const, label: 'Send', icon: ( + + ) }, + { id: 'swap' as const, label: 'Swap', 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} + + ) + })} + - {/* Refresh + Reports buttons — below chart */} - {!watchOnly && ( - - {!isHiddenWallet && setShowReports(true)} - > - - - - - - - - - {t("reports")} - - } - refreshBalances(true)} - css={isStale && !loadingBalances ? { animation: "pulseGold 2s ease-in-out infinite" } : undefined} - > - - {loadingBalances ? ( - - ) : ( - - - - - )} - {loadingBalances - ? t("refreshing") - : cacheUpdatedAt - ? <> - { - const age = Date.now() - cacheUpdatedAt - if (age < 3_600_000) return "var(--teal)" - if (age < 86_400_000) return "var(--gold)" - return "var(--rose)" - })()}> - {formatTimeAgo(cacheUpdatedAt, t)} + {/* Token list (only when the chain has clean tokens). */} + {cleanTokens.length > 0 && ( + + + {t("tokensCount", { count: cleanTokens.length })} - {" · "}{t("refreshBalances")} - - : t("refreshPrompt")} - - + + {cleanTokens + .slice() + .sort((a, b) => (b.balanceUsd ?? 0) - (a.balanceUsd ?? 0)) + .map((tok) => ( + openChainPage(dchain, undefined, tok)} + align="center" + gap="2.5" + p="2" + borderRadius="md" + bg="transparent" + _hover={{ bg: "kk.cardBg" }} + cursor="pointer" + transition="all 0.15s" + border="0" + title={tok.name || tok.symbol} + > + + + + {tok.symbol} + + + {tok.name || dchain.coin} + + + + + {formatBalance(tok.balance)} + + + ${(tok.balanceUsd ?? 0).toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} + + + + ))} + + + )} + + ) + })()} - )} + + + {/* Big glowing CTA when balances haven't been checked in over a day */} {cacheOlderThanDay && ( @@ -1166,7 +1857,7 @@ export function Dashboard({ onLoaded, watchOnly, watchOnlyDeviceId, onOpenSettin )} - + {false && {sortedChains.map((chain) => { const bal = balances.get(chain.id) const clean = cleanBalanceUsd.get(chain.id) @@ -1322,7 +2013,21 @@ export function Dashboard({ onLoaded, watchOnly, watchOnlyDeviceId, onOpenSettin )} - + } + + {drilledChainId === 'dogecoin' && } + + {swapDialogChain && ( + + setSwapDialogChain(null)} + chain={swapDialogChain} + balance={balances.get(swapDialogChain.id)} + address={balances.get(swapDialogChain.id)?.address} + /> + + )} {showAddChain && ( )} - + + ) } diff --git a/projects/keepkey-vault/src/mainview/components/DeviceGrid.tsx b/projects/keepkey-vault/src/mainview/components/DeviceGrid.tsx index e4650f3b..dee04e54 100644 --- a/projects/keepkey-vault/src/mainview/components/DeviceGrid.tsx +++ b/projects/keepkey-vault/src/mainview/components/DeviceGrid.tsx @@ -175,8 +175,6 @@ export function DeviceGrid({ onViewPortfolio, onReady, emulatorEnabled = false } fontSize="28px" fontWeight="500" color="var(--text-0)" - fontFamily="serif" - fontStyle="italic" letterSpacing="-0.01em" lineHeight="1.1" > @@ -260,7 +258,7 @@ export function DeviceGrid({ onViewPortfolio, onReady, emulatorEnabled = false } KeepKey - + {d.label || "KeepKey"} @@ -275,7 +273,7 @@ export function DeviceGrid({ onViewPortfolio, onReady, emulatorEnabled = false } Cached balance - + {showValues ? `$${formatUsd(d.totalUsd)}` : '$ ••••'} @@ -305,7 +303,7 @@ export function DeviceGrid({ onViewPortfolio, onReady, emulatorEnabled = false } - + {displayName} {w.label && w.label !== w.name && ( @@ -320,7 +318,7 @@ export function DeviceGrid({ onViewPortfolio, onReady, emulatorEnabled = false } Cached balance - + {showValues ? `$${formatUsd(w.totalUsd ?? 0)}` : '$ ••••'} diff --git a/projects/keepkey-vault/src/mainview/components/DogeEasterEgg.tsx b/projects/keepkey-vault/src/mainview/components/DogeEasterEgg.tsx new file mode 100644 index 00000000..892fc18d --- /dev/null +++ b/projects/keepkey-vault/src/mainview/components/DogeEasterEgg.tsx @@ -0,0 +1,173 @@ +import { useCallback, useEffect, useRef, useState } from "react" +import { Box, Image } from "@chakra-ui/react" +import { playBark } from "../lib/sounds" +import dogeImg from "../assets/doge.png" + +const PHRASES = [ + // Classic doge + "wow!", + "much wow", + "such doge", + "very bark", + "so amaze", + "many wow", + "so scare", + "very confuse", + "such excite", + "plz", + "hello frens", + // Crypto-flavored + "to the moon", + "hodl strong", + "much hodl", + "such crypto", + "very blockchain", + "so decentralize", + "many gainz", + "such bull", + "wow much profit", + "diamond paws", + "never sell", + // KeepKey / vault-flavored + "such cold storage", + "very keepkey", + "much secure", + "many chains", + "so private key", + "such hardware", + "very sign", + "no rug", + "so balance", + "many token", +] +/** Auto-dismiss timeout (ms). Resets each time the user clicks the dog. */ +const DISMISS_AFTER_MS = 8000 + +const ANIMS = ` +@keyframes doge-slide-in { + from { transform: translate(140%, 60%) rotate(12deg); opacity: 0; } + to { transform: translate(0, 0) rotate(0); opacity: 1; } +} +@keyframes doge-slide-out { + from { transform: translate(0, 0); opacity: 1; } + to { transform: translate(140%, 60%) rotate(12deg); opacity: 0; } +} +@keyframes doge-bob { + 0%, 100% { transform: translateY(0) rotate(-3deg); } + 50% { transform: translateY(-8px) rotate(3deg); } +} +@keyframes doge-bubble-pop { + 0% { opacity: 0; transform: translate(-50%, 12px) scale(0.85); } + 60% { opacity: 1; transform: translate(-50%, 0) scale(1.06); } + 100% { opacity: 1; transform: translate(-50%, 0) scale(1); } +} +` + +/** Bottom-right mascot, shown only when the user has drilled into Dogecoin. + * Slides in, gently bobs, barks on first appearance and on click, then + * auto-dismisses after a few seconds (timer resets on each click). */ +export function DogeEasterEgg() { + const [bubble, setBubble] = useState(null) + const [leaving, setLeaving] = useState(false) + const [unmounted, setUnmounted] = useState(false) + const bubbleTimer = useRef | null>(null) + const dismissTimer = useRef | null>(null) + + function flashBubble(msg: string) { + setBubble(msg) + if (bubbleTimer.current) clearTimeout(bubbleTimer.current) + bubbleTimer.current = setTimeout(() => setBubble(null), 1800) + } + + const scheduleDismiss = useCallback(() => { + if (dismissTimer.current) clearTimeout(dismissTimer.current) + dismissTimer.current = setTimeout(() => setLeaving(true), DISMISS_AFTER_MS) + }, []) + + // Auto-bark shortly after appearing + arm the auto-dismiss timer. + // Pick a random phrase so every remount (e.g. when the user navigates + // back to Dogecoin from another chain) feels different. + useEffect(() => { + const id = setTimeout(() => { + playBark() + flashBubble(PHRASES[Math.floor(Math.random() * PHRASES.length)]!) + }, 650) + scheduleDismiss() + return () => { + clearTimeout(id) + if (bubbleTimer.current) clearTimeout(bubbleTimer.current) + if (dismissTimer.current) clearTimeout(dismissTimer.current) + } + }, [scheduleDismiss]) + + if (unmounted) return null + + return ( + { + if (e.animationName === "doge-slide-out") setUnmounted(true) + }} + style={{ + animation: leaving + ? "doge-slide-out 0.6s cubic-bezier(0.4,0,0.2,1) both" + : "doge-slide-in 0.7s cubic-bezier(0.2,0.8,0.2,1) both", + }} + > + + + {bubble && ( + + {bubble} + + )} + + { + playBark() + flashBubble(PHRASES[Math.floor(Math.random() * PHRASES.length)]!) + scheduleDismiss() + }} + style={{ + animation: "doge-bob 2.2s ease-in-out infinite", + filter: "drop-shadow(0 8px 18px rgba(0,0,0,0.5)) drop-shadow(0 0 28px rgba(233,196,106,0.55))", + }} + title="click for bark" + > + doge + + + ) +} diff --git a/projects/keepkey-vault/src/mainview/components/DonutChart.tsx b/projects/keepkey-vault/src/mainview/components/DonutChart.tsx index 488e6a41..661e3b67 100644 --- a/projects/keepkey-vault/src/mainview/components/DonutChart.tsx +++ b/projects/keepkey-vault/src/mainview/components/DonutChart.tsx @@ -77,19 +77,16 @@ export function DonutChart({ data, size = 210, activeIndex, onHoverSlice }: Donu ) })} - {/* Center circle with total */} + {/* Inner cutout — no stroke, blends into the background. */} - {/* Center text overlay */} + {/* Center text overlay — matches the orbital "TOTAL" treatment. */} - - Portfolio + + Total - + ) @@ -117,9 +128,9 @@ interface ChartLegendProps { onHoverItem: (index: number | null) => void } -export function ChartLegend({ data, total, activeIndex, onHoverItem }: ChartLegendProps) { +export function ChartLegend({ data, total, activeIndex, onHoverItem: _onHoverItem }: ChartLegendProps) { if (activeIndex === null || !data[activeIndex]) { - return + return } const item = data[activeIndex] @@ -127,22 +138,30 @@ export function ChartLegend({ data, total, activeIndex, onHoverItem }: ChartLege return ( - - {item.name} - {percent}% - + + + {item.name} + {percent}% + + ) } diff --git a/projects/keepkey-vault/src/mainview/components/HeatmapView.tsx b/projects/keepkey-vault/src/mainview/components/HeatmapView.tsx new file mode 100644 index 00000000..fdd7470e --- /dev/null +++ b/projects/keepkey-vault/src/mainview/components/HeatmapView.tsx @@ -0,0 +1,301 @@ +import { useEffect, useMemo, useRef, useState } from "react" +import { Box, Flex, Image, Text } from "@chakra-ui/react" +import type { ChainDef } from "../../shared/chains" +import type { ChainBalance, TokenBalance } from "../../shared/types" +import { getAssetIcon } from "../../shared/assetLookup" + +/** A portfolio heatmap (squarified treemap). Each tile is a chain (or a + * drilled chain's clean tokens), with area proportional to USD value. + * Bigger holdings = bigger tile. Tile color = chain.color (chains) or a + * rotating palette (tokens). Click a tile to drill in or open detail. */ + +interface HeatmapTile { + id: string + label: string + subLabel: string + icon: string + color: string + value: number + onSelect: () => void +} + +interface HeatmapViewProps { + tiles: HeatmapTile[] + /** Optional explicit size — if omitted the view fills its parent container + * and re-lays out on resize via ResizeObserver. */ + width?: number + height?: number +} + +/** Squarified treemap algorithm — Bruls et al, 2000. + * Lays out rectangles in rows, picking the row that keeps aspect ratios + * closest to 1:1. Good enough for ~30 tiles. + * + * `valueExponent` (default 0.65) compresses the dynamic range so a + * tiny-balance chain still gets a usable tile next to a five-figure + * position. value=raw uses pure proportional sizing. */ +function layoutSquarified( + items: HeatmapTile[], + x: number, + y: number, + w: number, + h: number, + valueExponent: number = 0.65, +): Array<{ tile: HeatmapTile; x: number; y: number; w: number; h: number }> { + if (items.length === 0) return [] + // Apply the compression curve once. We treat the transformed value as the + // "area weight" — total adjusts accordingly so the bounding rect still + // fills exactly. + const weighted = items.map(t => ({ tile: t, weight: Math.pow(Math.max(t.value, 0), valueExponent) })) + const total = weighted.reduce((s, t) => s + t.weight, 0) + if (total <= 0) return [] + + // Scale weights to area of the bounding rect + const area = w * h + const scaled = weighted.map(t => ({ tile: t.tile, area: (t.weight / total) * area })) + scaled.sort((a, b) => b.area - a.area) + + const result: Array<{ tile: HeatmapTile; x: number; y: number; w: number; h: number }> = [] + let curX = x, curY = y, curW = w, curH = h + + const worstRatio = (row: { area: number }[], width: number) => { + const rowSum = row.reduce((s, r) => s + r.area, 0) + const max = Math.max(...row.map(r => r.area)) + const min = Math.min(...row.map(r => r.area)) + const widthSq = width * width + const sumSq = rowSum * rowSum + return Math.max((widthSq * max) / sumSq, sumSq / (widthSq * min)) + } + + const layoutRow = (row: typeof scaled, width: number, horizontal: boolean) => { + const rowSum = row.reduce((s, r) => s + r.area, 0) + const rowDepth = rowSum / width + let pos = horizontal ? curX : curY + for (const r of row) { + const len = r.area / rowDepth + if (horizontal) { + result.push({ tile: r.tile, x: pos, y: curY, w: len, h: rowDepth }) + pos += len + } else { + result.push({ tile: r.tile, x: curX, y: pos, w: rowDepth, h: len }) + pos += len + } + } + // Advance into the remaining rect + if (horizontal) { + curY += rowDepth + curH -= rowDepth + } else { + curX += rowDepth + curW -= rowDepth + } + } + + let pending = [...scaled] + while (pending.length > 0) { + const horizontal = curW >= curH + const shortSide = horizontal ? curW : curH + const row: typeof scaled = [] + while (pending.length > 0) { + const candidate = [...row, pending[0]!] + const newRatio = worstRatio(candidate, shortSide) + const currentRatio = row.length === 0 ? Infinity : worstRatio(row, shortSide) + if (newRatio <= currentRatio) { + row.push(pending.shift()!) + } else { + break + } + } + if (row.length === 0) break + layoutRow(row, shortSide, horizontal) + } + + return result +} + +function HeatmapTileBox({ + rect, +}: { + rect: { tile: HeatmapTile; x: number; y: number; w: number; h: number } +}) { + const { tile, x, y, w, h } = rect + const area = w * h + // Decide what fits inside the tile based on its area + const showIcon = area > 2200 + const showSubLabel = area > 4500 + const showLabel = area > 1300 + const iconSize = Math.min(48, Math.max(18, Math.floor(Math.sqrt(area) / 4))) + const labelSize = Math.min(15, Math.max(10, Math.floor(Math.sqrt(area) / 9))) + return ( + + 4500 ? "3" : "2"} gap="1"> + + {showIcon && ( + + )} + {showLabel && ( + + + {tile.label} + + {showSubLabel && ( + + {tile.subLabel} + + )} + + )} + + {area > 1800 && ( + + ${tile.value.toLocaleString("en-US", { maximumFractionDigits: 2 })} + + )} + + + ) +} + +export function HeatmapView({ tiles, width, height }: HeatmapViewProps) { + const wrapRef = useRef(null) + const [measured, setMeasured] = useState<{ w: number; h: number }>({ w: width ?? 0, h: height ?? 0 }) + + useEffect(() => { + // Explicit dimensions short-circuit measurement. + if (width !== undefined && height !== undefined) { + setMeasured({ w: width, h: height }) + return + } + const el = wrapRef.current + if (!el) return + const ro = new ResizeObserver((entries) => { + for (const entry of entries) { + const { width: w, height: h } = entry.contentRect + setMeasured({ w: Math.floor(w), h: Math.floor(h) }) + } + }) + ro.observe(el) + const r = el.getBoundingClientRect() + setMeasured({ w: Math.floor(r.width), h: Math.floor(r.height) }) + return () => ro.disconnect() + }, [width, height]) + + const laid = useMemo(() => { + if (measured.w <= 0 || measured.h <= 0) return [] + return layoutSquarified(tiles.filter(t => t.value > 0), 0, 0, measured.w, measured.h) + }, [tiles, measured.w, measured.h]) + + return ( + + {laid.map((rect) => ( + + ))} + + ) +} + +/** Helpers to build the tile lists from Dashboard's existing state. */ +export function buildAllChainsTiles( + chains: ChainDef[], + cleanBalanceUsd: Map, + onSelectChain: (chainId: string) => void, +): HeatmapTile[] { + const tiles: HeatmapTile[] = [] + for (const chain of chains) { + const usd = cleanBalanceUsd.get(chain.id)?.usd ?? 0 + if (usd <= 0) continue + tiles.push({ + id: chain.id, + label: chain.coin, + subLabel: chain.symbol, + icon: getAssetIcon(chain.caip), + color: chain.color, + value: usd, + onSelect: () => onSelectChain(chain.id), + }) + } + return tiles +} + +const TOKEN_PALETTE = ["#e9c46a", "#8be3c4", "#6c7be8", "#e08c7b", "#9f8ce0", "#f0a85c", "#4eb591", "#4f7fc8"] + +export function buildChainDetailTiles( + chain: ChainDef, + balance: ChainBalance | undefined, + cleanTokens: TokenBalance[], + nativeUsd: number, + onSelectToken: (tok?: TokenBalance) => void, +): HeatmapTile[] { + const tiles: HeatmapTile[] = [] + const nativeBalance = balance?.balance || "0" + if (nativeUsd > 0) { + tiles.push({ + id: `${chain.id}:native`, + label: chain.symbol, + subLabel: `${parseFloat(nativeBalance).toLocaleString("en-US", { maximumFractionDigits: 6 })} ${chain.symbol}`, + icon: getAssetIcon(chain.caip), + color: chain.color, + value: nativeUsd, + onSelect: () => onSelectToken(undefined), + }) + } + const sorted = cleanTokens.slice().sort((a, b) => (b.balanceUsd ?? 0) - (a.balanceUsd ?? 0)) + sorted.forEach((tok, i) => { + const usd = tok.balanceUsd ?? 0 + if (usd <= 0) return + tiles.push({ + id: tok.caip, + label: tok.symbol, + subLabel: tok.name || chain.coin, + icon: tok.icon || getAssetIcon(tok.caip), + color: TOKEN_PALETTE[i % TOKEN_PALETTE.length]!, + value: usd, + onSelect: () => onSelectToken(tok), + }) + }) + return tiles +} diff --git a/projects/keepkey-vault/src/mainview/components/StackedBarView.tsx b/projects/keepkey-vault/src/mainview/components/StackedBarView.tsx new file mode 100644 index 00000000..078c46b1 --- /dev/null +++ b/projects/keepkey-vault/src/mainview/components/StackedBarView.tsx @@ -0,0 +1,213 @@ +import { useState } from "react" +import { Box, Flex, Text } from "@chakra-ui/react" + +/** Horizontal stacked-bar portfolio view. Big total on top, single + * bar segmented by share, in-line labels for segments above an area + * threshold, smaller items collapsed into a chip legend below. */ + +export interface StackedBarItem { + id: string + label: string + color: string + value: number + onSelect?: () => void +} + +interface StackedBarViewProps { + items: StackedBarItem[] + /** Big number on top — e.g. "$8,617.19". */ + totalLabel?: string + /** Total value to use for percentages. Defaults to the sum of items. */ + total?: number + /** Optional 24h delta to display in green/red under the total + * (number is fine — we render the sign + percentage from it). */ + deltaUsd?: number + deltaPct?: number + /** Max bar width in px. */ + maxWidth?: number +} + +const INLINE_LABEL_THRESHOLD = 0.05 // 5% — segments at or above get an inline label + +export function StackedBarView({ + items, + totalLabel, + total: totalProp, + deltaUsd, + deltaPct, + maxWidth = 720, +}: StackedBarViewProps) { + const total = totalProp ?? items.reduce((s, it) => s + it.value, 0) + const [hoverId, setHoverId] = useState(null) + if (total <= 0) return null + + const sorted = items + .slice() + .filter(it => it.value > 0) + .sort((a, b) => b.value - a.value) + + let runningOffset = 0 + const segments = sorted.map((it) => { + const pct = it.value / total + const seg = { ...it, pct, offsetPct: runningOffset } + runningOffset += pct + return seg + }) + + const labeled = segments.filter(s => s.pct >= INLINE_LABEL_THRESHOLD) + const chips = segments.filter(s => s.pct < INLINE_LABEL_THRESHOLD) + + const dollars = Math.floor(total) + const cents = (total % 1).toFixed(2).slice(2) || "00" + + const deltaPositive = (deltaUsd ?? 0) >= 0 + const deltaColor = deltaPositive ? "var(--teal)" : "var(--rose)" + + return ( + + {/* Total */} + + + {totalLabel || "Total"} + + + + ${dollars.toLocaleString()} + + + .{cents} + + + {(deltaUsd !== undefined || deltaPct !== undefined) && ( + + {deltaUsd !== undefined && ( + + {deltaPositive ? "+" : ""}${Math.abs(deltaUsd).toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 })} + + )} + {deltaPct !== undefined && ( + + · {deltaPositive ? "+" : ""}{deltaPct.toFixed(2)}% 24h + + )} + + )} + + + {/* Bar */} + + + {segments.map((s) => { + const isHover = hoverId === s.id + return ( + setHoverId(s.id)} + onMouseLeave={() => setHoverId(null)} + onClick={s.onSelect} + border="0" + p={0} + title={`${s.label} · ${(s.pct * 100).toFixed(1)}% · $${s.value.toLocaleString("en-US", { maximumFractionDigits: 2 })}`} + /> + ) + })} + + + + {/* Inline labels under segments above the threshold */} + {labeled.length > 0 && ( + + {labeled.map((s) => { + const isHover = hoverId === s.id + return ( + setHoverId(s.id)} + onMouseLeave={() => setHoverId(null)} + onClick={s.onSelect} + cursor={s.onSelect ? "pointer" : "default"} + opacity={hoverId && !isHover ? 0.55 : 1} + transition="opacity 0.15s" + > + + {s.label} + + + {(s.pct * 100).toFixed(1)}% · ${s.value.toLocaleString("en-US", { maximumFractionDigits: 2 })} + + + ) + })} + + )} + + {/* Chip legend for small segments */} + {chips.length > 0 && ( + + {chips.map((s) => ( + + + + {s.label} · ${s.value.toLocaleString("en-US", { maximumFractionDigits: 2 })} + + + ))} + + )} + + ) +} diff --git a/projects/keepkey-vault/src/mainview/components/SwapDialog.tsx b/projects/keepkey-vault/src/mainview/components/SwapDialog.tsx index 58fa7191..6188f9b6 100644 --- a/projects/keepkey-vault/src/mainview/components/SwapDialog.tsx +++ b/projects/keepkey-vault/src/mainview/components/SwapDialog.tsx @@ -1439,6 +1439,7 @@ export function SwapDialog({ open, onClose, chain, balance, address, resumeSwap fromAddress, toAddress, slippageBps, + isMax: sendIsMax, }, 30000) if (version !== quoteVersionRef.current) return setQuote(result) @@ -2009,7 +2010,7 @@ export function SwapDialog({ open, onClose, chain, balance, address, resumeSwap letterSpacing="-0.03em" textAlign="center" lineHeight="1.1"> {t("swap", "Swap")}{" "} + fontWeight={500} fontSize="1.2em"> {t("completed", "completed").toLowerCase()} @@ -2342,8 +2343,7 @@ export function SwapDialog({ open, onClose, chain, balance, address, resumeSwap + color={isSwapComplete ? "var(--teal)" : isSwapFailed ? "var(--rose)" : "kk.textPrimary"}> {isSwapComplete ? t("swapCompleted") : isSwapFailed ? t("swapFailed") : t("swapSubmitted")} {!isSwapComplete && !isSwapFailed && ( @@ -2772,8 +2772,6 @@ export function SwapDialog({ open, onClose, chain, balance, address, resumeSwap fontSize="20px" fontWeight="600" color="kk.textPrimary" - fontFamily="serif" - fontStyle="italic" letterSpacing="-0.01em" > {subStage === 'approve-signing' ? t("approveOnDevice", "Approve on device") diff --git a/projects/keepkey-vault/src/mainview/components/TopNav.tsx b/projects/keepkey-vault/src/mainview/components/TopNav.tsx index f9d131c5..481dbb41 100644 --- a/projects/keepkey-vault/src/mainview/components/TopNav.tsx +++ b/projects/keepkey-vault/src/mainview/components/TopNav.tsx @@ -4,6 +4,7 @@ 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" @@ -345,8 +346,9 @@ export function TopNav({ })} - {/* Right: walletconnect + mobile + settings */} - + {/* Right: portfolio view picker (vault tab only) + walletconnect + mobile + settings */} + + {activeTab === "vault" && } {onWalletConnectToggle && ( = [ + { + id: "orbital", + label: "Orbital", + description: "Chains orbiting the portfolio total", + preview: ( + + + + + + + + + ), + }, + { + id: "donut", + label: "Donut", + description: "Slice breakdown with percentages", + preview: ( + + + + + + + ), + }, + { + id: "heatmap", + label: "Heatmap", + description: "Treemap sized by value", + preview: ( + + + + + + + ), + }, + { + id: "stack", + label: "Stack", + description: "Horizontal stacked bar by share", + preview: ( + + + + + + + + + + + + + + ), + }, +] + +export function ViewPickerButton() { + const { viewMode, setViewMode } = useDashboardView() + const [open, setOpen] = useState(false) + const wrapRef = useRef(null) + + // Close on outside click / Esc + useEffect(() => { + if (!open) return + const onClick = (e: MouseEvent) => { + if (!wrapRef.current) return + if (!wrapRef.current.contains(e.target as Node)) setOpen(false) + } + const onKey = (e: KeyboardEvent) => { + if (e.key === "Escape") setOpen(false) + } + window.addEventListener("mousedown", onClick) + window.addEventListener("keydown", onKey) + return () => { + window.removeEventListener("mousedown", onClick) + window.removeEventListener("keydown", onKey) + } + }, [open]) + + return ( + + setOpen(o => !o)} + display="flex" + alignItems="center" + justifyContent="center" + w="34px" + h="26px" + borderRadius="999px" + bg={open ? "var(--ink-4)" : "var(--ink-2)"} + color={open ? "var(--gold)" : "var(--text-2)"} + border="1px solid var(--line)" + _hover={{ color: "var(--text-0)", bg: "var(--ink-3)" }} + transition="all 0.18s" + cursor="pointer" + title="Switch view" + aria-haspopup="menu" + aria-expanded={open} + > + + + + + + + {open && ( + + {VIEWS.map((v) => { + const isActive = viewMode === v.id + return ( + { setViewMode(v.id); setOpen(false) }} + role="menuitem" + w="100%" + textAlign="left" + px="2.5" + py="2" + my="0.5" + borderRadius="md" + bg={isActive ? "var(--ink-3)" : "transparent"} + _hover={{ bg: "var(--ink-3)" }} + cursor="pointer" + transition="background 0.15s" + display="flex" + alignItems="center" + gap="3" + > + + {v.preview} + + + + + {v.label} + + {isActive && ( + + + + )} + + + {v.description} + + + + ) + })} + + )} + + ) +} diff --git a/projects/keepkey-vault/src/mainview/index.css b/projects/keepkey-vault/src/mainview/index.css index ceba06ca..439b13ae 100644 --- a/projects/keepkey-vault/src/mainview/index.css +++ b/projects/keepkey-vault/src/mainview/index.css @@ -9,7 +9,7 @@ html, body, #root { width: 100%; background: transparent; color: #ffffff; - font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + font-family: 'Geist Mono', ui-monospace, monospace; } /* Prevent native window drag from capturing form inputs */ diff --git a/projects/keepkey-vault/src/mainview/index.html b/projects/keepkey-vault/src/mainview/index.html index da1e29b2..9746a46b 100644 --- a/projects/keepkey-vault/src/mainview/index.html +++ b/projects/keepkey-vault/src/mainview/index.html @@ -6,7 +6,7 @@ KeepKey Vault - +
diff --git a/projects/keepkey-vault/src/mainview/lib/commandBus.ts b/projects/keepkey-vault/src/mainview/lib/commandBus.ts new file mode 100644 index 00000000..244a333d --- /dev/null +++ b/projects/keepkey-vault/src/mainview/lib/commandBus.ts @@ -0,0 +1,93 @@ +/** + * Tiny imperative command bus for cross-cutting UI commands that need to + * reach into a component (Dashboard) without lifting all its state up to App. + * + * Used today by CommandPalette (⌘K) to ask Dashboard to drill into a chain + * or open a specific token without restructuring the Dashboard's existing + * `drilledChainId` + `openChainPage` ownership. + * + * Also exposes a small balances bridge: Dashboard publishes its current + * balances Map, and CommandPalette reads them via useLatestBalances() so + * we can list token rows in search results without lifting balances state + * up to App.tsx. + */ + +import { useEffect, useState } from "react" +import type { ChainBalance } from "../../shared/types" + +// ── Commands ──────────────────────────────────────────────────────────── +export type VaultCommand = + | { type: "open-chain"; chainId: string } + | { type: "open-token"; chainId: string; tokenCaip: string } + +type CommandListener = (cmd: VaultCommand) => void + +const commandListeners = new Set() +// Holds one pending command when Dashboard isn't mounted (no listeners). +// CommandPalette calls onJumpToVault() then dispatchVaultCommand() synchronously, +// but React hasn't re-rendered yet so Dashboard's useEffect hasn't subscribed. +// On the next subscribe() call (Dashboard mount) we drain this immediately. +let pendingCommand: VaultCommand | null = null + +/** Dispatch a vault command. If Dashboard isn't mounted yet, queues it for delivery on next subscribe. */ +export function dispatchVaultCommand(cmd: VaultCommand): void { + if (commandListeners.size === 0) { + pendingCommand = cmd + return + } + for (const fn of commandListeners) { + try { fn(cmd) } catch (e) { console.error("[commandBus] listener threw:", e) } + } +} + +/** Subscribe to vault commands. Returns an unsubscribe function. Drains any pending command immediately. */ +export function subscribeVaultCommand(fn: CommandListener): () => void { + commandListeners.add(fn) + if (pendingCommand) { + const cmd = pendingCommand + pendingCommand = null + try { fn(cmd) } catch (e) { console.error("[commandBus] listener threw:", e) } + } + return () => { commandListeners.delete(fn) } +} + +// ── Balances bridge ───────────────────────────────────────────────────── +type BalancesListener = (balances: Map) => void + +const balancesListeners = new Set() +let latestBalances: Map = new Map() + +/** Called by Dashboard whenever its balances Map changes. */ +export function publishBalances(balances: Map): void { + latestBalances = balances + for (const fn of balancesListeners) { + try { fn(balances) } catch (e) { console.error("[commandBus] balances listener threw:", e) } + } +} + +/** Imperative read — useful for non-React call sites. */ +export function getLatestBalances(): Map { + return latestBalances +} + +/** Clear the balance cache — call when Dashboard unmounts so stale wallet + * balances don't bleed into the next wallet session or disconnected state. */ +export function clearBalances(): void { + latestBalances = new Map() + for (const fn of balancesListeners) { + try { fn(latestBalances) } catch (e) { console.error("[commandBus] balances listener threw:", e) } + } +} + +/** React hook that subscribes to balance updates published by Dashboard. */ +export function useLatestBalances(): Map { + const [balances, setBalances] = useState>(latestBalances) + useEffect(() => { + // Sync once on mount in case Dashboard published before we subscribed. + setBalances(latestBalances) + const fn: BalancesListener = (b) => setBalances(b) + balancesListeners.add(fn) + return () => { balancesListeners.delete(fn) } + }, []) + return balances +} diff --git a/projects/keepkey-vault/src/mainview/lib/dashboardViewContext.tsx b/projects/keepkey-vault/src/mainview/lib/dashboardViewContext.tsx new file mode 100644 index 00000000..a8e32516 --- /dev/null +++ b/projects/keepkey-vault/src/mainview/lib/dashboardViewContext.tsx @@ -0,0 +1,37 @@ +import { createContext, useContext, useEffect, useState, type ReactNode } from "react" + +export type DashboardView = "orbital" | "donut" | "heatmap" | "stack" +const STORAGE_KEY = "keepkey.dashboard.view" + +interface Ctx { + viewMode: DashboardView + setViewMode: (v: DashboardView) => void +} + +const DashboardViewContext = createContext(null) + +export function DashboardViewProvider({ children }: { children: ReactNode }) { + const [viewMode, setViewMode] = useState(() => { + try { + const saved = localStorage.getItem(STORAGE_KEY) + if (saved === "donut" || saved === "heatmap" || saved === "stack") return saved + return "orbital" + } catch { return "orbital" } + }) + useEffect(() => { + try { localStorage.setItem(STORAGE_KEY, viewMode) } catch { /* private mode etc. */ } + }, [viewMode]) + return ( + + {children} + + ) +} + +/** Returns the current dashboard view + setter. Returns a noop pair when used + * outside the provider so non-Dashboard screens don't crash. */ +export function useDashboardView(): Ctx { + const ctx = useContext(DashboardViewContext) + if (ctx) return ctx + return { viewMode: "orbital", setViewMode: () => {} } +} diff --git a/projects/keepkey-vault/src/mainview/lib/iconColor.ts b/projects/keepkey-vault/src/mainview/lib/iconColor.ts new file mode 100644 index 00000000..f781e3a6 --- /dev/null +++ b/projects/keepkey-vault/src/mainview/lib/iconColor.ts @@ -0,0 +1,99 @@ +/* Sample a small canvas of an icon and return its dominant saturated color. + Used to make token glow ring colors match the logo (PEPE glows green, + USDC glows blue, etc.) instead of inheriting the chain's brand color. + + Strategy: + - Draw the image into a 32×32 canvas (downsample = fast + averages noise). + - Quantize pixels to a coarse RGB histogram (32 levels per channel). + - Skip transparent, near-black, near-white, and near-grayscale pixels — + those tend to be background / outlines, not the logo's identity. + - Pick the bucket with the highest pixel count, return its mean RGB as hex. + - Cache by URL; canvas-read failures (CORS) resolve to null so the caller + can fall back to a brand color. +*/ + +import { useEffect, useState } from "react" + +const cache = new Map() +const inflight = new Map>() + +export function extractDominantColor(url: string): Promise { + if (cache.has(url)) return Promise.resolve(cache.get(url) ?? null) + const existing = inflight.get(url) + if (existing) return existing + + const p = new Promise((resolve) => { + const img = new Image() + img.crossOrigin = "anonymous" + img.onload = () => { + try { + const canvas = document.createElement("canvas") + const w = (canvas.width = 32) + const h = (canvas.height = 32) + const ctx = canvas.getContext("2d", { willReadFrequently: true }) + if (!ctx) return resolve(null) + ctx.drawImage(img, 0, 0, w, h) + const data = ctx.getImageData(0, 0, w, h).data + + const buckets = new Map() + for (let i = 0; i < data.length; i += 4) { + const r = data[i]! + const g = data[i + 1]! + const b = data[i + 2]! + const a = data[i + 3]! + if (a < 200) continue + const max = Math.max(r, g, b) + const min = Math.min(r, g, b) + if (max < 30) continue // too dark + if (min > 235) continue // near-white + if (max - min < 30) continue // grayscale-ish + const key = `${r >> 5}-${g >> 5}-${b >> 5}` + const entry = buckets.get(key) + if (entry) { + entry.r += r; entry.g += g; entry.b += b; entry.count++ + } else { + buckets.set(key, { r, g, b, count: 1 }) + } + } + if (buckets.size === 0) return resolve(null) + + let best: { r: number; g: number; b: number; count: number } | null = null + for (const v of buckets.values()) { + if (!best || v.count > best.count) best = v + } + if (!best) return resolve(null) + + const r = Math.round(best.r / best.count) + const g = Math.round(best.g / best.count) + const b = Math.round(best.b / best.count) + const hex = "#" + [r, g, b].map((n) => n.toString(16).padStart(2, "0")).join("") + resolve(hex) + } catch { + resolve(null) + } + } + img.onerror = () => resolve(null) + img.src = url + }).then((hex) => { + cache.set(url, hex) + inflight.delete(url) + return hex + }) + + inflight.set(url, p) + return p +} + +/** Hook: returns the dominant color of an icon URL, defaulting to fallback. */ +export function useIconColor(url: string | undefined, fallback: string): string { + const [color, setColor] = useState(fallback) + useEffect(() => { + if (!url) { setColor(fallback); return } + let cancelled = false + extractDominantColor(url).then((c) => { + if (!cancelled) setColor(c ?? fallback) + }) + return () => { cancelled = true } + }, [url, fallback]) + return color +} diff --git a/projects/keepkey-vault/src/mainview/lib/iconPreload.ts b/projects/keepkey-vault/src/mainview/lib/iconPreload.ts new file mode 100644 index 00000000..9e57f357 --- /dev/null +++ b/projects/keepkey-vault/src/mainview/lib/iconPreload.ts @@ -0,0 +1,23 @@ +/* Module-level cache of preloaded Image objects. + Holding a live reference to the Image keeps the bitmap in memory so the + browser doesn't re-fetch when an element with the same URL mounts + again (e.g. when the user drills into a new chain and the token icons + re-mount). The set is keyed by URL so we never preload the same icon + twice. */ + +const PRELOADED = new Map() + +export function preloadIcon(url: string | undefined | null): void { + if (!url) return + if (PRELOADED.has(url)) return + const img = new Image() + img.decoding = "async" + img.src = url + // Decode immediately so the bitmap is paint-ready when re-rendered. + if (typeof img.decode === "function") img.decode().catch(() => {}) + PRELOADED.set(url, img) +} + +export function preloadIcons(urls: Iterable): void { + for (const u of urls) preloadIcon(u) +} diff --git a/projects/keepkey-vault/src/mainview/lib/sounds.ts b/projects/keepkey-vault/src/mainview/lib/sounds.ts index 460abaa1..d4b1f105 100644 --- a/projects/keepkey-vault/src/mainview/lib/sounds.ts +++ b/projects/keepkey-vault/src/mainview/lib/sounds.ts @@ -27,6 +27,35 @@ export function playChaChing() { } } +/** Synthesize a two-yip bark — no audio file required. */ +export function playBark() { + try { + const ctx = getCtx() + const now = ctx.currentTime + barkBurst(ctx, now, 200, 0.14) + barkBurst(ctx, now + 0.18, 300, 0.10) + } catch { + // Audio blocked (e.g. before first user gesture) — silently skip. + } +} + +function barkBurst(ctx: AudioContext, startTime: number, freq: number, duration: number) { + const osc = ctx.createOscillator() + osc.type = "sawtooth" + osc.frequency.setValueAtTime(freq * 1.3, startTime) + osc.frequency.exponentialRampToValueAtTime(freq * 0.55, startTime + duration) + + const gain = ctx.createGain() + gain.gain.setValueAtTime(0.0001, startTime) + gain.gain.exponentialRampToValueAtTime(0.32, startTime + 0.012) + gain.gain.exponentialRampToValueAtTime(0.0001, startTime + duration) + + osc.connect(gain) + gain.connect(ctx.destination) + osc.start(startTime) + osc.stop(startTime + duration) +} + function playTone(ctx: AudioContext, freq: number, startTime: number, duration: number, volume: number) { const osc = ctx.createOscillator() const gain = ctx.createGain() diff --git a/projects/keepkey-vault/src/mainview/main.tsx b/projects/keepkey-vault/src/mainview/main.tsx index 8954b86c..f966abf5 100644 --- a/projects/keepkey-vault/src/mainview/main.tsx +++ b/projects/keepkey-vault/src/mainview/main.tsx @@ -8,6 +8,7 @@ import "./i18n" import splashBg from "./assets/splash-bg.png" import App from "./App" import { FiatProvider } from "./lib/fiat-context" +import { DashboardViewProvider } from "./lib/dashboardViewContext" // Global error handler — prevent stray promise rejections from crashing the WebView window.addEventListener('unhandledrejection', (e) => { @@ -22,7 +23,9 @@ createRoot(document.getElementById("root")!).render( - + + + , diff --git a/projects/keepkey-vault/src/mainview/styles/tokens.css b/projects/keepkey-vault/src/mainview/styles/tokens.css index 9483aeee..125cb820 100644 --- a/projects/keepkey-vault/src/mainview/styles/tokens.css +++ b/projects/keepkey-vault/src/mainview/styles/tokens.css @@ -46,10 +46,10 @@ --shadow-2: 0 1px 0 rgba(255, 255, 255, 0.05) inset, 0 8px 24px -8px rgba(0, 0, 0, 0.6); --shadow-glow: 0 0 0 1px rgba(139, 227, 196, 0.25), 0 0 40px -8px rgba(139, 227, 196, 0.35); - /* Type stacks */ - --font-sans: 'Space Grotesk', -apple-system, system-ui, sans-serif; + /* Type stacks — mono is the app-wide default; sans/serif aliases collapse to mono. */ --font-mono: 'Geist Mono', ui-monospace, monospace; - --font-serif: 'Instrument Serif', serif; + --font-sans: var(--font-mono); + --font-serif: var(--font-mono); } /* Class-scoped helpers — do NOT bind to body so existing Chakra screens are @@ -64,7 +64,7 @@ } .v3-mono { font-family: var(--font-mono); font-feature-settings: "tnum"; } -.v3-serif { font-family: var(--font-serif); font-style: italic; } +.v3-serif { font-family: var(--font-mono); font-style: normal; } /* Page enter transition (mirrors study) */ @keyframes v3-fadeUp { diff --git a/projects/keepkey-vault/src/mainview/theme.ts b/projects/keepkey-vault/src/mainview/theme.ts index d4741e71..aabdafbe 100644 --- a/projects/keepkey-vault/src/mainview/theme.ts +++ b/projects/keepkey-vault/src/mainview/theme.ts @@ -7,9 +7,9 @@ import { createSystem, defaultConfig, defineConfig } from "@chakra-ui/react"; var(--ink-0) etc.) lives in src/mainview/styles/tokens.css; this file is the bridge for screens still rendered through Chakra. */ -const FONT_SANS = "'Space Grotesk', -apple-system, system-ui, sans-serif" const FONT_MONO = "'Geist Mono', ui-monospace, monospace" -const FONT_SERIF = "'Instrument Serif', serif" +const FONT_SANS = FONT_MONO // mono is now the app-wide default +const FONT_SERIF = FONT_MONO // serif usages collapse to mono too const config = defineConfig({ globalCss: { diff --git a/projects/keepkey-vault/src/shared/types.ts b/projects/keepkey-vault/src/shared/types.ts index ecc9bed4..8caca0ed 100644 --- a/projects/keepkey-vault/src/shared/types.ts +++ b/projects/keepkey-vault/src/shared/types.ts @@ -729,6 +729,8 @@ export interface SwapQuoteParams { fromAddress: string // sender address toAddress: string // destination address slippageBps?: number // slippage tolerance (default 300 = 3%) + isMax?: boolean // true when sending full balance (UTXO fee must be deducted before quoting) + feeLevel?: number // 1=slow, 3=avg, 5=fast — passed to UTXO fee estimator } /** Parameters for executeSwap RPC. CAIP-only identification — the tracker