Skip to content

Zcash: shielded transactions with a Ledger (official Zcash app, Ironwood) - #3633

Open
macsrock wants to merge 18 commits into
cake-tech:devfrom
macsrock:zcash-ledger
Open

macsrock wants to merge 18 commits into
cake-tech:devfrom
macsrock:zcash-ledger

Conversation

@macsrock

@macsrock macsrock commented Sep 16, 2026

Copy link
Copy Markdown

Summary

Adds shielded Zcash support with a Ledger running the official Zcash app (LedgerHQ app-zcash 3.9.2+, Ironwood/NU6.3). A Zcash wallet can be paired from the device over Bluetooth or USB, syncs from the exported unified viewing key, sends from the shielded pool with every transaction reviewed and signed on the Ledger, and shields transparent funds from a dashboard card.

Tested end to end on an iPhone 17 Pro Max with a Ledger Nano Gen5 over Bluetooth: pairing (viewing-key export approved on the device), receive, two shielded Ironwood sends (2f1d5c68a8fb819e5766c69816b19a460e2cc85356ce45149d3f8bce6cc90bf6, 5f81c937ec8e45f25fd59ab932425e863263f5f9061f663f6fcf2615411e1354) and a transparent-to-shielded sweep from the card.

How it works

  • Rust side (zkool): upstream zkool 6.30.0 brought the official app's PCZT v2 signer (official_sign.rs). This branch adds a Device implementation that hands every APDU to a Dart closure, so the phone's ledger_flutter_plus connection carries the protocol; exposed through api::ledger (app version probe, UFVK export, signing with progress events). Official accounts can also be created from a UFVK the host already exported.
  • cw_zcash: ZcashLedgerService bridges ledger_flutter_plus to those calls. Pairing goes through the Monero-style options page (name + birth height): the viewing key is exported during the restore, one device approval per account. Sends sign on the device inside createTransaction, so the send screen shows "proceed on your device", and the slide only broadcasts, matching the other hardware wallets. Shielding follows the Keystone design: a dashboard card sweeps transparent funds into the shielded pool, reviewed on the device, then broadcast; the card hides and the swept notes stop counting until the sweep confirms.
  • Ledger wallets open without the device (like Bitcoin) and connect from the send screen; requireHardwareWalletConnection stays false because the startup reconnect flow is Monero-specific.

Dependencies (merge these first, then re-point the refs at cake-tech)

The last commit pins these three to the review branches on macsrock; swap the URLs/refs once merged.

Notes for review

  • Account index is fixed at 0 (the plumbing for other indices exists, no UI yet). Ironwood and transparent pools only, per the official app. The device shows at most four shielded outputs per transaction.
  • Autoshield is skipped for hardware wallets (every transaction needs the device); the card replaces it.
  • The shield card string was added with tool/append_translation.dart; the machine translations should keep "Ledger" untranslated.
  • The Keystone/Cupcake cw_zcash commits from the cupcake-test branch are not included here; they merge on top of this.

Audit follow-ups (added after an external review)

  • Wallet lock no longer held during the device review. Sends and shields plan the transaction under the shared coin lock and run the Ledger review on a separate database handle afterwards, so sync, balance and history keep refreshing while the device waits for the user.

  • Pairing uses the restored wallet's network for the viewing-key export (coin type 1 off mainnet) instead of always mainnet.

  • An unparseable Zcash app version is reported instead of skipping the minimum-version check; a stale transport error can no longer be attached to a later, unrelated failure.

  • zkool re-pinned to 2c792005, which adds the matching Rust fixes (Ledger header fields, viewing-key chunk guard) and repairs the Keystone/Cupcake PCZT translation for the upgraded pczt; see Upstream 6.30.0 + Flutter-driven Ledger transport for the official Zcash app zkool2#1.

  • Device prompt timing (caf4500d0): "Proceed on your device" used to appear the moment send was tapped, several seconds before the Ledger had anything to show (the app was still planning the transaction and streaming it to the device). The wallet now reports the signing stage (new shared HardwareSigningStage in cw_core, driven by the Rust signer's progress events); the send sheet and the shield notice show "Preparing transaction" / "Sending to your device" and switch to "Proceed on your device" only when the device's review is up. Three new strings, translated for all locales.

  • Device errors in plain words: a Ledger status word (locked 0x5515, wrong app, rejected on device, unreadable data, app internal error) is now shown as a one-line instruction, and the Rust backtrace is stripped from any other signer error. Before, a locked device produced a wrong "open the Zcash app (0x6e01)" line followed by a full backtrace. A dropped connection is refused up front with "The Ledger is not connected…" (new ZcashLedgerException, whose text is the message itself, so nothing prefixes or nests it).

  • zkool re-pinned to aeb698d0: the signer's "Confirm on your Ledger" event now follows the last packet, so the sheet says "Sending to your device…" during the stream and "Proceed on your device" exactly when the review is up.

Second security review (Seth's panel)

Its P1 finding (unauthenticated viewing-key import at pairing) is fixed in 8a534d49f: after the export the device shows the account's unified address on its own screen while the app shows the address it derived from the received key, and asks the user to approve on the device only if they match; the device's reply is also compared so a wrong account or path fails outright. New strings ledger_verify_address / ledger_verify_address_description, all locales. zkool re-pinned to d34590e0.

  • Sheet timing: the stage is set before the sheet opens and only moves forward; zkool re-pinned to 67a2513e so "Proceed on your device" stays until the device has been approved (it used to flip to "Signing…" while the Ledger was still waiting).

  • History (4bc6cb568): a shield into Ironwood showed as an outgoing transaction of only the fee. The existing two-entry display ("Shielding" for what left the transparent side, "Received" for what arrived shielded) only recognised Orchard; it now counts Ironwood too. Affects software-wallet auto-shielding as well.

🤖 Generated with Claude Code

Vikrant Sharma and others added 7 commits September 16, 2026 17:04
A Zcash wallet can now be created from a Ledger (Official Zcash app 3.9.2+,
BLE or USB) and send from it. The device exports its unified viewing key
while pairing, the wallet tracks transparent and Ironwood funds from that
key, and every send goes back to the device for review and signing; the
backend proves and finalizes the transaction afterwards.

Pairing reuses the birth-height page Monero hardware wallets use, since the
viewing key is fetched during the restore rather than picked from a list:
each export needs an approval on the device screen.

Ledger wallets keep their transparent funds spendable instead of
auto-shielding them: shielding needs the device, which a background sync
cannot ask for.

The zkool dependency moves to the ledger-official branch (upstream 6.30.0
plus the Cake and Keystone patches and a Flutter-driven APDU transport),
which also removes PaymentOptions.mode, renames NewAccount.ledger to hw and
replaces Coin.setUseTor with a transport setting; flutter_rust_bridge is
pinned to 2.12.0 to match its bindings.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Requiring the device before the wallet opens routed a Zcash wallet through
the app's startup reconnect flow, which is written for Monero: after the
Bluetooth link came up it dereferenced the Monero proxy, threw in a
Zcash-only build, and never sent a command, so the Ledger stayed on its
pairing animation. A Zcash Ledger wallet opens from its viewing key and only
needs the device to sign, so it now behaves like Bitcoin and connects from
the send screen.

Signing also checks that the Zcash app is open first, so the wrong app is
reported as such instead of as a rejected transaction.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The device review ran after the slide-to-send, once the transaction was
being committed. Cake's hardware wallets do it the other way round: the
device signs while the transaction is prepared (the send screen shows
"proceed on your device" then), the confirmation sheet shows the signed
result, and the slide only broadcasts. Match that: a Ledger wallet signs
in createTransaction and the pending transaction carries the finalized
package for commit.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Sending again right after a send finds no spendable notes, because the
change is not recorded until the network picks the transaction up. The
planner's "No feasible note selection found" arrived with a Rust backtrace
in the error dialog; say what it means instead.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
A hardware wallet cannot auto-shield: every transaction is a device review,
which a background sync cannot ask for. Follow the Keystone design instead:
when a hardware Zcash wallet holds transparent funds above the dust floor,
the dashboard shows a card that sweeps them into the shielded pool. On a
Ledger the sweep is reviewed and signed on the device while it is prepared,
then broadcast, from one tap.

The card hides and the balance stops counting the swept notes until the
sweep confirms, and the transaction is marked as a shield so history shows
it as one rather than a transfer whose value is only the fee.

Sends draw from the shielded pools only again, as for every other Zcash
wallet; transparent funds are shielded first through the card.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Pins zkool (and its rlz plugin) to macsrock/zkool2 ledger-official and
ledger_flutter_plus to macsrock/ledger-flutter-plus ble-frame-length, the
branches under review alongside this change. Re-point them at cake-tech
once those are merged.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Added with tool/append_translation.dart; the non-English entries are
machine translations and "Ledger" should stay untranslated in them.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Vikrant Sharma and others added 11 commits September 16, 2026 18:58
- Sends and shields planned the transaction and then waited for the device
  review inside the shared coin lock, so sync, balance, history and mempool
  refreshes all stalled for as long as the Ledger sat on its review screen.
  The plan is now built under the lock and the review runs on its own
  database handle after it is released; the signer only reads this account's
  keys and addresses.
- Pairing exported the viewing key on mainnet regardless of the wallet being
  restored; it now uses that wallet's network (coin type 1 off mainnet).
- A Zcash app version string that cannot be parsed no longer skips the
  minimum-version check; it is reported instead.
- A transport error recorded for one exchange is cleared by the next
  successful one, so it cannot be attached to an unrelated later failure.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ing to show

"Proceed on your device" went up the moment send was tapped, but the Ledger
draws its review only once the whole transaction has reached it. Before that
the phone plans the transaction (two lightwalletd round trips and the note
witnesses), checks the app version and streams the transaction in 255-byte
packets, so the user was told to look at a device that showed nothing for
several seconds.

The wallet now reports where a Ledger send or shield is (preparing, sending
to the device, awaiting the device, signing, finalizing) through a shared
HardwareSigningStage, driven by the signer's own progress events; the sheet
on both send screens and the shield card's notice show that stage, and only
say "proceed on your device" when the device is actually waiting on the user.
Three new strings, translated for every locale.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
… error

Shielding with the Ledger locked produced "Open the Zcash app on your Ledger
(0x6e01)" followed by the Rust error and a full backtrace. The code was
wrong (0x5515 is "device locked", not "wrong app") and the rest was noise.

Status words the device answers with are now translated for the person
holding it: locked, wrong app, rejected on the device, unreadable
transaction, app internal error, and a fallback that shows the code; the
backtrace is stripped from anything else.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…issing

The send screen showed "Exception: Open the Zcash app on your Ledger, then
try again (Your Ledger is locked. ...)": the version probe's guard had
already explained the status word, and the app check wrapped it again.

Ledger problems are now a ZcashLedgerException whose text is the message
itself, so nothing prefixes or nests it. A connection that has dropped is
refused up front with "The Ledger is not connected. Make sure it is unlocked
and nearby, then try again." instead of after a failed exchange with the
transport's own words; the transport cause goes to the log.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The signer now reports "Sending to Ledger" while streaming and "Confirm on
your Ledger" only once the device can draw its review; the sheet follows.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ce screen

A security review of the pairing flow found that the viewing key crosses
the Bluetooth/USB link with nothing authenticating it, and the wallet
imported whatever arrived: a key substituted on the link would have made
every receive address an attacker's, with nothing for the user to notice
until a spend failed.

After the export the device now derives the account's unified address and
shows it on its own screen (GET_SHIELD_ADDR with display), while the app
shows the address it derived from the key it received and asks the user to
approve on the device only if the two match. The device's reply is compared
as well, so a plain mismatch (wrong account or path) fails without the user
having to spot it. Pairing is refused if either check fails.

zkool re-pinned to d34590e0 for the new command, which also refuses a
transparent signature reply with no DER bytes.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…een approved

The signer announced signing before its first signing command, which is
the one that waits for the user's approval, so the sheet showed "proceed on
your device" for a moment and then "signing" while the Ledger was still
waiting. zkool now reports signing only after a signature has come back
(re-pinned to 67a2513e); the wallet follows those events. The stage is
also set before the sheet opens and can only move forward, so nothing
earlier can flash back in.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
A logged send showed the device answers the last packet only after the
user approves, so the sheet said "sending to your device" through the
whole review and "proceed" for the second the signature took. The signer
now reports the confirm step just before that packet goes out.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
… as a lone fee

The history already splits a shield into what left the transparent side
and what arrived shielded, so it never reads as money vanishing. The rule
only looked for funds arriving in Orchard; since Ironwood activated a
shield lands there, the rule stopped firing, and the entry showed just the
net of the transaction, which is the fee. Both pools now count. This
affects automatic shielding on software wallets as much as the Ledger
shield card.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…s fee

Until a shield confirms the wallet only knows the transaction's net effect
on the account, which is the fee, so the history briefly showed a small
amount going out and nothing else. The swept amount is now recorded when
the shield is broadcast and shown for the pending entry; once the shielded
note is attached the entry splits into "shielding" and "received" as
before.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
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