release: v1.4.1 — incoming-tx toast, Solana SPL swaps, device-switch & swap fixes#210
Merged
Conversation
v1.4.0 published as prerelease.
…echeck w/ rescan
Two related swap-tracking fixes.
1. Broadcast error mislabeling. When the direct-RPC broadcast of a native EVM
swap returned an "insufficient funds" string, executeSwap discarded the raw
node message and surfaced "Insufficient ETH for gas — add ETH". But the
pre-sign balance check in buildRelaySwapTx already verified value + gas <=
native balance against the same RPC URL, so that label is provably false
(observed: 2.47 ETH balance, 2.059 ETH required, ~0.0009 ETH of gas). The
node's rejection is a stale view from the load-balanced endpoint or an
in-flight pending tx — not a gas shortage. Now we keep the original error,
log it, and fall through to the Pioneer broadcast (which may reach a
better-synced node) instead of hard-failing with a misleading message.
2. Recheck button. The swap tracking screen hid the Recheck button on
failed/refunded swaps — exactly when a user most wants to force a re-check.
It's now always visible on the in-progress/failed view, and a manual press
forces a Pioneer rescan (GET /swaps/pending/{txHash}?rescan=true) so a
mis-classified swap can be re-derived from chain on demand. Background
polling still passes rescan=false to keep Pioneer load down.
Threads an optional `rescan` flag through the refreshSwap RPC
(rpc-schema -> index handler -> swap-tracker).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… blind-sign gate + raw API link Staking — delegate "Max" no longer over-spends: - Max button now drives an isMax flag instead of dumping the full balance; buildCosmosStakingTx computes balance − fee server-side (mirrors the proven buildCosmosTx send-max path) so the fee value isn't duplicated into the UI. - canBuild + confirmation row honor isMax; input shows "MAX" and disables. - Fixes "insufficient funds" where delegate amount + 0.005 ATOM fee > balance. Swap — Solana blind-signing gate: - Solana swaps are v0 txs the device can only blind-sign, hard-gated behind the AdvancedMode policy. When that policy is off the device returns an opaque ActionCancelled. executeSwap now detects the disabled policy up front and throws a SOLANA_BLIND_SIGNING_REQUIRED sentinel; SwapDialog shows a dedicated enable page that calls applyPolicy and returns to review on success. Swap history — add a raw "api" link on active/historical swap cards that opens Pioneer's pending-swap JSON for debugging.
fix(staking): delegate Max fee + swap Solana blind-sign gate + broadcast-error fix
Solana sendMax swaps could fail on-chain with "insufficient lamports" off by a single lamport. The MAX amount is derived from a balance that reaches the UI via floating-point lamports->SOL division upstream (Pioneer solana-network get_balance does lamports / 1e9), which can round the final lamport up. The swap path reserved exactly the 5000-lamport base fee, leaving zero headroom, so a 1-lamport-high balance made the Relay deposit exceed (balance - fee) by one lamport and the deposit's System transfer failed (need ...900, have ...899). Reserve 2x the base fee (10000 lamports / 0.00001 SOL) on the native-Solana MAX path to absorb that imprecision. The native send path is unaffected: it fetches exact integer lamports from Solana RPC and uses integer math, so its exact-fee reserve is already correct. The extra ~0.000005 SOL withheld from MAX is negligible.
fix(swap): add headroom to Solana sendMax fee reserve
… Pioneer Pioneer's swap record already carries the inbound (input) transaction's on-chain location (blockchainTxData), its confirm time (confirmedAt), and structured failure data (error.actionable). Vault polled this but only read status/confirmations/outboundTxid. This wires the rest through end-to-end and renders it in the swap dialog + history. New fields (PendingSwap / SwapStatusUpdate / SwapHistoryRecord): inboundBlockNumber, inboundBlockHash, inboundGasUsed, inboundEffectiveGasPrice, inboundConfirmedAt, errorActionable, errorElapsedMinutes. - swap-tracker: extract in applyRemoteSwapData; gate gasUsed/effectiveGasPrice to EVM inputs (UTXO gasUsed is vbytes, not gas); parse confirmedAt ISO->ms with NaN guard; fold into change-detection; carry in pushUpdate. - db: 7 ALTER-migration columns + writer (undefined=skip, so a later rescan that drops blockHash can't null a value an earlier poll set) + mapSwapRow. - chains: getExplorerBlockUrl (EVM/UTXO only, derived from the tx template). - SwapDialog: Block # row (clickable), EVM gas, "confirmed in Xm", and a failure-reason box (message + actionable + elapsed). The in-progress/failed view previously surfaced no failure reason at all. - SwapHistoryDialog: same fields in the expanded card; resume passthrough now also carries outboundChainId/refundReason/nearTxHash (were silently dropped). All fields render conditionally — blockchainTxData is null on many swaps and blockHash/effectiveGasPrice are frequently null even when present.
feat(swap): surface input-tx block, timing & failure guidance from Pioneer
Resolve base58 mints via Solana RPC (authoritative decimals) + Jupiter enrichment in addCustomToken and lookupTokenContract. Persists under the /token: CAIP namespace; never resolves a base58 mint against Solana when an EVM chainId was passed.
Tiered ordering for the picker's destination list (held -> stablecoins -> native -> popularity via discoveryRank -> junk-last) so USDT/USDC lead and spam sinks. Adds isStablecoinEntry/isJunkEntry/discoveryRank + tests, and wires the Solana token-add flow into AssetPickerDialog.
Surface a Retry button (gated on canQuote) and a quoteTimedOut message when the quote request times out, re-firing getSwapQuote via requoteTick.
…ted-chains endpoint
…on timeout Addresses PR #206 review: - AssetPickerDialog: the paste-a-contract lane resolved a lookup hit filtered only by chain, not by excludeCaip, so pasting the source asset's own contract/mint into the destination picker could re-select it and start a self-swap. Exclude the source caip from the lookup hit (case-insensitive, to cover EVM address casing) and add a defense-in-depth guard in handleAddAndSelect; update both effect/callback deps. - SwapDialog: the Retry button rendered for any error when canQuote, so deterministic failures (pool unavailable, amount below minimum) showed a Retry that just repeats the same error. Track retryability separately (quoteRetryable, reset on each quote, set true only on the timeout branch) and gate Retry on it.
feat(swap): Solana SPL custom tokens, destination-list ordering, quote retry
Add src/shared/device-switch.ts (pure, no I/O — modeled on swap-revert.ts) exporting shouldResetManagersOnReady() and nextReadyDeviceId(), with __tests__/device-switch.test.ts pinning: the A->B edge-trigger, the benign same-device re-emit no-op, the cold-start (first device) skip, and the critical 'never null the tracker on disconnect' invariant — plus the BtcAccountManager .reset() contract the wiring depends on.
On an A->B hardware swap the deviceId changes but seed-changed can't fire (it
keys on the per-device seed_eth_${id}), so btcAccounts/evmAddresses — kept in
memory across disconnect for the watch-only UI — reused device A's
xpubs/addresses. Result: stale receive addresses that didn't even match the
device's own OLED on verify (a security-relevant mismatch).
Reset both managers on the edge where a different truthy deviceId reaches
'ready', then proactively push the cleared sets so the UI drops device-A data
immediately; the existing !isInitialized guards re-derive device B on the next
account/balance fetch. Edge-triggered + first-device-skip avoids the cold-start
remount race that sank the prior attempt (PR #202, frontend key= remount). DB
caches are left intact (deviceId-scoped + self-correcting); clearing stays
exclusive to the seed-changed handler. Adds the test file to make test-unit.
An already-open Receive/AssetPage tab holds a local address state seeded from the previous device, and its derive effects did not refire on a swap: - EVM: the selected-index effect early-returned on the empty cache that the backend reset pushes, leaving device A's address on screen. Now it clears the address on the empty set, so the next (device-B) push reseeds it. - BTC: the account-aware re-derive effect keyed only on scriptType/path, which do not change when device B reuses A's default account/scriptType — so it never refired. Now also keyed on the selected xpub identity, so it re-derives on a device swap, standard<->hidden switch, or seed change. ReceiveView is driven entirely by these (address + currentPath props; showOnDevice sends only the path), so after a swap both the on-screen address and the device OLED resolve to device B.
fix(device-switch): reset account managers + self-heal open Receive tab on device swap
When the SSE event-stream detects an incoming tx on a watched address, show a global toast and resync only the affected chain. Resync now matches by networkId (always present on SSE events) instead of caip (which can be empty), fixing the case where balances silently failed to update on receive. - backend: forward networkId + type on tx-push-received - chains: findChainByNetwork() helper (networkId-first, caip fallback) - Dashboard: resync via networkId; still covers custom chains - App + IncomingTxToast: global bottom-right toast, auto-dismiss owned by App so it never sticks if the device disconnects mid-display
… direction
Addresses PR review:
P2 — the per-chain resync lived in Dashboard, which unmounts on the apps
tab, so an SSE tx landing there triggered the toast but no balance refresh.
Move the resync into App (always mounted); it now fires for any resolvable
chain regardless of direction. App loads custom chains itself (reloading on
'keepkey-settings-changed', which AddChainDialog now dispatches on add) so a
tx on a user-added chain still resolves. Dashboard's redundant handler and
its now-unused findChainByNetwork/rpcFire imports are removed.
False toast — tx:incoming SSE frames carry data.type ('incoming'|'outgoing').
Forward the real direction instead of hardcoding 'incoming'; the toast shows
only for type === 'incoming' (resync still fires either way). If the server
omits the field, no toast shows — the safe direction (never a false positive).
feat(events): toast + per-network resync on inbound payment
The asset picker ranked ZEC swappable because Mayachain pools it, with no awareness of device firmware — but Zcash signing/derivation needs firmware >= 7.15.0 (chains.ts minFirmware). On older firmware the picker offered a swap the device can't honor. Same latent gap for Solana/Tron/TON/Hive (7.14.0). - Add `unsupported_firmware` AvailabilityStatus. - Add `assessWithFirmware(caip, firmwareVersion)`: runs the provider matrix, then downgrades an otherwise-swappable/unknown asset to `unsupported_firmware` when its chain's minFirmware isn't met (reuses isChainSupported; unknown firmware fails closed). - Wire firmware through buildAssetEntries (all 3 build sites) and the picker (FROM/TO, discovery-search and paste-a-contract paths) via useDeviceState. Selectability already keys off availability.status, so gated rows become non-selectable and sink; network tile shows "Update firmware" and the unavailable-route view explains the upgrade. - Keep the debug REST endpoints faithful to the picker: /swap/availability and /swap/discovery now gate by device firmware, and discovery's status filter accepts the new value. Tests: assessWithFirmware gating + bucket/tier behavior for ZEC/SOL/BTC.
Three gaps where a firmware-gated asset (e.g. ZEC below 7.15.0) could still surface as swappable despite the picker gate: - bucketFor ranked entry.swappable (Pioneer-listed) before availability.status, so a Pioneer-pooled ZEC floated into the "confirmed" buckets while being unselectable. Now non-selectable statuses sink to bucket 7 (held assets stay exempt), mirroring isRowSelectable/pickerTier. - /api/v2/swap/assets and the open/set seed validator used the raw Pioneer list, so authenticated REST clients saw assets the device can't sign. Both now use a firmware-filtered list via a new getDeviceSwapAssets callback that shares one helper with the RPC getSwapAssets handler. - Paste-a-contract "Add & select" called onSelect without the selectability guard; a gated pasted token now routes to the unavailable view (with the firmware-upgrade message) instead of a silent no-op. Test: bucketFor sinks a firmware-gated entry even when entry.swappable is set.
BIP-85 was slated for 2026-04 but postponed. Its map entry carried '7.15.0', colliding with the real 7.15.0 (Zcash/Insight) entry and falsely advertising BIP-85 in the 7.15.0 upgrade preview. Set its version to null (unscheduled) and skip null entries in the version-ordered helpers, so it's documented but never surfaced as a gained feature until a release is assigned.
fix(swap): hide Zcash in picker when firmware can't sign it
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.
Brings
masterin line with the published v1.4.1 release (pre-release).Included PRs
Features
Fixes
Plus
chore(firmware-versions): null entry for postponed BIP-85.Release verification (macOS + Linux)
make build-signed), notarizedAccepted, stapled, Gatekeeperaccepted / Notarized Developer ID, backend smoke test clean (no missing externals).make sign-release-intel), notarizedAccepted, stapled, Gatekeeper accepted.tar.zsthash verified byte-for-byte against the release asset (no CI-rebuild clobber; tag does not re-trigger CI).SHA256SUMS.txtregenerated over all macOS + Linux shippable assets from on-the-wire bytes.Notes
developine2b7beef.win-x64-setup.exe) is built + signed on the dedicated Windows box and folded into the same release separately — out of scope for this macOS release session.Merge after CI is green. No post-merge tagging — the
v1.4.1tag already exists from publish.