diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 5efb368e..9e0c0314 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -2,7 +2,7 @@ name: Build KeepKey Vault on: push: - branches: [master, 'release/*'] + branches: [master, develop, 'release/*'] # NOTE: deliberately NOT triggering on tag push. action-gh-release creates # the v* tag when it creates the draft release, which used to trigger a # SECOND CI run on the tag ref. That rerun rebuilt the unsigned macOS x64 @@ -11,7 +11,7 @@ on: # placed. v1.2.16 release hit this and had to be manually repaired. # If you ever need to re-run CI on a tag, use workflow_dispatch. pull_request: - branches: [master, 'release/*'] + branches: [master, develop, 'release/*'] workflow_dispatch: concurrency: @@ -66,7 +66,7 @@ jobs: uses: actions/cache@v4 with: path: modules/proto-tx-builder/node_modules - key: proto-tx-builder-${{ runner.os }}-${{ hashFiles('modules/proto-tx-builder/package.json') }} + key: proto-tx-builder-${{ runner.os }}-${{ hashFiles('modules/proto-tx-builder/package.json', 'modules/proto-tx-builder/yarn.lock') }} - name: Cache hdwallet uses: actions/cache@v4 @@ -100,8 +100,8 @@ jobs: yarn tsc --build cd ../proto-tx-builder - bun install git submodule update --init osmosis-frontend + yarn install --frozen-lockfile npx tsc -p . test -f dist/index.js diff --git a/Makefile b/Makefile index 7b649d2c..ec022536 100644 --- a/Makefile +++ b/Makefile @@ -52,9 +52,9 @@ $(DEVICE_PROTOCOL_BUILD_STAMP): $(DEVICE_PROTOCOL_INPUTS) $(SUBMODULES_STAMP) | # --- Module Builds (hdwallet + proto-tx-builder from source) --- $(PROTO_INSTALL_STAMP): modules/proto-tx-builder/package.json modules/proto-tx-builder/yarn.lock $(SUBMODULES_STAMP) | $(STAMP_DIR) - cd modules/proto-tx-builder && bun install @# Init the nested osmosis-frontend submodule (provides Cosmos/Osmosis proto codegen) cd modules/proto-tx-builder && git submodule update --init osmosis-frontend + cd modules/proto-tx-builder && yarn install --frozen-lockfile @touch $@ $(PROTO_BUILD_STAMP): $(PROTO_BUILD_INPUTS) $(PROTO_INSTALL_STAMP) | $(STAMP_DIR) diff --git a/modules/device-protocol b/modules/device-protocol index 98ca1e2f..f2246ceb 160000 --- a/modules/device-protocol +++ b/modules/device-protocol @@ -1 +1 @@ -Subproject commit 98ca1e2fcb12af28c3cfa6b5ade969a237d46269 +Subproject commit f2246cebea8f96fcd7ec2883588a784a60b430ae diff --git a/modules/hdwallet b/modules/hdwallet index e6838b20..94ed6cdb 160000 --- a/modules/hdwallet +++ b/modules/hdwallet @@ -1 +1 @@ -Subproject commit e6838b20b959d2266c6892af5482fd38867e221c +Subproject commit 94ed6cdb525708f3eb14933ec4a68b291c151431 diff --git a/modules/keepkey-firmware b/modules/keepkey-firmware index ddade55d..292786e3 160000 --- a/modules/keepkey-firmware +++ b/modules/keepkey-firmware @@ -1 +1 @@ -Subproject commit ddade55d97a1c2252cd3e132d99459a1b19907bd +Subproject commit 292786e3fd936a8c4d4a971af2dc37855e5e1186 diff --git a/projects/keepkey-vault/src/bun/txbuilder/zcash-deshield.ts b/projects/keepkey-vault/src/bun/txbuilder/zcash-deshield.ts index 0aa84043..51ba6b5e 100644 --- a/projects/keepkey-vault/src/bun/txbuilder/zcash-deshield.ts +++ b/projects/keepkey-vault/src/bun/txbuilder/zcash-deshield.ts @@ -1,5 +1,5 @@ /** - * Zcash Orchard → transparent deshielding transaction builder. + * Zcash Ironwood → transparent deshielding transaction builder. * * Orchestrates the flow: * 1. Sidecar builds deshield PCZT (Orchard spends + transparent output) @@ -26,7 +26,8 @@ interface DeshieldBuildResult { account: number branch_id: number sighash: string - digests: { header: string; transparent: string; orchard: string } + pool: "orchard" | "ironwood" + digests: { header: string; transparent: string; orchard: string; ironwood: string } header_fields?: { tx_version: number; version_group_id: number; lock_time: number; expiry_height: number } bundle_meta: { flags: number; value_balance: number; anchor: string } actions: Array<{ @@ -45,7 +46,7 @@ interface DeshieldBuildResult { let deshieldInProgress = false /** - * Full deshield flow: Orchard shielded pool → transparent ZEC. + * Full deshield flow: Ironwood shielded pool → transparent ZEC. * * @param wallet - hdwallet instance with zcashSignPczt method * @param params - Deshield parameters @@ -108,10 +109,10 @@ async function _deshieldZecInner( }, 600000) // Halo2 proof can take a while const sr = buildResult.orchard_signing_request - console.log(`[zcash-deshield] PCZT built: ${sr.n_actions} Orchard actions`) + console.log(`[zcash-deshield] PCZT built: ${sr.n_actions} ${sr.pool} actions`) console.log(`[zcash-deshield] Display: ${buildResult.display.amount} → ${buildResult.display.action}`) - // 2. Device signs Orchard actions (same as shielded send — no transparent signing needed). + // 2. Device signs Ironwood actions (no transparent signing needed). // The transparent output MUST be declared and streamed: the firmware recomputes the // transparent digest from plaintext (reviewing the t-address + amount on-device) and // derives the sighash from it. Omitting it makes the device sign against the EMPTY @@ -132,9 +133,9 @@ async function _deshieldZecInner( throw new Error("Device did not return signatures") } - console.log(`[zcash-deshield] Got ${signatures.length} Orchard signatures`) + console.log(`[zcash-deshield] Got ${signatures.length} Ironwood signatures`) - // 3. Finalize via sidecar — only Orchard signatures, no transparent sigs + // 3. Finalize via sidecar — only Ironwood signatures, no transparent sigs console.log("[zcash-deshield] Finalizing deshield transaction...") const { raw_tx, txid } = await sendCommand("finalize_deshield", { orchard_signatures: signatures, diff --git a/projects/keepkey-vault/src/bun/txbuilder/zcash-shield.ts b/projects/keepkey-vault/src/bun/txbuilder/zcash-shield.ts index f741eed8..4251d7f5 100644 --- a/projects/keepkey-vault/src/bun/txbuilder/zcash-shield.ts +++ b/projects/keepkey-vault/src/bun/txbuilder/zcash-shield.ts @@ -1,5 +1,5 @@ /** - * Zcash transparent → Orchard shielding transaction builder. + * Zcash transparent → Ironwood shielding transaction builder. * * Orchestrates the flow: * 1. Fetch transparent UTXOs (via Pioneer) @@ -78,12 +78,12 @@ interface ShieldBuildResult { transparent_inputs: TransparentSigningInput[] transparent_outputs?: Array<{ index: number; value: number; script_pubkey: string }> orchard_signing_request: any - digests: { header: string; transparent: string; orchard: string } + digests: { header: string; transparent: string; orchard: string; ironwood: string } display: { amount: string; fee: string; action: string } } /** - * Full shield flow: transparent ZEC → Orchard shielded pool. + * Full shield flow: transparent ZEC → Ironwood shielded pool. * * @param wallet - hdwallet instance with zcashSignPczt + Pioneer access * @param pioneer - Pioneer API client for UTXO lookup @@ -424,13 +424,13 @@ async function _shieldZecInner( account, }, 600000) // Halo2 proof can take a while - console.log(`[zcash-shield] Shield PCZT built: ${buildResult.transparent_inputs.length} transparent inputs, ${buildResult.orchard_signing_request.n_actions} Orchard actions`) + console.log(`[zcash-shield] Shield PCZT built: ${buildResult.transparent_inputs.length} transparent inputs, ${buildResult.orchard_signing_request.n_actions} Ironwood actions`) - // 5. Device signs — two-phase: Orchard first, then transparent + // 5. Device signs — two-phase: Ironwood plus transparent authorization // // The hybrid signing protocol (ZcashTransparentInput/ZcashTransparentSig) // requires firmware support that may not be present. Check first and - // fall back to Orchard-only signing with a clear error for transparent. + // fail with a clear error if transparent authorization is unavailable. console.log("[zcash-shield] Requesting device signatures...") opts?.onProgress?.("signing") @@ -485,7 +485,7 @@ async function _shieldZecInner( const transparentSigs: string[] = (signatures as any)._transparentSignatures || [] const orchardSigs: string[] = signatures - console.log(`[zcash-shield] Got ${transparentSigs.length} transparent sigs, ${orchardSigs.length} Orchard sigs`) + console.log(`[zcash-shield] Got ${transparentSigs.length} transparent sigs, ${orchardSigs.length} Ironwood sigs`) if (transparentSigs.length > 0) { console.log(`[zcash-shield] Transparent sig[0]: ${transparentSigs[0]?.slice(0, 40)}...`) } diff --git a/projects/keepkey-vault/src/bun/txbuilder/zcash-shielded.ts b/projects/keepkey-vault/src/bun/txbuilder/zcash-shielded.ts index 55ca3920..6a8fd8e5 100644 --- a/projects/keepkey-vault/src/bun/txbuilder/zcash-shielded.ts +++ b/projects/keepkey-vault/src/bun/txbuilder/zcash-shielded.ts @@ -1,5 +1,5 @@ /** - * Zcash Orchard shielded transaction builder. + * Zcash Orchard-family shielded transaction builder (Ironwood from NU6.3). * * Orchestrates the three-way flow: sidecar (crypto) + device (signing) + sidecar (finalize). * @@ -51,6 +51,7 @@ export async function displayOrchardAddressOnDevice(wallet: any, account: number export interface SigningRequest { n_actions: number + pool: "orchard" | "ironwood" account: number branch_id: number sighash: string @@ -58,6 +59,7 @@ export interface SigningRequest { header: string transparent: string orchard: string + ironwood: string } header_fields?: { tx_version: number diff --git a/projects/keepkey-vault/src/mainview/components/ZcashPrivacyTab.tsx b/projects/keepkey-vault/src/mainview/components/ZcashPrivacyTab.tsx index bc42adbe..c94588e6 100644 --- a/projects/keepkey-vault/src/mainview/components/ZcashPrivacyTab.tsx +++ b/projects/keepkey-vault/src/mainview/components/ZcashPrivacyTab.tsx @@ -1146,7 +1146,7 @@ export function ZcashPrivacyTab() {

Receive ZEC

-

Share this address. Senders pay into your Orchard pool automatically.

+

Share this unified address. New shielded funds enter the Ironwood pool.

diff --git a/projects/keepkey-vault/zcash-cli/Cargo.lock b/projects/keepkey-vault/zcash-cli/Cargo.lock index 54295f4c..0856bee6 100644 --- a/projects/keepkey-vault/zcash-cli/Cargo.lock +++ b/projects/keepkey-vault/zcash-cli/Cargo.lock @@ -1358,9 +1358,9 @@ checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" [[package]] name = "orchard" -version = "0.14.0" +version = "0.15.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a54f8d29bfb1e76a9d4e868a1a08cce2e57dd2bdc66232982822ad3114b91ab3" +checksum = "793e2e8c2323f35f082d1b3467ca8f576d646f9c93aef8c5168809d099245af8" dependencies = [ "aes", "bitvec", @@ -1426,9 +1426,9 @@ dependencies = [ [[package]] name = "pasta_curves" -version = "0.5.1" +version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3e57598f73cc7e1b2ac63c79c517b31a0877cd7c402cdcaa311b5208de7a095" +checksum = "3437083215c505e867eea5478371feba43d7689d6d15ec0a209eb46fb0d4cda6" dependencies = [ "blake2b_simd", "ff", @@ -2075,9 +2075,9 @@ dependencies = [ [[package]] name = "shardtree" -version = "0.6.2" +version = "0.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "359e552886ae54d1642091645980d83f7db465fd9b5b0248e3680713c1773388" +checksum = "8147447aed7be4736e271825b8c3a4432efb7182e71363169b572371ddef452e" dependencies = [ "bitflags", "either", @@ -2951,9 +2951,9 @@ dependencies = [ [[package]] name = "zcash_address" -version = "0.12.0" +version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "58342d0aaa8e2fa98849636f52800ac4bf020574c944c974742fc933db58cac2" +checksum = "5a854b28c07dba372f4410ea8ad62b4bf7d5c2bf8be32fc4b31bc0db6521a975" dependencies = [ "bech32", "bs58", @@ -2976,9 +2976,9 @@ dependencies = [ [[package]] name = "zcash_keys" -version = "0.14.0" +version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2fbcdfbb5c8edb247439d72a397abaae9b7dd14a1c070e7e4fc3536924f9065f" +checksum = "def800f128e459eedebc900f36f408eaf0687634128dcf64ecfeaeebc3e16c14" dependencies = [ "bech32", "blake2b_simd", @@ -3004,9 +3004,9 @@ dependencies = [ [[package]] name = "zcash_note_encryption" -version = "0.4.1" +version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77efec759c3798b6e4d829fcc762070d9b229b0f13338c40bf993b7b609c2272" +checksum = "e1cb1b9170c94370e3d66c5cc0877661db743337588b64de7711239eed462198" dependencies = [ "chacha20", "chacha20poly1305", @@ -3017,9 +3017,9 @@ dependencies = [ [[package]] name = "zcash_primitives" -version = "0.28.0" +version = "0.30.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c69e07f5eb3f682a6467b4b08ee4956f1acd1e886d70b21c4766953b3a1beba2" +checksum = "34ca4de11896f704ffe6319c2cd7bc8fc6ab31a55cec80d26def15c009d83678" dependencies = [ "blake2b_simd", "block-buffer 0.11.0-rc.3", @@ -3047,9 +3047,9 @@ dependencies = [ [[package]] name = "zcash_protocol" -version = "0.9.0" +version = "0.10.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5bec496a0bd62dae98c4b26f51c5dab112d0c5350bbc2ccfdfd05bb3454f714d" +checksum = "9f074493fff337207e28bcfa5bbdf0e2a125c4203a4bdd07e72067eea81e9e7b" dependencies = [ "corez", "document-features", @@ -3086,9 +3086,9 @@ dependencies = [ [[package]] name = "zcash_transparent" -version = "0.8.0" +version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "15df1908b428d4edeb7c7caae5692e05e2e92e5c38007a40b20ac098efdffd96" +checksum = "547c012778bae17f58007731af074d638aa146ab0ecfc120adebf23d049aff6c" dependencies = [ "bip32", "bs58", diff --git a/projects/keepkey-vault/zcash-cli/Cargo.toml b/projects/keepkey-vault/zcash-cli/Cargo.toml index 782b9b7a..c40519a1 100644 --- a/projects/keepkey-vault/zcash-cli/Cargo.toml +++ b/projects/keepkey-vault/zcash-cli/Cargo.toml @@ -28,16 +28,15 @@ env_logger = "0.11" # BLAKE2b for ZIP-244 sighash computation blake2b_simd = "1.0" -# Zcash crates — NU6.2 cohort (all published 2026-06-03). orchard 0.14 ships -# the FixedPostNu6_2 Orchard circuit; every prior version is yanked and builds -# pre-fork proofs that current Zebra/zcashd nodes reject ("could not validate -# orchard proof"). These versions must be upgraded together. -orchard = "0.14" -zcash_address = "0.12" -zcash_protocol = "0.9" -zcash_note_encryption = "0.4" -zcash_primitives = "0.28" -zcash_keys = "0.14" +# Zcash crates — NU6.3 cohort. These versions add the post-NU6.3 Orchard +# circuit, the Ironwood value pool, v3 (quantum-recoverable) note plaintexts, +# and transaction-v6 serialization/digests. Keep this cohort in lockstep. +orchard = "0.15.4" +zcash_address = "0.13" +zcash_protocol = "0.10.3" +zcash_note_encryption = "0.4.2" +zcash_primitives = "0.30" +zcash_keys = "0.16.1" # Lightwalletd gRPC client tonic = { version = "0.12", features = ["tls", "tls-roots"] } @@ -53,7 +52,7 @@ rand = "0.8" pasta_curves = "0.5" ff = "0.13" incrementalmerkletree = "0.8" -shardtree = "0.6" +shardtree = "0.7" # ZIP-32 key derivation types zip32 = "0.2" diff --git a/projects/keepkey-vault/zcash-cli/proto/compact_formats.proto b/projects/keepkey-vault/zcash-cli/proto/compact_formats.proto index 2e393192..8cace756 100644 --- a/projects/keepkey-vault/zcash-cli/proto/compact_formats.proto +++ b/projects/keepkey-vault/zcash-cli/proto/compact_formats.proto @@ -4,6 +4,7 @@ package cash.z.wallet.sdk.rpc; message ChainMetadata { uint32 saplingCommitmentTreeSize = 1; uint32 orchardCommitmentTreeSize = 2; + uint32 ironwoodCommitmentTreeSize = 3; } message CompactBlock { @@ -26,6 +27,9 @@ message CompactTx { repeated CompactOrchardAction actions = 6; repeated CompactTxIn vin = 7; repeated TxOut vout = 8; + // Ironwood reuses the Orchard compact action encoding, but has a distinct + // note commitment tree and nullifier set from NU6.3 onward. + repeated CompactOrchardAction ironwoodActions = 9; } message CompactTxIn { diff --git a/projects/keepkey-vault/zcash-cli/proto/service.proto b/projects/keepkey-vault/zcash-cli/proto/service.proto index 8ca24146..c1d53cf0 100644 --- a/projects/keepkey-vault/zcash-cli/proto/service.proto +++ b/projects/keepkey-vault/zcash-cli/proto/service.proto @@ -89,6 +89,7 @@ message GetAddressUtxosReplyList { enum ShieldedProtocol { sapling = 0; orchard = 1; + ironwood = 2; } message SubtreeRoot { @@ -110,6 +111,7 @@ message TreeState { uint32 time = 4; string saplingTree = 5; string orchardTree = 6; + string ironwoodTree = 7; } service CompactTxStreamer { diff --git a/projects/keepkey-vault/zcash-cli/src/main.rs b/projects/keepkey-vault/zcash-cli/src/main.rs index d7856a5d..19abdf59 100644 --- a/projects/keepkey-vault/zcash-cli/src/main.rs +++ b/projects/keepkey-vault/zcash-cli/src/main.rs @@ -7,6 +7,7 @@ mod pczt_builder; mod scanner; mod wallet_db; +mod zip229; mod zip244; use anyhow::Result; @@ -584,6 +585,8 @@ fn record_pending_spent(state: &mut State, raw_tx_hex: String, nullifiers: Vec<[ async fn handle_balance(state: &mut State, _params: &Value) -> Result { let db = state.ensure_db()?; let balance = db.get_balance()?; + let orchard_balance = db.get_balance_for_pool(wallet_db::ShieldedPool::Orchard)?; + let ironwood_balance = db.get_balance_for_pool(wallet_db::ShieldedPool::Ironwood)?; let (total, unspent) = db.get_note_count()?; let synced_to = db.last_scanned_height()?; @@ -593,12 +596,15 @@ async fn handle_balance(state: &mut State, _params: &Value) -> Result { // "Max" button) need this view so they don't propose amounts the builder // would later reject as "all unspent notes are within N confs". let max_h = synced_to.unwrap_or(0).saturating_sub(MIN_CONFIRMATIONS); - let spendable_notes = db.get_spendable_notes(Some(max_h))?; + let spendable_notes = + db.get_spendable_notes_for_pool(Some(max_h), Some(wallet_db::ShieldedPool::Ironwood))?; let spendable_confirmed: u64 = spendable_notes.iter().map(|n| n.value).sum(); let spendable_count = spendable_notes.len() as u64; Ok(serde_json::json!({ "confirmed": balance, + "orchard_confirmed": orchard_balance, + "ironwood_confirmed": ironwood_balance, "pending": 0, "notes_total": total, "notes_unspent": unspent, @@ -651,11 +657,16 @@ async fn handle_build_pczt(state: &mut State, params: &Value) -> Result { let max_block_height = tip.saturating_sub(MIN_CONFIRMATIONS); let db = state.ensure_db()?; - let notes = db.get_spendable_notes(Some(max_block_height))?; + let notes = db.get_spendable_notes_for_pool( + Some(max_block_height), + Some(wallet_db::ShieldedPool::Ironwood), + )?; if notes.is_empty() { // Either truly empty, or every note is too recent. Distinguish so the // user sees an actionable message instead of "no spendable notes". - let total_unspent = db.get_spendable_notes(None)?.len(); + let total_unspent = db + .get_spendable_notes_for_pool(None, Some(wallet_db::ShieldedPool::Ironwood))? + .len(); if total_unspent > 0 { return Err(anyhow::anyhow!( "All {} unspent notes are within {} confirmations of the chain tip ({}). \ @@ -826,7 +837,7 @@ async fn handle_build_shield_pczt(state: &mut State, params: &Value) -> Result Result let max_block_height = tip.saturating_sub(MIN_CONFIRMATIONS); let db = state.ensure_db()?; - let notes = db.get_spendable_notes(Some(max_block_height))?; + let notes = db.get_spendable_notes_for_pool( + Some(max_block_height), + Some(wallet_db::ShieldedPool::Ironwood), + )?; if notes.is_empty() { - let total_unspent = db.get_spendable_notes(None)?.len(); + let total_unspent = db + .get_spendable_notes_for_pool(None, Some(wallet_db::ShieldedPool::Ironwood))? + .len(); if total_unspent > 0 { return Err(anyhow::anyhow!( "All {} unspent notes are within {} confirmations of the chain tip ({}). \ @@ -1074,6 +1090,7 @@ async fn handle_get_transactions(state: &mut State, _params: &Value) -> Result Result { @@ -1453,7 +1470,10 @@ async fn handle_broadcast(state: &mut State, params: &Value) -> Result { || lower.contains("already in block chain") || lower.contains("txn-already-known") { - info!("Broadcast to {}: transaction already known to the network", url); + info!( + "Broadcast to {}: transaction already known to the network", + url + ); already_known = true; } else { log::error!("Broadcast REJECTED by {}: {}", url, e); @@ -1464,7 +1484,10 @@ async fn handle_broadcast(state: &mut State, params: &Value) -> Result { // SendTransaction must not strand an already-signed tx forever — // record the timeout and move on to the next node. Err(_) => { - log::warn!("Broadcast to {} timed out after 15s — trying next node", url); + log::warn!( + "Broadcast to {} timed out after 15s — trying next node", + url + ); last_err = format!("{}: send_transaction timed out after 15s", url); } }, @@ -1977,6 +2000,7 @@ mod tests { // Insert a note so we can detect a reset let db = state.db.as_ref().unwrap(); db.insert_note(&wallet_db::ScannedNote { + pool: wallet_db::ShieldedPool::Orchard, value: 100000, recipient: vec![0u8; 43], rho: [1u8; 32], @@ -2019,6 +2043,7 @@ mod tests { .as_ref() .unwrap() .insert_note(&wallet_db::ScannedNote { + pool: wallet_db::ShieldedPool::Orchard, value: 100000, recipient: vec![0u8; 43], rho: [1u8; 32], diff --git a/projects/keepkey-vault/zcash-cli/src/pczt_builder.rs b/projects/keepkey-vault/zcash-cli/src/pczt_builder.rs index 57ffe0b5..3b73823a 100644 --- a/projects/keepkey-vault/zcash-cli/src/pczt_builder.rs +++ b/projects/keepkey-vault/zcash-cli/src/pczt_builder.rs @@ -16,9 +16,10 @@ use incrementalmerkletree::Retention; use orchard::primitives::redpallas::{self, SpendAuth}; use orchard::{ builder::{Builder, BundleType}, + bundle::BundleVersion, circuit::{ProvingKey, VerifyingKey}, keys::{FullViewingKey, Scope}, - note::{ExtractedNoteCommitment, RandomSeed, Rho}, + note::{ExtractedNoteCommitment, NoteVersion, RandomSeed, Rho}, tree::MerkleHashOrchard, value::NoteValue, Address, Anchor, Note, @@ -34,6 +35,11 @@ const ZIP317_MARGINAL_FEE: u64 = 5000; /// ZIP-317 grace actions — minimum baseline (2 actions are "free"). const ZIP317_GRACE_ACTIONS: u64 = 2; +/// The legacy Orchard bundle version used by v5 construction before NU6.3. +/// NU6.3-facing flows use `orchard_v3()` or `ironwood_v3()` explicitly. +const LEGACY_ORCHARD_BUNDLE_VERSION: BundleVersion = BundleVersion::orchard_v2(); +const IRONWOOD_BUNDLE_VERSION: BundleVersion = BundleVersion::ironwood_v3(); + /// Compute ZIP-317 fee for an Orchard-only transaction. /// fee = marginal_fee × max(grace_actions, logical_actions) /// where logical_actions = max(n_spends, n_outputs) for Orchard. @@ -109,6 +115,8 @@ pub struct HeaderFields { /// The signing request sent to Electrobun, which forwards fields to the device. #[derive(Debug, Serialize)] pub struct SigningRequest { + /// Orchard-family value pool carried by `actions` and `bundle_meta`. + pub pool: &'static str, pub n_actions: u32, pub account: u32, pub branch_id: u32, @@ -130,6 +138,8 @@ pub struct DigestFields { // sapling omitted — clear-signing firmware rejects sapling_digest if set #[serde(with = "hex_bytes")] pub orchard: Vec, + #[serde(with = "hex_bytes", skip_serializing_if = "Vec::is_empty")] + pub ironwood: Vec, } #[derive(Debug, Serialize)] @@ -184,6 +194,21 @@ pub async fn build_pczt( memo: Option, ) -> Result { let mut rng = OsRng; + if branch_id != crate::zip229::NU6_3_BRANCH_ID { + return Err(anyhow::anyhow!( + "Ironwood transactions require NU6.3 branch 0x{:08x}; node reported 0x{:08x}", + crate::zip229::NU6_3_BRANCH_ID, + branch_id + )); + } + if notes + .iter() + .any(|note| note.pool != crate::wallet_db::ShieldedPool::Ironwood) + { + return Err(anyhow::anyhow!( + "Normal private sends can only consume Ironwood notes. Migrate legacy Orchard funds first." + )); + } let total_input: u64 = notes.iter().map(|n| n.value).sum(); let spent_nullifiers: Vec<[u8; 32]> = notes.iter().map(|n| n.nullifier).collect(); @@ -211,7 +236,7 @@ pub async fn build_pczt( let ak_bytes = &fvk_bytes[..32]; debug!("FVK ak (first 4 bytes): {}", hex::encode(&ak_bytes[..4])); - info!("Building Orchard transaction:"); + info!("Building Ironwood transaction-v6:"); info!(" Inputs: {} ZAT from {} notes", total_input, notes.len()); info!(" Amount: {} ZAT", amount); info!(" Fee: {} ZAT", fee); @@ -231,7 +256,7 @@ pub async fn build_pczt( } else { let tree_size_before = if spendable.block_height > 0 { lwd_client - .get_orchard_tree_size_at(spendable.block_height - 1) + .get_ironwood_tree_size_at(spendable.block_height - 1) .await? } else { 0 @@ -249,15 +274,9 @@ pub async fn build_pczt( // Step 2: Fetch all subtree roots + chain tip height let lwd_tip_height = lwd_client.get_latest_block_height().await?; - let subtree_roots = lwd_client.get_subtree_roots(0, 0).await?; + let subtree_roots = lwd_client.get_ironwood_subtree_roots(0, 0).await?; let num_shards = subtree_roots.len(); - info!("Chain has {} completed Orchard subtree shards", num_shards); - - if subtree_roots.is_empty() { - return Err(anyhow::anyhow!( - "No Orchard subtree roots available from lightwalletd" - )); - } + info!("Chain has {} completed Ironwood subtree shards", num_shards); // Build cmx lookup for detecting note positions during tree walk let note_cmx_set: std::collections::HashMap<[u8; 32], usize> = @@ -321,23 +340,24 @@ pub async fn build_pczt( // The previous shard's completing block may contain actions that belong // to THIS shard (cross-boundary). We must include them. let (fetch_start_height, actions_to_skip) = if *shard_idx == 0 { - (1687104u64, 0u64) // Orchard activation — no prior shard + (3428143u64, 0u64) // Ironwood activation — no prior shard } else { let prev_completing = subtree_roots .iter() .find(|(idx, _, _)| *idx == shard_idx - 1) .map(|(_, _, h)| *h) - .unwrap_or(1687104); + .unwrap_or(3428143); let tree_size_before_completing = if prev_completing > 0 { lwd_client - .get_orchard_tree_size_at(prev_completing - 1) + .get_ironwood_tree_size_at(prev_completing - 1) .await? } else { 0 }; - let tree_size_after_completing = - lwd_client.get_orchard_tree_size_at(prev_completing).await?; + let tree_size_after_completing = lwd_client + .get_ironwood_tree_size_at(prev_completing) + .await?; let plan = plan_incomplete_shard_fetch( prev_completing, @@ -402,7 +422,9 @@ pub async fn build_pczt( let mut global_action_counter = 0u64; 'block_fetch: while current_height <= shard_end_height { let end = std::cmp::min(current_height + chunk_size - 1, shard_end_height); - let blocks = lwd_client.fetch_block_actions(current_height, end).await?; + let blocks = lwd_client + .fetch_ironwood_block_actions(current_height, end) + .await?; for (block_height, txs) in &blocks { for (tx_idx, cmxs) in txs { @@ -477,18 +499,18 @@ pub async fn build_pczt( // loop above walks it to the tip (shard_end_pos = u64::MAX), so a second // pass here would double-append. This mirrors build_deshield_pczt. let last_completed_shard = num_shards as u32; - let last_completed_height = subtree_roots.last().map(|(_, _, h)| *h).unwrap_or(1687104); + let last_completed_height = subtree_roots.last().map(|(_, _, h)| *h).unwrap_or(3428143); if !note_shards.contains(&last_completed_shard) && lwd_tip_height > last_completed_height { let shard_start_pos = (last_completed_shard as u64) * SHARD_SIZE; let tree_size_before_completing = if last_completed_height > 0 { lwd_client - .get_orchard_tree_size_at(last_completed_height - 1) + .get_ironwood_tree_size_at(last_completed_height - 1) .await? } else { 0 }; let tree_size_after_completing = lwd_client - .get_orchard_tree_size_at(last_completed_height) + .get_ironwood_tree_size_at(last_completed_height) .await?; let plan = plan_incomplete_shard_fetch( last_completed_height, @@ -507,7 +529,9 @@ pub async fn build_pczt( let mut global_action_counter = 0u64; while current_height <= lwd_tip_height { let end = std::cmp::min(current_height + chunk_size - 1, lwd_tip_height); - let blocks = lwd_client.fetch_block_actions(current_height, end).await?; + let blocks = lwd_client + .fetch_ironwood_block_actions(current_height, end) + .await?; for (_block_height, txs) in &blocks { for (_tx_idx, cmxs) in txs { @@ -539,7 +563,7 @@ pub async fn build_pczt( } // Verify leaf count against lightwalletd's tree size - let expected_tree_size = lwd_client.get_orchard_tree_size_at(lwd_tip_height).await?; + let expected_tree_size = lwd_client.get_ironwood_tree_size_at(lwd_tip_height).await?; // Our tree should cover positions 0..(num_shards * SHARD_SIZE - 1) via shard roots // plus individually-inserted leaves for the incomplete shard. // The total tree size is: (completed shards) * SHARD_SIZE + leaves_in_incomplete_shard @@ -579,6 +603,7 @@ pub async fn build_pczt( NoteValue::from_raw(spendable.value), rho, rseed, + NoteVersion::V3, ) .into_option() .ok_or_else(|| anyhow::anyhow!("Failed to reconstruct note {}", i))?; @@ -610,9 +635,9 @@ pub async fn build_pczt( // If the ShardTree reconstruction produced the wrong root, the tx will be // rejected with "unknown Orchard anchor" — catch that here instead. let expected_anchor = lwd_client - .get_orchard_anchor(lwd_tip_height) + .get_ironwood_anchor(lwd_tip_height) .await - .context("Failed to fetch authoritative Orchard anchor from lightwalletd")?; + .context("Failed to fetch authoritative Ironwood anchor from lightwalletd")?; info!( "Expected anchor (lwd tip {}): {}", lwd_tip_height, @@ -637,7 +662,10 @@ pub async fn build_pczt( // Diagnostic: check if the completed-shards-only root matches lightwalletd // at the completing height of the last completed shard if let Some((_, _, last_completing_height)) = subtree_roots.last() { - match lwd_client.get_orchard_anchor(*last_completing_height).await { + match lwd_client + .get_ironwood_anchor(*last_completing_height) + .await + { Ok(anchor_at_last_shard) => { // Build a tree with only completed shard roots (no individual leaves) let mut diag_tree: ShardTree, 32, 16> = @@ -681,7 +709,7 @@ pub async fn build_pczt( } return Err(anyhow::anyhow!( - "Orchard anchor mismatch: ShardTree={} vs lightwalletd={}. \ + "Ironwood anchor mismatch: ShardTree={} vs lightwalletd={}. \ The tree reconstruction is wrong.", hex::encode(&computed_anchor_bytes), hex::encode(&expected_anchor), @@ -695,7 +723,13 @@ pub async fn build_pczt( ); // Step 7: Build PCZT bundle — add spends sorted by position - let mut builder = Builder::new(BundleType::DEFAULT, anchor); + let mut builder = Builder::new( + BundleType::DEFAULT, + IRONWOOD_BUNDLE_VERSION, + IRONWOOD_BUNDLE_VERSION.default_flags(), + anchor, + ) + .map_err(|e| anyhow::anyhow!("Failed to initialize Ironwood builder: {:?}", e))?; let mut sorted_notes: Vec<(u64, usize)> = note_positions .iter() @@ -806,14 +840,15 @@ pub async fn build_pczt( .build_for_pczt(&mut rng) .map_err(|e| anyhow::anyhow!("Failed to build PCZT: {:?}", e))?; - // Step 3: Compute ZIP-244 digests + // Step 3: Compute transaction-v6 / ZIP-229 digests. let effects_bundle = pczt_bundle .extract_effects::() .map_err(|e| anyhow::anyhow!("Failed to extract effects: {:?}", e))? .ok_or_else(|| anyhow::anyhow!("Empty effects bundle"))?; - let digests = zip244::compute_zip244_digests_effects(&effects_bundle, branch_id, 0, 0); - let sighash = zip244::compute_sighash(&digests, branch_id); + let digests = + crate::zip229::compute_digests_hybrid(&effects_bundle, &[], &[], branch_id, 0, 0)?; + let sighash = crate::zip229::compute_sighash(&digests, branch_id); // ── DEBUG: Log all digest components ── debug!("DEBUG sighash: {}", hex::encode(&sighash)); @@ -830,6 +865,10 @@ pub async fn build_pczt( "DEBUG orchard: {}", hex::encode(&digests.orchard_digest) ); + debug!( + "DEBUG ironwood: {}", + hex::encode(&digests.ironwood_digest) + ); // Log effects rk before randomization for (i, action) in effects_bundle.actions().iter().enumerate() { @@ -856,7 +895,7 @@ pub async fn build_pczt( // Step 5: Generate Halo2 proof info!("Generating Halo2 proof (this may take a while on first run)..."); - let pk = ProvingKey::build(); + let pk = ProvingKey::build(IRONWOOD_BUNDLE_VERSION.circuit_version()); pczt_bundle .create_proof(&pk, &mut rng) .map_err(|e| anyhow::anyhow!("Proof generation failed: {:?}", e))?; @@ -939,11 +978,12 @@ pub async fn build_pczt( }); } - let orchard_flags = effects_bundle.flags().to_byte() as u32; - let orchard_value_balance: i64 = *effects_bundle.value_balance(); - let orchard_anchor_bytes = effects_bundle.anchor().to_bytes(); + let ironwood_flags = effects_bundle.flag_byte() as u32; + let ironwood_value_balance: i64 = *effects_bundle.value_balance(); + let ironwood_anchor_bytes = effects_bundle.anchor().to_bytes(); let signing_request = SigningRequest { + pool: "ironwood", n_actions: n_actions as u32, account, branch_id, @@ -952,17 +992,18 @@ pub async fn build_pczt( header: digests.header_digest.to_vec(), transparent: digests.transparent_digest.to_vec(), orchard: digests.orchard_digest.to_vec(), + ironwood: digests.ironwood_digest.to_vec(), }, header_fields: HeaderFields { - tx_version: 5, - version_group_id: 0x26A7270A, + tx_version: 6, + version_group_id: crate::zip229::VERSION_GROUP_ID, lock_time: 0, expiry_height: 0, }, bundle_meta: BundleMeta { - flags: orchard_flags, - value_balance: orchard_value_balance, - anchor: orchard_anchor_bytes.to_vec(), + flags: ironwood_flags, + value_balance: ironwood_value_balance, + anchor: ironwood_anchor_bytes.to_vec(), }, actions: action_fields, display: DisplayInfo { @@ -981,7 +1022,7 @@ pub async fn build_pczt( }) } -/// Apply device signatures to the PCZT and produce the final v5 transaction bytes. +/// Apply device signatures to the PCZT and produce the final v6 Ironwood transaction. pub fn finalize_pczt( mut pczt_bundle: orchard::pczt::Bundle, sighash: [u8; 32], @@ -1072,103 +1113,20 @@ pub fn finalize_pczt( .apply_binding_signature(sighash, &mut rng) .ok_or_else(|| anyhow::anyhow!("Binding signature verification failed"))?; - // In-process proof verification — catches circuit constraint violations BEFORE - // broadcast. If this fails, the chain rejects with "could not validate orchard - // proof". The shield path already does this; the z→z spend path did not, so the - // only signal was the opaque consensus rejection. Now we get the real halo2 error - // locally and know it's the proof (not serialization) for an aged deep-shard spend. - let vk = VerifyingKey::build(); - authorized_bundle.verify_proof(&vk).map_err(|e| { - anyhow::anyhow!( - "Local Orchard proof verification FAILED (would be rejected on-chain): {:?}", - e - ) - })?; - info!("Local Orchard proof verification: PASSED"); - - // FULL consensus check: proof + spend-auth sigs + binding sig together, the - // exact thing zebra runs. verify_proof() above only covers the zk proof and - // always passes for a self-consistent bundle — it can't catch a binding-sig - // or sighash problem. Run the BatchValidator with the SAME sighash the device - // signed AND with the sighash recomputed from the final tx; a divergence in - // outcome localizes the bug to the sighash. If both pass, the chain rejection - // is consensus STATE (already-spent nullifier / unknown anchor), not our tx. - { - let mut bv = orchard::bundle::BatchValidator::new(); - bv.add_bundle(&authorized_bundle, sighash); - let ok_signing = bv.validate(&VerifyingKey::build(), OsRng); - info!( - "BatchValidator (proof+sigs+binding, signing sighash): {}", - if ok_signing { "PASS" } else { "FAIL" } - ); - // FAIL-CLOSED: a FAIL here is exactly what the chain runs — proof, spend-auth - // sigs, and binding sig together. Broadcasting past it just burns a doomed tx - // and surfaces as the opaque "could not validate orchard proof" rejection. - if !ok_signing { - return Err(anyhow::anyhow!( - "BatchValidator FAILED under the device-signed sighash — proof/spend-auth/\ - binding signatures are inconsistent; the network would reject this tx. \ - Aborting before broadcast." - )); - } - - // Recompute the consensus sighash from the FINAL authorized bundle. - let cs_header = zip244::digest_header(branch_id, 0, 0); - let cs_orchard = zip244::digest_orchard(&authorized_bundle); - let cs_digests = zip244::Zip244Digests { - header_digest: cs_header, - transparent_digest: zip244::EMPTY_TRANSPARENT_DIGEST, - sapling_digest: zip244::EMPTY_SAPLING_DIGEST, - orchard_digest: cs_orchard, - }; - let consensus_sighash = zip244::compute_sighash(&cs_digests, branch_id); - if consensus_sighash != sighash { - // The chain recomputes the sighash from the tx and checks sigs against - // it. The device signed a DIFFERENT sighash, so on-chain verification - // is guaranteed to fail — abort rather than broadcast a doomed tx. - log::error!( - "SIGHASH DIVERGENCE: device signed {} but final-tx consensus sighash is {}", - hex::encode(&sighash), - hex::encode(&consensus_sighash) - ); - let mut bv2 = orchard::bundle::BatchValidator::new(); - bv2.add_bundle(&authorized_bundle, consensus_sighash); - let ok_consensus = bv2.validate(&VerifyingKey::build(), OsRng); - info!( - "BatchValidator (consensus sighash): {}", - if ok_consensus { "PASS" } else { "FAIL" } - ); - return Err(anyhow::anyhow!( - "Consensus sighash {} diverges from the device-signed sighash {} \ - (BatchValidator under consensus sighash: {}). The network computes the \ - consensus sighash, so this tx is doomed. Aborting before broadcast.", - hex::encode(&consensus_sighash), - hex::encode(&sighash), - if ok_consensus { "PASS" } else { "FAIL" }, - )); - } else { - info!( - "Signing sighash == consensus sighash ({})", - hex::encode(&sighash) - ); - } - } + let ironwood_digest = crate::zip229::digest_bundle_authorized(&authorized_bundle)?; + validate_hybrid_ironwood_consensus( + "Private send", + &authorized_bundle, + sighash, + branch_id, + &[], + &[], + ironwood_digest, + )?; - // Serialize as v5 transaction - let tx_bytes = serialize_v5_shielded_tx(&authorized_bundle, branch_id)?; - - // Compute txid per ZIP-244: BLAKE2b("ZcashTxHash_" || branch_id, - // header_digest || transparent_digest || sapling_digest || orchard_digest) - // For pure shielded: transparent_digest = EMPTY, sapling_digest = EMPTY - let header_digest = zip244::digest_header(branch_id, 0, 0); - let orchard_digest = zip244::digest_orchard(&authorized_bundle); - let txid_digests = zip244::Zip244Digests { - header_digest, - transparent_digest: zip244::EMPTY_TRANSPARENT_DIGEST, - sapling_digest: zip244::EMPTY_SAPLING_DIGEST, - orchard_digest, - }; - let txid_hash = zip244::compute_sighash(&txid_digests, branch_id); + let tx_bytes = + serialize_v6_ironwood_hybrid_tx(&authorized_bundle, &[], &[], &[], branch_id, None)?; + let txid_hash = crate::zip229::compute_txid(ironwood_digest, &[], &[], branch_id, 0, 0); let txid = hex::encode(&txid_hash); info!( @@ -1188,7 +1146,8 @@ fn validate_hybrid_orchard_consensus( transparent_outputs: &[zip244::TransparentOutput], expected_orchard_digest: [u8; 32], ) -> Result<[u8; 32]> { - let vk = VerifyingKey::build(); + let circuit_version = authorized_bundle.bundle_version().circuit_version(); + let vk = VerifyingKey::build(circuit_version); authorized_bundle.verify_proof(&vk).map_err(|e| { anyhow::anyhow!( "{} local Orchard proof verification FAILED (would be rejected on-chain): {:?}", @@ -1198,9 +1157,10 @@ fn validate_hybrid_orchard_consensus( })?; info!("{} local Orchard proof verification: PASSED", context); - let mut bv = orchard::bundle::BatchValidator::new(); - bv.add_bundle(authorized_bundle, signing_sighash); - let ok_signing = bv.validate(&vk, OsRng); + let mut bv = orchard::bundle::BatchValidator::new(&vk); + bv.add_bundle(authorized_bundle, signing_sighash) + .map_err(|e| anyhow::anyhow!("Batch validation setup failed: {:?}", e))?; + let ok_signing = bv.validate(OsRng); info!( "{} BatchValidator (proof+sigs+binding, signing sighash): {}", context, @@ -1248,9 +1208,10 @@ fn validate_hybrid_orchard_consensus( hex::encode(signing_sighash), hex::encode(consensus_sighash) ); - let mut bv2 = orchard::bundle::BatchValidator::new(); - bv2.add_bundle(authorized_bundle, consensus_sighash); - let ok_consensus = bv2.validate(&vk, OsRng); + let mut bv2 = orchard::bundle::BatchValidator::new(&vk); + bv2.add_bundle(authorized_bundle, consensus_sighash) + .map_err(|e| anyhow::anyhow!("Batch validation setup failed: {:?}", e))?; + let ok_consensus = bv2.validate(OsRng); return Err(anyhow::anyhow!( "{} consensus sighash {} diverges from device-signed sighash {} \ (BatchValidator under consensus sighash: {}). The network computes the \ @@ -1318,7 +1279,7 @@ fn serialize_v5_shielded_tx( } // Orchard flags - tx.push(bundle.flags().to_byte()); + tx.push(bundle.flag_byte()); // valueBalanceOrchard (i64, 8 bytes LE) tx.extend_from_slice(&bundle.value_balance().to_le_bytes()); @@ -1430,7 +1391,7 @@ pub struct ShieldPcztState { pub transparent_signing_inputs: Vec, } -/// Build a shield PCZT: transparent inputs → Orchard output. +/// Build a shield PCZT: transparent inputs → Ironwood output (NU6.3). /// /// Creates an Orchard bundle with output only (builder auto-creates dummy spend), /// computes ZIP-244 hybrid digests, and returns per-input transparent sighashes. @@ -1503,14 +1464,15 @@ pub async fn build_shield_pczt( }); } - // Build Orchard bundle with output only (shielding — no spends from Orchard pool). + // Build an Ironwood bundle with output only. NU6.3 forbids a negative + // Orchard value balance, so all newly shielded value must enter Ironwood. // Must use BundleType::DEFAULT (enableSpends=true) because ZIP-225 requires it // for non-coinbase transactions. // // We need a REAL chain anchor for the Halo2 proof to verify. - // Build a ShardTree from subtree roots to get the current Orchard tree root. + // Build a ShardTree from subtree roots to get the current Ironwood tree root. // For output-only (no real spends), we don't need witnesses — just the root. - let subtree_roots = lwd_client.get_subtree_roots(0, 0).await?; + let subtree_roots = lwd_client.get_ironwood_subtree_roots(0, 0).await?; info!( "Fetched {} subtree roots for anchor computation", subtree_roots.len() @@ -1543,20 +1505,22 @@ pub async fn build_shield_pczt( use orchard::note::ExtractedNoteCommitment; let last_completed_shard = subtree_roots.len() as u32; - let last_completed_height = subtree_roots.last().map(|(_, _, h)| *h).unwrap_or(1687104); + // Mainnet NU6.3 activation. Before the first completed subtree there is no + // subtree-root height to use as the lower scan boundary. + let last_completed_height = subtree_roots.last().map(|(_, _, h)| *h).unwrap_or(3428143); let tip = lwd_client.get_latest_block_height().await?; if tip > last_completed_height { let shard_start_pos = (last_completed_shard as u64) * (1 << 16); let tree_size_before_completing = if last_completed_height > 0 { lwd_client - .get_orchard_tree_size_at(last_completed_height - 1) + .get_ironwood_tree_size_at(last_completed_height - 1) .await? } else { 0 }; let tree_size_after_completing = lwd_client - .get_orchard_tree_size_at(last_completed_height) + .get_ironwood_tree_size_at(last_completed_height) .await?; let fetch_plan = plan_incomplete_shard_fetch( last_completed_height, @@ -1594,7 +1558,9 @@ pub async fn build_shield_pczt( while current_height <= tip { let end = std::cmp::min(current_height + chunk_size - 1, tip); - let blocks = lwd_client.fetch_block_actions(current_height, end).await?; + let blocks = lwd_client + .fetch_ironwood_block_actions(current_height, end) + .await?; for (_block_height, txs) in &blocks { for (_tx_idx, cmxs) in txs { @@ -1636,28 +1602,34 @@ pub async fn build_shield_pczt( .ok_or_else(|| anyhow::anyhow!("Empty tree root"))?; let anchor: Anchor = tree_root.into(); info!( - "Using Orchard anchor (from chain subtree roots): {}", + "Using Ironwood anchor (from chain subtree roots): {}", hex::encode(&anchor.to_bytes()) ); let expected_anchor = lwd_client - .get_orchard_anchor(tip) + .get_ironwood_anchor(tip) .await - .context("Failed to fetch authoritative Orchard anchor from lightwalletd")?; + .context("Failed to fetch authoritative Ironwood anchor from lightwalletd")?; if anchor.to_bytes() != expected_anchor { return Err(anyhow::anyhow!( - "Shield Orchard anchor mismatch: reconstructed={} vs lightwalletd={} at tip {}", + "Shield Ironwood anchor mismatch: reconstructed={} vs lightwalletd={} at tip {}", hex::encode(anchor.to_bytes()), hex::encode(expected_anchor), tip, )); } info!( - "Shield Orchard anchor verified against lightwalletd: {}", + "Shield Ironwood anchor verified against lightwalletd: {}", hex::encode(&expected_anchor) ); - let mut builder = Builder::new(BundleType::DEFAULT, anchor); + let mut builder = Builder::new( + BundleType::DEFAULT, + IRONWOOD_BUNDLE_VERSION, + IRONWOOD_BUNDLE_VERSION.default_flags(), + anchor, + ) + .map_err(|e| anyhow::anyhow!("Failed to initialize Ironwood builder: {:?}", e))?; let recipient = fvk.address_at(0u32, Scope::External); @@ -1675,7 +1647,7 @@ pub async fn build_shield_pczt( NoteValue::from_raw(amount), memo_bytes, ) - .map_err(|e| anyhow::anyhow!("Failed to add Orchard output: {:?}", e))?; + .map_err(|e| anyhow::anyhow!("Failed to add Ironwood output: {:?}", e))?; let (mut pczt_bundle, _) = builder .build_for_pczt(&mut rng) @@ -1687,17 +1659,17 @@ pub async fn build_shield_pczt( .map_err(|e| anyhow::anyhow!("Failed to extract effects: {:?}", e))? .ok_or_else(|| anyhow::anyhow!("Empty effects bundle"))?; - // Compute ZIP-244 hybrid digests (real transparent + Orchard) - let digests = zip244::compute_zip244_digests_hybrid( + // Compute ZIP-229 v6 digests (real transparent + Ironwood; empty Orchard). + let digests = crate::zip229::compute_digests_hybrid( &effects_bundle, &zip_inputs, &zip_outputs, branch_id, 0, 0, - ); + )?; - let sighash = zip244::compute_sighash(&digests, branch_id); + let sighash = crate::zip229::compute_sighash(&digests, branch_id); info!("Hybrid digests computed:"); info!(" header: {}", hex::encode(&digests.header_digest)); @@ -1707,6 +1679,7 @@ pub async fn build_shield_pczt( ); info!(" sapling: {}", hex::encode(&digests.sapling_digest)); info!(" orchard: {}", hex::encode(&digests.orchard_digest)); + info!(" ironwood: {}", hex::encode(&digests.ironwood_digest)); info!(" sighash: {}", hex::encode(&sighash)); // Compute per-input transparent sighashes @@ -1720,13 +1693,11 @@ pub async fn build_shield_pczt( let mut transparent_signing: Vec = Vec::new(); for (i, input) in zip_inputs.iter().enumerate() { - let input_sighash = zip244::compute_transparent_sig_hash( + let input_sighash = crate::zip229::compute_transparent_sig_hash( i, &zip_inputs, &zip_outputs, - &digests.orchard_digest, - &digests.header_digest, - &digests.sapling_digest, + &digests, branch_id, ); @@ -1742,19 +1713,19 @@ pub async fn build_shield_pczt( }); } - // Finalize IO + proof for the Orchard bundle + // Finalize IO + proof for the Ironwood bundle. pczt_bundle .finalize_io(sighash, &mut rng) .map_err(|e| anyhow::anyhow!("IO finalization failed: {:?}", e))?; info!("Generating Halo2 proof for shield tx..."); - let pk = ProvingKey::build(); + let pk = ProvingKey::build(IRONWOOD_BUNDLE_VERSION.circuit_version()); pczt_bundle .create_proof(&pk, &mut rng) .map_err(|e| anyhow::anyhow!("Proof generation failed: {:?}", e))?; info!("Proof generated"); - // Extract Orchard signing fields + // Extract Ironwood signing fields. let n_actions = pczt_bundle.actions().len(); let mut action_fields: Vec = Vec::new(); @@ -1834,11 +1805,12 @@ pub async fn build_shield_pczt( }); } - let orchard_flags = effects_bundle.flags().to_byte() as u32; - let orchard_value_balance: i64 = *effects_bundle.value_balance(); - let orchard_anchor_bytes = effects_bundle.anchor().to_bytes(); + let ironwood_flags = effects_bundle.flag_byte() as u32; + let ironwood_value_balance: i64 = *effects_bundle.value_balance(); + let ironwood_anchor_bytes = effects_bundle.anchor().to_bytes(); let orchard_signing_request = SigningRequest { + pool: "ironwood", n_actions: n_actions as u32, account, branch_id, @@ -1847,23 +1819,24 @@ pub async fn build_shield_pczt( header: digests.header_digest.to_vec(), transparent: digests.transparent_digest.to_vec(), orchard: digests.orchard_digest.to_vec(), + ironwood: digests.ironwood_digest.to_vec(), }, header_fields: HeaderFields { - tx_version: 5, - version_group_id: 0x26A7270A, + tx_version: 6, + version_group_id: crate::zip229::VERSION_GROUP_ID, lock_time: 0, expiry_height: 0, }, bundle_meta: BundleMeta { - flags: orchard_flags, - value_balance: orchard_value_balance, - anchor: orchard_anchor_bytes.to_vec(), + flags: ironwood_flags, + value_balance: ironwood_value_balance, + anchor: ironwood_anchor_bytes.to_vec(), }, actions: action_fields, display: DisplayInfo { amount: format!("{:.8} ZEC", amount as f64 / 1e8), fee: format!("{:.8} ZEC", fee as f64 / 1e8), - to: "Orchard (self-shield)".to_string(), + to: "Ironwood (self-shield)".to_string(), }, }; @@ -1944,30 +1917,31 @@ pub fn finalize_shield_pczt( .apply_binding_signature(sighash, &mut rng) .ok_or_else(|| anyhow::anyhow!("Binding signature verification failed"))?; - let effects_orchard_digest: [u8; 32] = state + let effects_ironwood_digest: [u8; 32] = state .orchard_signing_request .digests - .orchard + .ironwood .as_slice() .try_into() .map_err(|_| { anyhow::anyhow!( - "Shield signing request Orchard digest must be 32 bytes, got {}", - state.orchard_signing_request.digests.orchard.len(), + "Shield signing request Ironwood digest must be 32 bytes, got {}", + state.orchard_signing_request.digests.ironwood.len(), ) })?; - let orchard_digest = validate_hybrid_orchard_consensus( + let ironwood_digest = validate_hybrid_ironwood_consensus( "Shield", &authorized_bundle, state.sighash, state.branch_id, &state.transparent_inputs, &state.transparent_outputs, - effects_orchard_digest, + effects_ironwood_digest, )?; - // Serialize as hybrid v5 transaction - let tx_bytes = serialize_v5_hybrid_tx( + // Serialize as a hybrid v6 transaction with an empty Orchard slot and an + // authorized Ironwood slot. + let tx_bytes = serialize_v6_ironwood_hybrid_tx( &authorized_bundle, &state.transparent_inputs, &state.transparent_outputs, @@ -1976,19 +1950,14 @@ pub fn finalize_shield_pczt( compressed_pubkey, )?; - // Compute txid per ZIP-244: BLAKE2b("ZcashTxHash_" || branch_id, - // header_digest || transparent_digest(txid ver) || sapling_digest || orchard_digest) - // Note: txid uses the NON-sig transparent_digest (no hash_type, no txin_sig_digest) - let header_digest = zip244::digest_header(state.branch_id, 0, 0); - let transparent_txid_digest = - zip244::digest_transparent_txid(&state.transparent_inputs, &state.transparent_outputs); - let txid_digests = zip244::Zip244Digests { - header_digest, - transparent_digest: transparent_txid_digest, - sapling_digest: zip244::EMPTY_SAPLING_DIGEST, - orchard_digest, - }; - let txid_hash = zip244::compute_sighash(&txid_digests, state.branch_id); + let txid_hash = crate::zip229::compute_txid( + ironwood_digest, + &state.transparent_inputs, + &state.transparent_outputs, + state.branch_id, + 0, + 0, + ); let txid = hex::encode(&txid_hash); info!("Shield tx built: {} bytes, txid: {}", tx_bytes.len(), txid); @@ -2057,6 +2026,179 @@ fn plan_orchard_signature_application( )) } +/// Fail-closed validation for an authorized Ironwood bundle and the complete +/// transaction-v6 sighash that the device signed. +fn validate_hybrid_ironwood_consensus( + context: &str, + authorized_bundle: &orchard::Bundle, + signing_sighash: [u8; 32], + branch_id: u32, + transparent_inputs: &[zip244::TransparentInput], + transparent_outputs: &[zip244::TransparentOutput], + expected_ironwood_digest: [u8; 32], +) -> Result<[u8; 32]> { + if authorized_bundle.bundle_version() != IRONWOOD_BUNDLE_VERSION { + return Err(anyhow::anyhow!( + "{} expected an Ironwood v3 bundle, got {:?}", + context, + authorized_bundle.bundle_version() + )); + } + + let vk = VerifyingKey::build(IRONWOOD_BUNDLE_VERSION.circuit_version()); + authorized_bundle.verify_proof(&vk).map_err(|e| { + anyhow::anyhow!( + "{} local Ironwood proof verification FAILED: {:?}", + context, + e + ) + })?; + + let mut validator = orchard::bundle::BatchValidator::new(&vk); + validator + .add_bundle(authorized_bundle, signing_sighash) + .map_err(|e| anyhow::anyhow!("{} batch setup failed: {:?}", context, e))?; + if !validator.validate(OsRng) { + return Err(anyhow::anyhow!( + "{} Ironwood proof/signature/binding validation failed", + context + )); + } + + let ironwood_digest = crate::zip229::digest_bundle_authorized(authorized_bundle)?; + if ironwood_digest != expected_ironwood_digest { + return Err(anyhow::anyhow!( + "{} Ironwood digest changed after authorization: effects={} authorized={}", + context, + hex::encode(expected_ironwood_digest), + hex::encode(ironwood_digest) + )); + } + + let consensus_digests = crate::zip229::Zip229Digests { + header_digest: crate::zip229::digest_header(branch_id, 0, 0), + transparent_digest: zip244::digest_transparent_sig_for_orchard( + transparent_inputs, + transparent_outputs, + ), + sapling_digest: zip244::EMPTY_SAPLING_DIGEST, + orchard_digest: crate::zip229::empty_orchard_digest(), + ironwood_digest, + }; + let consensus_sighash = crate::zip229::compute_sighash(&consensus_digests, branch_id); + if consensus_sighash != signing_sighash { + return Err(anyhow::anyhow!( + "{} transaction-v6 consensus sighash {} diverges from signed sighash {}", + context, + hex::encode(consensus_sighash), + hex::encode(signing_sighash) + )); + } + + info!( + "{} Ironwood proof, bundle digest, and transaction-v6 sighash verified", + context + ); + Ok(ironwood_digest) +} + +/// Serialize a transaction-v6 hybrid with transparent components and an +/// Ironwood bundle. The Orchard bundle slot is encoded first and is empty. +fn serialize_v6_ironwood_hybrid_tx( + bundle: &orchard::Bundle, + transparent_inputs: &[zip244::TransparentInput], + transparent_outputs: &[zip244::TransparentOutput], + transparent_signatures: &[Vec], + branch_id: u32, + compressed_pubkey: Option<&[u8]>, +) -> Result> { + if bundle.bundle_version() != IRONWOOD_BUNDLE_VERSION { + return Err(anyhow::anyhow!( + "Refusing to serialize a non-Ironwood bundle in the v6 Ironwood slot" + )); + } + if transparent_signatures.len() < transparent_inputs.len() { + return Err(anyhow::anyhow!( + "Not enough transparent signatures: got {} but need {}", + transparent_signatures.len(), + transparent_inputs.len() + )); + } + + let mut tx = Vec::new(); + tx.extend_from_slice(&crate::zip229::TX_VERSION.to_le_bytes()); + tx.extend_from_slice(&crate::zip229::VERSION_GROUP_ID.to_le_bytes()); + tx.extend_from_slice(&branch_id.to_le_bytes()); + tx.extend_from_slice(&0u32.to_le_bytes()); + tx.extend_from_slice(&0u32.to_le_bytes()); + + write_compact_size(&mut tx, transparent_inputs.len() as u64); + for (index, input) in transparent_inputs.iter().enumerate() { + tx.extend_from_slice(&input.prevout_hash); + tx.extend_from_slice(&input.prevout_index.to_le_bytes()); + + let signature = &transparent_signatures[index]; + let pubkey = compressed_pubkey + .ok_or_else(|| anyhow::anyhow!("Compressed pubkey required for P2PKH scriptSig"))?; + if pubkey.len() != 33 || signature.len() + 1 > 75 { + return Err(anyhow::anyhow!( + "Invalid P2PKH signature or public key length" + )); + } + let mut script_sig = Vec::with_capacity(signature.len() + pubkey.len() + 3); + script_sig.push((signature.len() + 1) as u8); + script_sig.extend_from_slice(signature); + script_sig.push(0x01); + script_sig.push(pubkey.len() as u8); + script_sig.extend_from_slice(pubkey); + write_compact_size(&mut tx, script_sig.len() as u64); + tx.extend_from_slice(&script_sig); + tx.extend_from_slice(&input.sequence.to_le_bytes()); + } + + write_compact_size(&mut tx, transparent_outputs.len() as u64); + for output in transparent_outputs { + tx.extend_from_slice(&(output.value as i64).to_le_bytes()); + write_compact_size(&mut tx, output.script_pubkey.len() as u64); + tx.extend_from_slice(&output.script_pubkey); + } + + tx.push(0); // Sapling spends + tx.push(0); // Sapling outputs + tx.push(0); // Orchard actions (empty v6 Orchard slot) + + write_orchard_family_bundle(&mut tx, bundle); + Ok(tx) +} + +fn write_orchard_family_bundle( + tx: &mut Vec, + bundle: &orchard::Bundle, +) { + write_compact_size(tx, bundle.actions().len() as u64); + for action in bundle.actions() { + tx.extend_from_slice(&action.cv_net().to_bytes()); + tx.extend_from_slice(&action.nullifier().to_bytes()); + tx.extend_from_slice(&<[u8; 32]>::from(action.rk())); + tx.extend_from_slice(&action.cmx().to_bytes()); + tx.extend_from_slice(action.encrypted_note().epk_bytes.as_ref()); + tx.extend_from_slice(&action.encrypted_note().enc_ciphertext); + tx.extend_from_slice(&action.encrypted_note().out_ciphertext); + } + tx.push(bundle.flag_byte()); + tx.extend_from_slice(&bundle.value_balance().to_le_bytes()); + tx.extend_from_slice(&bundle.anchor().to_bytes()); + let proof = bundle.authorization().proof().as_ref(); + write_compact_size(tx, proof.len() as u64); + tx.extend_from_slice(proof); + for action in bundle.actions() { + tx.extend_from_slice(&<[u8; 64]>::from(action.authorization())); + } + tx.extend_from_slice(&<[u8; 64]>::from( + bundle.authorization().binding_signature(), + )); +} + /// Serialize a v5 transaction with both transparent and Orchard components. fn serialize_v5_hybrid_tx( bundle: &orchard::Bundle, @@ -2141,7 +2283,7 @@ fn serialize_v5_hybrid_tx( tx.extend_from_slice(&action.encrypted_note().out_ciphertext); } - tx.push(bundle.flags().to_byte()); + tx.push(bundle.flag_byte()); tx.extend_from_slice(&bundle.value_balance().to_le_bytes()); tx.extend_from_slice(&bundle.anchor().to_bytes()); @@ -2211,6 +2353,21 @@ pub async fn build_deshield_pczt( _db: &crate::wallet_db::WalletDb, ) -> Result { let mut rng = OsRng; + if branch_id != crate::zip229::NU6_3_BRANCH_ID { + return Err(anyhow::anyhow!( + "Ironwood transactions require NU6.3 branch 0x{:08x}; node reported 0x{:08x}", + crate::zip229::NU6_3_BRANCH_ID, + branch_id + )); + } + if notes + .iter() + .any(|note| note.pool != crate::wallet_db::ShieldedPool::Ironwood) + { + return Err(anyhow::anyhow!( + "Deshield can only consume Ironwood notes. Migrate legacy Orchard funds first." + )); + } let total_input: u64 = notes.iter().map(|n| n.value).sum(); let spent_nullifiers: Vec<[u8; 32]> = notes.iter().map(|n| n.nullifier).collect(); @@ -2250,7 +2407,7 @@ pub async fn build_deshield_pczt( info!(" Inputs: {} ZAT from {} notes", total_input, notes.len()); info!(" Amount: {} ZAT → transparent", amount); info!(" Fee: {} ZAT", fee); - info!(" Change: {} ZAT → Orchard", change); + info!(" Change: {} ZAT → Ironwood", change); // Build transparent output let script_pubkey_bytes = hex::decode(&transparent_output.script_pubkey)?; @@ -2272,7 +2429,7 @@ pub async fn build_deshield_pczt( } else { let tree_size_before = if spendable.block_height > 0 { lwd_client - .get_orchard_tree_size_at(spendable.block_height - 1) + .get_ironwood_tree_size_at(spendable.block_height - 1) .await? } else { 0 @@ -2289,15 +2446,9 @@ pub async fn build_deshield_pczt( } let lwd_tip_height = lwd_client.get_latest_block_height().await?; - let subtree_roots = lwd_client.get_subtree_roots(0, 0).await?; + let subtree_roots = lwd_client.get_ironwood_subtree_roots(0, 0).await?; let num_shards = subtree_roots.len(); - if subtree_roots.is_empty() { - return Err(anyhow::anyhow!( - "No Orchard subtree roots available from lightwalletd" - )); - } - let note_cmx_set: std::collections::HashMap<[u8; 32], usize> = notes.iter().enumerate().map(|(i, n)| (n.cmx, i)).collect(); @@ -2344,22 +2495,23 @@ pub async fn build_deshield_pczt( let shard_start_pos = (*shard_idx as u64) * SHARD_SIZE; let (fetch_start_height, actions_to_skip) = if *shard_idx == 0 { - (1687104u64, 0u64) + (crate::zip229::NU6_3_ACTIVATION_HEIGHT, 0u64) } else { let prev_completing = subtree_roots .iter() .find(|(idx, _, _)| *idx == shard_idx - 1) .map(|(_, _, h)| *h) - .unwrap_or(1687104); + .unwrap_or(crate::zip229::NU6_3_ACTIVATION_HEIGHT); let tree_size_before_completing = if prev_completing > 0 { lwd_client - .get_orchard_tree_size_at(prev_completing - 1) + .get_ironwood_tree_size_at(prev_completing - 1) .await? } else { 0 }; - let tree_size_after_completing = - lwd_client.get_orchard_tree_size_at(prev_completing).await?; + let tree_size_after_completing = lwd_client + .get_ironwood_tree_size_at(prev_completing) + .await?; let plan = plan_incomplete_shard_fetch( prev_completing, shard_start_pos, @@ -2396,7 +2548,9 @@ pub async fn build_deshield_pczt( let mut global_action_counter = 0u64; 'block_fetch: while current_height <= shard_end_height { let end = std::cmp::min(current_height + chunk_size - 1, shard_end_height); - let blocks = lwd_client.fetch_block_actions(current_height, end).await?; + let blocks = lwd_client + .fetch_ironwood_block_actions(current_height, end) + .await?; for (_block_height, txs) in &blocks { for (_tx_idx, cmxs) in txs { @@ -2448,18 +2602,21 @@ pub async fn build_deshield_pczt( // = u64::MAX, shard_end_height = lwd_tip_height), so a second pass here // would double-append leaves. let last_completed_shard = subtree_roots.len() as u32; - let last_completed_height = subtree_roots.last().map(|(_, _, h)| *h).unwrap_or(1687104); + let last_completed_height = subtree_roots + .last() + .map(|(_, _, h)| *h) + .unwrap_or(crate::zip229::NU6_3_ACTIVATION_HEIGHT); if !note_shards.contains(&last_completed_shard) && lwd_tip_height > last_completed_height { let shard_start_pos = (last_completed_shard as u64) * SHARD_SIZE; let tree_size_before_completing = if last_completed_height > 0 { lwd_client - .get_orchard_tree_size_at(last_completed_height - 1) + .get_ironwood_tree_size_at(last_completed_height - 1) .await? } else { 0 }; let tree_size_after_completing = lwd_client - .get_orchard_tree_size_at(last_completed_height) + .get_ironwood_tree_size_at(last_completed_height) .await?; let plan = plan_incomplete_shard_fetch( last_completed_height, @@ -2478,7 +2635,9 @@ pub async fn build_deshield_pczt( let mut global_action_counter = 0u64; while current_height <= lwd_tip_height { let end = std::cmp::min(current_height + chunk_size - 1, lwd_tip_height); - let blocks = lwd_client.fetch_block_actions(current_height, end).await?; + let blocks = lwd_client + .fetch_ironwood_block_actions(current_height, end) + .await?; for (_block_height, txs) in &blocks { for (_tx_idx, cmxs) in txs { @@ -2531,6 +2690,7 @@ pub async fn build_deshield_pczt( NoteValue::from_raw(spendable.value), rho, rseed, + NoteVersion::V3, ) .into_option() .ok_or_else(|| anyhow::anyhow!("Failed to reconstruct note {}", i))?; @@ -2551,10 +2711,10 @@ pub async fn build_deshield_pczt( .ok_or_else(|| anyhow::anyhow!("Empty Merkle tree"))?; let computed_anchor_bytes = root.to_bytes(); - let expected_anchor = lwd_client.get_orchard_anchor(lwd_tip_height).await?; + let expected_anchor = lwd_client.get_ironwood_anchor(lwd_tip_height).await?; if computed_anchor_bytes != expected_anchor { return Err(anyhow::anyhow!( - "Orchard anchor mismatch: computed={} vs expected={}", + "Ironwood anchor mismatch: computed={} vs expected={}", hex::encode(&computed_anchor_bytes), hex::encode(&expected_anchor), )); @@ -2563,7 +2723,13 @@ pub async fn build_deshield_pczt( // ── Build PCZT bundle ────────────────────────────────────────── - let mut builder = Builder::new(BundleType::DEFAULT, anchor); + let mut builder = Builder::new( + BundleType::DEFAULT, + IRONWOOD_BUNDLE_VERSION, + IRONWOOD_BUNDLE_VERSION.default_flags(), + anchor, + ) + .map_err(|e| anyhow::anyhow!("Failed to initialize Ironwood builder: {:?}", e))?; let mut sorted_notes: Vec<(u64, usize)> = note_positions .iter() @@ -2631,29 +2797,29 @@ pub async fn build_deshield_pczt( .build_for_pczt(&mut rng) .map_err(|e| anyhow::anyhow!("Failed to build PCZT: {:?}", e))?; - // ── Compute ZIP-244 digests (hybrid: transparent outputs + Orchard) ── + // ── Compute ZIP-229 digests (hybrid: transparent outputs + Ironwood) ── let effects_bundle = pczt_bundle .extract_effects::() .map_err(|e| anyhow::anyhow!("Failed to extract effects: {:?}", e))? .ok_or_else(|| anyhow::anyhow!("Empty effects bundle"))?; - let digests = zip244::compute_zip244_digests_hybrid( + let digests = crate::zip229::compute_digests_hybrid( &effects_bundle, &[], &transparent_outputs, branch_id, 0, 0, - ); - let sighash = zip244::compute_sighash(&digests, branch_id); + )?; + let sighash = crate::zip229::compute_sighash(&digests, branch_id); pczt_bundle .finalize_io(sighash, &mut rng) .map_err(|e| anyhow::anyhow!("IO finalization failed: {:?}", e))?; info!("Generating Halo2 proof for deshield..."); - let pk = ProvingKey::build(); + let pk = ProvingKey::build(IRONWOOD_BUNDLE_VERSION.circuit_version()); pczt_bundle .create_proof(&pk, &mut rng) .map_err(|e| anyhow::anyhow!("Proof generation failed: {:?}", e))?; @@ -2725,6 +2891,7 @@ pub async fn build_deshield_pczt( } let signing_request = SigningRequest { + pool: "ironwood", n_actions: n_actions as u32, account, branch_id, @@ -2733,15 +2900,16 @@ pub async fn build_deshield_pczt( header: digests.header_digest.to_vec(), transparent: digests.transparent_digest.to_vec(), orchard: digests.orchard_digest.to_vec(), + ironwood: digests.ironwood_digest.to_vec(), }, header_fields: HeaderFields { - tx_version: 5, - version_group_id: 0x26A7270A, + tx_version: 6, + version_group_id: crate::zip229::VERSION_GROUP_ID, lock_time: 0, expiry_height: 0, }, bundle_meta: BundleMeta { - flags: effects_bundle.flags().to_byte() as u32, + flags: effects_bundle.flag_byte() as u32, value_balance: *effects_bundle.value_balance(), anchor: effects_bundle.anchor().to_bytes().to_vec(), }, @@ -2763,7 +2931,7 @@ pub async fn build_deshield_pczt( }) } -/// Finalize a deshield PCZT: apply Orchard signatures, serialize hybrid v5 tx. +/// Finalize a deshield PCZT: apply Ironwood signatures, serialize hybrid v6 tx. /// /// No transparent signatures needed — deshield has no transparent inputs. pub fn finalize_deshield_pczt( @@ -2826,30 +2994,31 @@ pub fn finalize_deshield_pczt( .apply_binding_signature(sighash, &mut rng) .ok_or_else(|| anyhow::anyhow!("Binding signature verification failed"))?; - let effects_orchard_digest: [u8; 32] = state + let effects_ironwood_digest: [u8; 32] = state .orchard_signing_request .digests - .orchard + .ironwood .as_slice() .try_into() .map_err(|_| { anyhow::anyhow!( - "Deshield signing request Orchard digest must be 32 bytes, got {}", - state.orchard_signing_request.digests.orchard.len(), + "Deshield signing request Ironwood digest must be 32 bytes, got {}", + state.orchard_signing_request.digests.ironwood.len(), ) })?; - let orchard_digest = validate_hybrid_orchard_consensus( + let ironwood_digest = validate_hybrid_ironwood_consensus( "Deshield", &authorized_bundle, state.sighash, state.branch_id, &[], &state.transparent_outputs, - effects_orchard_digest, + effects_ironwood_digest, )?; - // Serialize as hybrid v5 tx: no transparent inputs, transparent outputs, Orchard bundle - let tx_bytes = serialize_v5_hybrid_tx( + // Serialize as hybrid v6 tx: no transparent inputs, transparent outputs, + // an empty Orchard slot, and the authorized Ironwood bundle. + let tx_bytes = serialize_v6_ironwood_hybrid_tx( &authorized_bundle, &[], // no transparent inputs &state.transparent_outputs, @@ -2858,16 +3027,14 @@ pub fn finalize_deshield_pczt( None, // no pubkey needed (no transparent inputs) )?; - // Compute txid - let header_digest = zip244::digest_header(state.branch_id, 0, 0); - let transparent_txid_digest = zip244::digest_transparent_txid(&[], &state.transparent_outputs); - let txid_digests = zip244::Zip244Digests { - header_digest, - transparent_digest: transparent_txid_digest, - sapling_digest: zip244::EMPTY_SAPLING_DIGEST, - orchard_digest, - }; - let txid_hash = zip244::compute_sighash(&txid_digests, state.branch_id); + let txid_hash = crate::zip229::compute_txid( + ironwood_digest, + &[], + &state.transparent_outputs, + state.branch_id, + 0, + 0, + ); let txid = hex::encode(&txid_hash); info!( @@ -2884,6 +3051,7 @@ pub fn finalize_deshield_pczt( mod tests { use super::{ plan_incomplete_shard_fetch, plan_orchard_signature_application, IncompleteShardFetchPlan, + LEGACY_ORCHARD_BUNDLE_VERSION, }; use incrementalmerkletree::Retention; use orchard::tree::MerkleHashOrchard; @@ -2912,13 +3080,18 @@ mod tests { use orchard::Anchor; use rand::rngs::OsRng; - let sk: SpendingKey = - Option::::from(SpendingKey::from_bytes([7u8; 32])) - .expect("valid spending key"); + let sk: SpendingKey = Option::::from(SpendingKey::from_bytes([7u8; 32])) + .expect("valid spending key"); let fvk = FullViewingKey::from(&sk); let recipient = fvk.address_at(0u32, Scope::External); - let mut builder = Builder::new(BundleType::DEFAULT, Anchor::empty_tree()); + let mut builder = Builder::new( + BundleType::DEFAULT, + LEGACY_ORCHARD_BUNDLE_VERSION, + LEGACY_ORCHARD_BUNDLE_VERSION.default_flags(), + Anchor::empty_tree(), + ) + .unwrap(); let mut memo = [0u8; 512]; memo[0] = 0xF6; builder @@ -3782,15 +3955,18 @@ mod tests { use incrementalmerkletree::{Address, Position}; let shard_size: u64 = 1 << 4; // 16 - let n_complete = 3u64; // shards 0,1,2 complete - let note_shard = 1u64; // note in a completed shard BELOW shard 2 + let n_complete = 3u64; // shards 0,1,2 complete + let note_shard = 1u64; // note in a completed shard BELOW shard 2 let note_pos = note_shard * shard_size + 5; - let incomplete = 7u64; // frontier leaves in shard 3 + let incomplete = 7u64; // frontier leaves in shard 3 let shard_root = |s: u64| -> [u8; 32] { let mut st: ShardTree, 4, 4> = ShardTree::new(MemoryShardStore::empty(), 100); - for j in 0..shard_size { st.append(test_leaf(s * shard_size + j), Retention::Ephemeral).unwrap(); } + for j in 0..shard_size { + st.append(test_leaf(s * shard_size + j), Retention::Ephemeral) + .unwrap(); + } st.checkpoint(0u32).unwrap(); st.root_at_checkpoint_id(&0u32).unwrap().unwrap().to_bytes() }; @@ -3802,19 +3978,36 @@ mod tests { if s == note_shard { for j in 0..shard_size { let i = s * shard_size + j; - let r = if i == note_pos { Retention::Marked } else { Retention::Ephemeral }; + let r = if i == note_pos { + Retention::Marked + } else { + Retention::Ephemeral + }; tree.append(test_leaf(i), r).unwrap(); } } else { let root = MerkleHashOrchard::from_bytes(&shard_root(s)).unwrap(); - tree.insert(Address::above_position(4.into(), Position::from(s * shard_size)), root).unwrap(); + tree.insert( + Address::above_position(4.into(), Position::from(s * shard_size)), + root, + ) + .unwrap(); } } - for j in 0..incomplete { tree.append(test_leaf(n_complete * shard_size + j), Retention::Ephemeral).unwrap(); } + for j in 0..incomplete { + tree.append(test_leaf(n_complete * shard_size + j), Retention::Ephemeral) + .unwrap(); + } let ckpt = u32::MAX; tree.checkpoint(ckpt).unwrap(); - assert_witness_recomputes_root(&mut tree, note_pos, test_leaf(note_pos), ckpt, "note_in_lower_completed_shard"); + assert_witness_recomputes_root( + &mut tree, + note_pos, + test_leaf(note_pos), + ckpt, + "note_in_lower_completed_shard", + ); } /// Mirrors the deshield builder's tree shape: insert N-1 completed shard @@ -4054,8 +4247,11 @@ mod tests { // is direction-specific, this is where it shows up. #[cfg(test)] -mod roundtrip_v5_tests { - use super::{serialize_v5_hybrid_tx, serialize_v5_shielded_tx}; +mod transaction_roundtrip_tests { + use super::{ + serialize_v5_hybrid_tx, serialize_v5_shielded_tx, serialize_v6_ironwood_hybrid_tx, + }; + use crate::zip229; use crate::zip244::{ self, TransparentInput, TransparentOutput, Zip244Digests, EMPTY_SAPLING_DIGEST, EMPTY_TRANSPARENT_DIGEST, @@ -4187,11 +4383,15 @@ mod roundtrip_v5_tests { .expect("synthetic action parts are well-formed") } - fn synthetic_bundle(n_actions: usize, value_balance: i64) -> orchard::Bundle { + fn synthetic_bundle_for_version( + n_actions: usize, + value_balance: i64, + bundle_version: orchard::bundle::BundleVersion, + ) -> orchard::Bundle { assert!(n_actions >= 1); let actions: Vec<_> = (0..n_actions).map(|_| synthetic_action()).collect(); let actions_ne = NonEmpty::from_vec(actions).unwrap(); - let flags = Flags::from_byte(0x03).unwrap(); + let flags = Flags::from_byte(0x03, bundle_version).unwrap(); let anchor = Anchor::from_bytes(TV_CMX).unwrap(); let effects = orchard::Bundle::<_, i64>::from_parts( actions_ne, @@ -4199,8 +4399,10 @@ mod roundtrip_v5_tests { value_balance, anchor, orchard::bundle::EffectsOnly, - ); - let proof = Proof::new(vec![0u8; 1500]); + bundle_version, + ) + .expect("synthetic bundle parts are well-formed"); + let proof = Proof::new(vec![0u8; Proof::expected_proof_size(n_actions)]); let binding_sig: redpallas::Signature = [0xcd; 64].into(); let spend_auth_sig: redpallas::Signature = [0xab; 64].into(); // Graft authorizing data on, transitioning EffectsOnly → Authorized. @@ -4211,6 +4413,25 @@ mod roundtrip_v5_tests { ) } + fn synthetic_bundle(n_actions: usize, value_balance: i64) -> orchard::Bundle { + synthetic_bundle_for_version( + n_actions, + value_balance, + orchard::bundle::BundleVersion::orchard_v2(), + ) + } + + fn synthetic_ironwood_bundle( + n_actions: usize, + value_balance: i64, + ) -> orchard::Bundle { + synthetic_bundle_for_version( + n_actions, + value_balance, + orchard::bundle::BundleVersion::ironwood_v3(), + ) + } + /// Recompute the txid the way `finalize_pczt` (shielded-only) does. fn our_txid_shielded(bundle: &orchard::Bundle, branch_id: u32) -> [u8; 32] { let digests = Zip244Digests { @@ -4398,6 +4619,57 @@ mod roundtrip_v5_tests { with the canonical reference; this is the bug deshield broadcasts hit" ); } + + /// The consensus regression that motivated Ironwood support: transparent + /// value enters the new v6 Ironwood slot, while the v6 Orchard slot stays + /// empty. The canonical parser and ZIP-229 txid must agree with our bytes. + #[test] + fn roundtrip_v6_hybrid_ironwood_shield() { + let bundle = synthetic_ironwood_bundle(1, 100_000); + let inputs = vec![TransparentInput { + prevout_hash: [0x22; 32], + prevout_index: 1, + value: 105_000, + script_pubkey: p2pkh_script([0xca; 20]), + sequence: 0xffff_ffff, + }]; + let synth_sig = vec![0u8; 71]; + let synth_pubkey = [0x03u8; 33]; + let tx_bytes = serialize_v6_ironwood_hybrid_tx( + &bundle, + &inputs, + &[], + &[synth_sig], + zip229::NU6_3_BRANCH_ID, + Some(&synth_pubkey), + ) + .unwrap(); + + let parsed = Transaction::read(&tx_bytes[..], BranchId::Nu6_3) + .expect("canonical reader must accept our v6 Ironwood shield bytes"); + assert!( + parsed.orchard_bundle().is_none(), + "legacy Orchard slot must be empty" + ); + assert_eq!( + parsed + .ironwood_bundle() + .expect("Ironwood bundle present") + .actions() + .len(), + bundle.actions().len(), + "Ironwood action count round-tripped", + ); + + let ironwood_digest = zip229::digest_bundle_authorized(&bundle).unwrap(); + let ours = + zip229::compute_txid(ironwood_digest, &inputs, &[], zip229::NU6_3_BRANCH_ID, 0, 0); + assert_eq!( + *parsed.txid().as_ref(), + ours, + "v6 Ironwood shield txid differs from the canonical reference" + ); + } } /// Batch-validate a saved live transaction using orchard 0.10.2's BatchValidator. @@ -4455,9 +4727,13 @@ mod batch_validate_test { let ob = parsed.orchard_bundle().expect("orchard bundle present"); println!("anchor: {}", hex::encode(ob.anchor().to_bytes())); - let mut validator = BatchValidator::new(); - validator.add_bundle(ob, sighash_arr); - let result = validator.validate(&VerifyingKey::build(), OsRng); + let vk = + VerifyingKey::build(orchard::bundle::BundleVersion::orchard_v2().circuit_version()); + let mut validator = BatchValidator::new(&vk); + validator + .add_bundle(ob, sighash_arr) + .expect("saved bundle version must match validator"); + let result = validator.validate(OsRng); println!( "BatchValidator (T.1 sighash): {}", if result { "PASS" } else { "FAIL" } diff --git a/projects/keepkey-vault/zcash-cli/src/scanner.rs b/projects/keepkey-vault/zcash-cli/src/scanner.rs index 0bf0c50e..10eed0dd 100644 --- a/projects/keepkey-vault/zcash-cli/src/scanner.rs +++ b/projects/keepkey-vault/zcash-cli/src/scanner.rs @@ -12,10 +12,10 @@ use tonic::transport::{Channel, ClientTlsConfig}; use orchard::keys::{FullViewingKey, PreparedIncomingViewingKey, Scope}; use orchard::note::ExtractedNoteCommitment; use orchard::note::Nullifier; -use orchard::note_encryption::{CompactAction, OrchardDomain}; +use orchard::note_encryption::{CompactAction, IronwoodDomain, OrchardDomain}; use zcash_note_encryption::{try_compact_note_decryption, try_note_decryption, EphemeralKeyBytes}; -use crate::wallet_db::{ScannedNote, WalletDb}; +use crate::wallet_db::{ScannedNote, ShieldedPool, WalletDb}; /// A transparent UTXO from lightwalletd. #[derive(Debug, Clone)] @@ -163,9 +163,32 @@ impl LightwalletClient { start_index: u32, max_entries: u32, ) -> Result> { + self.get_pool_subtree_roots(ShieldedPool::Orchard, start_index, max_entries) + .await + } + + pub async fn get_ironwood_subtree_roots( + &mut self, + start_index: u32, + max_entries: u32, + ) -> Result> { + self.get_pool_subtree_roots(ShieldedPool::Ironwood, start_index, max_entries) + .await + } + + async fn get_pool_subtree_roots( + &mut self, + pool: ShieldedPool, + start_index: u32, + max_entries: u32, + ) -> Result> { + let shielded_protocol = match pool { + ShieldedPool::Orchard => proto::ShieldedProtocol::Orchard, + ShieldedPool::Ironwood => proto::ShieldedProtocol::Ironwood, + }; let request = proto::GetSubtreeRootsArg { start_index, - shielded_protocol: proto::ShieldedProtocol::Orchard as i32, + shielded_protocol: shielded_protocol as i32, max_entries, }; @@ -189,8 +212,9 @@ impl LightwalletClient { } info!( - "Fetched {} Orchard subtree roots (start_index={})", + "Fetched {} {} subtree roots (start_index={})", roots.len(), + pool.as_str(), start_index ); Ok(roots) @@ -200,6 +224,20 @@ impl LightwalletClient { /// Returns the orchardCommitmentTreeSize from ChainMetadata at that height. #[allow(dead_code)] pub async fn get_tree_state(&mut self, height: u64) -> Result<(u64, String)> { + self.get_pool_tree_state(height, ShieldedPool::Orchard) + .await + } + + pub async fn get_ironwood_tree_state(&mut self, height: u64) -> Result<(u64, String)> { + self.get_pool_tree_state(height, ShieldedPool::Ironwood) + .await + } + + async fn get_pool_tree_state( + &mut self, + height: u64, + pool: ShieldedPool, + ) -> Result<(u64, String)> { let request = proto::BlockId { height, hash: vec![], @@ -212,12 +250,17 @@ impl LightwalletClient { .context("GetTreeState failed")?; let state = response.into_inner(); + let tree = match pool { + ShieldedPool::Orchard => state.orchard_tree, + ShieldedPool::Ironwood => state.ironwood_tree, + }; info!( - "Tree state at height {}: orchard_tree len={}", + "Tree state at height {}: {}_tree len={}", height, - state.orchard_tree.len() + pool.as_str(), + tree.len() ); - Ok((state.height, state.orchard_tree)) + Ok((state.height, tree)) } /// Get the Orchard anchor (tree root) at the latest block. @@ -232,11 +275,20 @@ impl LightwalletClient { /// 1. Start: combine left and right (or left and empty) /// 2. For each parent level: combine parent (or empty) with current pub async fn get_orchard_anchor(&mut self, height: u64) -> Result<[u8; 32]> { - let (_, tree_hex) = self.get_tree_state(height).await?; + self.get_pool_anchor(height, ShieldedPool::Orchard).await + } + + pub async fn get_ironwood_anchor(&mut self, height: u64) -> Result<[u8; 32]> { + self.get_pool_anchor(height, ShieldedPool::Ironwood).await + } + + async fn get_pool_anchor(&mut self, height: u64, pool: ShieldedPool) -> Result<[u8; 32]> { + let (_, tree_hex) = self.get_pool_tree_state(height, pool).await?; if tree_hex.is_empty() { return Err(anyhow::anyhow!( - "Empty Orchard tree state at height {}", + "Empty {} tree state at height {}", + pool.as_str(), height )); } @@ -245,7 +297,8 @@ impl LightwalletClient { hex::decode(&tree_hex).map_err(|e| anyhow::anyhow!("Invalid tree state hex: {}", e))?; info!( - "Parsing Orchard CommitmentTree ({} bytes) at height {}", + "Parsing {} CommitmentTree ({} bytes) at height {}", + pool.as_str(), tree_bytes.len(), height ); @@ -416,7 +469,7 @@ impl LightwalletClient { } let anchor_bytes = current.to_bytes(); - info!("Orchard anchor: {}", hex::encode(&anchor_bytes)); + info!("{} anchor: {}", pool.as_str(), hex::encode(&anchor_bytes)); Ok(anchor_bytes) } @@ -426,6 +479,25 @@ impl LightwalletClient { &mut self, start_height: u64, end_height: u64, + ) -> Result)>)>> { + self.fetch_pool_block_actions(ShieldedPool::Orchard, start_height, end_height) + .await + } + + pub async fn fetch_ironwood_block_actions( + &mut self, + start_height: u64, + end_height: u64, + ) -> Result)>)>> { + self.fetch_pool_block_actions(ShieldedPool::Ironwood, start_height, end_height) + .await + } + + async fn fetch_pool_block_actions( + &mut self, + pool: ShieldedPool, + start_height: u64, + end_height: u64, ) -> Result)>)>> { let request = proto::BlockRange { start: Some(proto::BlockId { @@ -451,7 +523,11 @@ impl LightwalletClient { let mut txs = Vec::new(); for tx in &block.vtx { let mut cmxs = Vec::new(); - for action in &tx.actions { + let actions = match pool { + ShieldedPool::Orchard => &tx.actions, + ShieldedPool::Ironwood => &tx.ironwood_actions, + }; + for action in actions { if action.cmx.len() == 32 { let mut cmx = [0u8; 32]; cmx.copy_from_slice(&action.cmx); @@ -470,9 +546,10 @@ impl LightwalletClient { .flat_map(|(_, txs)| txs.iter().map(|(_, cmxs)| cmxs.len())) .sum(); info!( - "Fetched {} blocks with {} total Orchard actions ({} to {})", + "Fetched {} blocks with {} total {} actions ({} to {})", blocks.len(), total_actions, + pool.as_str(), start_height, end_height ); @@ -482,6 +559,16 @@ impl LightwalletClient { /// Get the Orchard commitment tree size at a given block height by fetching /// the compact block's ChainMetadata. pub async fn get_orchard_tree_size_at(&mut self, height: u64) -> Result { + self.get_pool_tree_size_at(height, ShieldedPool::Orchard) + .await + } + + pub async fn get_ironwood_tree_size_at(&mut self, height: u64) -> Result { + self.get_pool_tree_size_at(height, ShieldedPool::Ironwood) + .await + } + + async fn get_pool_tree_size_at(&mut self, height: u64, pool: ShieldedPool) -> Result { let request = proto::BlockId { height, hash: vec![], @@ -496,10 +583,13 @@ impl LightwalletClient { let block = response.into_inner(); let size = block .chain_metadata - .map(|m| m.orchard_commitment_tree_size as u64) + .map(|m| match pool { + ShieldedPool::Orchard => m.orchard_commitment_tree_size as u64, + ShieldedPool::Ironwood => m.ironwood_commitment_tree_size as u64, + }) .unwrap_or(0); - debug!("Orchard tree size at height {}: {}", height, size); + debug!("{} tree size at height {}: {}", pool.as_str(), height, size); Ok(size) } @@ -575,15 +665,17 @@ impl LightwalletClient { txid: &[u8; 32], action_index: usize, fvk: &FullViewingKey, + pool: ShieldedPool, ) -> Result> { let raw_tx = self.get_transaction(txid).await?; - let actions = parse_orchard_actions_from_raw_tx(&raw_tx)?; + let actions = parse_shielded_actions_from_raw_tx(&raw_tx, pool)?; if action_index >= actions.len() { return Err(anyhow::anyhow!( - "Action index {} out of range (tx has {} Orchard actions)", + "Action index {} out of range (tx has {} {} actions)", action_index, - actions.len() + actions.len(), + pool.as_str(), )); } @@ -593,11 +685,23 @@ impl LightwalletClient { for scope in &[Scope::External, Scope::Internal] { let ivk = fvk.to_ivk(*scope); let prepared_ivk = PreparedIncomingViewingKey::new(&ivk); - let domain = OrchardDomain::for_action(action); - - if let Some((_note, _addr, memo)) = try_note_decryption(&domain, &prepared_ivk, action) - { - return Ok(Some(memo)); + match pool { + ShieldedPool::Orchard => { + let domain = OrchardDomain::for_action(action); + if let Some((_note, _addr, memo)) = + try_note_decryption(&domain, &prepared_ivk, action) + { + return Ok(Some(memo)); + } + } + ShieldedPool::Ironwood => { + let domain = IronwoodDomain::for_action(action); + if let Some((_note, _addr, memo)) = + try_note_decryption(&domain, &prepared_ivk, action) + { + return Ok(Some(memo)); + } + } } } @@ -706,68 +810,75 @@ impl LightwalletClient { blocks_scanned += 1; for tx in &block.vtx { - for (action_idx, action) in tx.actions.iter().enumerate() { - // Check nullifier — does this action spend one of our notes? - if action.nullifier.len() == 32 { - let mut nf_bytes = [0u8; 32]; - nf_bytes.copy_from_slice(&action.nullifier); - if db.mark_note_spent(&nf_bytes)? { - spent_notes += 1; + for (pool, actions) in [ + (ShieldedPool::Orchard, tx.actions.as_slice()), + (ShieldedPool::Ironwood, tx.ironwood_actions.as_slice()), + ] { + for (action_idx, action) in actions.iter().enumerate() { + // Check nullifier — does this action spend one of our notes? + if action.nullifier.len() == 32 { + let mut nf_bytes = [0u8; 32]; + nf_bytes.copy_from_slice(&action.nullifier); + if db.mark_note_spent(&nf_bytes)? { + spent_notes += 1; + } } - } - // Try to decrypt — is this action a note to us? - // Try External scope first (received notes), then Internal (change notes) - let decrypted = try_decrypt_action(action, &prepared_ivk_ext) - .or_else(|| try_decrypt_action(action, &prepared_ivk_int)); - if let Some((note, addr)) = decrypted { - let value = note.value().inner(); - let recipient_bytes = addr.to_raw_address_bytes().to_vec(); - - let nf = note.nullifier(fvk); - let mut nf_bytes = [0u8; 32]; - nf_bytes.copy_from_slice(&nf.to_bytes()); - - let mut rho_bytes = [0u8; 32]; - rho_bytes.copy_from_slice(¬e.rho().to_bytes()); - - let mut rseed_bytes = [0u8; 32]; - rseed_bytes.copy_from_slice(note.rseed().as_bytes()); - - let mut cmx_bytes = [0u8; 32]; - cmx_bytes.copy_from_slice(&action.cmx); - - // Capture txid for later memo backfill - let txid = if tx.txid.len() == 32 { - let mut arr = [0u8; 32]; - arr.copy_from_slice(&tx.txid); - Some(arr) - } else { - None - }; - - let scanned = ScannedNote { - value, - recipient: recipient_bytes, - rho: rho_bytes, - rseed: rseed_bytes, - cmx: cmx_bytes, - nullifier: nf_bytes, - block_height: block.height, - tx_index: tx.index as u32, - action_index: action_idx as u32, - txid, - memo: None, // Filled in during backfill (compact blocks lack memos) - }; - - if db.insert_note(&scanned)? { - new_notes += 1; - info!( - "Found note: {} ZAT ({:.8} ZEC) in block {}", + // Try to decrypt — is this action a note to us? + // Try External scope first (received notes), then Internal (change notes) + let decrypted = try_decrypt_action(action, &prepared_ivk_ext, pool) + .or_else(|| try_decrypt_action(action, &prepared_ivk_int, pool)); + if let Some((note, addr)) = decrypted { + let value = note.value().inner(); + let recipient_bytes = addr.to_raw_address_bytes().to_vec(); + + let nf = note.nullifier(fvk); + let mut nf_bytes = [0u8; 32]; + nf_bytes.copy_from_slice(&nf.to_bytes()); + + let mut rho_bytes = [0u8; 32]; + rho_bytes.copy_from_slice(¬e.rho().to_bytes()); + + let mut rseed_bytes = [0u8; 32]; + rseed_bytes.copy_from_slice(note.rseed().as_bytes()); + + let mut cmx_bytes = [0u8; 32]; + cmx_bytes.copy_from_slice(&action.cmx); + + // Capture txid for later memo backfill + let txid = if tx.txid.len() == 32 { + let mut arr = [0u8; 32]; + arr.copy_from_slice(&tx.txid); + Some(arr) + } else { + None + }; + + let scanned = ScannedNote { + pool, value, - value as f64 / 1e8, - block.height, - ); + recipient: recipient_bytes, + rho: rho_bytes, + rseed: rseed_bytes, + cmx: cmx_bytes, + nullifier: nf_bytes, + block_height: block.height, + tx_index: tx.index as u32, + action_index: action_idx as u32, + txid, + memo: None, // Filled in during backfill (compact blocks lack memos) + }; + + if db.insert_note(&scanned)? { + new_notes += 1; + info!( + "Found {} note: {} ZAT ({:.8} ZEC) in block {}", + pool.as_str(), + value, + value as f64 / 1e8, + block.height, + ); + } } } } @@ -807,6 +918,7 @@ impl LightwalletClient { fn try_decrypt_action( action: &proto::CompactOrchardAction, prepared_ivk: &PreparedIncomingViewingKey, + pool: ShieldedPool, ) -> Option<(orchard::Note, orchard::Address)> { if action.nullifier.len() != 32 || action.cmx.len() != 32 @@ -840,9 +952,16 @@ fn try_decrypt_action( enc_ciphertext, ); - let domain = OrchardDomain::for_compact_action(&compact); - - try_compact_note_decryption(&domain, prepared_ivk, &compact) + match pool { + ShieldedPool::Orchard => { + let domain = OrchardDomain::for_compact_action(&compact); + try_compact_note_decryption(&domain, prepared_ivk, &compact) + } + ShieldedPool::Ironwood => { + let domain = IronwoodDomain::for_compact_action(&compact); + try_compact_note_decryption(&domain, prepared_ivk, &compact) + } + } } pub struct OrchardScanResult { @@ -900,7 +1019,8 @@ fn read_compact_size(data: &[u8]) -> Result<(u64, usize)> { } } -/// Parse a v5 Zcash transaction and extract Orchard actions with full ciphertext. +/// Parse a v5/v6 Zcash transaction and extract an Orchard-family pool's actions +/// with full ciphertext. /// This allows `try_note_decryption` to recover the 512-byte memo field. /// /// v5 layout: @@ -908,7 +1028,10 @@ fn read_compact_size(data: &[u8]) -> Result<(u64, usize)> { /// transparent inputs/outputs (variable) /// sapling spends/outputs (variable) /// orchard actions (variable — what we want) -pub fn parse_orchard_actions_from_raw_tx(raw: &[u8]) -> Result>> { +pub fn parse_shielded_actions_from_raw_tx( + raw: &[u8], + pool: ShieldedPool, +) -> Result>> { if raw.len() < 20 { return Err(anyhow::anyhow!( "Transaction too short: {} bytes", @@ -917,9 +1040,15 @@ pub fn parse_orchard_actions_from_raw_tx(raw: &[u8]) -> Result>> } let version = u32::from_le_bytes([raw[0], raw[1], raw[2], raw[3]]); - if version != 0x80000005 { + if version != 0x80000005 && version != crate::zip229::TX_VERSION { return Err(anyhow::anyhow!( - "Not a v5 transaction (version=0x{:08x})", + "Not a v5/v6 transaction (version=0x{:08x})", + version + )); + } + if pool == ShieldedPool::Ironwood && version != crate::zip229::TX_VERSION { + return Err(anyhow::anyhow!( + "Ironwood actions require transaction v6 (version=0x{:08x})", version )); } @@ -1080,120 +1209,112 @@ pub fn parse_orchard_actions_from_raw_tx(raw: &[u8]) -> Result>> })?; } - // Now parse Orchard actions - let (n_orchard_actions, sz) = read_compact_size(&raw[offset..])?; - offset += sz; + let orchard_actions = parse_orchard_family_bundle(raw, &mut offset, "Orchard")?; + let actions = match pool { + ShieldedPool::Orchard => orchard_actions, + ShieldedPool::Ironwood => parse_orchard_family_bundle(raw, &mut offset, "Ironwood")?, + }; - if n_orchard_actions == 0 { - return Ok(Vec::new()); - } + debug!( + "Parsed {} {} actions from raw tx ({} bytes)", + actions.len(), + pool.as_str(), + raw.len() + ); + Ok(actions) +} - // Cap the pre-allocation against a malicious lightwalletd: a crafted compact_size - // could request a huge Vec and OOM-abort the sidecar before the per-iteration - // bounds check runs. Each action is >= 820 bytes, so the remaining buffer bounds - // the real count (CC-1). - let max_actions = raw.len().saturating_sub(offset) / 820; - if n_orchard_actions as usize > max_actions { +/// Parse and advance over one Orchard-family bundle, including authorizing data. +/// This is shared by the Orchard and Ironwood slots in transaction v6. +fn parse_orchard_family_bundle( + raw: &[u8], + offset: &mut usize, + pool_name: &str, +) -> Result>> { + let (n_actions, sz) = read_compact_size(raw.get(*offset..).ok_or_else(|| { + anyhow::anyhow!("Missing {} action count at offset {}", pool_name, *offset) + })?)?; + *offset = offset + .checked_add(sz) + .ok_or_else(|| anyhow::anyhow!("Offset overflow reading {} count", pool_name))?; + + let max_actions = raw.len().saturating_sub(*offset) / 820; + if n_actions as usize > max_actions { return Err(anyhow::anyhow!( - "Declared {} Orchard actions but the buffer holds at most {}", - n_orchard_actions, + "Declared {} {} actions but the buffer holds at most {}", + n_actions, + pool_name, max_actions )); } - let mut actions: Vec> = Vec::with_capacity(n_orchard_actions as usize); + let mut actions = Vec::with_capacity(n_actions as usize); - for _ in 0..n_orchard_actions { - if offset + 820 > raw.len() { + for _ in 0..n_actions { + let end = offset + .checked_add(820) + .ok_or_else(|| anyhow::anyhow!("Offset overflow reading {} action", pool_name))?; + if end > raw.len() { return Err(anyhow::anyhow!( - "Not enough bytes for Orchard action at offset {} (need 820, have {})", - offset, - raw.len() - offset + "Not enough bytes for {} action at offset {}", + pool_name, + *offset )); } - // cv_net: 32 bytes + let action_bytes = &raw[*offset..end]; + *offset = end; let mut cv_bytes = [0u8; 32]; - cv_bytes.copy_from_slice(&raw[offset..offset + 32]); - offset += 32; - - // nullifier: 32 bytes + cv_bytes.copy_from_slice(&action_bytes[..32]); let mut nf_bytes = [0u8; 32]; - nf_bytes.copy_from_slice(&raw[offset..offset + 32]); - offset += 32; - - // rk: 32 bytes + nf_bytes.copy_from_slice(&action_bytes[32..64]); let mut rk_bytes = [0u8; 32]; - rk_bytes.copy_from_slice(&raw[offset..offset + 32]); - offset += 32; - - // cmx: 32 bytes + rk_bytes.copy_from_slice(&action_bytes[64..96]); let mut cmx_bytes = [0u8; 32]; - cmx_bytes.copy_from_slice(&raw[offset..offset + 32]); - offset += 32; - - // epk: 32 bytes + cmx_bytes.copy_from_slice(&action_bytes[96..128]); let mut epk_bytes = [0u8; 32]; - epk_bytes.copy_from_slice(&raw[offset..offset + 32]); - offset += 32; - - // enc_ciphertext: 580 bytes + epk_bytes.copy_from_slice(&action_bytes[128..160]); let mut enc_ciphertext = [0u8; 580]; - enc_ciphertext.copy_from_slice(&raw[offset..offset + 580]); - offset += 580; - - // out_ciphertext: 80 bytes + enc_ciphertext.copy_from_slice(&action_bytes[160..740]); let mut out_ciphertext = [0u8; 80]; - out_ciphertext.copy_from_slice(&raw[offset..offset + 80]); - offset += 80; + out_ciphertext.copy_from_slice(&action_bytes[740..820]); - // Construct Action<()> - let nf = Nullifier::from_bytes(&nf_bytes); - if bool::from(nf.is_none()) { + let Some(nf) = Nullifier::from_bytes(&nf_bytes).into_option() else { continue; - } - - let cmx = ExtractedNoteCommitment::from_bytes(&cmx_bytes); - if bool::from(cmx.is_none()) { + }; + let Some(cmx) = ExtractedNoteCommitment::from_bytes(&cmx_bytes).into_option() else { continue; - } - - let cv_net = ValueCommitment::from_bytes(&cv_bytes); - if bool::from(cv_net.is_none()) { + }; + let Some(cv_net) = ValueCommitment::from_bytes(&cv_bytes).into_option() else { + continue; + }; + let Ok(rk): Result, _> = rk_bytes.try_into() else { continue; - } - - let rk: redpallas::VerificationKey = match rk_bytes.try_into() { - Ok(k) => k, - Err(_) => continue, }; - let encrypted_note = TransmittedNoteCiphertext { epk_bytes, enc_ciphertext, out_ciphertext, }; + if let Ok(action) = Action::from_parts(nf, rk, cmx, encrypted_note, cv_net, ()) { + actions.push(action); + } + } - // orchard 0.14: from_parts validates the parts and returns a Result. - // A malformed action from a raw tx is skipped, matching the other - // parse-failure `continue`s above. - let action = match Action::from_parts( - nf.unwrap(), - rk, - cmx.unwrap(), - encrypted_note, - cv_net.unwrap(), - (), - ) { - Ok(a) => a, - Err(_) => continue, - }; - actions.push(action); + if n_actions > 0 { + // flags + value balance + anchor + *offset = offset + .checked_add(41) + .filter(|end| *end <= raw.len()) + .ok_or_else(|| anyhow::anyhow!("Truncated {} bundle metadata", pool_name))?; + let (proof_len, proof_len_size) = read_compact_size(&raw[*offset..])?; + *offset = offset + .checked_add(proof_len_size) + .and_then(|o| o.checked_add(proof_len as usize)) + .and_then(|o| o.checked_add(n_actions as usize * 64)) + .and_then(|o| o.checked_add(64)) + .filter(|end| *end <= raw.len()) + .ok_or_else(|| anyhow::anyhow!("Truncated {} authorizing data", pool_name))?; } - debug!( - "Parsed {} Orchard actions from raw tx ({} bytes)", - actions.len(), - raw.len() - ); Ok(actions) } diff --git a/projects/keepkey-vault/zcash-cli/src/wallet_db.rs b/projects/keepkey-vault/zcash-cli/src/wallet_db.rs index 83808e1e..a7249ebf 100644 --- a/projects/keepkey-vault/zcash-cli/src/wallet_db.rs +++ b/projects/keepkey-vault/zcash-cli/src/wallet_db.rs @@ -8,9 +8,35 @@ use log::{debug, info}; use rusqlite::{params, Connection}; use std::path::PathBuf; +/// Orchard-family value pool containing a note. Orchard and Ironwood reuse +/// viewing keys and action encodings, but have separate trees and nullifiers. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ShieldedPool { + Orchard, + Ironwood, +} + +impl ShieldedPool { + pub const fn as_str(self) -> &'static str { + match self { + Self::Orchard => "orchard", + Self::Ironwood => "ironwood", + } + } + + fn from_str(value: &str) -> Option { + match value { + "orchard" => Some(Self::Orchard), + "ironwood" => Some(Self::Ironwood), + _ => None, + } + } +} + /// A scanned Orchard note with all fields needed to reconstruct it for spending. #[derive(Debug, Clone)] pub struct ScannedNote { + pub pool: ShieldedPool, pub value: u64, pub recipient: Vec, // 43-byte Orchard address pub rho: [u8; 32], @@ -36,6 +62,7 @@ pub struct NoteRecord { pub nullifier: [u8; 32], pub txid: Option<[u8; 32]>, pub action_index: u32, + pub pool: ShieldedPool, } /// A spendable (unspent) note with its database ID. @@ -53,6 +80,7 @@ pub struct SpendableNote { pub tx_index: u32, pub action_index: u32, pub position: Option, + pub pool: ShieldedPool, } pub struct WalletDb { @@ -106,7 +134,8 @@ impl WalletDb { tx_index INTEGER NOT NULL, action_index INTEGER NOT NULL, is_spent INTEGER NOT NULL DEFAULT 0, - position INTEGER + position INTEGER, + pool TEXT NOT NULL DEFAULT 'orchard' ); CREATE TABLE IF NOT EXISTS scan_state ( @@ -181,6 +210,26 @@ impl WalletDb { info!("Migrated notes table: added txid column"); } + // NU6.3 migration: rows created before this column existed are + // historical Orchard notes. + let has_pool = self + .conn + .query_row( + "SELECT COUNT(*) FROM pragma_table_info('notes') WHERE name='pool'", + [], + |row| row.get::<_, i64>(0), + ) + .unwrap_or(0); + if has_pool == 0 { + self.conn + .execute( + "ALTER TABLE notes ADD COLUMN pool TEXT NOT NULL DEFAULT 'orchard'", + [], + ) + .context("Failed to add shielded pool column")?; + info!("Migrated notes table: added pool column"); + } + debug!("Database schema initialized"); Ok(()) } @@ -217,8 +266,8 @@ impl WalletDb { /// Returns true if the note was inserted, false if it already exists (duplicate nullifier). pub fn insert_note(&self, note: &ScannedNote) -> Result { let result = self.conn.execute( - "INSERT OR IGNORE INTO notes (value, recipient, rho, rseed, cmx, nullifier, block_height, tx_index, action_index, txid, memo) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)", + "INSERT OR IGNORE INTO notes (value, recipient, rho, rseed, cmx, nullifier, block_height, tx_index, action_index, txid, memo, pool) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)", params![ note.value as i64, note.recipient, @@ -231,6 +280,7 @@ impl WalletDb { note.action_index as i64, note.txid.as_ref().map(|t| t.as_slice()), note.memo.as_deref(), + note.pool.as_str(), ], ).context("Failed to insert note")?; @@ -263,18 +313,32 @@ impl WalletDb { /// after a small reorg, or lightwalletd's tree state may lag the cmx scan. /// Industry default is 10 confirmations (matches zcashd / ywallet). pub fn get_spendable_notes(&self, max_block_height: Option) -> Result> { + self.get_spendable_notes_for_pool(max_block_height, None) + } + + /// Get spendable notes from a specific Orchard-family pool. Pool selection + /// is explicit because a normal transaction cannot silently combine the + /// two independent commitment trees. + pub fn get_spendable_notes_for_pool( + &self, + max_block_height: Option, + pool: Option, + ) -> Result> { // Single statement form using a sentinel: when max is None, pass i64::MAX // as the bound so the WHERE clause matches every row. Avoids the dance // of building two different prepared statements with different param // arity. let max_h = max_block_height.map(|h| h as i64).unwrap_or(i64::MAX); let mut stmt = self.conn.prepare( - "SELECT id, value, recipient, rho, rseed, cmx, nullifier, block_height, tx_index, action_index, position - FROM notes WHERE is_spent = 0 AND block_height <= ?1 ORDER BY value DESC" + "SELECT id, value, recipient, rho, rseed, cmx, nullifier, block_height, tx_index, action_index, position, pool + FROM notes + WHERE is_spent = 0 AND block_height <= ?1 + AND (?2 IS NULL OR pool = ?2) + ORDER BY value DESC" )?; let notes = stmt - .query_map(params![max_h], |row| { + .query_map(params![max_h, pool.map(ShieldedPool::as_str)], |row| { let rho_blob: Vec = row.get(3)?; let rseed_blob: Vec = row.get(4)?; let cmx_blob: Vec = row.get(5)?; @@ -305,6 +369,10 @@ impl WalletDb { rseed.copy_from_slice(&rseed_blob); cmx.copy_from_slice(&cmx_blob); nullifier.copy_from_slice(&nf_blob); + let pool_string: String = row.get(11)?; + let pool = ShieldedPool::from_str(&pool_string).ok_or_else(|| { + rusqlite::Error::InvalidColumnType(11, pool_string, rusqlite::types::Type::Text) + })?; Ok(SpendableNote { id: row.get(0)?, @@ -318,6 +386,7 @@ impl WalletDb { tx_index: row.get::<_, i64>(8)? as u32, action_index: row.get::<_, i64>(9)? as u32, position: row.get::<_, Option>(10)?.map(|p| p as u64), + pool, }) })? .collect::, _>>() @@ -351,9 +420,9 @@ impl WalletDb { } /// Get notes that have a txid but no memo (candidates for backfill). - pub fn get_notes_without_memo(&self) -> Result> { + pub fn get_notes_without_memo(&self) -> Result> { let mut stmt = self.conn.prepare( - "SELECT id, txid, block_height, action_index FROM notes WHERE memo IS NULL AND txid IS NOT NULL" + "SELECT id, txid, block_height, action_index, pool FROM notes WHERE memo IS NULL AND txid IS NOT NULL" )?; let rows = stmt .query_map([], |row| { @@ -362,11 +431,16 @@ impl WalletDb { if txid_blob.len() == 32 { txid.copy_from_slice(&txid_blob); } + let pool_string: String = row.get(4)?; + let pool = ShieldedPool::from_str(&pool_string).ok_or_else(|| { + rusqlite::Error::InvalidColumnType(4, pool_string, rusqlite::types::Type::Text) + })?; Ok(( row.get::<_, i64>(0)?, txid, row.get::<_, i64>(2)? as u64, row.get::<_, i64>(3)? as u32, + pool, )) })? .collect::, _>>() @@ -377,7 +451,7 @@ impl WalletDb { /// Get all notes for transaction history display. pub fn get_all_notes(&self) -> Result> { let mut stmt = self.conn.prepare( - "SELECT id, value, block_height, tx_index, is_spent, memo, nullifier, txid, action_index + "SELECT id, value, block_height, tx_index, is_spent, memo, nullifier, txid, action_index, pool FROM notes ORDER BY block_height DESC, tx_index DESC" )?; let notes = stmt @@ -397,6 +471,10 @@ impl WalletDb { None } }); + let pool_string: String = row.get(9)?; + let pool = ShieldedPool::from_str(&pool_string).ok_or_else(|| { + rusqlite::Error::InvalidColumnType(9, pool_string, rusqlite::types::Type::Text) + })?; Ok(NoteRecord { id: row.get(0)?, value: row.get::<_, i64>(1)? as u64, @@ -407,6 +485,7 @@ impl WalletDb { nullifier, txid, action_index: row.get::<_, i64>(8)? as u32, + pool, }) })? .collect::, _>>() @@ -430,6 +509,23 @@ impl WalletDb { Ok(balance as u64) } + /// Return the balance of one Orchard-family value pool. + pub fn get_balance_for_pool(&self, pool: ShieldedPool) -> Result { + let balance: i64 = self.conn.query_row( + "SELECT COALESCE(SUM(value), 0) FROM notes WHERE is_spent = 0 AND pool = ?1", + params![pool.as_str()], + |row| row.get(0), + )?; + if balance < 0 { + return Err(anyhow::anyhow!( + "Corrupt wallet state: negative {} balance sum ({})", + pool.as_str(), + balance + )); + } + Ok(balance as u64) + } + /// Get total count of notes (spent + unspent). pub fn get_note_count(&self) -> Result<(u64, u64)> { let total: i64 = self diff --git a/projects/keepkey-vault/zcash-cli/src/zip229.rs b/projects/keepkey-vault/zcash-cli/src/zip229.rs new file mode 100644 index 00000000..dd9f98b8 --- /dev/null +++ b/projects/keepkey-vault/zcash-cli/src/zip229.rs @@ -0,0 +1,211 @@ +//! ZIP-229 transaction-v6 digest helpers for NU6.3 / Ironwood. +//! +//! Transaction v6 keeps the ZIP-244 transparent and Sapling component +//! digests, adds a distinct Ironwood component, and moves Orchard-family +//! anchors from the txid/sighash commitment into the authorizing-data digest. + +use blake2b_simd::Params; +use orchard::bundle::{BundleVersion, TxVersion}; + +use crate::zip244::{self, TransparentInput, TransparentOutput}; + +pub const NU6_3_BRANCH_ID: u32 = 0x37A5165B; +pub const NU6_3_ACTIVATION_HEIGHT: u64 = 3_428_143; +pub const TX_VERSION: u32 = 6 | (1 << 31); +pub const VERSION_GROUP_ID: u32 = 0xD884B698; + +#[derive(Debug, Clone)] +pub struct Zip229Digests { + pub header_digest: [u8; 32], + pub transparent_digest: [u8; 32], + pub sapling_digest: [u8; 32], + pub orchard_digest: [u8; 32], + pub ironwood_digest: [u8; 32], +} + +fn blake2b_256(personal: &[u8; 16], data: &[u8]) -> [u8; 32] { + let hash = Params::new().hash_length(32).personal(personal).hash(data); + hash.as_bytes().try_into().expect("BLAKE2b-256 output") +} + +pub fn digest_header(branch_id: u32, lock_time: u32, expiry_height: u32) -> [u8; 32] { + let mut data = Vec::with_capacity(20); + data.extend_from_slice(&TX_VERSION.to_le_bytes()); + data.extend_from_slice(&VERSION_GROUP_ID.to_le_bytes()); + data.extend_from_slice(&branch_id.to_le_bytes()); + data.extend_from_slice(&lock_time.to_le_bytes()); + data.extend_from_slice(&expiry_height.to_le_bytes()); + blake2b_256(b"ZTxIdHeadersHash", &data) +} + +pub fn empty_orchard_digest() -> [u8; 32] { + blake2b_256(b"ZTxIdOrchardH_v6", &[]) +} + +pub fn empty_ironwood_digest() -> [u8; 32] { + blake2b_256(b"ZTxIdIronwd_H_v6", &[]) +} + +pub fn digest_bundle_effects( + bundle: &orchard::Bundle, +) -> anyhow::Result<[u8; 32]> +where + V: Copy + Into, +{ + if bundle.bundle_version() != BundleVersion::ironwood_v3() { + return Err(anyhow::anyhow!( + "ZIP-229 Ironwood slot requires an ironwood_v3 bundle" + )); + } + Ok(bundle + .commitment(TxVersion::V6) + .map_err(|e| anyhow::anyhow!("Ironwood commitment failed: {}", e))? + .into()) +} + +pub fn digest_bundle_authorized( + bundle: &orchard::Bundle, +) -> anyhow::Result<[u8; 32]> { + if bundle.bundle_version() != BundleVersion::ironwood_v3() { + return Err(anyhow::anyhow!( + "ZIP-229 Ironwood slot requires an ironwood_v3 bundle" + )); + } + Ok(bundle + .commitment(TxVersion::V6) + .map_err(|e| anyhow::anyhow!("Ironwood commitment failed: {}", e))? + .into()) +} + +pub fn compute_digests_hybrid( + ironwood_bundle: &orchard::Bundle, + transparent_inputs: &[TransparentInput], + transparent_outputs: &[TransparentOutput], + branch_id: u32, + lock_time: u32, + expiry_height: u32, +) -> anyhow::Result +where + V: Copy + Into, +{ + Ok(Zip229Digests { + header_digest: digest_header(branch_id, lock_time, expiry_height), + transparent_digest: zip244::digest_transparent_sig_for_orchard( + transparent_inputs, + transparent_outputs, + ), + sapling_digest: zip244::EMPTY_SAPLING_DIGEST, + orchard_digest: empty_orchard_digest(), + ironwood_digest: digest_bundle_effects(ironwood_bundle)?, + }) +} + +pub fn compute_sighash(digests: &Zip229Digests, branch_id: u32) -> [u8; 32] { + let mut personal = [0u8; 16]; + personal[..12].copy_from_slice(b"ZcashTxHash_"); + personal[12..].copy_from_slice(&branch_id.to_le_bytes()); + + let mut data = Vec::with_capacity(160); + data.extend_from_slice(&digests.header_digest); + data.extend_from_slice(&digests.transparent_digest); + data.extend_from_slice(&digests.sapling_digest); + data.extend_from_slice(&digests.orchard_digest); + data.extend_from_slice(&digests.ironwood_digest); + blake2b_256(&personal, &data) +} + +pub fn compute_transparent_sig_hash( + input_index: usize, + inputs: &[TransparentInput], + outputs: &[TransparentOutput], + digests: &Zip229Digests, + branch_id: u32, +) -> [u8; 32] { + let input = &inputs[input_index]; + let mut per_input_data = Vec::new(); + per_input_data.extend_from_slice(&input.prevout_hash); + per_input_data.extend_from_slice(&input.prevout_index.to_le_bytes()); + per_input_data.extend_from_slice(&(input.value as i64).to_le_bytes()); + write_compact_size(&mut per_input_data, input.script_pubkey.len() as u64); + per_input_data.extend_from_slice(&input.script_pubkey); + per_input_data.extend_from_slice(&input.sequence.to_le_bytes()); + let txin_sig_digest = blake2b_256(b"Zcash___TxInHash", &per_input_data); + + let mut transparent_sig_data = Vec::new(); + transparent_sig_data.push(0x01); // SIGHASH_ALL + transparent_sig_data.extend_from_slice(&zip244::digest_transparent_prevouts(inputs)); + transparent_sig_data.extend_from_slice(&zip244::digest_transparent_amounts(inputs)); + transparent_sig_data.extend_from_slice(&zip244::digest_transparent_scripts(inputs)); + transparent_sig_data.extend_from_slice(&zip244::digest_transparent_sequence(inputs)); + transparent_sig_data.extend_from_slice(&zip244::digest_transparent_outputs(outputs)); + transparent_sig_data.extend_from_slice(&txin_sig_digest); + + let transparent_digest = blake2b_256(b"ZTxIdTranspaHash", &transparent_sig_data); + let per_input = Zip229Digests { + header_digest: digests.header_digest, + transparent_digest, + sapling_digest: digests.sapling_digest, + orchard_digest: digests.orchard_digest, + ironwood_digest: digests.ironwood_digest, + }; + compute_sighash(&per_input, branch_id) +} + +pub fn compute_txid( + ironwood_digest: [u8; 32], + inputs: &[TransparentInput], + outputs: &[TransparentOutput], + branch_id: u32, + lock_time: u32, + expiry_height: u32, +) -> [u8; 32] { + compute_sighash( + &Zip229Digests { + header_digest: digest_header(branch_id, lock_time, expiry_height), + transparent_digest: zip244::digest_transparent_txid(inputs, outputs), + sapling_digest: zip244::EMPTY_SAPLING_DIGEST, + orchard_digest: empty_orchard_digest(), + ironwood_digest, + }, + branch_id, + ) +} + +fn write_compact_size(buf: &mut Vec, n: u64) { + if n < 253 { + buf.push(n as u8); + } else if n <= u16::MAX as u64 { + buf.push(253); + buf.extend_from_slice(&(n as u16).to_le_bytes()); + } else if n <= u32::MAX as u64 { + buf.push(254); + buf.extend_from_slice(&(n as u32).to_le_bytes()); + } else { + buf.push(255); + buf.extend_from_slice(&n.to_le_bytes()); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn empty_pool_digests_are_domain_separated() { + assert_ne!(empty_orchard_digest(), empty_ironwood_digest()); + assert_eq!( + empty_orchard_digest(), + blake2b_256(b"ZTxIdOrchardH_v6", &[]) + ); + assert_eq!( + empty_ironwood_digest(), + blake2b_256(b"ZTxIdIronwd_H_v6", &[]) + ); + } + + #[test] + fn v6_header_commits_to_v6_group_id() { + let digest = digest_header(NU6_3_BRANCH_ID, 0, 0); + assert_ne!(digest, zip244::digest_header(NU6_3_BRANCH_ID, 0, 0)); + } +} diff --git a/projects/keepkey-vault/zcash-cli/src/zip244.rs b/projects/keepkey-vault/zcash-cli/src/zip244.rs index 8b9bb405..cb9ac32f 100644 --- a/projects/keepkey-vault/zcash-cli/src/zip244.rs +++ b/projects/keepkey-vault/zcash-cli/src/zip244.rs @@ -64,7 +64,7 @@ pub fn digest_orchard(bundle: &orchard::Bundle data.extend_from_slice(&compact_hash); data.extend_from_slice(&memos_hash); data.extend_from_slice(&noncompact_hash); - data.push(bundle.flags().to_byte()); + data.push(bundle.flag_byte()); data.extend_from_slice(&bundle.value_balance().to_le_bytes()); data.extend_from_slice(&bundle.anchor().to_bytes()); @@ -136,7 +136,7 @@ where orchard_data.extend_from_slice(&compact_hash); orchard_data.extend_from_slice(&memos_hash); orchard_data.extend_from_slice(&noncompact_hash); - orchard_data.push(bundle.flags().to_byte()); + orchard_data.push(bundle.flag_byte()); orchard_data.extend_from_slice(&(*bundle.value_balance()).into().to_le_bytes()); orchard_data.extend_from_slice(&bundle.anchor().to_bytes());