release: v1.5.4 — certified signing and emulator integration - #430
Merged
Conversation
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
…withheld-not-rejected
…ejected fix(swap): don't attach a Solana schema the device cannot verify
feat(solana): authenticate Relay LUT routes without blind signing
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.
Release
755d99419494fbccad5642dc0184742a1fa12f08develop:71c88d1c1Included work
Release verification
libkkemu.dylib.7.16.0.SHA256SUMS.txtwas 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.