fix(swap): resolve silent quote dead-end when destination address is unresolved#237
Merged
Merged
Conversation
…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.
… 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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
canQuotehas 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:fromAddressuses the cache-independent device-derivedevmAddresses, but the destination address read only the balance cache (and the address resolver runs for UTXO chains only), sotoAddresswas''with no error when the balance server was unavailable →canQuotefalse → quote never attempted → blank panel.Not related to amounts under $100 — that
$100is only a frontend auto-default to MAX; the backend has no$100gate and small-amount rejections surface as visible errors.Changes
cachedToAddressfalls back to the EVM device address for EVM destinations (mirrorsfromAddress), 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.canQuotestays false until the address exists).0/invalid amounts instead of a blank panel (!amount→!manualAmountReady).resolvingSendAddress,resolvingReceiveAddress.Test plan
tsc --noEmitclean — no errors in the changed files.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, unlikenativeMaxInsufficient.