Skip to content

Upstream 6.30.0 + Flutter-driven Ledger transport for the official Zcash app - #1

Open
macsrock wants to merge 190 commits into
cake-tech:cyjan-2026-08-08from
macsrock:ledger-official
Open

macsrock wants to merge 190 commits into
cake-tech:cyjan-2026-08-08from
macsrock:ledger-official

Conversation

@macsrock

@macsrock macsrock commented Sep 16, 2026

Copy link
Copy Markdown

Summary

Brings this fork to upstream hhanh00/zkool2 v6.30.0, which added the official Ledger Zcash app signer (Ironwood/v6 PCZT, rust/src/ledger/official_sign.rs), re-applies the Cake and Keystone patches on top, and makes the Ledger protocol usable from a phone.

Companion to the Cake Wallet pull request adding shielded Zcash over Ledger: cake-tech/cake_wallet#3633 (which also needs cake-tech/ledger-flutter-plus#1).

Changes on top of upstream 6.30.0

  • The 11 fork commits from keystone-batch rebased: the Cake wallet patch, prove-and-finalize for externally signed PCZTs, external-signer input identification, the Keystone wire dialect and batch signing, transaction fee exposure.
  • Ledger transport split: the protocol code (official app, APDU framing) builds on every target; hidapi and the Zondax (Sapling) app stay behind the ledger feature. New ledger::dart_device::DartDevice hands each APDU to a Dart closure (DartFnFuture), and api::ledger exposes ledger_app_version, ledger_get_ufvk, ufvk_default_address and ledger_sign_transaction (progress + result over a stream). Official accounts (hw = 2) can be created from a UFVK the host already exported.
  • Nym is no longer a default feature (wallet builds do not ship the mixnet transport; is_valid_nym_url stays exported so the bindings do not depend on it).
  • cargokit no longer injects zcash_unstable="nu7"; upstream removed that hack and the crates.io releases reach Ironwood without it. Release builds stay forced.
  • Bindings regenerated with flutter_rust_bridge 2.12.0 (the Dart side of Cake pins the same version).

Testing

  • cargo test --lib ledger:: (8 new tests: APDU framing, version probe parsing, UFVK chunk reassembly, refusal handling) and cargo test --test ledger_accounts_test (5) pass.
  • Type-checks for aarch64-apple-ios.
  • Exercised on device through Cake Wallet: viewing-key export, Ironwood sends and a transparent sweep signed on a Ledger Nano Gen5 over Bluetooth.

Note: targeted at cyjan-2026-08-08, the branch Cake Wallet currently pins (36e72b53, upstream 6.24.0 + the Cake patch); main shares no history with upstream. The diff is large because it includes upstream 6.24.0 to 6.30.0; the fork-specific changes are the commits after chore(main): release zkool 6.30.0.

Audit follow-ups (added after an external review)

  • Keystone/Cupcake regression fixed (keystone_wire: speak the upgraded pczt encoding…). The upgrade to upstream 6.30.0 changed the pinned pczt's v2 wire layout, so the wire mirrors could no longer read PCZTs Cake builds; the old tests passed only by replaying a pre-upgrade fixture. There are now three mirrors: cake (the pinned encoding, verified byte for byte against a freshly built PCZT), dst (Keystone 0.8.0-rc.1) and src_ (lrz 0.7, which the Cupcake signer still parses). New pczt_to_cupcake / pczt_from_cupcake; prove_and_finalize also accepts a Cupcake-dialect PCZT. Non-ZEC assets, split notes, ZSA note versions and issuance bundles are refused rather than dropped. Tests round-trip a real shield PCZT through both devices, keep the Keystone golden bytes, and parse every translated result with the pinned library.
    • Needed on the Keystone/Cupcake app branch when it moves onto this zkool: in PendingZcashTransaction.commitUR, send Cupcake pcztToCupcake(txPlan.pczt) instead of the raw bytes (Keystone already goes through pcztToKeystone / the batch request, whose APIs are unchanged).
    • Not yet exercised on a physical Keystone or Cupcake after this change.
  • Ledger header (ledger: send the PCZT's own header fields…): lock time, coin type and modifiable flags now come from the PCZT; a coin type that disagrees with the account's network is refused; a zero lock time is still sent as absent, so today's transactions produce the exact header bytes already signed on a device (pinned by a test).
  • get_ufvk stops with a protocol error if the device answers a continuation with success and no data, instead of looping.

Full suite: 165 library tests plus ledger_accounts_test (5), parse_shield_tx (1), zsa_transfer_test (8); iOS target type-checks.

  • Progress events (aeb698d0): "Confirm on your Ledger" was emitted before the transaction was streamed, several seconds before the device could show anything; it now follows the last packet, and the streaming is reported as "Sending to Ledger".

Second security review (Seth's panel)

Its P1 finding: the exported UFVK crosses the BLE/USB link unauthenticated and was imported as-is, so a key substituted on the link would silently redirect every deposit. Fixed in d34590e0: get_shield_address (GET_SHIELD_ADDR 0x51 with display, Orchard account path + m/44'/coin'/account'/0/0) has the device derive the account's unified address and show it on its own screen; ledger_show_address exposes it to the host, which shows the address derived from the imported key alongside so the user compares the two (see the Cake PR). Truncated, non-unified or Orchard-less replies and a refusal are errors. Its two P3s were already fixed here (empty GET_VK continuation chunk) or are now (empty DER in a transparent signature reply).

  • Confirm timing (d2fa4e39, amended): a logged send showed the device answers the P2_FINISHED packet only after the user approves (17 s gap, signature 1 s later), so "Confirm on your Ledger" is now emitted just before that packet is sent and the reply marks the approval.
  • Signing progress (67a2513e): "Signing on Ledger" was emitted before the first signing command, i.e. while the device was still waiting for approval; progress is now "Signed … n/m" after each signature returns, so "Confirm on your Ledger" stays current until the user acts.

🤖 Generated with Claude Code

hhanh00 added 30 commits July 27, 2026 21:32
- ZSA asset tracking in coin selection (asset_index on notes/outputs)
- Per-asset balance and change computation
- ZSA memo decryption (612-byte ciphertexts via OrchardZSADomain)
- Wire format: read/write full ZSA enc_ciphertexts with asset field
- V2/V3 bundle signing: add_orchard_change_output for Ironwood/V3,
  add_orchard_output for V2 (IO Finalizer handles dummies)
- Sync refactor: CompactTx instead of SyncTx, drop issuance synthesis
- Schema: UNIQUE(asset_base), idx_assets_asset_base, asset_name column
- Cargo.toml: switch path overrides to git revs (orchard bd4be3b,
  lrz f53afe2a)
…uance; add zsa-circuit feature; add ZSA issuance test
… CLI

- All Nu7 orchard transactions use ORCHARD_ZSA_PK (ProvingKey::build_zsa())
- Add is_zsa() helper to detect Nu7 consensus branch
- Add pczt_replay CLI binary for reproducible PCZT proving
- Add test_dump_instances test and PCZT save-to-file in tests
…T domain support

- TransactionData.orchard_bundle is now Option<OrchardBundle<A::OrchardAuth>>
- OrchardBundle enum with OrchardVanilla/OrchardZSA variants
- All callers updated to match on variant
- ZSA commitment/digest support with 612-byte ciphertext indices
- PCZT Output::parse now generic over Domain for ZSA ciphertext parsing
- write_v6_bundle_zsa accepts raw ZSA enc_ciphertexts
…domain support, test: block-mining wait loop, per-account coins
- orchard: zcash-shielded-assets/orchard @ 7ba8e8a
- lrz crates: zcash-shielded-assets/librustzcash @ f90335ab
- orchard: zcash-shielded-assets/orchard @ 8c1c9bee
- lrz: zcash-shielded-assets/librustzcash @ b2d59ba8
- migrate: use mounted check instead of context.mounted for error display
- frost: use orchard_pk from PCZT consensus branch instead of network check
- plan: replace unwrap() with map_err, add OrchardProvingKeyKind, PCZT-DUMP diagnostics
Drop the fee-vs-privacy coin-selection toggle and collapse the solver to a
single strategy: minimize cross-pool turnstile value, then minimize the
ZIP-317 fee among equally private solutions.

Solver (rust/src/pay/solve.rs):
- evaluate_privacy now breaks change-pool ties on fee instead of keeping
  whichever pool it reached first.
- Branch-and-bound compares (turnstile, fee) lexicographically and keeps
  expanding nodes whose bound equals the incumbent, since an equal-privacy
  subtree can still yield a cheaper fee.
- The reported fee is the fee of the selected change pool. It used to be
  recomputed as the globally cheapest pool, which could pair the
  privacy-optimal change pool with a fee computed for a different one.
- Remove Mode, evaluate_fee, lower_bound_fee, compute_min_fee, the fee
  branch of local_score, and the unused Selection::change_amount.
- Add tests for the fee tie-break and the equal-bound acceptance rule.

Plumbing: drop the mode parameter from plan_transaction, PaymentOptions and
the GraphQL Payment input, remove getCoinSelectionMode/setCoinSelectionMode
and the coin_selection_mode DB property, and delete the "Privacy
Preservation" switch from the send page.

Callers that previously requested Mode::Fee — asset issuance, note
migration, prepare_migration — now go through the privacy-first solver.
* chore: update build number / release

* chore(main): release zkool 6.25.0
hhanh00 and others added 20 commits September 12, 2026 18:28
Add a "nym" cargo feature (on by default) covering the mixnet transport
and nym:// nym-rpc endpoints, making the nym crates optional dependencies.
CI graphql builds now pass --no-default-features so the server no longer
compiles the Nym stack or the Flutter bridge.
The transparent sweep stores every used external address but left
accounts.dindex untouched, so addresses found above the current index
were missing from the 0..=dindex address list and next-address
generation could land on an already-used index. After the scan, bump
accounts.dindex to the greatest stored external (scope 0) index, never
backwards.
The gap counter was cumulative: used addresses found between unused ones
did not reset it, so a sparse used/unused pattern ended the scan early and
never discovered later used addresses. Reset the gap on each used address.
Unshield All used sourcePools 6 (Sapling | Orchard) and silently left any
Ironwood shielded balance untouched. Use 14 (Sapling | Orchard |
Ironwood) so the whole shielded balance is swept to the transparent
address.
autoSync() read the interval from appSettingsProvider, which only updates
after the settings page is saved, so changing the interval from 0 to a
positive value cancelled the subscription instead of starting it. Pass
the new interval explicitly to autoSync().
After restoring an account from a seed phrase, the 'synchronize now'
prompt did nothing: syncIfNeeded read a stale getAccountsProvider cache
that lacked the new account. Refresh and await the account list before
switching the selection, and set selectedAccountIdProvider so the
restored account is the selected one (also fixing resume-after-restart
and the null currentAccount crash when the offstage AccountViewPage
rebuilt mid-restore).
(cherry picked from commit 36e72b5)
sign_transaction fuses signing, proving and spend finalization, so a PCZT
signed off-device (Keystone, Cupcake) had no way to be completed: proving
is hot-side work but was only reachable through the code path that needs
spending keys.

prove_and_finalize takes an already-signed PCZT and runs just the Prover
(sapling/orchard/ironwood) and SpendFinalizer, so the result can go to
extract_transaction and broadcast. It touches no spending keys, making it
usable on watch-only accounts, and is exposed through api::pay for FRB.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
(cherry picked from commit 2a52d2e)
The PCZT's transparent bip32_derivation entries carried a zeroed seed
fingerprint and a two-element path. Nothing checked either while signing
happened in-process, so both were effectively placeholders.

A hardware wallet does check: it matches the fingerprint against its own
seed and parses the path as BIP 44, and rejects anything that fails as
belonging to a different wallet. Write the account's stored fingerprint
and the full m/44'/coin'/account'/scope/index path instead.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
(cherry picked from commit 972c702)
Orchard and Ironwood spends were built with no ZIP-32 derivation, because
the Orchard updater was never run at all — only the transparent and
sapling ones were. A shielded spend therefore reached an external signer
with nothing identifying the account that owns it, and was refused as
belonging to a different wallet.

zip32_derivation is the only ownership marker the Updater role can place
on an Orchard spend, so set it on every real spend in both bundles. The
path is m/32'/coin_type'/account', fully hardened and exactly three
elements — the five-element BIP 44 path transparent inputs use is
rejected here. Dummy spends are builder padding and stay unclaimed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
(cherry picked from commit 2fbdb98)
Cake pins the lrz fork of pczt 0.7; a Keystone running Cypherpunk firmware
speaks 0.8.0-rc.1. Both call their encoding "v2", but six fields differ:
anchor, cv_net, nullifier, rk and cmx each gained an Option, and
enc_ciphertext became an enum. postcard is positional and not
self-describing, so an Option's tag byte shifts everything after it.

The result is a silent misread rather than an error. A Keystone parses a
PCZT Cake built without complaint and finds zero actions in every pool,
so it reports that none of the inputs belong to the wallet -- which
surfaces as "Incongruent Transaction" with nothing to suggest an
encoding problem.

Translate at the QR boundary rather than moving either side's pinned
version: Cake's ZSA features and the device's firmware both stay put.
The ZSA issue bundle has no counterpart on the device and is dropped;
it is empty for any transaction the device could sign anyway.

Verified against the firmware's own parser, which reads two Ironwood
actions and one signable spend from the transcoded fixture where Cake's
dialect yields none. The reverse direction reproduces the original bytes
exactly, so the returned signature carries back losslessly.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
(cherry picked from commit e5fa0a0)
A signer returns what it needed to produce, not the document it was given:
the Keystone redacts prover-only fields, including the Orchard full
viewing key. Adopting its reply wholesale therefore loses data proving
requires, and the spend fails at the prover with MissingFullViewingKey
after the user has already approved it on the device.

Keep the PCZT this wallet built and merge in only the authorising
signatures. A spend that already carries one keeps it, since the IO
Finalizer signs dummy spends before the device ever sees them, and a
reply whose shape does not match the transaction that was sent is
rejected rather than partially applied.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
(cherry picked from commit 35b5361)
decrypt_memo computes each transaction's fee and writes it to
transactions.fee, but fetch_txs never selected the column, so every
consumer saw zero and had no way to recover the real value: outputs the
wallet sent are stored separately from notes it received, making the fee
derivable only by combining three lists.

Select the column and carry it on Tx.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
(cherry picked from commit 5afa396)
An external signer displays each transparent output's recipient for
review and refuses one it cannot name: keystone3-firmware rejects a PCZT
whose transparent output has no user_address, whether or not the output
belongs to the wallet. The address is recoverable from the script, but
the signer will not derive it, so any transaction creating a transparent
output — deshielding, or paying a t-address — fails on the device.

State the address alongside the script. Outputs whose script is not a
recognised transparent address are left alone rather than guessed at.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
(cherry picked from commit 4b2a164)
Adds to_batch_request and apply_batch_sig_result in keystone_wire, exposed as
pczt_to_batch_request / pczt_apply_batch_signatures. The request carries the
device's v2 PCZT dialect headerless under one shared version ("PCZB" magic);
the reply is only Orchard/Ironwood spend-auth signatures ("PCZS"), applied to
the wallet-owned PCZT -- an order of magnitude fewer QR frames on the return
leg. Shielded spends only; a transparent-input shield stays on zcash-pczt
(firmware rejects transparent inputs in a batch). Byte-exactness of the batch
inner PCZT is asserted against the device-verified golden fixture.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
(cherry picked from commit 880ff9cefb9e5a670b85330e40073e60428bb0f8)
The device rejects a batch request carrying any Orchard/Ironwood spend-auth
signature ("batch request must not contain Ironwood spend authorization
signatures"), including the IO-Finalizer sigs on preauthorized padding spends.
Clear them from the request; the wallet-owned base keeps them for extraction
and apply_batch_sig_result adds the device's real-spend signatures on top.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
(cherry picked from commit cd6982bc96ee35bbebbace424078db4ed6c4b3e9)
The Official Ledger app signer (Ironwood/v6 PCZT) only reached the device
through the desktop USB HID transport, which does not exist on a phone:
there the BLE or USB connection is owned by the Flutter side.

Split the transport so the protocol code (Official app, APDU framing) builds
on every target, keep hidapi and the Zondax app behind the `ledger` feature,
and add a Device that hands each APDU to a Dart closure. Expose it through
api::ledger: app version probe, UFVK export, and PCZT signing with progress
events. Official accounts can now also be created from a UFVK the host
already exported, since the mobile wallet cannot let Rust talk to the device
while creating the account.

Nym is no longer a default feature: the wallet builds that consume this crate
do not ship the mixnet transport, and is_valid_nym_url stays exported so the
generated bindings do not depend on the feature.

Bindings regenerated with flutter_rust_bridge 2.12.0.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The flag came with the Cake patch for the ZSA git branch of librustzcash.
The crates.io releases this branch builds against reach Ironwood without
it, and enabling unstable NU7 code paths in a production wallet build is
not something to do by accident. Release builds stay forced.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Vikrant Sharma and others added 8 commits September 16, 2026 16:27
Both came in with the external-signer work as scratch probes. The encoding
dump wrote v1/v2 encodings to /tmp for inspection and no longer compiles
against pczt 0.9. The cross-version check parsed a Cupcake PCZT directly
and fails (DeserializeBadOption: the fork's Ironwood/ZSA fields are not the
crates.io layout), which is exactly the incompatibility the Keystone wire
translation and the signatures-only replies were introduced to avoid; those
paths are covered by the keystone_wire unit tests with committed fixtures.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The three commits on that branch (the upstream 6.24.0 prerelease config,
the earlier ZSA re-enable, and the Cake wallet patch) are already
represented here: the Cake patch was rebased onto upstream 6.30.0 as
"cake wallet patch", and 6.30.0 carries upstream's later ZSA work that
superseded the earlier re-enable. Keeps this tree, so the pull request
against that branch has it as an ancestor.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The move to upstream zkool 6.30.0 changed the pczt this crate pins, and its
v2 encoding with it: optional anchors, cv_net, nullifier, rk and cmx; a
split-note seed and an asset on every Orchard/Ironwood spend and output; a
ZSA note version; a trailing issuance bundle. The wire mirrors still decoded
Cake's PCZTs with the old layout, so every airgapped path (Keystone single
and batch, Cupcake) would misread or reject a PCZT built today. The tests
kept passing only because they replay a fixture recorded before the upgrade.

There are now three dialects: `cake` (a new mirror of the pinned encoding,
verified byte for byte against a freshly built PCZT), `dst` (Keystone
0.8.0-rc.1, unchanged) and `src_` (the lrz 0.7 fork, which is what the
Cupcake signer still parses). Translations go from Cake's dialect to each
device and back:

- to_keystone / from_keystone, apply_signatures, to_batch_request and
  apply_batch_sig_result now read and write Cake's dialect;
- new to_cupcake / from_cupcake (exposed as pczt_to_cupcake and
  pczt_from_cupcake), since Cupcake can no longer read Cake's own bytes;
- prove_and_finalize accepts a Cupcake-dialect PCZT and translates it.

Nothing a device reviews is silently dropped: a spend or output carrying any
asset other than ZEC, a split note, a ZSA note version or an issuance bundle
is refused. The ZEC asset id the library writes on plain ZEC notes is
restored on the way back, and an empty Sapling bundle (present without an
anchor in the new encoding, absent in the old) maps between the two.

Tests build a real shield PCZT with the pinned library and check it round
trips losslessly through both devices, that the Keystone golden bytes
(verified against the firmware's parser) still come out of the recorded
transaction, that the pinned library parses every translated and merged
result, and that a non-ZEC asset is refused.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Audit follow-ups on the Official app path:

- The PCZT_HEADER carried a hardcoded "no fallback lock time", nothing
  modifiable and the network's coin type rather than the PCZT's. Harmless
  while zkool never sets a lock time, but a future one would have produced
  signatures that fail verification. The fields are now read from the PCZT
  (the pinned pczt has no getters for them, so through the wire mirror); a
  coin type that disagrees with the account's network is refused, and a
  zero lock time is still sent as absent, so today's transactions produce
  exactly the header bytes the device has been signing (pinned by a test).
- get_ufvk looped forever if the device answered a continuation request with
  success and no data. That is now a protocol error.

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

The progress event was sent before the transaction was streamed, so the
host told the user to look at the device several seconds before it drew
anything; the device's review appears only after the last packet. The
streaming is now reported as "Sending to Ledger" and the confirm event
follows the final packet.

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

A review of the pairing flow pointed out that the viewing key crosses the
BLE/USB link unauthenticated and the host imported whatever came back: a
substituted key would have put every receive address in an attacker's
hands with nothing for the user to notice. The device's own screen is the
one part of the path a tampered link cannot alter.

GET_SHIELD_ADDR (0x51) with display asks the device to derive the account's
unified address (Orchard account path plus m/44'/coin'/account'/0/0) and
show it for approval; ledger_show_address exposes that to the host, which
displays the address it derived from the imported key alongside so the user
can compare them. A refusal, a truncated reply, or anything that is not a
unified address with an Orchard receiver is an error.

Also refuses a transparent signature reply with no DER bytes instead of
indexing into it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
"Signing on Ledger" was announced before the first signing command, which
is the command that blocks until the user approves on the device; a host
following the events told the user the transaction was being signed while
the device was still waiting for them. Progress is now reported after each
signature comes back ("Signed ... n/m"), so "Confirm on your Ledger" stays
current until the user has acted.

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

The device puts its review on screen when the packet carrying P2_FINISHED
arrives and answers that packet only once the user has approved (a real
send showed a 17 s gap before the reply and a signature 1 s after it). So
the confirm event, sent after the reply, came after the user had already
approved. It is now sent just before that packet, and the reply is what
marks the approval.

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.

7 participants