Skip to content

release: v1.3.6 — portfolio balance reliability, forceRefresh opt-in, swaps off by default#182

Merged
BitHighlander merged 268 commits into
masterfrom
release/1.3.6
May 18, 2026
Merged

release: v1.3.6 — portfolio balance reliability, forceRefresh opt-in, swaps off by default#182
BitHighlander merged 268 commits into
masterfrom
release/1.3.6

Conversation

@BitHighlander
Copy link
Copy Markdown
Collaborator

Summary

Release artifacts

  • KeepKey-Vault-1.3.6-arm64.dmg — built + notarized + stapled locally ✅
  • KeepKey-Vault-1.3.6-x86_64.dmg — signed + notarized + stapled via sign-release-intel ✅
  • KeepKey-Vault-1.3.6-win-x64-setup.exe — CI ✅
  • KeepKey-Vault-x86_64.AppImage — CI ✅
  • SHA256SUMS.txt — regenerated from signed on-wire artifacts ✅

Release: https://github.com/keepkey/keepkey-vault/releases/tag/v1.3.6
Merge-back to develop: d804856

Verification

  • arm64 DMG: stapled ✅, Gatekeeper accepted (Notarized Developer ID) ✅, backend smoke test ✅
  • x64 DMG: stapled ✅
  • Signed x64 tar.zst hash verified on-wire (not clobbered by CI) ✅

BitHighlander and others added 30 commits April 21, 2026 22:12
feat(rest-api): expose /ton/build-transfer + /ton/finalize-transfer
REST API port 1646 was serving stale pubkeys after a device swap because
pubkeyCache and addressCache were keyed only by request body (deviceId
was not part of the key) and never cleared on device disconnect or swap.
A user could connect KeepKey A, disconnect, connect KeepKey B, and get
A's xpubs from REST. Restarting Vault was the only workaround.

This change:

- Adds clearPubkeyCache() + clearAddressCache() exports and calls them
  on state-change when state === 'disconnected' OR when deviceId
  transitions to a different device between two ready states.
- Every pubkey/address cache key now includes engine.getDeviceState()
  .deviceId, so even without a flush, a swap cannot return another
  device's entry (entries collide on device id, not just request body).
- Adds a uiSetActive / uiHeartbeat RPC handshake. The Vault UI signals
  on mount + heartbeat and on unmount / beforeunload; the Bun side
  rejects pubkey/address endpoints with 503 when the UI is not active
  (no heartbeat within 45s). This prevents a 3rd-party REST client from
  pulling pubkeys while the user's Vault window is closed.
- When the UI is scoped to a specific device (e.g. watch-only view for
  device X), REST rejects pubkey requests if the connected device id
  doesn't match, satisfying "serve pubkeys only for the wallet the user
  has open".

Endpoints gated: /system/info/get-public-key, /api/pubkeys/batch, and
all /addresses/* (utxo, cosmos, osmosis, eth, tendermint, thorchain,
mayachain, xrp, solana, tron, ton).
- uiHeartbeat now no-ops when uiState.active is false. A stray
  heartbeat racing with an explicit setUiActive(false) (window close,
  unmount cleanup) cannot silently reopen the gate; the WebView must
  call setUiActive(true) again to reactivate. Heartbeats only refresh
  lastHeartbeat while the UI is already active.

- App.tsx defers the first uiSetActive(true) until viewDeviceId is
  meaningful (deviceId is known, or watch-only mode resolved). Without
  this, the brief mount window where deviceState.deviceId is still null
  would activate the gate with viewDeviceId=null, and requireUiActive's
  device-id check (which only fires when uiState.viewDeviceId is truthy)
  would let an on-disk pubkey cache from a prior session be served
  against the wrong / no device.
Unlocks TRON.TRX → any-THORChain-asset and TRON.USDT → any-THORChain-asset
swaps from the existing vault swap UI. Pioneer-router already supports both
CAIPs and THORChain has live liquidity in both pools; the missing piece was
the vault tx builder.

Two paths in the TRON `buildTx` case:
- Native TRX — TronGrid `createtransaction` already worked; thread
  `params.memo` through as `extra_data`, which lands in `raw_data.data`
  natively.
- TRC-20 USDT (detected via `tron:.../token:T...` CAIP) — TronGrid
  `triggersmartcontract` silently ignores `extra_data`, so `injectTronMemo`
  splices the memo into `raw_data` at canonical tag-order position (field 10,
  before contract/11) and recomputes txID. Appending at the end produces a
  hash that differs from what TronGrid canonicalizes to, which would break
  signature verification at broadcast.

SwapDialog swaps the green "verify on device" badge for an orange
blind-sign warning when the source chain is TRON, since firmware 7.14 can
only display a generic Tron transaction prompt for TriggerSmartContract
calls — users must verify amounts, vault, and memo in the vault UI before
approving on device.

8 unit tests cover the injection: memo round-trips through data field,
field 10 lands before field 11, all original raw_data fields preserved,
txID matches sha256 of new raw_data_hex, different memos produce different
txIDs, 250-byte THORChain memo limit works, input object not mutated.
Wired into the test-unit Makefile target.

144 tests pass.
THOR_TO_CHAIN had `TRX: 'tron'` (asset symbol as the key) where every
other entry uses the chain prefix from parseThorAsset (BTC, ETH, SOL,
XRP, TON, ...). Pioneer assets come in as `TRON.TRX` / `TRON.USDT-T...`,
so parseThorAsset returns `chain: 'TRON'` and the map lookup missed,
dropping TRON from getSwappableChainIds. Result: the swap pill never
rendered on the TRON asset page even with swapsEnabled + a paired
device. One-character key fix; nothing else needed.
Pioneer's /swap/available-assets currently returns zero TRON entries
(verified against api.keepkey.info — no objects with chain=TRON), even
though /quote accepts TRON CAIPs and returns valid THORChain quotes for
both TRON.TRX and TRON.USDT-TR7N... (both pools Available with deep
liquidity per thornode).

Without a local shim, getSwappableChainIds has nothing to return for
TRON and the swap pill never renders on the TRON asset page. Mirror the
existing THOR.RUNE pattern (line 134-147) that works around the same
Pioneer omission — push TRON.TRX + TRON.USDT into the asset list if
they're absent.

Can be deleted once pioneer-server starts listing TRON assets.
Three RPC handlers (setPioneerApiBase, removePioneerServer, setActivePioneerServer)
already call resetPioneer() and clear chainCatalog when the active Pioneer
URL changes, but they leave the swap asset cache (5-minute TTL in swap.ts)
populated with the previous server's response. Result: after pointing at a
local pioneer that exposes additional assets (e.g. TRON.USDT), those assets
don't appear in the swap dialog until the cache TTL expires or the process
restarts — even though resetPioneer() and the chain catalog refresh hint to
the user that the switch is "live."

Pair clearSwapCache() with each resetPioneer() call site via dynamic import
(swap.ts is otherwise loaded lazily). Caught while testing TRON THORChain
swaps end-to-end against a local pioneer build.
assetToCaip() reconstructed the CAIP from the THORChain asset string,
hardcoding `/erc20:` for any contract address and preserving THORChain's
uppercase form. For TRON USDT, that produced
  tron:0x2b6653dc/erc20:TR7NHQJEKQXGTCI8Q8ZY4PL8OTSZGJLJ6T
when pioneer's canonical CAIP (and what THORChain's pool list uses) is
  tron:0x2b6653dc/token:TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t
— different namespace AND case-sensitive base58 vs uppercase. Pioneer
rejected the malformed CAIP with "No quotes available".

Fix: thread the cached `getSwapAssets()` result into assetToCaip() and
prefer the canonical caip pioneer already gave us. The reconstruct path
stays as a fallback but is now chain-family-aware (`/token:` for TRON,
`/erc20:` for EVM). The reconstruct is still lossy for TRON because we
can't recover the proper-case base58 from THORChain's uppercased form,
but cached assets always exist for legitimate flows.
Two-layer fix.

1. swap.ts wraps pioneer.Quote() in a try/catch and unwraps the inner
   diagnostic from e.response.body.message (or e.response.text). Without
   this, swagger-client throws Error with message "Internal Server Error"
   while the actual reason ("amount less than min swap amount
   recommended_min_amount_in: 3069929100", "trading is halted", etc.)
   sits in the response body — the RPC layer + frontend never see it.

2. SwapDialog adds a pattern match for "amount less than min swap amount"
   and parses recommended_min_amount_in (THORNode emits in 8-decimal
   internal units regardless of source chain) into a human-readable
   minimum in the source asset's symbol. Falls back to a generic message
   if the integer can't be parsed.

Caught while testing TRX→USDT-on-TRON quotes against THORChain — the
2.93 TRX attempt was below the ~30.7 TRX minimum, but the user only saw
"Internal Server Error" in console and no useful message in the UI.
…h signing

signTx() called wallet.tronSignTx(unsignedTx) and returned the result
verbatim — but hdwallet only returns { signature, serializedTx, ... }
without echoing the build-side metadata. broadcastTx() then needed
signedTx.tronGridTx to reassemble the broadcast envelope and threw
"Tron broadcast requires tronGridTx and signature".

Same shape applied to TON's tonBuildResult — broadcast assembles the
BOC from { tonBuildResult, signature } so the build context must
survive signing for the orchestrator path.

Spread unsignedTx fields back into the signed result for both chains.
The vault's own /tron/sign-transaction REST handler (index.ts:1041) was
already doing this manually for the SDK path, but the swap orchestrator
went through signTx() directly and missed it.
Previously fell back to "Unknown error" whenever data.code and data.message
were both empty — which is exactly what happened on the TRX → USDT-on-TRON
swap and gave the user nothing to act on.

TronGrid uses several response shapes for broadcast failures:
  { result: false, code, message: "<hex>" }
  { result: false, message: "<hex|utf8>" }
  { Error: "..." }              (capitalized — validation)
  { code, message: "<hex>" }
  { error: "..." }              (generic HTTP-style)

Try each in order, decode hex-only messages to UTF-8, then fall back to
the HTTP status, then to a slice of the raw JSON. Plus log the broadcast
body and the response so we can see exactly what TronGrid choked on next
time the swap fails.
hdwallet's tronSignTx returns `signature` as a Uint8Array (per
hdwallet-keepkey/src/tron.ts:614 — getSignature_asU8). The broadcast
code wrapped it as `signature: [sigHex]` where the variable was
mis-named — the value is bytes, not hex. JSON.stringify on a Uint8Array
produces `{"0":n,"1":n,...}` (array-like serialized as object), which
TronGrid's Java parser chokes on with a NullPointerException — the same
"unhelpful" NPE we just got better diagnostics for.

Normalize to lowercase hex (handle Uint8Array, Buffer, or hex-string
input). Reject anything that doesn't look like hex with a clear error
instead of letting it through to TronGrid.
Tronscan URL routing is case-sensitive — `/transaction/EEB4...` 404s
while `/transaction/eeb4...` resolves the same hex tx, even though the
underlying tx is identical. THORChain emits txids uppercased (matches
its asset-name convention for case-insensitive lookup), so any
THORChain-routed swap landing on TRON handed the user a broken explorer
link in both the SwapDialog "view on explorer" and SendForm completion
screen.

Lowercase the txid for chain.chainFamily === 'tron' in both URL
builders. Mirrors the EVM 0x-prefix special-case that already lived
there. No behavior change for any other chain family.
Two review findings on PR #130.

1. **TRON token-source swaps fell through to native TRX**. executeSwap()
   called buildTx() without passing `caip` or `tokenDecimals`, but the
   TRC-20 detection in buildTx() keys off
   `params.caip.match(/^tron:.../token:T.../)`. Result: a USDT-on-TRON →
   BTC swap built a native TransferContract sending `params.amount` as
   TRX to the THORChain inbound address — wrong asset, wrong contract
   shape, broadcast either rejects or sends the wrong currency.
   Look up the source SwapAsset from the cached `getSwapAssets()` list
   and forward both `caip` (canonical) and `decimals` so the TRC-20 path
   triggers correctly.

2. **TRON shim was all-or-nothing**. The shim only pushed BOTH TRX and
   USDT when `TRON.TRX` was missing; if pioneer ever lists native TRX
   but omits the token (whitelist drift, partial deploy), USDT
   disappears silently. Switched to per-asset checks so each falls back
   independently. Same shape as the THOR.RUNE shim above it.

Pioneer #37 currently lists both, so neither bug bites today, but #1 is
load-bearing the moment a user tries USDT-as-source and #2 is the kind
of drift bug that surfaces months later in a release.
feat(tron): THORChain swaps end-to-end (TRX + USDT-on-TRON)
Firmware 7.14's TRON FSM (fsm_msg_tron.h:108) interprets the optional
to_address + amount hint fields as a native TRX transfer regardless of
the actual contract being signed:

    confirm("Send %s TRX to %s?", amount_str, msg->to_address)

For a TRC-20 transfer of 1 USDT (token base unit 1_000_000), the device
displayed "Send 1 TRX to <USDT contract address>" — accurate to the
exact bytes being signed but a misleading interpretation since the
unit boundary happens to line up at 1e6.

Drop the hint fields for the TRC-20 path so the firmware falls back to
the generic "Really sign this TRON transaction?" prompt. That matches
what SwapDialog's blind-sign warning already tells the user to expect
("device will display a generic Tron transaction prompt — verify
amounts here"). The native TRX path is unchanged — its hint fields
correctly describe the TransferContract being signed.

Once firmware adds TRC-20 clear-signing, swap the hint payload back in
with the right shape (recipient, amount, decimals, symbol).
Displays a truncated (6…6) contract address below each token's name,
click-to-copy the full value to clipboard. Always visible when a
contract exists — not gated on spam/scam status so the user can
identify any token at a glance (which is useful both for flagging new
scams to investigate and for confirming "is this the USDT I think it
is" for legitimate tokens).

Next step tracked separately: richer server-side token investigation
on pioneer so the spam filter can catch more before they render here.
Previous pass middle-truncated the address (6…6) which obscured the
middle characters even when there was plenty of horizontal room. The
token-row left column has no width constraint (only the name Text was
clipped at 140px), so the full 34–42 char address fits fine at 9px
mono.

- Drop the manual middle-ellipsis, display the full contract string.
- Add an explicit FaCopy icon next to the address so the copy action
  is discoverable (previously only visible via the `title` hover).
- Swap the icon to FaCheck (green) for 1.5s after copying, tracked per
  token via copiedCaip state — matches the pattern used elsewhere
  (ActivityPanel, SendForm txid copy).
…sync

Dashboard fetched the visibilityMap once on mount and never refetched.
Marking a token as scam from AssetPage updated AssetPage's local state
+ DB but Dashboard kept its stale snapshot — the spam token's USD stayed
in the chain total and grand total. Same bug in reverse for "mark as
safe."

Add a `token-visibility-changed` push from Bun (fired after both
setTokenVisibility and removeTokenVisibility) and have Dashboard +
AssetPage subscribe to refetch. Matches existing patterns
(`balance-updated`, `swap-update`, `pin-request` etc.) — minimal change,
correct invalidation regardless of which view originated the change.
fix(rest-api): gate pubkey/address serving on UI-active + device-id
The UI-active gate added in 10bfc40 rejected pubkey/address REST requests
with 503 whenever the Vault window was closed. This broke the paired
browser extension's core workflow: after a device reconnect, the BEX
refreshes pubkeys from Vault, but with the Vault window closed the gate
forced it to fall back to its local cache — which can be stale if the
user swapped to a different KeepKey while Vault was backgrounded.

The gate's threat model (prevent a 3rd-party localhost process from
pulling pubkeys when the user isn't actively using Vault) is already
covered by the other protections shipped in the same commit:

  - auth.requireAuth(req) on every pubkey/address endpoint rejects any
    caller without a paired-app API key.
  - scopedKey(engine, ...) prefixes every cache entry with the active
    deviceId, so no cross-device leakage is possible even if the gate
    is bypassed.
  - State-change listener clears both caches on disconnect and on
    deviceId transition between two ready states.

Together those handle the exfiltration concern without locking out the
paired extension, which is the design intent of the auth layer.

The UI lifecycle signal (setUiActive / uiHeartbeat RPC + cache flush
on active→inactive transition) is kept — it still usefully flushes
caches when the user closes the Vault window.

- Removes requireUiActive() helper and its 13 call sites
  (11 /addresses/* + /system/info/get-public-key + /api/pubkeys/batch).
- Removes UI_STALE_MS and collapses uiState to a single uiActive bool.
- uiHeartbeat becomes a no-op so the RPC contract stays stable.
Introduces an emulator_wallet sqlite table keyed by flash name so the
splash UI can identify each emulator wallet by its on-device label and
firmware version, and surface the cached USD total alongside real
devices. Real-device snapshots stay isolated.

- db.ts: emulator_wallet table + save/getAll/delete helpers (joins
  balances for totalUsd)
- engine-controller.ts: write metadata when emulator state hits ready
- index.ts: emulatorListWallets enriches rows from the meta table;
  emulatorDeleteFlash cleans up the row
- shared/types.ts: extend EmulatorWalletInfo with label, firmwareVersion,
  channel, deviceId, totalUsd
- DeviceGrid.tsx: render label (fallback to flash name), fw version,
  channel badge in channel color, USD respecting eyeball toggle; emu
  totals roll into the grand total
- EmulatorManager.tsx: same label/version/channel chips on the
  bottom-right panel rows
- App.tsx: mount EmulatorManager on splash when feature flag is on
Two separate "add emulator" UIs (the dashed tile in DeviceGrid and the
inline import form in EmulatorManager) duplicated wallet creation logic
and skipped the standard OobSetupWizard. Now there is one entry point:

- DeviceGrid: drop the "+ Add Emulator" tile and the new-emu inline
  ChannelPicker. Existing wallet rows + Pair Emulator card stay.
- EmulatorManager: "+ Add" opens a small channel-pick panel; selecting
  a channel calls emulatorInit on a fresh emu-N flash and closes the
  panel. The engine sees needs_init, App.tsx switches to the setup
  phase, and OobSetupWizard renders — same UX as plugging in a real
  KeepKey for Create / Recover.

Removes dead state: showAdd's import form, newName/newMnemonic/newLabel,
handleImport, handleAddEmu. emulatorImportWallet RPC is left intact
(still useful for tests / programmatic seeding) but no longer wired
into the UI.
OobSetupWizard already owns first-time device setup with isEmulator
branches throughout. Drop the duplicate channel pickers / inline forms
that were short-circuiting it.

EmulatorManager:
- "+ Add" calls emulatorInit({flashName:'emu-N'}) directly. No channel
  param uses the manifest default. Engine connects → state=needs_init
  → App.tsx phase=setup → OobSetupWizard fires. Same UX as plugging in
  a real KeepKey.
- Start on existing wallets passes the channel saved in metadata to
  emulatorSwitchWallet (falls back to manifest default).
- Removed: showAdd state, channels state, selectedChannel, the
  FIRMWARE CHANNEL selector, the inline "New Emulator" channel-pick
  panel.

DeviceGrid:
- Start on existing wallets uses the saved channel from metadata.
- Removed: ChannelPicker component, channelPicker / emuChannels state,
  handleStartEmuWithChannel, the per-row inline picker render path.

Net: -180 lines of dead UX code, one path to onboarding.
…/alpha

The previous bundled "alpha" emulator was built from release/7.10.0 and
reported firmware 7.10.0 to the host — confusing label, stale firmware,
and missing all the features the alpha branch has gained since.

Rebuild from BitHighlander/keepkey-firmware@bithighlander/alpha
(commit 62c49ca0, "chore: bump version 7.14.0 -> 7.15.0"):

- Submodule pointer: 9f52bb6 → 62c49ca
- Makefile build-emulator-alpha: _EMU_VERSION 7.14.0-alpha → 7.15.0-alpha,
  _EMU_REF origin/release/7.14.0 → bithighlander/alpha
- Bundled artifacts: drop firmware/emulators/7.10.0-alpha/, add
  firmware/emulators/7.15.0-alpha/ (kkemu + libkkemu.dylib + .build-sha)
- manifest.json: point at 7.15.0-alpha; description + source.ref updated

Verified MAJOR_VERSION=7 MINOR_VERSION=15 PATCH_VERSION=0 in the dylib's
compile flags, so the running emu now reports 7.15.0 in Features.
The 7.10 alpha dylib's kkemu_get_display returned NULL early in init, so
the displayPoll-driven sendToWindow('display-update') was a no-op while
the emulator BrowserWindow's WebView finished loading. The 7.15 build
exposes a real framebuffer immediately — first poll tick fires
emuWindow.webview.executeJavascript before window.handlePacket exists,
WebKit's WebPageProxy::launchInitialProcessIfNecessary trips
EXC_BREAKPOINT, and the parent bun process exits with code 133.

Adds a tiny ready handshake:
- bridge: new POST /_emu/ready endpoint sets a viewReady flag
- webview HTML: posts /_emu/ready right after window.handlePacket is
  registered (end of IIFE)
- sendToWindow: drops messages until viewReady; wraps executeJavascript
  in try/catch for defense in depth
- close handlers reset viewReady so a re-open re-handshakes
Bun's `toBuffer(ptr, off, len)` over a foreign pointer wraps the memory
in a JS Buffer and frees it on GC. Fine for malloc'd C buffers; fatal
for the dylib's static .bss framebuffer — the second setInterval tick
panics with "Bus error" / SIGSEGV at the freed page.

The 7.10 alpha hid this because kkemu_get_display returned NULL early
in init; the new 7.15 build returns the real framebuffer immediately
and the display poll exercises the broken path 15× per second.

Repro (isolated, no UI):
  setInterval(() => {
    const fb = ffi.kkemu_get_display(...)
    new Uint8Array(toBuffer(fb, 0, 2048))   // crash within 100ms
  }, 66)

Switching to `new Uint8Array(toArrayBuffer(fb, 0, 2048)).slice()` forces
a copy into a JS-owned ArrayBuffer; the borrowed view of dylib memory
is dropped on the same tick, no GC race.
BitHighlander and others added 26 commits May 13, 2026 14:51
* chore: simplify Zcash privacy UI and improve history table

- Remove "Overview" tab — promote quick actions and recent activity
  to an inline section below the balance card
- Add refresh button (↻) next to "Up to date" in balance header
- Add TXID column to history table with "View ↗" explorer links
- Default page to "Send" instead of "Overview"

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat: add QR scan button to Zcash privacy send tab

Wrap the recipient input in a flex container with a QR scan button that
opens the existing QrScannerOverlay. On scan, the raw QR data is set as
the recipient address and the overlay closes — matching the pattern used
in the standard SendForm.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
* chore: simplify Zcash privacy UI and improve history table

- Remove "Overview" tab — promote quick actions and recent activity
  to an inline section below the balance card
- Add refresh button (↻) next to "Up to date" in balance header
- Add TXID column to history table with "View ↗" explorer links
- Default page to "Send" instead of "Overview"

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat: add QR scan button to Zcash privacy send tab

Wrap the recipient input in a flex container with a QR scan button that
opens the existing QrScannerOverlay. On scan, the raw QR data is set as
the recipient address and the overlay closes — matching the pattern used
in the standard SendForm.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix: remove invalid camera entitlement

* fix: harden macOS release signing checks

* chore: harden release SOP and signing gates

* fix: make Electrobun camera patch idempotent
[codex] fix dashboard token warning for empty wallets
Fix balance refresh fallback handling
Merge PR #173: consolidate native and token MAX send fixes.
[codex] fix relay swap status tracking
)

* Fix duplicate tokens in list and scam tokens bypassing spam filter

- Add SCAM_CONTRACT_ADDRESSES blocklist in spamFilter.ts checked before the
  $5 value floor; IROB (0xd038bbd...) is added as the first entry. Scam tokens
  that carry a fake USD price above the floor were previously passing all
  heuristic checks and appearing as clean.

- Normalize token CAIP to lowercase when building TokenBalance objects so
  identical contracts from Pioneer with different address casing share one key.

- Deduplicate tokens within each chain after Pioneer grouping. Pioneer may
  return the same ERC-20 once per EVM address index; without dedup the same
  token appears multiple times in the list and hiding one entry did not
  reliably remove the other (different raw CAIP strings, same contract).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Address review findings: CAIP safety, dedup summing, single-chain path, blocklist scoping

P1 — CAIP lowercasing was corrupting Solana SPL mints and Tron token IDs
(base58 identifiers are case-sensitive). Reverted to canonical tok.caip on
TokenBalance. Dedup keys for EVM tokens now use tok.caip.toLowerCase() only
when tok.caip starts with 'eip155:'; all other chains use exact match.

P1 — Dedup was discarding the smaller holder when the same ERC-20 appeared
for multiple EVM address indices, underreporting portfolio value. Replaced
keep-highest with sum-both: balance and balanceUsd are accumulated across
entries sharing the same normalized CAIP key.

P2 — The single-chain getBalance path (used by AssetPage refresh) had the
same raw-CAIP and no-dedup issue. Added the identical EVM-gated dedup+sum
pass after parsedTokens is built; evmTokensByOwner retains pre-dedup
per-address entries (correct — individual address amounts stay intact).

P2 — Blocklist was keyed by bare contract address, which could false-positive
on a legitimate token deployed at the same address on another chain. Changed
SCAM_CONTRACT_ADDRESSES to SCAM_TOKEN_CAIPS Map<lowercasedCaip, reason>,
keyed on the full chain+contract CAIP. detectSpamToken now looks up by
(token.caip || '').toLowerCase() and includes the blocklist reason in the
spam result message.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
…177)

* Token spam: 1M quantity hard cap + always-available hide button

spamFilter.ts: Add Tier 2c — any non-whitelisted token with > 1 million
units is confirmed spam, checked before the \$5 value floor. Scam airdrops
regularly assign a fake price to clear that floor; quantity alone is the
reliable signal. KNOWN_LEGIT_SYMBOLS (SHIB, PEPE, etc.) are exempt.
Removes the old price < 0.0001 condition from the former dust-airdrop tier
since quantity alone is sufficient and scams can spoof any price.

AssetPage.tsx: Hide button is now shown for auto-spam tokens in the expanded
filtered section (when showActions is true). Previously only clean tokens had
a hide button; spam tokens showed only Mark-as-safe, leaving no way to
explicitly user-confirm a token as hidden without first unmarking it as safe.
Condition: (!isSpam || showActions) && !isUserHidden && !isUserSafe.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Debug: verbose logging for hide-token and spam-filter evaluation

Every token now logs: symbol, caip, contractAddress, qty, usd, override,
spam result and reason on each categorization pass.

Hide button logs: caip being sent, RPC success/failure, and updated
visibilityMap keys so we can see exactly where the chain breaks.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Fix IROB hide: correct chain in blocklist + collapse expanded section on hide

Root cause 1: IROB is on Base (eip155:8453), blocklist only had eip155:1
(Ethereum). Added the Base entry. Both chains now blocked.

Root cause 2: Hide was working — visibilityMap updated, token moved to
spamTokens — but the "show filtered" section stays open (showHidden=true)
so the token remains visible in the spam section. User sees it still on
screen and concludes the hide failed. Fix: setShowHidden(false) whenever
status='hidden' so the spam section collapses and the token disappears
immediately.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Fix duplicate tokens: dedup evmTokensByOwner in both balance paths

Chain-level tokensByChainId was deduped but EVM AssetPage shows tokens
from evmTokensByOwner (per-address map), which was never touched. Pioneer
returns the same token multiple times per address; without dedup the same
token appears once per Pioneer response entry.

Added identical dedup-and-sum pass for evmTokensByOwner in both paths:
- getBalances (full portfolio): runs after tokensByChainId dedup
- getBalance (single-chain): runs before setAddressChainBalance calls

EVM CAIP normalization rule is the same: eip155: → lowercase for key,
non-EVM → exact match.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Add /api/debug/portfolio REST endpoints for verbose balance and spam debugging

New endpoints (all require bearer auth):
  GET  /api/debug/portfolio            — full portfolio with per-token spam analysis
  GET  /api/debug/portfolio/chains     — per-chain summary (native + token count)
  GET  /api/debug/portfolio/tokens     — all tokens flat, sorted by USD, with spam verdict
  GET  /api/debug/portfolio/spam       — only spam tokens (?level=confirmed for confirmed-only)
  GET  /api/debug/token-visibility     — full token_visibility table (user overrides)
  PUT  /api/debug/token-visibility/:caip  — set 'visible'|'hidden' override
  DELETE /api/debug/token-visibility/:caip — remove override (revert to auto-detect)

All portfolio endpoints annotate each token with _spam (level + reason) and
_userOverride so callers can see exactly why a token is hidden or shown.

* Replace heuristic spam filter with pioneer-discovery catalog lookup

Any token whose CAIP is not in the ~30k-entry pioneer-discovery catalog is
hidden as unknown/spam. User token_visibility overrides still take precedence.

The old 7-tier heuristic system (URL patterns, quantity caps, stablecoin fakes,
value floors) had false positives (e.g. WETH.e on Avalanche) and required
constant maintenance. Discovery membership is the correct source of truth —
if a token is curated in the catalog it is legitimate; everything else is noise.

pioneer-discovery is already bundled in the Vite frontend (asset-data.js chunk),
so this adds no new bundle weight.

* feat(near-intents): allow memo-less UTXO swaps via NEAR Intents

NEAR Intents routes BTC→EVM without a memo — the only instruction is
a BTC deposit address. Add isMemolessTransfer (detected by swapper ===
'NEAR Intents') to skip the memo requirement in both parseQuoteResponse
and executeSwap/previewSwap, so these swaps can be quoted and executed
without "Quote returned no swap instructions" errors.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix: swap gas check, Solana max-send, token dedup, and swap UX polish

Relay gas check (Base/L2):
- Balance check now uses relay's quoted maxFeePerGas instead of the
  live-bumped 3x value. The live bump caused false "not enough gas"
  failures on tight Base balances when fees spiked between preview and
  execute. Signed tx still uses live-bumped fee for inclusion.
- Chain-specific relay gasLimit fallback: 300000 was only correct for
  Arbitrum; Base/Optimism now use 100000, others 150000. This prevents
  the fallback from overestimating gas reserve when the Relay API does
  not provide a gasLimit field.

Solana max-send:
- Pioneer's GetBalanceAddressByNetwork routes through /api/v1/evm/balance/
  which rejects non-Ethereum addresses with "Invalid Ethereum address".
  Now calls api.mainnet-beta.solana.com getBalance directly (same direct-
  RPC pattern used by Tron and TON). Integer lamport arithmetic avoids
  floating-point precision loss.

Token balance dedup (gnars 3x inflation):
- Pioneer can return the same token multiple times for the same address.
  Added (caip, ownerAddress) dedup Set before accumulation in both the
  all-chains getBalances path and the single-chain getBalance path.

Additional:
- swap-parsing: NEAR Intents memo-less detection via fromCaip UTXO check
  (swapper field not in ExecuteSwapParams); direction guard prevents
  EVM→BTC with no calldata from misrouting.
- Insufficient-funds RPC error now surfaces immediately with a clear
  message instead of silently falling back to Pioneer (which also fails).
- getSwapHealth, getWindowFocusState, forceReleaseWindowFocus RPC methods.
- SwapDialog: swap health indicator, provider animations, UX polish.
- DeviceSettingsDrawer: additional settings.
- max-send.ts, types.ts, rpc-schema.ts: supporting type/schema additions.
- Debug logging added to buildRelaySwapTx for gas diagnostics.

* fix: address PR #177 review findings

1. swap-parsing: relay gasLimit now passes undefined when Pioneer omits
   the field, so buildRelaySwapTx's chain-aware fallback (Base=100000,
   Optimism=100000, Arbitrum=300000) is reachable. Previously hardcoded
   '300000' bypassed it entirely.

2. swap: balance check and signed tx now use the same fee cap (relay's
   quoted maxFeePerGas). EIP-1559 nodes require the sender to cover
   gasLimit * maxFeePerGas upfront — using quoted fee for the check but
   live-bumped fee in the tx allowed it to pass preview and fail broadcast.
   The live-bumped values computed earlier are now unused in the tx build.

3. index: swapsEnabled uses opt-out model (default true, disabled only
   when swaps_enabled === '0'). The '=== 1' opt-in model broke swaps on
   fresh installs and existing users without that DB row.

4. rest-api: all four /api/debug/portfolio/* endpoints now return 403
   for passphrase wallet sessions. Reading deviceId-scoped cache in a
   passphrase session reveals the standard wallet's portfolio, which is
   both misleading and a privacy leak.

* fix(near-intents): guard against EVM address as BTC deposit address + add handoff docs and provider animations

swap-parsing: detect when Pioneer returns a 0x EVM address as inbound
address for a UTXO source chain. NEAR Intents BTC→ETH falls back to
the user's ETH address when no BTC deposit channel is available —
passing that to firmware as a Bitcoin output causes silent "Failed to
compile output (code 9)". Now throws a clear user-facing error instead.

Also adds relay.gif provider animation and handoff/spike docs.

* fix: shielded ZEC spam, BSC CAIP mismatch, relay approval gas cap

spamFilter: whitelist /orchard: CAIPs — synthetic shielded ZEC tokens
are injected by the vault and will never appear in pioneer-discovery,
causing shielded balances to vanish from the portfolio.

spamFilter: normalize eip155:56/erc20: → eip155:56/bep20: before
discovery lookup. Pioneer portfolio returns /erc20: for BSC tokens but
discovery catalogs them as /bep20:, causing all BSC tokens to be
falsely classified as spam.

swap: approval tx now uses signedFeePerGas/signedPrioFeePerGas instead
of the live-bumped maxFeePerGas. The balance check and swap tx already
used the relay's quoted fee cap; the approval tx was the odd one out,
meaning tight-balance ERC-20 relay swaps could pass preview and fail
on the approval broadcast.

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
… user action (#179)

* fix: use Pioneer cache on landing load, forceRefresh only on explicit user action

Default getBalances (dashboard open, auto-refresh after swap/zcash) now sends
forceRefresh=false so Pioneer serves balances from its Redis cache immediately.
forceRefresh=true is only sent when the user presses the Refresh or Retry button.

Root cause: THORChain node is offline (height=1 in Pioneer prod). Every balance
load was sending forceRefresh=true, forcing a live THOR node lookup, which hung
for 14s and 503'd the load balancer — taking the entire chunk with it and zeroing
all chains in that request.

Changes:
- rpc-schema.ts: getBalances params void → { forceRefresh?: boolean }
- index.ts: handler accepts forceRefresh (default false), passes to Pioneer
- Dashboard.tsx: refreshBalances(forceRefresh=false) default; Retry and
  Refresh icon buttons pass true

* fix: address review findings — event-as-arg bug, OOB forceRefresh, API.md

1. Line 1127: onClick={refreshBalances} → onClick={() => refreshBalances(true)}
   React was passing the click event as forceRefresh, sending { forceRefresh: event }
   to the RPC, which would fail serialization or send a non-boolean truthy value.

2. Line 629: refreshBalances() → refreshBalances(true) for the new-seed/OOB path.
   App.tsx passes forceRefresh={wizardComplete} for newly initialized wallets;
   without forced refresh they see cold-cache/zero results until a manual reload.

3. docs/API.md: update getBalances params from void to { forceRefresh?: boolean }.
- Bump version 1.3.5 → 1.3.6
- Flip swaps feature flag to opt-in (default OFF)
  Previously absent → enabled; now absent → disabled, requires swaps_enabled='1'
…ds cache, api-blue selector (#178)

* fix: block NEAR Intents BTC swaps below solver profitability minimum

Extract minAmountIn from quote response and surface it in SwapQuote type.
For NEAR Intents + BTC source routes, throw before the user signs if the
amount is below the protocol floor. Solvers must front ETH and wait for
2 BTC confirmations; amounts below ~$50 USD are unprofitable and refunded.

Also warn in logs when the quote deadline is < 60 min — NEAR Intents sets
a ~30 min default that can expire before BTC's 2-confirmation window closes.

Root cause: two consecutive swaps (4cf80783, 1161e8a0) were both refunded
because amounts (~$18-24 USD) were too small for any solver to fill.

* fix: show aggregate BTC balance in swap and send panels

SwapDialog and SendForm were receiving btcSelected.xpubData.balance
(a single account's balance) as the `balance` prop. This caused the
"AVAILABLE" line to show only the selected script type's funds — even
though BTC UTXOs from all accounts (Legacy, SegWit, Native SegWit)
can be spent together.

Pass btcAccounts.totalBalance / totalBalanceUsd instead — the aggregate
across all tracked xpubs. The fromBalance useMemo in SwapDialog already
prefers getCachedBalances (which is also aggregate), but the prop is
the fallback before the RPC round-trip completes.

* fix: timeout-protect all Pioneer API calls in swap-tracker

CreatePendingSwap and GetPendingSwap (all 4 callsites) now race against
a 30s PIONEER_SWAP_TIMEOUT_MS limit via withTimeout. Previously they
blocked for the full 60s built into the Pioneer client's customHttpClient,
causing the [ERROR] | Client | Operation error: Request timed out log spam
and blocking the RPC handler for a full minute on slow/unreachable servers.

* fix: sync BTC DB cache with aggregate total on every btc-accounts-update

SwapDialog calls getCachedBalances (reads DB) as its primary balance source.
The DB entry for bitcoin was written by setCachedBalances which only fires on
clean Pioneer responses — a timeout leaves it stale at a single-xpub value.

BtcAccountManager.toAccountSet().totalBalance is the accurate sum (updated
per-xpub on every Pioneer response regardless of chunk failures). Now we
also call updateCachedBalance after each btc-accounts-update push so the
DB stays in sync. getCachedBalances → SwapDialog then shows the aggregate.

* fix: portfolio balance reliability — failedPubkeySet, no-walk-backwards cache, api-blue selector

Root causes fixed:
1. failedPubkeySet keyed by pubkey-only caused all EVM chains to be excluded
   when any EVM chunk failed (Hyperliquid shares ETH address as pubkey, so a
   Hyperliquid timeout poisoned ETH, BSC, Base, Polygon, Arbitrum, etc.)
   → now keys by caip:pubkey composite

2. setCachedBalances/updateCachedBalance/saveCachedPubkey used INSERT OR REPLACE
   which wiped good balances whenever Pioneer returned 0 (cold-cache, chunk timeout,
   or chain momentarily offline) → now uses conditional upsert that only overwrites
   when the incoming value is > 0

3. setActivePioneerServer compared against DB is_default flag instead of the
   hardcoded DEFAULT_API_BASE constant; clicking api-blue (which has is_default=1
   in DB) would clear the override and silently revert to api.keepkey.info
   → now compares URL against DEFAULT_API_BASE directly

4. Partial Pioneer results were discarded entirely on any chunk failure; now written
   to cache (chains from failed chunks simply remain as previous values)

5. Dashboard merge started from an empty map so failed-chunk chains went blank on
   each refresh; now starts from current displayed balances (no-walk-backwards)

Also:
- Chunk timeout 20s→90s, max concurrency 4→2, total timeout 90s→180s
- BTC cached address now uses real Pioneer address, not raw xpub
- /api/debug/pioneer-audit endpoint for chunk-by-chunk Pioneer diagnostics
- REST error log now includes METHOD + path to identify failing endpoints
- Suppress repeated "Resolved BL hash" log (was firing on every getDeviceState call)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix: review P1-B/P2-A/P2-B — BTC send balance, RPC timeout, audit CAIP

P1-B: SendForm and SwapDialog were receiving btcAccounts.totalBalance (aggregate
across all xpubs) but signing only spends the selected xpub. Scoped balance to
btcSelected.xpubData.balance so validation/MAX reflects what can actually be signed.

P2-A: Frontend rpcRequest timeout was 120s vs 180s backend total portfolio timeout.
With 90s chunk timeout and concurrency 2, backend could outrun the UI. Raised frontend
to 200s (20s buffer above backend ceiling).

P2-B: /api/debug/pioneer-audit was reading pk.caip which getCachedPubkeys() does not
return (DB has no caip column). Added CHAIN_ID_TO_CAIP lookup map to derive CAIP from
chainId for BTC/cosmos/XRP entries.

P1-A (real zero vs stale non-zero) blocked on Pioneer fix: fetchFresh correctly throws
on RPC failure but getBatchBalances catch blocks swallow it and return {balance:"0"}.
Pioneer must omit failed entries from GetPortfolioBalances response. See handoff:
docs/handoffs/handoff-pioneer-fetch-error-zero.md

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix: derive audit CAIP from shared CHAINS registry instead of hardcoded map

The static CHAIN_ID_TO_CAIP map missed chains (BCH, DGB, ZEC, SOL, TRX, TON,
Hyperliquid, etc.) and would drift as new chains are added to chains.ts.
CHAINS is already imported in rest-api.ts and already derives its caip from
@pioneer-platform/pioneer-caip — use that directly.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix: restore parallel chunk concurrency — concurrency 2 caused 3-minute stall

Concurrency 2 + 90s chunk timeout = two sequential 90s waits = 180s wall time
before any results appear. UI showed spinner indefinitely.

Back to concurrency 4 (all chunks race in parallel) with a 45s per-chunk
timeout. With all chunks parallel, worst case is 45s (one slow chunk) not 180s.
The failedPubkeySet caip:pubkey fix from earlier handles partial failures
correctly, so we don't need low concurrency as a workaround anymore.

Frontend RPC timeout 200s → 150s (30s buffer above 120s backend ceiling).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix: gate Solana/Tron/TON endpoints on minFirmware before hitting device

Firmware 7.10.0 returns Failure_UnexpectedMessage (code 1) for any message
type it doesn't recognise. Solana/Tron/TON all require >= 7.14.0 per chains.ts.

Added requireChainSupport() helper that reads firmwareVersion from
engine.getDeviceState() and returns 501 if the chain's minFirmware isn't met.
Applied to /addresses/solana, /addresses/tron, /addresses/ton, and the
coinType 501/195/607 branches inside /api/pubkeys/batch.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix: filter supportedChains in /api/health by connected firmware version

Pioneer SDK uses supportedChains to decide which chain addresses to
request. Advertising all chains regardless of firmware caused the SDK
to request Solana/Tron/TON addresses on firmware 7.10.0, producing a
stream of "Unknown message (code 1)" errors. Now only chains whose
minFirmware requirement is met by the connected device are advertised.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix: suppress unhandled rejection from abandoned withTimeout promises

When withTimeout(pioneer.X(), 45s) fires before the 60s customHttpClient
timer, the original promise is abandoned. Its eventual rejection (from
the HTTP timeout) lands as an unhandled rejection in Bun, printing a
confusing "Operation error: Request timed out" stack in the logs even
though the vault handled the timeout correctly. Adding promise.catch(()=>{})
silences the dangling rejection without swallowing errors that are
actually awaited.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* debug: add explicit logs for nonEVM address derivation and chunk exclusions

Log each cosmos/xrp/etc attempt+result (including empty), log which
chains get excluded per failed chunk so we can pinpoint the exact
failure path for the 6 missing chains.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix: show failed-chunk chains as 0 instead of vanishing from results

When a Pioneer chunk times out (e.g. chunk 3 containing hyperliquid +
cosmos + thorchain + maya + osmosis + xrp), all 6 chains were excluded
from effectivePubkeys and vanished from the Dashboard entirely.

Now the results loop always iterates all pubkeys. Failed-chunk entries
still get balance=0 (no Pioneer data), but they appear in results so
the Dashboard shows them (or preserves their cached value via
no-walk-backwards merge). DB writes are still gated on !isFailedEntry
so zeros from failed fetches are not persisted.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix: suppress pioneer-client Request timed out unhandledRejection noise

The pioneer-client customHttpClient has a 60s internal timer. When our
withTimeout(45s) fires first and abandons the promise, the customHttpClient
fires 15s later into an already-abandoned promise. Bun's unhandledRejection
fires before the .catch() handler propagates. Suppress this specific known-
harmless rejection at the global handler level.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
@BitHighlander BitHighlander requested a review from pastaghost as a code owner May 18, 2026 15:45
Copy link
Copy Markdown
Collaborator Author

@BitHighlander BitHighlander left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

:shipit:

@BitHighlander BitHighlander merged commit fb71c1b into master May 18, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants