Skip to content

release: v1.4.1 — incoming-tx toast, Solana SPL swaps, device-switch & swap fixes#210

Merged
BitHighlander merged 26 commits into
masterfrom
release/1.4.1
Jun 4, 2026
Merged

release: v1.4.1 — incoming-tx toast, Solana SPL swaps, device-switch & swap fixes#210
BitHighlander merged 26 commits into
masterfrom
release/1.4.1

Conversation

@BitHighlander

Copy link
Copy Markdown
Collaborator

Brings master in 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)

  • arm64 DMG — built locally (make build-signed), notarized Accepted, stapled, Gatekeeper accepted / Notarized Developer ID, backend smoke test clean (no missing externals).
  • x86_64 DMG — CI-built app signed locally (make sign-release-intel), notarized Accepted, stapled, Gatekeeper accepted.
  • Signed x64 tar.zst hash verified byte-for-byte against the release asset (no CI-rebuild clobber; tag does not re-trigger CI).
  • SHA256SUMS.txt regenerated over all macOS + Linux shippable assets from on-the-wire bytes.
  • Linux AppImage / deb / tarballs from green CI.

Notes

  • Version bump merged back to develop in e2b7beef.
  • Windows (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.1 tag already exists from publish.

BitHighlander and others added 26 commits May 27, 2026 20:59
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.
…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
@BitHighlander BitHighlander requested a review from pastaghost as a code owner June 4, 2026 23:15
@BitHighlander BitHighlander merged commit b484dcf into master Jun 4, 2026
7 checks passed
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