Skip to content

fix(swap): resolve silent quote dead-end when destination address is unresolved#237

Merged
BitHighlander merged 9 commits into
developfrom
fix/swap-quote-silent-deadend
Jun 10, 2026
Merged

fix(swap): resolve silent quote dead-end when destination address is unresolved#237
BitHighlander merged 9 commits into
developfrom
fix/swap-quote-silent-deadend

Conversation

@BitHighlander

Copy link
Copy Markdown
Collaborator

Summary

Fixes a swap bug where the quote silently fails to attempt and the YOU RECEIVE panel renders blank (no quote, no error, no loading gif).

Root cause: the quote gate canQuote has several false conditions, but the quote effect only sets an error for two of them (destAddressError, toAddressIsXpub) — the rest return silently. The reported case (ETH→USDT, empty balance cache) hit an asymmetry: fromAddress uses the cache-independent device-derived evmAddresses, but the destination address read only the balance cache (and the address resolver runs for UTXO chains only), so toAddress was '' with no error when the balance server was unavailable → canQuote false → quote never attempted → blank panel.

Not related to amounts under $100 — that $100 is only a frontend auto-default to MAX; the backend has no $100 gate and small-amount rejections surface as visible errors.

Changes

  • cachedToAddress falls back to the EVM device address for EVM destinations (mirrors fromAddress), so a swap into an EVM asset no longer stalls on an empty receive address when the balance cache is empty. Fixes the reported case at the source.
  • Fallback Get Quote button + reason text in the previously-blank receive slot, shown only in the genuine send/receive-address dead-end. Derives a missing non-EVM destination address on demand, refreshes balances, and re-fires the quote (a bare requote can't help — canQuote stays false until the address exists).
  • Enter-amount hint now shows for 0/invalid amounts instead of a blank panel (!amount!manualAmountReady).
  • New i18n keys: resolvingSendAddress, resolvingReceiveAddress.

Test plan

  • tsc --noEmit clean — no errors in the changed files.
  • ETH→USDT with an empty/unavailable balance cache: receive address resolves and the quote fetches automatically instead of rendering blank.
  • Non-EVM destination with no cached address: Get Quote derives the address and fetches a quote.
  • Enter 0/junk amount: shows "Enter an amount to get a quote" instead of a blank panel.

Out of scope (latent)

tokenMaxInsufficient (ERC-20 MAX) gates the amount but has no on-screen message, unlike nativeMaxInsufficient.

…unresolved

The quote is gated by canQuote, but the quote effect only surfaces a reason
for two of its failure modes (destAddressError, toAddressIsXpub). Every other
false condition returns silently, leaving the YOU RECEIVE panel blank — no
quote, no error, no loading state.

The trigger is an asymmetry: fromAddress falls back to the device-derived
evmAddresses (independent of the balance cache), but the destination address
read only the balance cache and the address resolver runs for UTXO chains
only. When the balance cache is empty (e.g. balance server unavailable), an
EVM destination's toAddress is '' with no error, so canQuote is false and the
quote is never attempted.

- cachedToAddress falls back to the EVM device address for EVM destinations,
  mirroring fromAddress — fixes the blank panel at the source.
- Add a fallback "Get Quote" button + reason text in the empty receive slot,
  shown only in the address dead-end; it derives a missing non-EVM destination
  address on demand, refreshes balances, and re-fires the quote.
- Show the enter-amount hint for 0/invalid amounts instead of a blank panel.
- New i18n keys: resolvingSendAddress, resolvingReceiveAddress.
@BitHighlander
BitHighlander requested a review from pastaghost as a code owner June 9, 2026 19:12
BitHighlander and others added 8 commits June 9, 2026 14:23
… button to dest

Addresses two review findings on the manual Get Quote fallback:

- Stale-address race: handleManualQuote could write a destination address for
  the wrong chain if the user switched the output asset while the (up to 60s)
  address-derivation RPC was still pending. Capture the requested chainId and
  discard a late result (and a late error) if the output asset changed — the
  auto-resolver useEffect already had this guard via a cancel flag.

- Button scope: the fallback button now renders only when a DESTINATION address
  is missing (the case handleManualQuote actually derives). A missing SOURCE
  address is a transient the quote effect re-runs automatically once fromAddress
  populates, so it shows the reason text without an inert button.
The destination-address resolver ran for UTXO chains only, so for non-EVM
non-UTXO destinations (Solana, Cosmos, Tron, XRP, ...) the receive address
stayed '' whenever the balance cache was empty — canQuote never went true and
the quote was never attempted automatically. Only the manual "Get Quote"
button (which derives on demand) produced a quote.

Resolve from the device for any non-EVM destination (EVM still uses the
cache-free evmAddresses fallback in cachedToAddress), so auto-quoting works
without the button. The existing cancel flag already discards a stale result
if the output asset changes mid-derivation.
…-memory swap/activity for hidden wallets

**Root cause:** isPassphraseWallet gates block LIVE/DISPLAY state, not just
disk persistence. The privacy invariant is 'never write hidden-wallet data to
disk, but operate live from the device.' Three consumers read only the
persistence-coupled layer (intentionally empty for hidden wallets) instead of
falling through to live device/server paths.

**Symptom 1 — 'Your KeepKey is empty' swap picker:** SwapDialog seeds balances
only from getCachedBalances (null for hidden wallets). Added live getBalances
fallback on open, mirroring the Dashboard pattern (Dashboard.tsx:903-921).
getBalances derives addresses live from device and persists nothing for hidden
wallets. Also fixes non-EVM FROM address dead-end (fromAddress reads
balances.find()?.address which is empty for hidden wallets).

**Symptom 2 — 'No supported routes — only NEAR Intents':** parseQuoteResponse
picked quotes[0] unconditionally, so a single unbuildable head quote killed
the pair even with a buildable route behind it. Now scans for the first
buildable quote (using the exact throw predicate), logs loudly when skipping
higher-ranked unbuildable quotes (no silent rate downgrade). Re-throws the
head-quote error when none are buildable (preserves the existing user-facing
message).

**Symptom 3 — In-session hidden-wallet swaps invisible in Activity:**
getPendingSwaps short-circuited to [] before reading the in-memory map.
ActivityTracker skipped fetchSwaps() for hidden wallets. Relaxed both to read
the RAM-only pending swaps (scoped to walletId, never leaks to DB).

**Symptom 4 — Fresh passphrase entry leaves getWalletDbScope() null:**
sendPassphrase() nulls seedEthAddress and never re-derives it; reconnect probe
sets it but is gated on !passphraseSetThisSession. Added in-memory ETH address
derivation in sendPassphrase for hidden wallets (RAM only, NO setSetting disk
write — preserves plausible deniability).

**Symptom 5 — Activity panel empty / rescan no-ops for hidden wallets:**
Added write-free getLiveActivity path: scanChainHistory with dryRun+collectRows
fetches live from Pioneer, returns rows via in-memory session store (never
written to disk). getRecentActivity serves session store for hidden wallets.
Session store cleared on needs_passphrase/disconnect/seed-changed.

Privacy invariant preserved: every DB-write gate stays. Only display/live-lookup
gates are relaxed. All write consumers of getWalletDbScope() are correctly
double-gated on !isPassphraseWallet.

Tests: 3 new B1 tests pinning buildable-quote selection behavior.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Pioneer's /swap/available-assets carries no SPL tokens, so the backend
decimals lookup misses for e.g. USDT-on-Solana and buildTx throws
"tokenDecimals required for SPL token transfers" — the preview/build
dead-ends. Forward the picker asset's decimals through ExecuteSwapParams
(Pioneer's canonical value still wins when present); the SPL builder
hard-throws when both are missing rather than guessing scale.

Adds solana-spl-decimals.test.ts pinning the build contract and wires it
into the test-unit suite.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
getSwapByTxid and refreshSwap had a blanket isPassphraseWallet block, but
hidden-wallet swaps are in-memory only (skipPersist — no DB row), so the
block left a hidden session unable to read its OWN swap (detail view stuck
"pending") or advance it (the live poll is the only thing that can move a
skipPersist swap to completed, e.g. a NEAR Intents Solana deposit on
1Click). Replace the blanket block with walletId-scoping plus a live
tracker fallback: a hidden walletId (deviceId:hiddenSeedEthAddr) never
matches a standard swap's walletId, so standard-wallet swaps stay invisible
from a hidden session — same posture as getPendingSwaps.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Show a spinner in the FROM picker and asset list while balances/assets
  load, instead of a bare empty state.
- Surface a "Get Quote" button in the input view when a quote can fire but
  none is present (e.g. after returning from the confirm step), so the
  receive panel is never a silent blank.
- Forward SPL picker decimals from the dialog's preview and execute calls
  (pairs with the backend tokenDecimals fix).
- Harden canQuote (explicit boolean) and key the quote effect on asset CAIP
  rather than asset id so a switch between same-id assets requotes.
- Back button resets quote state and re-fires the quote via requoteTick.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@BitHighlander
BitHighlander merged commit 312c8c9 into develop Jun 10, 2026
@BitHighlander
BitHighlander deleted the fix/swap-quote-silent-deadend branch June 10, 2026 04:00
@BitHighlander BitHighlander mentioned this pull request Jun 13, 2026
3 tasks
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant