Skip to content

release: v1.5.4 — certified signing and emulator integration - #430

Merged
BitHighlander merged 76 commits into
masterfrom
release/1.5.4
Aug 28, 2026
Merged

release: v1.5.4 — certified signing and emulator integration#430
BitHighlander merged 76 commits into
masterfrom
release/1.5.4

Conversation

@BitHighlander

Copy link
Copy Markdown
Collaborator

Release

Included work

Release verification

  • Exact-head macOS and Linux CI passed.
  • Apple Silicon and Intel DMGs are Developer-ID signed, Apple-notarized, stapled, and Gatekeeper-accepted.
  • Intel launcher/Bun architecture verified as x86_64; Apple Silicon launcher/Bun verified as arm64.
  • Both apps contain the timestamped universal libkkemu.dylib.
  • Packaged emulator lifecycle smoke passed and reported 7.16.0.
  • Signed Intel auto-update archive was downloaded from the release and matched the local SHA-256 exactly.
  • SHA256SUMS.txt was regenerated from the final on-wire assets and verified after upload.

Windows packaging/signing remains the independent Windows release-machine workflow and is not a blocker for this macOS/Linux release merge.

BitHighlander and others added 30 commits August 14, 2026 17:13
Back-merge master into develop after v1.5.3
* fix(evm): a failed balance fetch is not a zero balance

"Build preview failed: Insufficient ETH: need 1.650208, have 0" on a funded
account. Three places treated an RPC failure as an empty wallet:

- getEvmBalance returned BigInt(result || '0x0'), so an RPC that answered
  without a result (rate limit, gateway error) read back as 0 wei with no
  error at all.
- buildEvmSwapTx only fell back to Pioneer when there was no rpcUrl. When the
  RPC existed and threw, the balance stayed at its 0n initializer and the
  next check reported "have 0". buildRelaySwapTx already had this right;
  this makes the THORChain/Maya deposit path match it.
- txbuilder/evm.ts had the same shape on the plain send path.

Now: RPC, then Pioneer, then throw. Refusing to build beats telling someone
their funded account is empty.

* fix(evm): a malformed Pioneer response is not a zero balance either

Review follow-up on the RPC → Pioneer → throw ladder. Two ways the old
behaviour could still reach the user:

- The Pioneer fallback kept `... || '0'`. An HTTP 200 with no balance field
  turned straight back into an empty wallet, so a malformed response
  reproduced the exact "funded account reads as 0" bug the ladder exists to
  prevent. Validate the field is present before parsing.
- The plain-send check was `amountWei + gasFee > nativeBalance && nativeBalance
  > 0n`. That second condition was a workaround for failed fetches landing as
  0n; now that an unverifiable balance throws, the only way to reach it with
  0n is a genuinely empty account — which must fail the check, not skip it.

Tests now drive the builder's whole ladder, not just the RPC leaf: malformed
response, blank balance, thrown call, verified-zero, and the funded case that
must still build. Each fails against the pre-fix code.

* test(evm): run the balance-fetch suite in make test-unit

The new file was not in the test-unit list, so CI would never have run it —
a regression on this path would have gone unnoticed despite the coverage.
#409)

* fix(pairing): export every known account in the mobile pairing payload

generateMobilePairing re-derived a fixed account-0 set at pairing time — BTC
[purpose'/0'/0'], EVM m/44'/60'/0', chain.defaultPath elsewhere — so a wallet
with funds in BTC account 1 or ETH account 2 paired a phone that silently
under-reported the portfolio (#406).

Build the payload from the accounts the vault already knows instead:
BtcAccountManager.getAllXpubMeta(), EvmAddressManager.toAddressSet(), and the
cached_pubkeys rows the audit "track" action persists for non-BTC UTXO chains.
That also removes the per-script-type device round-trips BTC was doing.

Two guards, since this now exports remembered rather than freshly derived data:
- ensureManagersForSeed()/stampManagers() around the manager reads, so a
  manager left over from another passphrase session can't reach the relay.
- the cached_pubkeys merge is device-scoped, not seed-scoped, so it stays
  gated on !isPassphraseWallet — same rule getBalances uses.

Payload assembly moves to pairing-pubkeys.ts (pure) with unit coverage for the
account-in-path math, dedup and script-type set.

Known gap: BTC accounts > 0 live only in the in-memory manager (initialize()
rebuilds account 0 alone), so pairing before the user opens the BTC page still
misses them. Persisting those account paths is separate work.

* fix(pairing): fail closed when the connected seed cannot be verified

This PR switched pairing from live device derivation to reading the BTC and
EVM account managers, which is what lets the phone see every account. It also
means pairing now exports whatever those managers happen to hold.

ensureManagersForSeed() cannot help when deriveSeedIdentity() returns null:
reconcileSeedManagers() opens with `if (!truth) return false`, so an
inconclusive identity check purges nothing and reports nothing. Every other
caller tolerates that ambiguity because the worst case is showing a stale
balance. This one uploads to the relay. A USB or device hiccup that lands
right after a passphrase or seed change would publish the PREVIOUS wallet's
xpubs and addresses to a paired phone.

Unverifiable seed → no export, with a message telling the user to reconnect.

No unit seam: the guard sits in the generateMobilePairing RPC handler, which
has no test harness. Manual check is to unplug mid-pairing and confirm the
payload is refused rather than built.
…it lied worse (#414)

Sweeping for the bug class fixed in #411 turned up a fourth site the review
missed: buildRelaySwapTx's Pioneer fallback (swap.ts) still read

    String(bd?.data?.nativeBalance || bd?.data?.balance || '0')

That one is worse than the two already fixed. On a malformed Pioneer response
it does not merely report "have 0" — it produces 0n, which is *defined*, so it
sails past the `nativeBalance === undefined` check and the "Unable to verify"
throw never fires. Execution reaches the mismatch heuristic:

    if (!relay.isDepositChannel && relayValue > nativeBalance * 2n)

`relayValue > 0` is trivially true, so the user is told "Quote was built for a
different address … select the correct address in the From address selector"
and sent to fix an address that was never wrong.

Rather than patch a fourth copy, all four reads now go through one exported
helper, readPioneerBalance():

  - buildRelaySwapTx      (swap.ts)      — the site above
  - buildEvmSwapTx        (swap.ts)      — fixed inline in #411, now folded in
  - buildEvmTx native     (txbuilder)    — fixed inline in #411, now folded in
  - buildEvmTx ERC-20 max (txbuilder)    — reported "Token balance is zero" on
                                           a malformed response; fails closed,
                                           but for the wrong stated reason

The helper keeps the old chain's fallthrough from a blank `nativeBalance` to
`balance` — only the invented `'0'` tail was ever wrong — and lets a real
numeric 0 through as the verified empty account it is.

This also buys a test seam. buildRelaySwapTx is unexported and calls
getPioneer() internally, so it cannot be driven from a test; the helper can,
and it is now the single point where this invariant lives. Net -20 lines.
The sidebar was wrapped in `hasUsableBalanceSnapshot`, so on a cold start with
no cached balances the whole chain list stayed hidden until Pioneer answered —
the app looked empty and the user had nothing to click.

The chain set comes from firmware + feature flags, not from balances, so render
it immediately. Rows with no answer yet show a spinner next to the symbol
instead of a confident "0", keep full opacity (they aren't known-empty), and
the "All Chains" total spins rather than reading $0.00.

Pending is `!balance && (loadingBalances || !initialLoaded)`, so a failed
refresh settles to "0" plus the existing error banner rather than spinning
forever.
Review follow-up on this PR. The spinner fixed the cold-start case but the
predicate still models two states where the system has three, so every
"unknown" collapses into a confident number the moment loadingBalances flips
false.

The hole is on the SUCCESS path, not the error path. getBalances RESOLVES on a
partial portfolio response — index.ts says so out loud: "chains from failed
chunks show 0" / "failed chains will show 0". By the time those rows render,
loadingBalances is already false and pending is false, so a chain nobody
successfully queried asserts "0 ETH". Worse, because a cache exists,
hasUsableBalanceSnapshot is true and stagePioneerError defers the banner by
PIONEER_ERROR_GRACE_MS — five minutes of unaccompanied, confident, wrong zero.

The component already had the answer and was not reading it: getBalances
stamps every result with syncState ('confirmed' | 'stale' | 'degraded',
index.ts:4151).

balanceDisplayState() now returns pending | unknown | known:
  - no entry, fetch in flight        -> pending  (spinner, unchanged)
  - no entry, fetch settled          -> unknown  ("— SYMBOL")
  - entry flagged degraded           -> unknown
  - entry, incl. a verified zero     -> known    (render it; 0 is a fact)
'stale' stays known — an old number is still a number, and staleness has its
own messaging. An absent syncState stays known, so cached and legacy rows are
not blanked.

Also fixes the second, disagreeing predicate: the "All Chains" total tested
balances.size === 0, far stricter than "every row answered", so a BTC-only
cache with a refresh in flight rendered a confident total above nine spinning
rows. It now waits on the same per-row state.

Truth-table test wired into make test-unit (a new file is not picked up
otherwise). The three cases that matter fail against the old predicate.

Not launched on hardware — the cold-start look still wants eyes.
fix(dashboard): always show the chain list, spinner per pending balance
#414 swept for the #411 bug and stopped at the EVM builders. Two more sites
live outside them, and the UTXO one is the worst instance found so far.

## utxo.ts — a dropped xpub silently shrinks the wallet

buildUtxoTx aggregates across accounts with Promise.allSettled and warns on a
rejected ListUnspent ("Finding 5: tolerate individual xpub failures"). The
tolerance is right; what follows it is not. `utxos` is then a strict subset,
and three statements assert things about the whole balance anyway:

- MAX built a valid tx spending only the visible accounts and called it a
  sweep. Not a wrong message — a wrong AMOUNT, signed by the user, no error
  shown. This is the one that can lose money.
- "Insufficient funds: have 0.005, need 0.008" on a wallet holding 0.01,
  when 1 of 2 accounts failed. Verbatim the #411 report, on the UTXO path.
- An all-fail landed on "No confirmed UTXOs found ... the transaction may
  still be confirming — please wait and try again": a fabricated explanation
  for a server outage, sending the user to wait for a tx that isn't pending.

Now a failed-lookup count gates all three. MAX refuses outright, and the two
messages name the gap instead of quoting a subtotal as if it were the total.
estimateUtxoFee had the same partial set behind its fee/net quote; it already
returns null on error, so a partial set returns null too.

## cosmos.ts — the ?? '0' that #414 fixed for EVM

Three MAX reads used `(balResp?.data?.balances || [])[0]?.balance ?? '0'`.
A missing balance became 0 - fee, clamped to 0, then "Amount must be greater
than zero" — user hits MAX on a funded account and is told their amount is
zero. Fails closed, wrong cause, server fault hidden. All three now go through
readCosmosBalance(), mirroring readPioneerBalance(): throw when the field is
absent, pass a real numeric 0 through as the verified empty account it is.

Checked and left alone: ton.ts `data.result.balance || '0'` sits behind an
`ok` check and the caller only reads `.initialized`, so no funds decision
depends on it.

## Dashboard "All Chains" total

Follow-up to #410, which taught the rows to say "—" but left the total summing
those chains as 0 and printing a confident $X above them. It can't spin like a
pending row — a chain the backend keeps failing would spin forever — so it now
renders "≥ $X" with a tooltip when any row is unknown.

## Tests

__tests__/failed-fetch-not-zero.test.ts — 13 cases. Reverting utxo.ts alone
fails exactly 4 (MAX, subtotal message, outage message, fee estimate) while
the reachable-path controls stay green, so they pin the behaviour and not the
wording.

Also wires src/bun/txbuilder/cosmos.test.ts into make test-unit. It was green
and unreferenced — 10 assertions that had never guarded a release, and the
regression net for the cosmos change above. It is script-style (own runner +
process.exit), so it gets its own line rather than joining the `bun test`
list, where the exit would cut the run short.

make test-unit   456 pass, 0 fail across 31 files, +35 btc-backend, +10 cosmos
tsc --noEmit     627 errors vs baseline 629 — none added, none in the new code
…the fix

Sweeping the layer above the builders turned up the send form making the same
claims from the same placeholder zero — plus one path where the form's value
walked straight past the guard added in the previous commit.

## The bypass (this is the one that mattered)

buildCosmosTx's token MAX read:

    params.tokenBalance ?? readCosmosBalance(await pioneer.GetPortfolioBalances(...))

`??` only falls through on null/undefined. The send form passes
`tokenBalance: token.balance`, and for a chain whose fetch failed that value is
the string '0' — truthy, so it won. readCosmosBalance never ran, and the
previous commit's guard was defeated by the single input most likely to be
wrong. buildEvmTx (evm.ts:299) and the Solana path (index.ts:196) both gate on
`parseFloat(...) > 0` and re-fetch otherwise; cosmos now matches them.

## The claims

A `degraded` entry carries balance '0'. SendForm read it as a figure:

- The "Low BTC for Gas" banner gates on `!(nativeBal > 0)`, so a chain Pioneer
  failed produced "You need ETH to pay network fees. Deposit ETH..." on an
  account holding plenty. The ponytail note above that line was already
  fighting a different false positive on the same banner.
- The "Available" readout and the small balance row printed a confident 0,
  and the USD conversion multiplied it into a confident $0.00.

All three now render "—" with a tooltip. isBalanceUnverified() lives beside
balanceDisplayState() so the 'degraded' rule stays in one module — the send
form has no global load state, so it needs the predicate, not the three-state
machine.

Deliberately unchanged: the form still lets the send proceed. The builder
re-fetches and either succeeds or throws a specific error, which is the right
gate; blocking in the UI would strand a user whenever one chain was degraded.
`exceedsBalance` already required `balanceNum > 0`, so it never fired on a
placeholder zero.

Noted, not touched: SendForm sends `nativeBalance` in the buildTx payload and
no builder reads it — `params.nativeBalance` has no consumer anywhere in
src/bun. Dead field, pre-existing, left alone.

## Tests

Four more cases in the same file (17 total). Reverting only the cosmos change
fails exactly the two frontend-'0' cases while the rest stay green.

make test-unit   460 pass, 0 fail across 31 files, +35 btc-backend, +10 cosmos
tsc --noEmit     627, unchanged by this commit, 2 below the 629 baseline
All three confirmed against the source before fixing. Two are bypasses of the
guards added in the first two commits; one is an inconsistency I introduced.

## 1. BTC MAX bypassed the completeness guard from upstream

btc-accounts.ts filtered xpubs on `parseFloat(xp.balance) > 0` — the CACHED
balance — before the builder ever saw them. A cached zero is not proof of an
empty account; it is also exactly what a degraded chain looks like. So the
degraded account was dropped, every surviving ListUnspent succeeded,
`unreachableXpubs` stayed 0, and MAX swept a subset of the wallet believing it
had swept all of it. The guard from commit 1 never saw the account it exists
to catch.

Same shape as the Cosmos `tokenBalance: '0'` bypass, one layer further up: a
caller pre-filtering on an untrustworthy zero, so the callee's check has
nothing left to catch.

getFundedXpubs → getSpendableXpubs, filter dropped, three call sites updated.
The name asserted a property it could not establish. Cost is one ListUnspent
per genuinely empty xpub, which returns [] and adds nothing; the builder
decides what is spendable, this method only says what exists.

Behaviour change worth knowing: a wallet with an unfunded xpub whose lookup
now ERRORS will block MAX where it previously proceeded on a subset. That is
the point — but it does widen what can block a MAX.

## 2. isBalanceUnverified said a missing entry was verified

It returned false for `undefined`, so SendForm's `balance?.balance || '0'`
printed a confident zero. That contradicts balanceDisplayState, which never
returns 'known' without an entry — my own predicate disagreed with the state
machine it was written to mirror. Reachable since #410: the chain list is
always visible, so Send opens for a chain that has no entry yet.

A missing entry is now unverified. An entry with no syncState stays known —
cached and legacy rows are real numbers, and blanking those would wipe every
balance on cold start, the exact opposite of the bug being fixed.

## 3. SwapDialog kept the claims SendForm stopped making

fromBalance ignored syncState, so a degraded '0' drove the displayed amount,
the USD conversion, the MAX arithmetic, and the low-gas warning. Worse than
SendForm's version: `sendAmount` fell back to `fromBalance || '0'`, so MAX on
an unverifiable balance submitted a 0-amount swap.

fromBalance and fromChainLowGas now yield null when the from-chain entry is
degraded, MAX resolves to empty rather than '0', and the amount field carries
a one-line explanation. Only a present-but-degraded entry counts — fromBalance
already returns null when no entry exists, so that path needed no help.

Preserved as the audit asked: isBalanceUnverified takes an optional assetCaip
and honours confirmedAssetCaips, so an SPL token proven directly over RPC keeps
its real figure while its parent chain is degraded — which is precisely what
mergeTrustedBalanceSnapshot merges through. CAIP comparison reuses
normalizeAssetCaip, so EVM contracts match case-insensitively and base58 token
ids stay byte-exact.

## Tests

24 cases now (was 17). Reverting only the btc-accounts filter fails exactly the
2 new getSpendableXpubs cases; the earlier utxo.ts and cosmos proofs are
untouched by this commit.

make test-unit   467 pass, 0 fail across 31 files, +35 btc-backend, +10 cosmos
tsc --noEmit     627, unchanged across all three commits, 2 below baseline 629
… on Windows

Three issues blocked scripts/build-windows-production.ps1 on Windows (the
prior Windows build never completed, so these were never hit before):

1. hdwallet-keepkey pulls @keepkey/device-protocol as a GIT dependency, whose
   `prepare` runs `mkdir -p ./lib && grpc_tools_node_protoc --plugin=protoc-gen-ts=./node_modules/.bin/protoc-gen-ts ...`.
   That fails under cmd.exe (`mkdir -p` invalid) and even under bash, because
   native protoc cannot exec the extensionless plugin wrapper ("%1 is not a
   valid Win32 application"). yarn (even with --ignore-scripts) still runs the
   git-dep prepare and returns non-zero, but node_modules is fully linked by
   then. Tolerate only that specific prepare failure, seed the git-dep's
   gitignored lib/ from the top-level device-protocol lib (same pinned commit
   -> identical output), and treat `yarn build` (tsc) as the real gate.

2. The zcash-cli sidecar's build.rs (tonic-build/prost) needs a system protoc,
   which Windows lacks on PATH. Resolve PROTOC: honor $env:PROTOC, else protoc
   on PATH, else the protoc.exe bundled with grpc-tools in device-protocol's
   node_modules.

3. Guard the protoc-on-PATH lookup against Set-StrictMode null-property access.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…or gas

Two selector bugs from the re-audit. Both confirmed against the source first.

## 1. SwapDialog checked one source and read from another

fromBalanceUnverified examined only the internal `balances` array — I wrote
`!!cb && ...` to avoid destabilising the dialog, and that guard created the
hole it was meant to close. fromBalance then fell through to the `balance`
prop, so a degraded prop cleared a check that never looked at it:

- confidence resolves as verified;
- the prop's placeholder zero drives Available / USD / MAX;
- an account-model balance gets pre-clamped and submitted with
  sendIsMax=false, past the builder's own MAX verification.

Reachable whenever getCachedBalances returns a non-empty cache that lacks the
selected chain, since the dialog then skips its live fetch.

Both now resolve through selectBalanceEntry(balances, balance, chainId), and
fromBalance's own tail reads that same entry instead of re-deriving the
fallback. One object, judged and read.

## 2. A verified token vouched for the native gas balance

isBalanceUnverified is asset-aware, and both screens passed the ACTIVE asset's
caip — then reused that one boolean to gate the native low-gas check. For a
degraded Solana chain with a directly-confirmed SPL token: the token is
correctly verified, SOL is still a placeholder zero, the shared flag lets the
gas verdict run, and "deposit SOL to send tokens" comes back. Exactly the
false warning this PR set out to remove, re-entering through the exception
that makes the token case correct.

Split per screen:
  SendForm     activeAssetUnverified (Available/USD) + nativeBalanceUnverified (gas)
  SwapDialog   fromBalanceUnverified (Available/MAX) + fromNativeUnverified (gas)

The native verdict uses the chain's native caip, so a confirmed token can
never answer for the coin that pays the fee.

## Test coverage, honestly

30 cases. selectBalanceEntry is real shared code, so finding 1 is pinned:
reverting it to the cache-only lookup fails exactly the one case.

Finding 2's split is per-screen wiring. There is no React render harness in
this repo and adding one is not this PR's job, so the tests pin the predicate
contract the split depends on (token verified / native not, from one entry)
while the wiring itself is verified by reading. The test file header says so
rather than implying coverage it does not have.

Also noted while verifying: fromBalance's EVM branch reads
evmAddresses[].chainBalances, a third source with no syncState of its own. It
is judged by the chain-level entry, which is right — but if `balances` has no
entry for that chain at all, that branch can still return a figure. Out of
scope here: that is the missing-entry case, not the degraded one.

make test-unit   473 pass, 0 fail across 31 files, +35 btc-backend, +10 cosmos
tsc --noEmit     627, unchanged across all four commits, 2 below baseline 629
fix: a failed lookup is not a zero — builders, send form, swap dialog
A relay swap died with a bare `Action cancelled` while the device sat there
having shown "Blocked". The reason existed and was thrown away.

## What actually happens

Relay routes are not in the firmware's pinned clear-sign allowlist, so the
device must blind-sign them — and blind-signing arbitrary contract data is
gated on AdvancedMode (ethereum.c). It renders "Blocked" and replies
Failure_ActionCancelled with the message "Blind signing disabled by policy".

hdwallet's transport then does:

    throw new core.ActionCancelled();          // message discarded
    if (response.message_type === FAILURE) throw response;   // next line: kept

Only ActionCancelled is flattened; every other failure keeps its payload. And
firmware uses that same code for a genuine user cancel, so once the string is
gone Vault cannot tell "user pressed cancel" from "policy refused" — it has
nothing left to build a prompt from.

Clear-signing is not an escape hatch here. Metadata verification needs a
runtime-loaded signer and loading one ALSO requires AdvancedMode; there is no
built-in trust anchor (signed_metadata.c only ever returns loaded keys —
"deliberately rejects persistent trust anchors"). The delegation work that
would change this is docs-only, gated on an owner custody decision:
docs/security/clearsign-key-delegation-roadmap.md. So AdvancedMode is required
for relay today, by design, and it is documented in
docs/security/7.15.0-rc21-clearsign-release-control.md:

  "runtime signer loading, and runtime metadata verification are usable only
   while the user has enabled AdvancedMode"

## The fix

Check before prompting, not after failing — same shape as the Solana opaque
gate directly above it, using the `isAdvancedModeEnabled` hook that was already
on SwapContext for exactly that purpose but never applied to EVM.

Fires only when all of: EVM, real calldata, NOT in the firmware allowlist
(firmwareClearSigns), and the policy is known-disabled. `undefined` (features
not cached / policy not reported) deliberately does not fire — guessing would
block a swap the device would have signed.

The message names the blocker, says where to fix it, and warns that it turns
itself back off on reboot (#373 made AdvancedMode session-scoped, which is why
this went from rare to constant).

Not fixed here: hdwallet still destroys the reason string, so a cancel arriving
from any other path is still ambiguous. That is the real repair and it belongs
in hdwallet transport.ts; this makes the reported case actionable now.

## Tests

Wires __tests__/firmware-clearsign-gate.test.ts into make test-unit. It already
asserts the exact predicate this gate depends on — including "firmware-unknown
contracts → blind (Uniswap / 1inch / relay)" — and had never run in CI, the
third such file found this session.

make test-unit   480 pass, 0 fail across 32 files, +35 btc-backend, +10 cosmos
tsc --noEmit     627, unchanged, 2 below baseline 629
…message

fix(swap): say "enable AdvancedMode" instead of "Action cancelled"
…SD check

Two defects from one swap into SOL: the balance never updated, and a wildly
wrong received amount displayed a perfectly plausible dollar figure.

## 1. Swapping INTO native SOL had no direct confirmation

index.ts post-swap reconcile matched destinations with:

    /^(solana:[^/]+)\/(?:token|spl):([1-9A-HJ-NP-Za-km-z]{32,44})$/

That is SPL-only. Native SOL is `solana:<net>/slip44:501` — no mint — so it hit
`continue` and got no direct read at all, falling back entirely to Pioneer's
portfolio indexer, which lags a completed swap. The dashboard kept showing the
pre-swap balance (0.00999 SOL after receiving ~0.23) across refreshes with
nothing to explain why. The comment right above that loop says a completed SPL
swap "can beat Pioneer's portfolio indexer by several seconds" — native SOL has
the same problem and was simply never covered.

Adds getSolanaNativeBalance() (getBalance RPC, 9 decimals — SOL is not a mint
and has no on-chain decimals field) and a native branch in the reconcile loop.

The entry match folds case deliberately: Pioneer returns Solana network ids
LOWERCASED (`solana:5eykt4usfv8...`) while the vault derives them mixed-case
(`solana:5eykt4UsFv8...`). A byte-exact compare finds nothing and silently drops
the fresh balance. Both spellings are in one getBalances log.

Following the file's own convention, an RPC that does not answer throws rather
than reporting 0 — a failed lookup is not an empty account.

## 2. The USD under "You received" could not disagree with the amount

toPriceUsd falls back to deriving the destination price from the quote ratio:

    derived = (inAmt / outAmt) * fromPriceUsd

and the panel renders `outAmt * toPriceUsd`. Substituting:

    outAmt × (inAmt / outAmt) × fromPriceUsd  ≡  inAmt × fromPriceUsd

The figure restates what was SENT and is independent of the output entirely. A
quote returning 232,196,097,603.76 SOL still printed "≈ $49.06" — correct to the
cent, and corroborating nothing. That is what made a ~1e12-scaled amount look
credible instead of obviously broken.

Suppress the valuation when the price is quote-derived. The wrong amount then
appears with no dollar figure, which reads as broken — which it is. (The "net vs
send" line already self-cancelled to 0 in this case and rendered nothing.)

Not fixed here: the bad `expectedOutput` itself. swap-parsing takes Pioneer's
value verbatim ("Trust Pioneer's value directly — no per-asset rescale") and the
destination symbol came back as "SOLANA" rather than "SOL", so that quote's
asset metadata is wrong upstream. Needs a Pioneer-side look with the raw quote
body; this change stops Vault from dressing it up as verified.

make test-unit   480 pass, 0 fail across 32 files, +35 btc-backend, +10 cosmos
tsc --noEmit     627, unchanged, 2 below baseline 629
…ular-usd

fix(solana): confirm native SOL after a swap, and stop the circular USD check
A user should never get a dead reject for a transaction AdvancedMode would
allow. They should be offered the switch.

That machinery already exists and I under-sold it in #417. SwapDialog.tsx:2307
routes on the error CONTENT:

    if (/AdvancedMode/i.test(raw)) { setBlindSignCause('device')
                                     setPhase('blind-signing-required') }

which opens the panel whose Enable button calls applyPolicy. So #417's message
was already reaching the opt-in, not a reject — the reject is the PRE-#417 path,
where hdwallet flattens the firmware's "Blind signing disabled by policy" into a
bare "Action cancelled" that matches nothing and falls through to the generic
"Cancelled on device".

Two things wrong with leaving it there.

1. The copy contradicted the affordance. It said "Enable AdvancedMode on your
   KeepKey, then try the swap again" — sending the user to device settings for
   something the dialog was about to offer as a button. Reworded to "Turn on
   AdvancedMode to continue", and it still states the setting dies on reboot
   (firmware #373 made it session state, which is why users hit this repeatedly).

2. The routing depended on an unpinned phrase inside prose. Rewording the copy
   is a normal, innocuous-looking edit that would silently turn the prompt back
   into a reject, with no test failing.

So the message moves to evmAdvancedModeRequiredMessage() in shared/types.ts,
beside SOLANA_BLIND_SIGNING_REQUIRED, documented as content-routed and
load-bearing. __tests__/advanced-mode-routing.test.ts asserts it matches the
SwapDialog predicate for every chain name, does NOT tell the user to go
elsewhere, and mentions the reboot. It also pins the predicate from the other
side: a bare "Action cancelled" must NOT match (that is the dead end), while
firmware-worded refusals must.

Content-based routing is deliberate, per the comment above that branch: it also
catches refusals phrased by firmware versions this code has never seen. The
tests protect the phrase without giving that up.

make test-unit   488 pass, 0 fail across 33 files, +35 btc-backend, +10 cosmos
tsc --noEmit     627, unchanged, 2 below baseline 629
fix(swap): make the AdvancedMode path an opt-in, and pin the routing
"[swap] SIGN FAILED: Invalid Solana instruction schema" on a relay Solana swap,
with no user interaction — the device rejected before any confirm screen.

## The perverse part

Having clear-sign material made the flow WORSE than not having it:

  no schema found  -> needsOpaqueSolanaFallback -> consent panel -> blind sign -> works
  schema found     -> fallback skipped -> schema attached -> HARD REJECT

swap.ts attached the schema whenever findSolanaSchema returned one, without
checking the device could verify it. Firmware then fails the entire request
(fsm_msg_solana.h:803) and deliberately does not fall back — the comment above
it is explicit: "Present-but-invalid schema material fails the request; it never
silently degrades to blind signing." That fail-closed stance is correct; sending
material we know is unusable is not.

Verification runs signed_metadata_verify_attestation, which resolves the key via
metadata_pubkey_for -> RAM-loaded signers only, and rejects a loaded signer
unless AdvancedMode is on. Both die on power cycle. So on a stock or
just-rebooted device the outcome is not "might fail" but GUARANTEED failure —
worded as though the user's transaction were malformed.

## Fix

Withhold the schema when AdvancedMode is known-off, and mirror that in the
opaque-fallback predicate so the swap takes the consent path it would have taken
had no schema existed. The user gets the opt-in they should always get instead
of a reject, consistent with #417/#419.

AdvancedMode is a NECESSARY condition for verification, so this removes the
whole guaranteed-failure class. It is not sufficient: AdvancedMode on with no
signer armed still fails, because Vault does not track which signers are loaded
(clearsignLoadSessionSigner sends the message and records an event, but keeps no
state). That residual now gets an actionable message naming the real cause
instead of "Invalid Solana instruction schema", which blames the transaction.

Closing the residual properly means tracking loaded key ids and invalidating
them on reboot/disconnect. Deliberately not done here: stale tracking state
would reintroduce exactly this bug, and the AdvancedMode check already covers
the reboot case since the policy resets too.

make test-unit   488 pass, 0 fail across 33 files, +35 btc-backend, +10 cosmos
tsc --noEmit     627, unchanged, 2 below baseline 629
Follow-up: the AdvancedMode-based withholding in the previous commit was not
enough, and it inverted.

Observed: AdvancedMode off correctly withheld the schema and the device asked to
enable it ("Enable AdvancedMode to blind-sign" -> opt-in panel, as designed).
The user enabled it. The next attempt re-attached the schema, the device could
not verify it, and they hit "Invalid Solana instruction schema". Fixing the
first refusal directly caused the second.

That is the tell that predicting device capability is unwinnable here.
Verification needs a signer loaded in RAM; Vault does not track which are
loaded; and both that and AdvancedMode die on power cycle. Any predicate built
on what we can see is wrong some of the time, and the AdvancedMode one is wrong
in the most confusing possible direction.

So react instead of predict. Firmware validates schema material BEFORE drawing
any confirm screen, so the refusal costs the user nothing and shows them
nothing. On refusal, ask for blind-sign consent.

Deliberately NOT a silent re-sign without the schema. Dropping it means the
transaction gets blind-signed, and the opaque-consent panel exists to show what
it actually moves (host-side outflow simulation) before that happens. Skipping
it would trade a gate for a silent downgrade — worse than the bug.

Schema attachment is now also suppressed once allowSolanaBlindSigning is set,
or the consent retry would re-attach, be refused again, and loop.

Resulting flow, no dead ends:

  AdvancedMode off            -> schema withheld -> consent -> blind sign
  AdvancedMode on + signer    -> schema attached -> verified -> clear sign
  AdvancedMode on, no signer  -> refused -> consent -> retry w/o schema -> signs

make test-unit   488 pass, 0 fail across 33 files
tsc --noEmit     627, unchanged, 2 below baseline 629
First piece of the clear-sign provider work: derive a provider signing key from
a BIP-85 child mnemonic, deterministically, with a fingerprint that matches what
the device displays.

Deliberately the derivation core and not UI. The Studio tab still needs design
decisions (it is being re-authored), but this part cannot be wrong: an operator
compares a fingerprint on the OLED against a file they are about to hand a live
service, and if those disagree they cannot tell which key they trusted. Firmware
calls that confirm "the thing the whole trust model hangs on".

Parity is asserted against the firmware algorithm rather than assumed —
signed_metadata_pubkey_fingerprint is sha256_Raw(pubkey, 33) then
data2hex(digest, 4), i.e. first 4 bytes as 8 hex chars over the COMPRESSED key.

What BIP-85 does and does not buy is stated in the module, because it is easy to
overclaim and I did exactly that earlier: it gives NO custody. The derived
private key is loaded into a live service and is fully exposed there like any
hot key. What it gives is a ceremony that is deterministic, repeatable and
documented — the key can be re-derived from device + index instead of existing
as a file of unexplained origin. That is what makes an unsigned provider
auditable rather than merely unverified.

Notes on the choices:

- Derivation path is `m` — the BIP-85 child mnemonic IS the key material, so
  the provider key is its master key. Not a coin-type path: this key signs
  clear-sign descriptors, not transactions, and borrowing m/44'/60'/… would
  imply an Ethereum account that does not exist.
- An invalid mnemonic throws instead of deriving from garbage. A typo would
  otherwise produce a valid-looking key whose fingerprint never matches any
  device, and the operator could not distinguish that from a bug.
- The key file states plainly what the key can and cannot do: it can MISLABEL
  a transaction under this provider identity, it can never conceal one, because
  a runtime signer is annotation-only and cannot remove the raw review.

Key file is plaintext JSON on the host for now; encryption is a follow-up.

make test-unit   501 pass, 0 fail across 34 files, +35 btc-backend, +10 cosmos
tsc --noEmit     627, unchanged, 2 below baseline 629
The previous commit landed the derivation core deliberately without UI,
because the Studio tab still needed design decisions. This is that tab.

The ceremony, as a task rather than a set of primitives: pick an alias, word
count and index; the device displays the child seed on its own screen; the
operator types the words back; the provider key is derived and written out,
and the fingerprint is shown so it can be compared against what the device
will display when the signer is loaded.

Derivation runs in bun, not the webview. The renderer only ever sees the
public half — the private key goes straight to a file and never crosses the
RPC boundary. That file is chmod 0600 explicitly: Bun.write creates 0644,
which for a live signing key in plaintext is not acceptable (measured, not
assumed). The mnemonic is cleared from component state on success.

The device fingerprint is stamped into the key file for provenance, so the
ceremony can be repeated from device + index, and skipped for passphrase
wallets per the existing privacy rule.

clearsignDeriveProviderKey is deliberately NOT gated on AdvancedMode: it
touches no device and exposes nothing. Every sibling clearsign RPC does gate,
so this is a considered inconsistency rather than an oversight — flagging it
for review rather than quietly matching the neighbours.

make test-unit  501 pass, 0 fail
tsc --noEmit    627, unchanged
Two device suites for the clear-sign provider tier. Both skip cleanly with a
reason when their server is absent, so run-all.js stays green without one.

provider-live-sign-flow.js — the live path. Asks a provider server to attest
the exact transaction about to be signed, loads the returned identity, and
clear-signs. It checks the three refusals before the happy path, because
those matter more: an uncurated contract, calldata with trailing bytes, and a
tx with no fee model must all be DECLINED. A matched blob replaces the
device's raw-data screen, so a signer that attests what it cannot decode is
worse than no signer at all.

provider-key-schema-flow.js — the static v2 path, consuming only what a
server publishes rather than building a schema in-process, so a pass means
those bytes reached the device.

Both print the fingerprint the device must display and say plainly to reject
anything else. That comparison cannot be automated — the device never reports
the fingerprint back over the wire, which is exactly why a human confirms it.

Verified on 7.15.0-rc29: live path 9/9, static path 8/8.
Adds Robinhood Chain mainnet (Arbitrum Orbit L2, chain id 4663, ETH gas,
18 decimals) as a data-level entry, mirroring the existing Gnosis fallback
pattern since the published pioneer-caip does not ship Chain.Robinhood yet:

- chains.ts: EVM chain config (standard eth path, ethSignTx) with
  Blockscout explorer links, plus RHD entries in the CAIP/networkId/decimal
  fallback maps
- swap-support-matrix.ts: eip155:4663 in RELAY_CHAINS and SHAPESHIFT_CHAINS
  (Relay and LI.FI both list chain 4663 in their public chain APIs, 2026-08)
- assetData.json: native asset entry for eip155:4663/slip44:60

bun test swap-support-matrix: 32 pass, 0 fail.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
fix(windows-build): make hdwallet git-dep and zcash-cli sidecar build on Windows
…ejected

fix(swap): don't attach a Solana schema the device cannot verify
BitHighlander and others added 28 commits August 26, 2026 00:55
feat(solana): authenticate Relay LUT routes without blind signing
@BitHighlander
BitHighlander merged commit 928342a into master Aug 28, 2026
11 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.

2 participants