From d424ada848737224720acf09b0f232772782c506 Mon Sep 17 00:00:00 2001 From: Veetrag Jain Date: Thu, 22 Oct 2026 23:53:41 +0530 Subject: [PATCH] feat(wasm-utxo): KMS out_ciphertext and proof helpers Ticket: CSHLD-2027 --- .../ZcashIronwoodBitGoPsbt.ts | 4 +- .../fixedScriptWallet/ZcashIronwoodTypes.ts | 10 +- .../src/fixed_script_wallet/bitgo_psbt/mod.rs | 6 +- .../bitgo_psbt/zcash/error.rs | 84 +++- .../bitgo_psbt/zcash/ironwood.rs | 47 +- .../bitgo_psbt/zcash/ironwood_spend.rs | 324 +++++++++++++- .../bitgo_psbt/zcash/parse.rs | 14 +- .../bitgo_psbt/zcash/propkv.rs | 25 +- .../bitgo_psbt/zcash/tests/ironwood.rs | 18 +- .../bitgo_psbt/zcash/tests/ironwood_spend.rs | 420 +++++++++++++++++- .../bitgo_psbt/zcash/v6.rs | 10 +- .../wasm/fixed_script_wallet/zcash_psbt.rs | 6 +- .../wasm-utxo/src/zcash/ironwood_build.rs | 20 +- .../wasm-utxo/src/zcash/ironwood_construct.rs | 26 +- packages/wasm-utxo/src/zcash/ironwood_pczt.rs | 12 +- .../wasm-utxo/src/zcash/ironwood_verify.rs | 96 ++-- packages/wasm-utxo/src/zcash/mod.rs | 4 +- packages/wasm-utxo/test/types/parsedTypes.ts | 2 +- 18 files changed, 971 insertions(+), 157 deletions(-) diff --git a/packages/wasm-utxo/js/fixedScriptWallet/ZcashIronwoodBitGoPsbt.ts b/packages/wasm-utxo/js/fixedScriptWallet/ZcashIronwoodBitGoPsbt.ts index 14555b67007..8ceae6ccead 100644 --- a/packages/wasm-utxo/js/fixedScriptWallet/ZcashIronwoodBitGoPsbt.ts +++ b/packages/wasm-utxo/js/fixedScriptWallet/ZcashIronwoodBitGoPsbt.ts @@ -124,8 +124,8 @@ export class ZcashIronwoodBitGoPsbt extends ZcashBitGoPsbt< /** * Parse the transaction through the Zcash parse, which surfaces the shielded side the * generic parse refuses to drop: per-action output indices, the shielded spends, and the - * bundle's value balance. The plaintext spend info is unverified until the key-free - * verifier lands (plan PR 4). + * bundle's value balance. The key-free verifier checks the bundle before any plaintext + * value is returned. */ override parseTransactionWithWalletKeys( walletKeys: WalletKeysArg, diff --git a/packages/wasm-utxo/js/fixedScriptWallet/ZcashIronwoodTypes.ts b/packages/wasm-utxo/js/fixedScriptWallet/ZcashIronwoodTypes.ts index 0743356044c..8a9ae9fa916 100644 --- a/packages/wasm-utxo/js/fixedScriptWallet/ZcashIronwoodTypes.ts +++ b/packages/wasm-utxo/js/fixedScriptWallet/ZcashIronwoodTypes.ts @@ -2,11 +2,11 @@ * Shared Zcash Ironwood (v6) types for the shielded-spend flow. * * These describe what the build service, the client and KMS each see as the PSBT moves - * through the flow in `docs/ironwood-shielded-spend-plan.md` §2. Parse-facing types live in + * through the build → KMS → sign → prove → combine flow. Parse-facing types live in * `ZcashIronwoodBitGoPsbt.ts`; this module holds the shared vocabulary. */ -/** One shielded note to spend, as passed to `addShieldedSpends` (plan PR 5). */ +/** One shielded note to spend, as passed to `addShieldedSpends`. */ export type ZcashShieldedSpendRequest = { /** Raw 43-byte Orchard recipient (diversifier ‖ pk_d). */ recipient: Uint8Array; @@ -24,7 +24,7 @@ export type ZcashShieldedSpendRequest = { nullifier: Uint8Array; }; -/** One shielded output to create, as passed to `addShieldedSpends` (plan PR 5). */ +/** One shielded output to create, as passed to `addShieldedSpends`. */ export type ZcashShieldedOutputRequest = { /** Raw 43-byte Orchard recipient. */ recipient: Uint8Array; @@ -73,7 +73,7 @@ export type ZcashShieldedSpend = { signed: boolean; }; -/** The client-side verification report of a shielded bundle (plan PR 4). */ +/** The client-side verification report of a shielded bundle. */ export type IronwoodVerificationReport = { /** The bundle's real spends, as verified. */ spends: ZcashShieldedSpend[]; @@ -85,7 +85,7 @@ export type IronwoodVerificationReport = { anchor: Uint8Array; }; -/** Orchestration state of the shielded side of a spend PSBT (plan PR 7). */ +/** Orchestration state of the shielded side of a spend PSBT. */ export type IronwoodShieldedStatus = { inputKind: ZcashInputKind; /** Action indices whose `out_ciphertext` is still pending. */ diff --git a/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/mod.rs b/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/mod.rs index 34619ba5ce7..f7b98c59559 100644 --- a/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/mod.rs +++ b/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/mod.rs @@ -795,7 +795,7 @@ impl BitGoPsbt { ..Default::default() }; - // Rule 0 (plan §3): the Zcash arm rejects a transparent input on a PSBT that already + // One input kind: the Zcash arm rejects a transparent input on a PSBT that already // spends shielded notes. A stored PCZT with only dummy spends — the shielding flow — // is fine. if let BitGoPsbt::Zcash(zcash, _) = self { @@ -901,7 +901,7 @@ impl BitGoPsbt { value: u64, options: ReplayProtectionOptions, ) -> Result { - // Rule 0 (plan §3): the Zcash arm rejects a transparent input on a PSBT that already + // One input kind: the Zcash arm rejects a transparent input on a PSBT that already // spends shielded notes (see `add_input_at_index`). if let BitGoPsbt::Zcash(zcash, _) = self { zcash.reject_transparent_input_with_spend_bundle()?; @@ -1170,7 +1170,7 @@ impl BitGoPsbt { script_id: ScriptId, options: WalletInputOptions, ) -> Result { - // Rule 0 (plan §3): the Zcash arm rejects a transparent input on a PSBT that already + // One input kind: the Zcash arm rejects a transparent input on a PSBT that already // spends shielded notes (see `add_input_at_index`). if let BitGoPsbt::Zcash(zcash, _) = self { zcash.reject_transparent_input_with_spend_bundle()?; diff --git a/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/zcash/error.rs b/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/zcash/error.rs index c56495c17ad..e447f639a44 100644 --- a/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/zcash/error.rs +++ b/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/zcash/error.rs @@ -1,4 +1,4 @@ -//! Typed errors for the Ironwood PSBT workflow (plan §6.4). +//! Typed errors for the Ironwood PSBT workflow. //! //! Every new Ironwood method on [`super::ZcashBitGoPsbt`] returns //! [`IronwoodPsbtError`], whose variant name is the JS `err.code` @@ -11,7 +11,7 @@ /// An Ironwood PSBT workflow error. #[derive(Debug, strum::IntoStaticStr)] pub enum IronwoodPsbtError { - /// Rule 0 (plan §3): a v6 PSBT spends either transparent inputs or shielded notes, + /// One input kind: a v6 PSBT spends either transparent inputs or shielded notes, /// never both. [`super::ZcashBitGoPsbt::add_shielded_spends`] rejects a PSBT that /// already carries transparent inputs. MixedInputKinds, @@ -21,6 +21,43 @@ pub enum IronwoodPsbtError { /// A caller-supplied Unified Address is not a valid Unified Address, has no Orchard /// receiver, or its Orchard receiver is not the output's `recipient`. BadUnifiedAddress(String), + /// The PSBT carries no Ironwood PCZT, or its bundle is not a spend bundle (no real + /// spends), and the operation needs one. + NotSpendBundle, + /// An action index the operation named does not exist in the bundle. + UnknownAction, + /// The action index names a fabricated (dummy) half, not a real spend or output. + DummyAction, + /// Ciphertexts first: these real outputs' `out_ciphertext`s are still pending — the KMS + /// ciphertext round must run before randomizers, sighashes, or proof requests. + OutCiphertextPending { + /// The pending real outputs' action indices. + action_indices: Vec, + }, + /// The action already has an `out_ciphertext`, and the entry carries different bytes; + /// re-delivering identical bytes is accepted. + OutCiphertextConflict { + /// The offending action index. + action_index: usize, + }, + /// Randomizers installed: these real spends still carry the placeholder `rk` — the signer's + /// randomizers must be installed before the proof request. + RandomizerPending { + /// The pending real spends' action indices. + action_indices: Vec, + }, + /// Signed before proven: these real spends have no `spend_auth_sig` yet — the proof is + /// requested only after the wallet has signed. + SpendSignaturePending { + /// The unsigned real spends' action indices. + action_indices: Vec, + }, + /// The bundle is frozen: a spend-auth signature (or a transparent + /// partial signature, as defense in depth) exists, so ciphertexts can no longer be + /// installed. + AlreadySigned, + /// The operation has nothing to act on: there is no pending item, or the batch is empty. + NothingPending, /// The Ironwood Constructor rejected the request; the wrapped /// [`IronwoodBuildError`](crate::zcash::ironwood_build::IronwoodBuildError) carries the /// reason (and its own `IronwoodBuildError.*` code). @@ -29,6 +66,10 @@ pub enum IronwoodPsbtError { /// [`IronwoodPcztError`](crate::zcash::ironwood_pczt::IronwoodPcztError) carries the /// reason. Pczt(crate::zcash::ironwood_pczt::IronwoodPcztError), + /// The key-free verifier rejected the bundle; the wrapped + /// [`IronwoodVerifyError`](crate::zcash::ironwood_build::IronwoodVerifyError) carries + /// the failing check (and its own `IronwoodVerifyError.` code). + Verify(crate::zcash::ironwood_build::IronwoodVerifyError), } impl std::fmt::Display for IronwoodPsbtError { @@ -44,8 +85,46 @@ impl std::fmt::Display for IronwoodPsbtError { "an Ironwood PCZT is already present; only one call is supported" ), IronwoodPsbtError::BadUnifiedAddress(e) => write!(f, "invalid unified address: {e}"), + IronwoodPsbtError::NotSpendBundle => { + write!(f, "this PSBT carries no Ironwood spend bundle") + } + IronwoodPsbtError::UnknownAction => { + write!(f, "the named action index does not exist in the bundle") + } + IronwoodPsbtError::DummyAction => write!( + f, + "the named action is a fabricated dummy, not a real spend or output" + ), + IronwoodPsbtError::OutCiphertextPending { action_indices } => write!( + f, + "real outputs {action_indices:?} are still pending their out_ciphertext; \ + run the KMS ciphertext round first" + ), + IronwoodPsbtError::OutCiphertextConflict { action_index } => write!( + f, + "action {action_index}'s out_ciphertext is already set to different bytes" + ), + IronwoodPsbtError::RandomizerPending { action_indices } => write!( + f, + "real spends {action_indices:?} still carry the placeholder rk; install the \ + signer's randomizers first" + ), + IronwoodPsbtError::SpendSignaturePending { action_indices } => write!( + f, + "real spends {action_indices:?} are not signed yet; the proof is requested \ + only after the spend-auth signatures are installed" + ), + IronwoodPsbtError::AlreadySigned => write!( + f, + "the bundle is frozen: a signature exists, so out_ciphertexts can no longer \ + be installed" + ), + IronwoodPsbtError::NothingPending => { + write!(f, "there is nothing pending for this operation") + } IronwoodPsbtError::Build(e) => write!(f, "{e}"), IronwoodPsbtError::Pczt(e) => write!(f, "{e}"), + IronwoodPsbtError::Verify(e) => write!(f, "{e}"), } } } @@ -71,6 +150,7 @@ impl crate::error::WasmErrorCode for IronwoodPsbtError { match self { IronwoodPsbtError::Build(error) => crate::error::WasmErrorCode::code(error), IronwoodPsbtError::Pczt(error) => crate::error::WasmErrorCode::code(error), + IronwoodPsbtError::Verify(error) => crate::error::WasmErrorCode::code(error), other => format!("IronwoodPsbtError.{}", <&'static str>::from(other)), } } diff --git a/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/zcash/ironwood.rs b/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/zcash/ironwood.rs index 5ef9bd7515b..a7b3a37b6da 100644 --- a/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/zcash/ironwood.rs +++ b/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/zcash/ironwood.rs @@ -42,7 +42,7 @@ pub struct IronwoodOutputRequest { /// (see [`ZcashBitGoPsbt::replace_ironwood_spend_rk`]). /// /// Oracle-only: production spends go through -/// [`crate::zcash::ironwood_construct::IronwoodKeylessSpendSpec`] (plan PR 5). +/// [`crate::zcash::ironwood_construct::IronwoodKeylessSpendSpec`]. #[cfg(any(test, feature = "orchard-proving"))] #[derive(Clone)] pub struct IronwoodSpendRequest { @@ -281,7 +281,7 @@ impl ZcashBitGoPsbt { /// /// Real spends leave the placeholder `rk` (= the spend's `ak`) in the stored PCZT. /// - /// Oracle-only (plan PR 5): production spends go through + /// Oracle-only: production spends go through /// [`Self::add_shielded_spends`], the keyless Constructor. This keyed path needs the FVK /// on the PSBT side and is compiled only under `test` / `orchard-proving`. #[cfg(any(test, feature = "orchard-proving"))] @@ -342,8 +342,19 @@ impl ZcashBitGoPsbt { let (pczt, action_map) = ironwood_build::construct_spend_pczt_multi(&spend_specs, &output_specs, anchor, rng) .map_err(|e| e.to_string())?; - self.store_ironwood_pczt_and_unified_addresses(pczt, action_map, outputs) - .map_err(|e| e.to_string()) + let action_count = pczt.actions().len(); + let action_map = self + .store_ironwood_pczt_and_unified_addresses(pczt, action_map, outputs) + .map_err(|e| e.to_string())?; + if !spends.is_empty() { + // The keyed Constructor encrypts every out_ciphertext under the FVK's ovk at + // construction, so its spend bundle starts with every ciphertext set. + super::propkv::set_ironwood_out_ciphertexts_set( + &mut self.psbt, + vec![0xff; action_count.div_ceil(8)], + ); + } + Ok(action_map) } /// Reject a caller-supplied Unified Address whose Orchard receiver is not exactly `recipient`: @@ -611,6 +622,9 @@ impl ZcashBitGoPsbt { let bytes = super::propkv::get_ironwood_pczt(&self.psbt) .ok_or_else(|| "no Ironwood PCZT stored in PSBT".to_string())?; let pczt = ironwood_pczt::deserialize_pczt(&bytes).map_err(|e| e.to_string())?; + // Ciphertexts first: the ciphertext round comes before the randomizers. + self.require_out_ciphertexts_set_in(&pczt) + .map_err(|e| e.to_string())?; ironwood_build::check_spend_rk_replaceable(&pczt, action_index, &rk) .map_err(Self::ironwood_action_error(action_index))?; @@ -633,7 +647,10 @@ impl ZcashBitGoPsbt { let bytes = super::propkv::get_ironwood_pczt(&self.psbt) .ok_or_else(|| "no Ironwood PCZT stored in PSBT".to_string())?; let mut pczt = ironwood_pczt::deserialize_pczt(&bytes).map_err(|e| e.to_string())?; - // Gate before the sighash: it is not final while any real spend's rk is the placeholder. + // Gate before the sighash: it is not final while any real output's out_ciphertext + // or any real spend's rk is still a placeholder. + self.require_out_ciphertexts_set_in(&pczt) + .map_err(|e| e.to_string())?; ironwood_build::require_spend_rks_replaced(&pczt).map_err(|e| e.to_string())?; let (amounts, scripts) = self.transparent_input_amounts_and_scripts()?; @@ -702,12 +719,12 @@ impl ZcashBitGoPsbt { Ok(true) } - /// The keyless verification of the stored PCZT (plan §5): run every check a party + /// The keyless verification of the stored PCZT: run every check a party /// without viewing keys can run before surfacing any plaintext value, so a tampered /// bundle fails loudly with the failing check instead of leaking unverified plaintext. /// Verification only reads the PCZT: its plaintext stays in place for the prover. /// - /// The stored spend-validating key (plan PR 5's `0x08` key) is passed through when + /// The stored spend-validating key is passed through when /// present; until then the `Rk` check is skipped and only the key-free checks run. pub(crate) fn keyless_verify( &self, @@ -805,7 +822,7 @@ impl ZcashBitGoPsbt { if self.require_no_shielded_output_ever_added()? { return Err("no Ironwood PCZT stored in PSBT".to_string()); } - // The keyless verifier (plan §5) runs every key-free check — including `ValueSum`, + // The keyless verifier runs every key-free check — including `ValueSum`, // which cross-checks the committed `value_sum` against the plaintext note values — // before any accounting flows out of here. The plaintext note values are NOT bound // after deserialization: the proofs bind only `cv_net`/`cm`. @@ -966,13 +983,17 @@ impl ZcashBitGoPsbt { } /// Transaction Extractor role: given the external prover's `proof` bytes, finalize the - /// transparent inputs, apply the shielded binding signature, and produce the broadcast-ready v6 - /// transaction bytes. + /// transparent inputs, apply the shielded binding signature, and produce the broadcast-ready + /// v6 transaction bytes. + /// + /// Shielding and shielded spends share this shape: signing is done first, then the + /// prover's proof (from [`Self::proof_request`] for a spend bundle) is passed straight in. /// /// The PSBT must already carry every transparent input's signatures (via - /// [`Self::add_v6_transparent_signature`]) and the PCZT (from [`Self::add_ironwood_output`]). - /// `rng` seeds both the dummy spend-auth signature (in the IO finalizer) and the binding - /// signature (in the Transaction Extractor). Consumes `self`. + /// [`Self::add_v6_transparent_signature`]) and the PCZT (from [`Self::add_ironwood_output`] + /// or [`Self::add_shielded_spends`]). `rng` seeds both the dummy spend-auth signature (in + /// the IO finalizer) and the binding signature (in the Transaction Extractor). Consumes + /// `self`. pub fn combine_ironwood_proof( self, proof: Vec, diff --git a/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/zcash/ironwood_spend.rs b/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/zcash/ironwood_spend.rs index e9e018885f3..3f546fc14df 100644 --- a/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/zcash/ironwood_spend.rs +++ b/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/zcash/ironwood_spend.rs @@ -1,13 +1,13 @@ -//! The Ironwood **spend** PSBT workflow (plan §4/§6.2): the keyless Constructor entry point -//! ([`ZcashBitGoPsbt::add_shielded_spends`]) and the one-input-kind rule (plan rule 0). +//! The Ironwood **spend** PSBT workflow: the keyless Constructor entry point +//! ([`ZcashBitGoPsbt::add_shielded_spends`]) and the one-input-kind rule. //! //! The build service (IMS) selects notes and calls [`ZcashBitGoPsbt::add_shielded_spends`] //! with the notes' plaintext, their witnesses, the KMS-derived nullifiers, the requested //! outputs and the wallet's spend-validating key `ak` — and no viewing key. The constructed //! PCZT is stored in the PSBT together with `ak` (proprietary key `0x08`), which the key-free -//! verifier (plan §5) reads for the `Rk` check, and the per-output Unified Addresses. +//! verifier reads for the `Rk` check, and the per-output Unified Addresses. //! -//! Rule 0 (plan §3): a v6 PSBT spends **either** transparent inputs (transparent v6 or +//! One input kind: a v6 PSBT spends **either** transparent inputs (transparent v6 or //! shielding) **or** shielded notes (deshield / shielded→shielded), never both. The shielded //! side enforces it here ([`ZcashBitGoPsbt::add_shielded_spends`] rejects transparent //! inputs); the transparent side enforces it in @@ -20,7 +20,7 @@ use super::{IronwoodOutputRequest, ZcashBitGoPsbt}; impl ZcashBitGoPsbt { /// Constructor role: build the shielded spends (and optionally shielded outputs) as a /// single **keyless** orchard PCZT and store it in the PSBT — the deshield and - /// shielded→shielded Constructor for a build service that holds no viewing keys (plan §4). + /// shielded→shielded Constructor for a build service that holds no viewing keys. /// /// Each entry of `spends` carries the spent note's plaintext (`recipient`/`value`/`rho`/ /// `rseed`/`cmx`), its Merkle witness, and the nullifier (from the note store, or from @@ -32,18 +32,19 @@ impl ZcashBitGoPsbt { /// /// Every real spend carries the wallet's `ak` as the placeholder `rk` and no `alpha`; the /// signer installs `alpha` and the matching `rk = ak.randomize(alpha)` before any sighash - /// is computed (plan rule 2). Every output's `out_ciphertext` is a random placeholder — - /// KMS replaces it under its FVK `ovk` (plan §2 step 3). No FVK ever enters the stored + /// is computed. Every output's `out_ciphertext` is a random placeholder — + /// KMS replaces it under its FVK `ovk`. No FVK ever enters the stored /// PCZT. /// - /// Rules enforced here (plan §3): - /// - **rule 0** — rejected when the PSBT already carries transparent inputs + /// Rules enforced here: + /// - **one input kind** — rejected when the PSBT already carries transparent inputs /// ([`IronwoodPsbtError::MixedInputKinds`]); /// - **one call per PSBT** — a second call would silently overwrite the first batch /// ([`IronwoodPsbtError::AlreadyAdded`]). /// /// Also stored: the caller's `ak` (proprietary key `0x08`) — the key-free verifier's `Rk` - /// check reads it — and each output's Unified Address, keyed by its action index. + /// check reads it — each output's Unified Address, keyed by its action index, and the + /// all-clear `out_ciphertext` bitset (proprietary key `0x09`). pub fn add_shielded_spends( &mut self, spends: &[crate::zcash::ironwood_construct::IronwoodKeylessSpendSpec], @@ -54,7 +55,7 @@ impl ZcashBitGoPsbt { ) -> Result { use crate::zcash::{ironwood_build, ironwood_construct}; - // Rule 0: a spend PSBT has no transparent inputs — the shielded side funds itself. + // One input kind: a spend PSBT has no transparent inputs — the shielded side funds itself. if !self.psbt.inputs.is_empty() { return Err(IronwoodPsbtError::MixedInputKinds); } @@ -85,15 +86,23 @@ impl ZcashBitGoPsbt { ak, rng, )?; + let action_count = pczt.actions().len(); let action_map = self.store_ironwood_pczt_and_unified_addresses(pczt, action_map, outputs)?; - // The wallet's spend-validating key, for the key-free verifier's `Rk` check (plan §5). + // The wallet's spend-validating key, for the key-free verifier's `Rk` check. super::propkv::set_ironwood_spend_validating_key(&mut self.psbt, ak); + // Every real output starts pending its `out_ciphertext`: the + // little-endian bitset over the bundle's actions starts all-clear, so + // `pending_out_ciphertexts` reports the full request until KMS's ciphertexts land. + super::propkv::set_ironwood_out_ciphertexts_set( + &mut self.psbt, + vec![0u8; action_count.div_ceil(8)], + ); Ok(action_map) } - /// Rule 0 on the transparent side: reject adding a transparent input to a PSBT whose - /// stored Ironwood bundle already spends real (non-dummy) notes. + /// The one-input-kind rule on the transparent side: reject adding a transparent input to a PSBT + /// whose stored Ironwood bundle already spends real (non-dummy) notes. /// /// A stored PCZT with only dummy spends — the **shielding** flow — is fine: transparent /// inputs funding shielded outputs is exactly what that flow builds. Called by the @@ -118,4 +127,291 @@ impl ZcashBitGoPsbt { } Ok(()) } + + // ---- The KMS out_ciphertext / proof workflow ---- + + /// The stored PCZT, raw and deserialized, or [`IronwoodPsbtError::NotSpendBundle`] if + /// absent. + fn stored_ironwood_pczt(&self) -> Result<(Vec, orchard::pczt::Bundle), IronwoodPsbtError> { + let bytes = super::propkv::get_ironwood_pczt(&self.psbt) + .ok_or(IronwoodPsbtError::NotSpendBundle)?; + let pczt = crate::zcash::ironwood_pczt::deserialize_pczt(&bytes)?; + Ok((bytes, pczt)) + } + + /// The action indices of every real (requested, non-dummy) output, classified from the + /// plaintext the way the key-free verifier does: a dummy spend's output is always real, + /// and a real spend's output is real unless it is the fabricated value-0 one. An output + /// with no plaintext value counts as real, so the ciphertexts-first gate fails closed. + /// + /// The ciphertexts-first gate runs on bundles mid-way through the signer's randomizer writes, + /// which the full verifier rejects, so it uses this classification alone; the paths that hand + /// data to KMS run the verifier first. + fn real_output_indices(pczt: &orchard::pczt::Bundle) -> Vec { + pczt.actions() + .iter() + .enumerate() + .filter(|(_, action)| { + action.spend().dummy_sk().is_some() + || action + .output() + .value() + .as_ref() + .is_none_or(|value| value.inner() != 0) + }) + .map(|(action_index, _)| action_index) + .collect() + } + + /// [`Self::pending_out_ciphertexts`] over an already-deserialized PCZT. + /// + /// Only spend bundles are tracked: shielding finalizes its ciphertexts at signing. A spend + /// bundle without the [`ZecV6KeySubtype::IronwoodOutCiphertextsSet`] bitset counts every real + /// output as pending — both Constructors always write it, so a missing bitset means the key was + /// stripped, and failing closed keeps the ciphertexts-first gate from being skipped that way. + /// + /// [`ZecV6KeySubtype::IronwoodOutCiphertextsSet`]: super::propkv::ZecV6KeySubtype::IronwoodOutCiphertextsSet + fn pending_out_ciphertexts_in( + &self, + pczt: &orchard::pczt::Bundle, + ) -> Result, IronwoodPsbtError> { + if !Self::bundle_has_real_spends(pczt) { + return Ok(Vec::new()); + } + let bits = super::propkv::get_ironwood_out_ciphertexts_set(&self.psbt).unwrap_or_default(); + Ok(Self::real_output_indices(pczt) + .into_iter() + .filter(|&action_index| !bitset_contains(&bits, action_index)) + .collect()) + } + + /// The real outputs whose `out_ciphertext` is still pending. Empty for a + /// bundle with no real spends (shielding). + pub fn pending_out_ciphertexts(&self) -> Result, IronwoodPsbtError> { + let (_, pczt) = self.stored_ironwood_pczt()?; + self.pending_out_ciphertexts_in(&pczt) + } + + /// The KMS out-ciphertext request: the raw PCZT bytes plus the real + /// outputs still pending their `out_ciphertext`. KMS decrypts nothing here — it + /// re-encrypts each pending output under its FVK `ovk` and returns one 80-byte + /// ciphertext per action index, applied with [`Self::add_out_ciphertexts`]. + /// + /// Errors with [`IronwoodPsbtError::NotSpendBundle`] when no PCZT is stored or the + /// bundle is not a spend bundle, and with [`IronwoodPsbtError::NothingPending`] when + /// every real output is already set (or there are none, as in a pure deshield). + pub fn out_ciphertext_request(&self) -> Result<(Vec, Vec), IronwoodPsbtError> { + let (bytes, pczt) = self.stored_ironwood_pczt()?; + if !Self::bundle_has_real_spends(&pczt) { + return Err(IronwoodPsbtError::NotSpendBundle); + } + self.keyless_verify(&pczt) + .map_err(IronwoodPsbtError::Verify)?; + let pending = self.pending_out_ciphertexts_in(&pczt)?; + if pending.is_empty() { + return Err(IronwoodPsbtError::NothingPending); + } + Ok((bytes, pending)) + } + + /// The ciphertexts-first gate for the sighash, randomizer and proof paths: every real output + /// of a spend bundle must have its `out_ciphertext` installed before anything commits + /// the action data (the ZIP-244 digest commits `out_ciphertext`). + /// + /// No PCZT (a transparent-only v6 PSBT, or one already extracted) passes: the caller + /// that needs a PCZT surfaces its own, more specific error. + pub(crate) fn require_out_ciphertexts_set(&self) -> Result<(), IronwoodPsbtError> { + match self.stored_ironwood_pczt() { + Ok((_, pczt)) => self.require_out_ciphertexts_set_in(&pczt), + Err(IronwoodPsbtError::NotSpendBundle) => Ok(()), + Err(error) => Err(error), + } + } + + /// [`Self::require_out_ciphertexts_set`] over an already-deserialized PCZT. + pub(crate) fn require_out_ciphertexts_set_in( + &self, + pczt: &orchard::pczt::Bundle, + ) -> Result<(), IronwoodPsbtError> { + let pending = self.pending_out_ciphertexts_in(pczt)?; + if pending.is_empty() { + return Ok(()); + } + Err(IronwoodPsbtError::OutCiphertextPending { + action_indices: pending, + }) + } + + /// Randomizers installed: every real spend's randomized `rk` must be installed — the ZIP-244 + /// digest and the proof both commit to `rk`. + fn require_randomizers_installed( + pczt: &orchard::pczt::Bundle, + ) -> Result<(), IronwoodPsbtError> { + use crate::zcash::ironwood_build::IronwoodBuildError; + + let mut pending = Vec::new(); + for (action_index, action) in pczt.actions().iter().enumerate() { + let spend = action.spend(); + if spend.dummy_sk().is_some() { + continue; + } + if !crate::zcash::ironwood_pczt::spend_rk_replaced(spend) + .map_err(IronwoodBuildError::PcztWire)? + { + pending.push(action_index); + } + } + if pending.is_empty() { + return Ok(()); + } + Err(IronwoodPsbtError::RandomizerPending { + action_indices: pending, + }) + } + + /// The bundle is frozen once a spend-auth signature exists — or, as + /// defense in depth, once any transparent input carries a partial signature. + fn require_unfrozen(&self, pczt: &orchard::pczt::Bundle) -> Result<(), IronwoodPsbtError> { + let spend_signed = pczt + .actions() + .iter() + .any(|action| action.spend().spend_auth_sig().is_some()); + let transparent_signed = self + .psbt + .inputs + .iter() + .any(|input| !input.partial_sigs.is_empty()); + if spend_signed || transparent_signed { + return Err(IronwoodPsbtError::AlreadySigned); + } + Ok(()) + } + + /// Apply the KMS out-ciphertext response: one `(action_index, + /// out_ciphertext)` pair per pending real output. **Atomic** — every entry is validated + /// before anything is written, so one bad entry stores nothing. + /// + /// Each index must exist ([`IronwoodPsbtError::UnknownAction`]) and name a real output + /// ([`IronwoodPsbtError::DummyAction`]). Re-delivering the bytes already installed — before + /// this call or earlier in the same batch — is accepted; different bytes for an index that + /// already has a ciphertext are rejected ([`IronwoodPsbtError::OutCiphertextConflict`]). Frozen + /// once signed: rejected once any signature exists ([`IronwoodPsbtError::AlreadySigned`]). Only + /// spend bundles take ciphertexts here ([`IronwoodPsbtError::NotSpendBundle`]). + pub fn add_out_ciphertexts( + &mut self, + entries: &[(usize, crate::zcash::ironwood_build::OutCiphertextBytes)], + ) -> Result<(), IronwoodPsbtError> { + use crate::zcash::ironwood_build::OutCiphertextBytes; + use crate::zcash::ironwood_pczt::with_out_ciphertext; + + let (bytes, pczt) = self.stored_ironwood_pczt()?; + self.require_unfrozen(&pczt)?; + if !Self::bundle_has_real_spends(&pczt) { + return Err(IronwoodPsbtError::NotSpendBundle); + } + if entries.is_empty() { + return Err(IronwoodPsbtError::NothingPending); + } + self.keyless_verify(&pczt) + .map_err(IronwoodPsbtError::Verify)?; + let real_outputs = Self::real_output_indices(&pczt); + let mut bits = + super::propkv::get_ironwood_out_ciphertexts_set(&self.psbt).unwrap_or_default(); + + // Validate everything first: unknown, dummy, and conflicting entries reject the + // whole batch without touching the PSBT. + let mut installs: Vec<(usize, OutCiphertextBytes)> = Vec::new(); + for &(action_index, out_ciphertext) in entries { + if action_index >= pczt.actions().len() { + return Err(IronwoodPsbtError::UnknownAction); + } + if !real_outputs.contains(&action_index) { + return Err(IronwoodPsbtError::DummyAction); + } + let installed = installs + .iter() + .find(|(index, _)| *index == action_index) + .map(|(_, ciphertext)| *ciphertext) + .or_else(|| { + bitset_contains(&bits, action_index).then(|| { + pczt.actions()[action_index] + .output() + .encrypted_note() + .out_ciphertext + }) + }); + match installed { + Some(installed) if installed != out_ciphertext => { + return Err(IronwoodPsbtError::OutCiphertextConflict { action_index }); + } + // Identical bytes: nothing to patch. + Some(_) => {} + None => installs.push((action_index, out_ciphertext)), + } + } + if installs.is_empty() { + return Ok(()); + } + + let mut patched = bytes; + for (action_index, out_ciphertext) in installs { + patched = with_out_ciphertext(&patched, action_index, out_ciphertext)?; + bitset_set(&mut bits, action_index); + } + super::propkv::set_ironwood_pczt(&mut self.psbt, patched); + super::propkv::set_ironwood_out_ciphertexts_set(&mut self.psbt, bits); + Ok(()) + } + + /// Signed before proven: every real spend's `spend_auth_sig` must be installed — the proof is + /// requested only once the wallet has signed, as shielding requests it only after its + /// transparent signing rounds. + fn require_spend_signatures(pczt: &orchard::pczt::Bundle) -> Result<(), IronwoodPsbtError> { + let pending: Vec = pczt + .actions() + .iter() + .enumerate() + .filter(|(_, action)| { + action.spend().dummy_sk().is_none() && action.spend().spend_auth_sig().is_none() + }) + .map(|(action_index, _)| action_index) + .collect(); + if pending.is_empty() { + return Ok(()); + } + Err(IronwoodPsbtError::SpendSignaturePending { + action_indices: pending, + }) + } + + /// The proof request: the raw PCZT bytes, handed to the keyed prover at + /// finalize time. The returned proof goes straight to [`Self::combine_ironwood_proof`]; + /// it is never stored in the PSBT. + /// + /// Every real output's `out_ciphertext`, every real spend's randomized `rk`, and every + /// real spend's `spend_auth_sig` must be installed first. Signing + /// freezes the bundle, so the proof is always made over the final action data. A + /// shielding bundle has none of these, so it is served as-is. + pub fn proof_request(&self) -> Result, IronwoodPsbtError> { + let (bytes, pczt) = self.stored_ironwood_pczt()?; + self.require_out_ciphertexts_set_in(&pczt)?; + Self::require_randomizers_installed(&pczt)?; + Self::require_spend_signatures(&pczt)?; + Ok(bytes) + } +} + +/// Whether the little-endian bitset has bit `index` set (bit `i` = byte `i / 8`, mask +/// `1 << (i % 8)`); bytes beyond the stored length read as clear. +fn bitset_contains(bits: &[u8], index: usize) -> bool { + bits.get(index / 8) + .is_some_and(|byte| byte & (1 << (index % 8)) != 0) +} + +/// Set bit `index` in the little-endian bitset, growing it if needed. +fn bitset_set(bits: &mut Vec, index: usize) { + if bits.len() <= index / 8 { + bits.resize(index / 8 + 1, 0); + } + bits[index / 8] |= 1 << (index % 8); } diff --git a/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/zcash/parse.rs b/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/zcash/parse.rs index 92c0430e115..74130bd25cf 100644 --- a/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/zcash/parse.rs +++ b/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/zcash/parse.rs @@ -6,8 +6,8 @@ //! [`ParseTransactionError::UseZcashParse`] instead of silently dropping it. This module //! composes that generic machinery with the shielded fold, producing the richer //! [`ZcashParsedTransaction`] (per-action output indices, shielded spends, the bundle's -//! value balance). The plaintext spend info is unverified until the key-free verifier -//! lands (plan PR 4). +//! value balance). The key-free verifier checks the bundle before any plaintext value is +//! returned. use super::ZcashBitGoPsbt; use crate::fixed_script_wallet::bitgo_psbt::{ @@ -18,7 +18,7 @@ use crate::zcash::ironwood_build::OrchardAddressBytes; /// One shielded (Ironwood) spend, as surfaced by the parse. /// -/// Plaintext values from the PCZT, verified by the key-free verifier (plan §5) before they +/// Plaintext values from the PCZT, verified by the key-free verifier before they /// reach the caller: the rebuilt note commits to a leaf under the bundle's anchor and /// matches the recorded `cmx`. What a keyless party cannot check (note ownership, /// nullifier validity) can only make the transaction invalid, never misdirected. @@ -38,8 +38,8 @@ pub struct ZcashShieldedSpend { pub position: u32, /// The single-receiver Unified Address of the spent note's Orchard receiver. pub address: String, - /// Whether the signer replaced the Constructor's placeholder `rk` (plan PR 7 derives - /// this from `alpha` instead of the wire marker). + /// Whether the signer replaced the Constructor's placeholder `rk` (read from the wire + /// marker). pub rk_replaced: bool, /// Whether a spend-auth signature is installed. pub signed: bool, @@ -96,7 +96,7 @@ pub enum ZcashParseTransactionError { /// The shielded side could not be read (no usable PCZT, an unencodable address, or a /// value overflow). Shielded(String), - /// The shielded side failed key-free verification (plan §5); the error code is the + /// The shielded side failed key-free verification; the error code is the /// failing verifier check (`IronwoodVerifyError.`). Verify(crate::zcash::ironwood_build::IronwoodVerifyError), } @@ -341,7 +341,7 @@ fn shielded_side( { return Ok((Vec::new(), Vec::new(), 0)); } - // The keyless verifier (plan §5) runs before any plaintext value leaves here: a + // The keyless verifier runs before any plaintext value leaves here: a // tampered bundle fails with the failing check's error code. The plaintext itself is // left in the PCZT untouched — the prover needs it, and the parse surfaces it. let pczt = z diff --git a/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/zcash/propkv.rs b/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/zcash/propkv.rs index 0cafbadcee3..fc2954b6361 100644 --- a/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/zcash/propkv.rs +++ b/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/zcash/propkv.rs @@ -98,9 +98,16 @@ pub enum ZecV6KeySubtype { /// so ZIP-317 fee estimation still sees the bundle's actions once the PCZT itself is gone. IronwoodExtractedCounts = 0x07, /// The wallet's spend-validating key (`ak`, 32 bytes) for the stored Ironwood bundle. - /// Written by plan PR 5's `add_shielded_spends`; the keyless verifier reads it for the - /// `Rk` check. Not written today, so the check only runs once the key exists. + /// Written by `add_shielded_spends`; the keyless verifier reads it for the `Rk` check, + /// which runs only when the key is present. IronwoodSpendValidatingKey = 0x08, + /// A little-endian bitset over Ironwood action indices: bit `i` set means action `i`'s + /// real output already carries its final `out_ciphertext`. Written all-clear by + /// `add_shielded_spends`, updated by `add_out_ciphertexts`, read by + /// `pending_out_ciphertexts`. Only spend bundles are tracked (shielding finalizes its + /// ciphertexts at signing); a spend bundle without the bitset counts every real output as + /// pending, so stripping the key cannot skip the ciphertexts-first rule. + IronwoodOutCiphertextsSet = 0x09, } fn set_zec_v6( @@ -293,7 +300,7 @@ pub fn clear_ironwood_unified_addresses(psbt: &mut miniscript::bitcoin::psbt::Ps } /// Store the wallet's spend-validating key (`ak`, 32 bytes) for the stored Ironwood bundle. -/// Written when the bundle is installed (plan PR 5's `add_shielded_spends`); the keyless +/// Written when the bundle is installed; the keyless /// verifier reads it for the `Rk` check. pub fn set_ironwood_spend_validating_key( psbt: &mut miniscript::bitcoin::psbt::Psbt, @@ -332,6 +339,18 @@ pub fn set_transparent_output_unified_address( psbt.proprietary.insert(key, ua.as_bytes().to_vec()); } +/// Store the `IronwoodOutCiphertextsSet` bitset ([`ZecV6KeySubtype::IronwoodOutCiphertextsSet`]): +/// one bit per action index, little-endian (bit `i` = byte `i / 8`, mask `1 << (i % 8)`), set +/// when that action's real output carries its final `out_ciphertext`. +pub fn set_ironwood_out_ciphertexts_set(psbt: &mut miniscript::bitcoin::psbt::Psbt, bits: Vec) { + set_zec_v6(psbt, ZecV6KeySubtype::IronwoodOutCiphertextsSet, bits); +} + +/// Fetch the `IronwoodOutCiphertextsSet` bitset, if present. +pub fn get_ironwood_out_ciphertexts_set(psbt: &miniscript::bitcoin::psbt::Psbt) -> Option> { + get_zec_v6(psbt, ZecV6KeySubtype::IronwoodOutCiphertextsSet) +} + /// Fetch the Unified Address string stored by [`set_transparent_output_unified_address`] for /// `output_index`, if present and valid UTF-8. pub fn get_transparent_output_unified_address( diff --git a/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/zcash/tests/ironwood.rs b/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/zcash/tests/ironwood.rs index 4fbbc7a3f13..ca450b96920 100644 --- a/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/zcash/tests/ironwood.rs +++ b/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/zcash/tests/ironwood.rs @@ -1647,8 +1647,14 @@ fn golden_shield1zec_psbt_sighash_verifies_real_signature() { &mut z.psbt, pczt_bytes_from_action_data(&bundle), ); + // The synthetic PCZT carries no plaintext or dummy markers, so it reads as a spend + // bundle; its ciphertexts are the golden transaction's final ones. + crate::fixed_script_wallet::bitgo_psbt::zcash::propkv::set_ironwood_out_ciphertexts_set( + &mut z.psbt, + vec![0xff; bundle.actions.len().div_ceil(8)], + ); - // The PSBT-derived sighash must equal the codec's (PR1-golden) sighash — i.e. the PSBT + // The PSBT-derived sighash must equal the codec's sighash — i.e. the PSBT // threaded the spent-output value + script through correctly. let psbt_sighash = z.v6_transparent_sighash(0).unwrap(); let codec_sighash = compute_v6_transparent_sighash( @@ -2007,8 +2013,8 @@ fn zip317_ironwood_term_matches_the_stored_action_count() { fn ironwood_bundle_info_rejects_a_tampered_plaintext_value() { // The spend's plaintext value is not bound by the proofs (only `cv_net`/`cm` are), so a // plaintext that disagrees with the bundle's commitments must be caught by the keyless - // verifier rather than silently flowing into the fee: the action's own `cv_net` (`K2`) - // rejects it before the bundle-level `value_sum` cross-check (`K3`) is reached. + // verifier rather than silently flowing into the fee: the action's own `cv_net` (`CvNet`) + // rejects it before the bundle-level `value_sum` cross-check (`ValueSum`) is reached. let (mut z, _keys) = build_unshield_psbt("ironwood_v6_tampered_plaintext"); // The honest bundle accounts cleanly: fee = 0 - 99_999_000 + 100_000_000. @@ -2471,8 +2477,8 @@ fn zcash_parse_reports_spend_signing_state() { assert!(spend.signed); } -/// Until the one-input-kind rule lands on the build side, the Zcash parse refuses a PSBT -/// that mixes transparent inputs with shielded spends rather than dropping either side. +/// The Zcash parse refuses a PSBT that mixes transparent inputs with shielded spends +/// rather than dropping either side. #[test] fn zcash_parse_rejects_mixed_input_kinds() { let wallet_keys = root_wallet_keys("ironwood_v6_parse_mixed"); @@ -2745,7 +2751,7 @@ fn transparent_only_v6_is_refused_by_the_v4_signing_paths() { assert!(err.contains("not supported for v6"), "unexpected: {err}"); } -/// Generator for the TS tampered-parse fixture (plan PR 4's `IronwoodVerifyError` test): +/// Generator for the TS tampered-parse fixture: /// a shielded-output PSBT whose stored PCZT output recipient was swapped at equal value. /// Run with `cargo test --lib write_tampered_psbt_fixture -- --ignored`. #[test] diff --git a/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/zcash/tests/ironwood_spend.rs b/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/zcash/tests/ironwood_spend.rs index beb5d55ad64..5355c8ca5b9 100644 --- a/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/zcash/tests/ironwood_spend.rs +++ b/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/zcash/tests/ironwood_spend.rs @@ -1,5 +1,5 @@ -//! Tests for the keyless Ironwood spend flow (plan PR 5): `add_shielded_spends`, the -//! one-input-kind rule (rule 0), and the deshield / shielded→shielded lifecycles the build +//! Tests for the keyless Ironwood spend flow: `add_shielded_spends`, the +//! one-input-kind rule, and the deshield / shielded→shielded lifecycles the build //! service drives. Native-only (orchard + zebra). use super::*; @@ -11,7 +11,6 @@ use crate::fixed_script_wallet::script_id::ScriptId; use crate::networks::Network; use crate::zcash::ironwood_build::{compute_out_ciphertext, IronwoodActionMap}; use crate::zcash::ironwood_construct::test_util::keyless_fixture; -use crate::zcash::ironwood_pczt::with_out_ciphertext; use crate::zcash::NetworkUpgrade; use ff::{Field, PrimeField}; use orchard::keys::{FullViewingKey, Scope, SpendAuthorizingKey, SpendingKey}; @@ -110,25 +109,33 @@ fn keyless_transfer_psbt( ) } -/// KMS's out-ciphertext round (plan §2 step 3): re-encrypt every requested output's -/// `out_ciphertext` under the FVK's external `ovk` and splice it into the stored PCZT. +/// KMS's out-ciphertext round: re-encrypt every requested output's +/// `out_ciphertext` under the FVK's external `ovk` and deliver it through +/// [`ZcashBitGoPsbt::add_out_ciphertexts`] — the production entry point, so the pending +/// marker tracks every install. fn kms_out_ciphertexts(z: &mut ZcashBitGoPsbt, map: &IronwoodActionMap, fvk: &FullViewingKey) { let ovk = *fvk.to_ovk(Scope::External).as_ref(); let pczt = z.ironwood_pczt().unwrap(); - let mut bytes = z.raw_ironwood_pczt_bytes().unwrap(); - for &action_index in map.output_action_indices() { - let expected = compute_out_ciphertext(&pczt, action_index, ovk, &mut OsRng).unwrap(); - bytes = with_out_ciphertext(&bytes, action_index, expected).unwrap(); + let entries: Vec<_> = map + .output_action_indices() + .iter() + .map(|&action_index| { + ( + action_index, + compute_out_ciphertext(&pczt, action_index, ovk, &mut OsRng).unwrap(), + ) + }) + .collect(); + if !entries.is_empty() { + z.add_out_ciphertexts(&entries).unwrap(); } - crate::fixed_script_wallet::bitgo_psbt::zcash::propkv::set_ironwood_pczt(&mut z.psbt, bytes); } /// The FROST stand-in round: install `(alpha, rk = ak.randomize(alpha))` on every real spend /// and return the `(action_index, alpha)` pairs the local prover needs. /// -/// PR 7's `add_spend_randomizers` batch method does this atomically; until it lands, the -/// alpha goes through the wire patcher and the matching `rk` through the existing -/// `replace_ironwood_spend_rk` — the same two writes, in the same order. +/// The alpha goes through the wire patcher and the matching `rk` through +/// `replace_ironwood_spend_rk`, in that order. fn install_randomizers( z: &mut ZcashBitGoPsbt, map: &IronwoodActionMap, @@ -168,7 +175,7 @@ fn install_spend_signatures( } } -/// The full keyless deshield lifecycle (plan §2, no transparent inputs): build → FROST +/// The full keyless deshield lifecycle: build → FROST /// randomizers → signatures → combine. Each step survives a serialize/deserialize round-trip, /// and zebra independently decodes the result. #[test] @@ -381,7 +388,7 @@ fn keyless_parse_reports_verified_spends() { assert!(!spends[0].signed, "no signature yet"); } -/// Rule 0 (plan §3), shielded side: `add_shielded_spends` rejects a PSBT that already carries +/// One input kind, shielded side: `add_shielded_spends` rejects a PSBT that already carries /// transparent inputs, with the typed error code. #[test] fn add_shielded_spends_rejects_transparent_inputs() { @@ -413,7 +420,7 @@ fn add_shielded_spends_rejects_transparent_inputs() { ); } -/// Rule 0, transparent side: the generic input adders reject a PSBT whose stored bundle +/// One input kind, transparent side: the generic input adders reject a PSBT whose stored bundle /// already spends real notes. A PSBT with only a shielded *output* (the shielding flow) /// still accepts transparent inputs — see the shielding tests in [`super::ironwood`]. #[test] @@ -438,7 +445,7 @@ fn transparent_adders_reject_after_shielded_spends() { ); } -/// Rule 0 at deserialize: a PSBT mixing transparent inputs with real spends (built through +/// One input kind at deserialize: a PSBT mixing transparent inputs with real spends (built through /// the oracle path, which predates the rule) is rejected by `deserialize_v6`. #[test] fn deserialize_rejects_mixed_input_kinds() { @@ -747,3 +754,382 @@ fn prove_and_verify_locally( .verify_proof(&vk) .expect("the keyless bundle's locally-produced proof verifies"); } + +// ---- The KMS out_ciphertext / proof-request workflow ---- + +/// KMS's ciphertext for one action, under the FVK's external `ovk`. +fn kms_out_ciphertext(z: &ZcashBitGoPsbt, fvk: &FullViewingKey, action_index: usize) -> [u8; 80] { + let ovk = *fvk.to_ovk(Scope::External).as_ref(); + compute_out_ciphertext(&z.ironwood_pczt().unwrap(), action_index, ovk, &mut OsRng).unwrap() +} + +/// A 2→1 PSBT: one real output and one fabricated dummy output (on a real spend). +fn keyless_two_to_one_psbt(seed: &str) -> (ZcashBitGoPsbt, IronwoodActionMap, FullViewingKey) { + let (specs, anchor, fvk, ak) = keyless_fixture(); + let mut z = empty_v6_psbt(seed); + let map = z + .add_shielded_spends( + &specs, + &[wallet_output(&fvk, 100, 90_000_000)], + &anchor, + &ak, + OsRng, + ) + .unwrap(); + (z, map, fvk) +} + +/// Remove the `IronwoodOutCiphertextsSet` bitset, as a tampering party could. +fn strip_out_ciphertexts_marker(z: &mut ZcashBitGoPsbt) { + use crate::fixed_script_wallet::bitgo_psbt::zcash::propkv::{ZecV6KeySubtype, BITGO_ZEC_V6}; + z.psbt.proprietary.retain(|key, _| { + !(key.prefix == BITGO_ZEC_V6 + && key.subtype == ZecV6KeySubtype::IronwoodOutCiphertextsSet as u8) + }); +} + +/// A recognizable proof of the right size, so tests can tell which proof reached the wire. +fn patterned_proof(n_actions: usize) -> Vec { + (0..Proof::expected_proof_size(n_actions)) + .map(|i| (i % 251) as u8) + .collect() +} + +/// The proof bytes of a combined v6 transaction. +fn wire_proof(raw: &[u8]) -> Vec { + crate::zcash::v6::decode_v6_transaction(raw) + .unwrap() + .ironwood_bundle + .unwrap() + .proof +} + +/// The out-ciphertext request lists exactly the real outputs — dummy outputs excluded — +/// and errors once every real output is set, when there are none, or on a shielding PSBT. +#[test] +fn out_ciphertext_request_lists_real_outputs_and_errors_when_empty() { + let (mut z, map, fvk) = keyless_two_to_one_psbt("ironwood_v6_keyless_request"); + let (bytes, pending) = z.out_ciphertext_request().unwrap(); + assert_eq!(bytes, z.raw_ironwood_pczt_bytes().unwrap()); + assert_eq!(pending, vec![map.output_action_index(0).unwrap()]); + assert_eq!(z.pending_out_ciphertexts().unwrap(), pending); + + // The KMS round resolves the request; a further request errors with NothingPending. + let ct = kms_out_ciphertext(&z, &fvk, pending[0]); + z.add_out_ciphertexts(&[(pending[0], ct)]).unwrap(); + assert!(z.pending_out_ciphertexts().unwrap().is_empty()); + assert!(matches!( + z.out_ciphertext_request(), + Err(IronwoodPsbtError::NothingPending) + )); + + // A spend-only bundle (no real outputs) has nothing to request either. + let (specs, anchor, _fvk, ak) = keyless_fixture(); + let mut spend_only = empty_v6_psbt("ironwood_v6_keyless_request_spend_only"); + spend_only + .add_shielded_spends(&specs[..1], &[], &anchor, &ak, OsRng) + .unwrap(); + assert!(matches!( + spend_only.out_ciphertext_request(), + Err(IronwoodPsbtError::NothingPending) + )); + + // Shielding is not a spend bundle, and neither is a PSBT without a PCZT. + let mut shield = build_shield_psbt("ironwood_v6_keyless_request_shield"); + assert!(matches!( + shield.out_ciphertext_request(), + Err(IronwoodPsbtError::NotSpendBundle) + )); + assert!(matches!( + shield.add_out_ciphertexts(&[(0, [0u8; 80])]), + Err(IronwoodPsbtError::NotSpendBundle) + )); + assert!(shield.pending_out_ciphertexts().unwrap().is_empty()); + assert!(matches!( + empty_v6_psbt("ironwood_v6_keyless_request_empty").out_ciphertext_request(), + Err(IronwoodPsbtError::NotSpendBundle) + )); +} + +/// `add_out_ciphertexts` is atomic: a valid entry followed by a bad one (an unknown or a +/// dummy action index) stores nothing — the PCZT bytes and the pending marker are untouched. +#[test] +fn add_out_ciphertexts_is_atomic() { + let (mut z, map, fvk) = keyless_two_to_one_psbt("ironwood_v6_keyless_atomic"); + let before = z.raw_ironwood_pczt_bytes().unwrap(); + let real_index = map.output_action_index(0).unwrap(); + let ct = kms_out_ciphertext(&z, &fvk, real_index); + + let error = z + .add_out_ciphertexts(&[(real_index, ct), (99, [0u8; 80])]) + .unwrap_err(); + assert!(matches!(error, IronwoodPsbtError::UnknownAction)); + assert_eq!(z.raw_ironwood_pczt_bytes().unwrap(), before); + assert_eq!(z.pending_out_ciphertexts().unwrap(), vec![real_index]); + + let dummy_index = 1 - real_index; + let error = z + .add_out_ciphertexts(&[(real_index, ct), (dummy_index, ct)]) + .unwrap_err(); + assert!(matches!(error, IronwoodPsbtError::DummyAction)); + assert_eq!(z.raw_ironwood_pczt_bytes().unwrap(), before); + assert_eq!(z.pending_out_ciphertexts().unwrap(), vec![real_index]); + + let error = z.add_out_ciphertexts(&[]).unwrap_err(); + assert!(matches!(error, IronwoodPsbtError::NothingPending)); +} + +/// Re-delivering the installed bytes is a no-op; different bytes on a set index are +/// rejected and change nothing. +#[test] +fn add_out_ciphertexts_is_idempotent_for_identical_bytes_only() { + let (mut z, map, fvk) = keyless_two_to_one_psbt("ironwood_v6_keyless_idempotent"); + let action_index = map.output_action_index(0).unwrap(); + let ct = kms_out_ciphertext(&z, &fvk, action_index); + + z.add_out_ciphertexts(&[(action_index, ct)]).unwrap(); + let after = z.raw_ironwood_pczt_bytes().unwrap(); + + // Identical bytes: accepted, nothing changes. + z.add_out_ciphertexts(&[(action_index, ct)]).unwrap(); + assert_eq!(z.raw_ironwood_pczt_bytes().unwrap(), after); + assert!(z.pending_out_ciphertexts().unwrap().is_empty()); + + // Different bytes: rejected, nothing changes. + let error = z + .add_out_ciphertexts(&[(action_index, [7u8; 80])]) + .unwrap_err(); + assert!(matches!( + error, + IronwoodPsbtError::OutCiphertextConflict { action_index: idx } if idx == action_index + )); + assert_eq!(z.raw_ironwood_pczt_bytes().unwrap(), after); +} + +/// Duplicates within one batch follow the same rule as re-delivery: identical bytes are +/// accepted, different bytes are a conflict that stores nothing. +#[test] +fn add_out_ciphertexts_rejects_conflicting_duplicates_in_one_batch() { + let (mut z, map, fvk) = keyless_two_to_one_psbt("ironwood_v6_keyless_batch_duplicates"); + let action_index = map.output_action_index(0).unwrap(); + let ct = kms_out_ciphertext(&z, &fvk, action_index); + let before = z.raw_ironwood_pczt_bytes().unwrap(); + + let error = z + .add_out_ciphertexts(&[(action_index, ct), (action_index, [7u8; 80])]) + .unwrap_err(); + assert!(matches!( + error, + IronwoodPsbtError::OutCiphertextConflict { action_index: idx } if idx == action_index + )); + assert_eq!(z.raw_ironwood_pczt_bytes().unwrap(), before); + assert_eq!(z.pending_out_ciphertexts().unwrap(), vec![action_index]); + + z.add_out_ciphertexts(&[(action_index, ct), (action_index, ct)]) + .unwrap(); + assert!(z.pending_out_ciphertexts().unwrap().is_empty()); + assert_eq!( + z.ironwood_pczt().unwrap().actions()[action_index] + .output() + .encrypted_note() + .out_ciphertext, + ct + ); +} + +/// A spend bundle whose bitset was stripped fails closed: every real output counts as +/// pending, the gates hold, and the KMS round still completes (re-creating the bitset). +#[test] +fn missing_out_ciphertexts_marker_fails_closed() { + let seed = "ironwood_v6_keyless_marker_stripped"; + let (mut z, map, _ak, fvk, ask) = keyless_transfer_psbt(seed); + kms_out_ciphertexts(&mut z, &map, &fvk); + assert!(z.pending_out_ciphertexts().unwrap().is_empty()); + + strip_out_ciphertexts_marker(&mut z); + let mut expected = map.output_action_indices().to_vec(); + expected.sort_unstable(); + assert_eq!(z.pending_out_ciphertexts().unwrap(), expected); + assert!(matches!( + z.proof_request(), + Err(IronwoodPsbtError::OutCiphertextPending { .. }) + )); + + // The stripped state survives the PSBT wire too. + let bytes = z.serialize_v6(); + let mut z = ZcashBitGoPsbt::deserialize_v6(&bytes, Network::ZcashTestnet).unwrap(); + assert_eq!(z.pending_out_ciphertexts().unwrap(), expected); + + // Re-delivering (the same KMS response would carry fresh randomness) resolves it. + let entries: Vec<_> = map + .output_action_indices() + .iter() + .map(|&action_index| (action_index, kms_out_ciphertext(&z, &fvk, action_index))) + .collect(); + z.add_out_ciphertexts(&entries).unwrap(); + assert!(z.pending_out_ciphertexts().unwrap().is_empty()); + let pairs = install_randomizers(&mut z, &map, &ask); + install_spend_signatures(&mut z, &pairs, &ask); + z.proof_request().unwrap(); +} + +/// Frozen once signed: no ciphertext install once a spend-auth signature exists — even for the +/// already-installed (identical) bytes, which idempotent re-delivery alone would have accepted. +#[test] +fn add_out_ciphertexts_rejects_after_a_spend_signature() { + let seed = "ironwood_v6_keyless_rule3_spend_sig"; + let (mut z, map, _ak, fvk, ask) = keyless_transfer_psbt(seed); + + kms_out_ciphertexts(&mut z, &map, &fvk); + let pairs = install_randomizers(&mut z, &map, &ask); + install_spend_signatures(&mut z, &pairs, &ask); + + let entries: Vec<(usize, [u8; 80])> = map + .output_action_indices() + .iter() + .map(|&action_index| (action_index, [0u8; 80])) + .collect(); + let error = z.add_out_ciphertexts(&entries).unwrap_err(); + assert!(matches!(error, IronwoodPsbtError::AlreadySigned)); +} + +/// The freeze's transparent defense in depth: a shielding PSBT with a collected transparent +/// signature refuses `add_out_ciphertexts` too. +#[test] +fn add_out_ciphertexts_rejects_after_a_transparent_signature() { + use crate::bitcoin::secp256k1::Secp256k1; + + let seed = "ironwood_v6_keyless_rule3_transparent_sig"; + let secp = Secp256k1::new(); + let wallet_keys = root_wallet_keys(seed); + let mut shield = build_shield_psbt(seed); + shield + .sign_ironwood_v6(&test_wallet_xpriv(seed, 0), &wallet_keys, &secp) + .unwrap(); + assert!( + shield + .psbt + .inputs + .iter() + .any(|input| !input.partial_sigs.is_empty()), + "the transparent round collected a signature" + ); + + let error = shield.add_out_ciphertexts(&[(0, [0u8; 80])]).unwrap_err(); + assert!(matches!(error, IronwoodPsbtError::AlreadySigned)); +} + +/// The proof request refuses a spend bundle until its ciphertexts, then its randomizers, +/// then its spend signatures are installed; a shielding bundle is served as-is. +#[test] +fn proof_request_requires_ciphertexts_randomizers_then_signatures() { + let seed = "ironwood_v6_keyless_proof_request"; + let (mut z, map, _ak, fvk, ask) = keyless_transfer_psbt(seed); + + let mut outputs = map.output_action_indices().to_vec(); + outputs.sort_unstable(); + assert!(matches!( + z.proof_request(), + Err(IronwoodPsbtError::OutCiphertextPending { action_indices }) if action_indices == outputs + )); + + kms_out_ciphertexts(&mut z, &map, &fvk); + let mut spends = map.spend_action_indices().to_vec(); + spends.sort_unstable(); + assert!(matches!( + z.proof_request(), + Err(IronwoodPsbtError::RandomizerPending { action_indices }) if action_indices == spends + )); + + let pairs = install_randomizers(&mut z, &map, &ask); + assert!(matches!( + z.proof_request(), + Err(IronwoodPsbtError::SpendSignaturePending { action_indices }) if action_indices == spends + )); + + install_spend_signatures(&mut z, &pairs, &ask); + assert_eq!( + z.proof_request().unwrap(), + z.raw_ironwood_pczt_bytes().unwrap() + ); + + let shield = build_shield_psbt("ironwood_v6_keyless_proof_request_shield"); + assert_eq!( + shield.proof_request().unwrap(), + shield.raw_ironwood_pczt_bytes().unwrap() + ); +} + +/// The ciphertexts-first gate covers every path that commits the action data: both sighashes, the +/// randomizer installer and the spend-signature path — not just the proof request. +#[test] +fn pending_out_ciphertexts_gate_sighashes_randomizers_and_signatures() { + let seed = "ironwood_v6_keyless_rule1_gates"; + let (mut z, map, _ak, _fvk, _ask) = keyless_transfer_psbt(seed); + let spend_index = map.spend_action_indices()[0]; + let is_rule1 = |e: &str| e.contains("pending their out_ciphertext"); + + let error = z.v6_sig_digest().unwrap_err(); + assert!(is_rule1(&error), "unexpected: {error}"); + let error = z.v6_transparent_sighash(0).unwrap_err(); + assert!(is_rule1(&error), "unexpected: {error}"); + let error = z + .replace_ironwood_spend_rk(spend_index, [1u8; 32]) + .unwrap_err(); + assert!(is_rule1(&error), "unexpected: {error}"); + let error = z + .apply_ironwood_spend_signature(spend_index, [0u8; 64]) + .unwrap_err(); + assert!(is_rule1(&error), "unexpected: {error}"); +} + +/// The full sign-then-prove lifecycle, the same shape as shielding: sign first, then the +/// finalize step requests the proof and passes it straight to `combine_ironwood_proof`. The +/// signatures made before proving stay valid (the sig digest excludes the proof), the proof +/// request survives a PSBT round trip, and exactly that proof reaches the wire. +#[test] +fn sign_then_prove_lifecycle_combines_the_requested_proof() { + let seed = "ironwood_v6_keyless_sign_then_prove"; + let (mut z, map, _ak, fvk, ask) = keyless_transfer_psbt(seed); + + kms_out_ciphertexts(&mut z, &map, &fvk); + let pairs = install_randomizers(&mut z, &map, &ask); + install_spend_signatures(&mut z, &pairs, &ask); + let sighash = z.v6_sig_digest().unwrap(); + + // Finalize, possibly in another request: the signed PSBT is all KMS needs. + let bytes = z.serialize_v6(); + let z = ZcashBitGoPsbt::deserialize_v6(&bytes, Network::ZcashTestnet).unwrap(); + let request = z.proof_request().unwrap(); + assert!( + crate::zcash::ironwood_pczt::deserialize_pczt(&request) + .unwrap() + .zkproof() + .is_none(), + "the request carries no proof; the proof never enters the PSBT" + ); + + let proof = patterned_proof(2); + let raw = z.combine_ironwood_proof(proof.clone(), OsRng).unwrap(); + assert_eq!(wire_proof(&raw), proof); + + // The spend signatures collected before proving verify against the final digest. + let tx = crate::zcash::v6::decode_v6_transaction(&raw).unwrap(); + let bundle = tx.ironwood_bundle.as_ref().unwrap(); + assert_eq!( + crate::zcash::v6::compute_v6_sig_digest(&tx, &[], &[]), + sighash + ); + for &(action_index, _) in &pairs { + let rk = VerificationKey::::try_from(bundle.actions[action_index].rk).unwrap(); + let sig = orchard::primitives::redpallas::Signature::::from( + bundle.spend_auth_sigs[action_index], + ); + rk.verify(&sighash, &sig) + .expect("the pre-proof spend signature verifies on the broadcast transaction"); + } + + use zebra_chain::serialization::ZcashDeserialize; + use zebra_chain::transaction::Transaction as ZebraTx; + ZebraTx::zcash_deserialize(&raw[..]).expect("zebra decodes the signed-then-proven transfer"); +} diff --git a/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/zcash/v6.rs b/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/zcash/v6.rs index cc2212cdfd2..9f815abc51a 100644 --- a/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/zcash/v6.rs +++ b/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/zcash/v6.rs @@ -437,6 +437,10 @@ impl ZcashBitGoPsbt { /// [`Self::unsigned_v6_txid`] does for the txid. pub fn v6_transparent_sighash(&self, index: usize) -> Result<[u8; 32], String> { let (amounts, scripts) = self.transparent_input_amounts_and_scripts()?; + // Ciphertexts first: a tracked spend bundle must have every real output's + // `out_ciphertext` installed before anything commits the action data. + self.require_out_ciphertexts_set() + .map_err(|e| e.to_string())?; let bundle = if self.require_no_shielded_output_ever_added()? { None } else { @@ -467,6 +471,10 @@ impl ZcashBitGoPsbt { /// before it is signed. pub fn v6_sig_digest(&self) -> Result<[u8; 32], String> { let (amounts, scripts) = self.transparent_input_amounts_and_scripts()?; + // Ciphertexts first: a tracked spend bundle must have every real output's + // `out_ciphertext` installed before the digest is final. + self.require_out_ciphertexts_set() + .map_err(|e| e.to_string())?; let bundle = self.final_ironwood_action_data()?; let tx = self.to_v6_transaction(self.psbt.unsigned_tx.clone(), Some(bundle))?; Ok(crate::zcash::v6::compute_v6_sig_digest( @@ -807,7 +815,7 @@ impl ZcashBitGoPsbt { "v6 PSBT is missing its Ironwood PCZT after extraction".to_string(), )); } - // Rule 0 (plan §3): a v6 PSBT carries either transparent inputs or shielded spends, + // One input kind: a v6 PSBT carries either transparent inputs or shielded spends, // never both. The shielded side of the rule lives in // `add_shielded_spends` (transparent-input check) and the generic input adders' // Zcash arms; this closes the deserialize hole a PSBT could be edited through. diff --git a/packages/wasm-utxo/src/wasm/fixed_script_wallet/zcash_psbt.rs b/packages/wasm-utxo/src/wasm/fixed_script_wallet/zcash_psbt.rs index 7e49c43237b..b34a4c95713 100644 --- a/packages/wasm-utxo/src/wasm/fixed_script_wallet/zcash_psbt.rs +++ b/packages/wasm-utxo/src/wasm/fixed_script_wallet/zcash_psbt.rs @@ -762,8 +762,8 @@ impl TryIntoJsValue for ZcashParsedOutput { impl TryIntoJsValue for ZcashShieldedSpend { fn try_to_js_value(&self) -> Result { - // Until plan PR 7 derives the state from `alpha`, `installed` means the signer - // replaced the Constructor's placeholder `rk` (= the wallet `ak`). + // `installed` means the signer replaced the Constructor's placeholder `rk` + // (= the wallet `ak`). let randomizer = if self.rk_replaced { "installed" } else { @@ -774,7 +774,7 @@ impl TryIntoJsValue for ZcashShieldedSpend { "value" => self.value, "recipient" => self.recipient.to_vec(), "nullifier" => self.nullifier.to_vec(), - // Recomputed from the verified plaintext (the keyless verifier's K4). + // Recomputed from the verified plaintext (the keyless verifier's `SpendWitness` check). "cmx" => self.cmx.to_vec(), "position" => self.position, "address" => self.address, diff --git a/packages/wasm-utxo/src/zcash/ironwood_build.rs b/packages/wasm-utxo/src/zcash/ironwood_build.rs index 0f064e8254e..9efb91c8703 100644 --- a/packages/wasm-utxo/src/zcash/ironwood_build.rs +++ b/packages/wasm-utxo/src/zcash/ironwood_build.rs @@ -153,7 +153,7 @@ pub enum IronwoodBuildError { BadAnchor, /// An Ironwood output must carry a positive value: a zero-valued output is how the /// construction marks fabricated dummy outputs, so accepting one from a caller would - /// make real and fabricated outputs indistinguishable (plan F8). + /// make real and fabricated outputs indistinguishable. ZeroValueOutput { output_index: usize }, /// The orchard builder rejected the bundle parameters. Builder(String), @@ -243,12 +243,12 @@ pub enum IronwoodBuildError { /// the Constructor's placeholder (the spend's `ak`). Marking that `rk` as replaced would let /// a linkable `rk` past the [`PlaceholderRkNotReplaced`](Self::PlaceholderRkNotReplaced) gate. UnrandomizedRk, - /// The keyless spend constructor was called without any real spend (plan §4 invariant 6): + /// The keyless spend constructor was called without any real spend: /// an output-only bundle is a *shielding* build — use [`construct_shield_pczt_multi`] /// for it. NoSpends, - /// An output passed to the keyless spend constructor carried an explicit `ovk` (plan §4 - /// invariant 7): in the spend flow KMS owns encryption, so the PSBT side may not choose + /// An output passed to the keyless spend constructor carried an explicit `ovk`: in the + /// spend flow KMS owns encryption, so the PSBT side may not choose /// one. The placeholder `out_ciphertext` the constructor installs is replaced by KMS's /// `out_ciphertext` later. OvkNotAllowed { @@ -256,7 +256,7 @@ pub enum IronwoodBuildError { output_index: usize, }, /// The spend-validating key bytes are not a canonical, non-identity RedPallas - /// verification key (plan §4 invariant 5): the placeholder `rk` must be a key the + /// verification key: the placeholder `rk` must be a key the /// signer's `ak.randomize(alpha)` can replace. BadSpendValidatingKey, /// A keyless spend's caller-supplied nullifier is not a canonical Pallas base-field @@ -268,8 +268,8 @@ pub enum IronwoodBuildError { /// A note or output value, or the spend/output total, exceeds Zcash `MAX_MONEY` /// (21 000 000 ZEC): no such value can appear in a valid transaction. ValueOutOfRange, - /// The keyless spend constructor's own output failed the key-free verifier (plan §4 step - /// 4) — an internal inconsistency; the wrapped error names the failing check. + /// The keyless spend constructor's own output failed the key-free verifier — an + /// internal inconsistency; the wrapped error names the failing check. SelfCheck(IronwoodVerifyError), } @@ -603,7 +603,7 @@ impl IronwoodActionMap { impl IronwoodActionMap { /// Assemble a map from already-shuffled action indices — the keyless spend - /// constructor's shuffle (plan §4) produces them directly, without a builder. + /// constructor's shuffle produces them directly, without a builder. pub(crate) fn from_parts( spend_action_indices: Vec, output_action_indices: Vec, @@ -644,7 +644,7 @@ impl IronwoodActionMap { /// known, then hand the PCZT to the prover and [`combine`]. /// /// This is the oracle path: keyed construction through the orchard `Builder`, available only -/// under `test` / `orchard-proving` (plan PR 5). The production deshield/shielded→shielded +/// under `test` / `orchard-proving`. The production deshield/shielded→shielded /// Constructor is [`crate::zcash::ironwood_construct::construct_spend_pczt_keyless`]. #[cfg(any(test, feature = "orchard-proving"))] pub fn construct_spend_pczt_multi( @@ -984,7 +984,7 @@ pub fn verify_spend_pczt(bundle: &PcztBundle) -> Result<(), IronwoodBuildError> IronwoodBuildError::Verify(format!("output action {i}: note commitment: {e}")) })?; } - // F3 fix: `cv_net` commits to the plaintext net value — no FVK is needed for that, + // `cv_net` commits to the plaintext net value — no FVK is needed for that, // so the check runs whenever both sides carry their values. if spend.value().is_some() && output.value().is_some() { action.verify_cv_net().map_err(|e| { diff --git a/packages/wasm-utxo/src/zcash/ironwood_construct.rs b/packages/wasm-utxo/src/zcash/ironwood_construct.rs index 91e02a2e420..57792e35584 100644 --- a/packages/wasm-utxo/src/zcash/ironwood_construct.rs +++ b/packages/wasm-utxo/src/zcash/ironwood_construct.rs @@ -1,4 +1,4 @@ -//! The **keyless** Ironwood spend Constructor (plan §4): build a deshield or +//! The **keyless** Ironwood spend Constructor: build a deshield or //! shielded→shielded bundle from real spends and requested outputs without any viewing key. //! //! The keyed constructor ([`construct_spend_pczt_multi`]) needs each spend's FVK — the orchard @@ -18,7 +18,7 @@ //! form stays keyless by construction; //! - each output's `out_ciphertext` is a random **placeholder** (encrypted under `ovk = ⊥`): //! KMS re-encrypts it under its FVK `ovk` and splices the real one in via -//! [`with_out_ciphertext`](super::ironwood_pczt::with_out_ciphertext) (plan §2 step 3). +//! [`with_out_ciphertext`](super::ironwood_pczt::with_out_ciphertext). //! //! Surplus outputs (more outputs than spends) are fabricated as **dummy-spend** actions by an //! outputs-only [`Builder`] — reusing orchard's own dummy-spend fabrication (its `dummy_sk`, @@ -99,7 +99,7 @@ struct ActionOrigin { /// Constructor: build a keyless Ironwood spend bundle as an orchard PCZT from real spends /// and/or requested outputs — the deshield and shielded→shielded Constructor for a build -/// service that holds no viewing keys (plan §4). +/// service that holds no viewing keys. /// /// The bundle has `max(spends.len(), outputs.len())` actions (the Ironwood V3 default flags /// enable cross-address transfers, so a spend and an output share an action): where one side @@ -155,7 +155,7 @@ pub fn construct_spend_pczt_keyless( if spec.ovk.is_some() { return Err(IronwoodBuildError::OvkNotAllowed { output_index }); } - // A zero-valued output is how a bundle marks fabricated dummies (invariant 4). + // A zero-valued output is how a bundle marks fabricated dummies. if spec.amount == 0 { return Err(IronwoodBuildError::ZeroValueOutput { output_index }); } @@ -258,7 +258,7 @@ pub fn construct_spend_pczt_keyless( let bundle = assemble_bundle(wires, value_sum, anchor)?; - // Self-check (plan §4 step 4): the Constructor's own output must pass every key-free + // Self-check: the Constructor's own output must pass every key-free // check the client will later run on it — note commitments, value commitments, the // value sum, the witnesses, the placeholder-rk state, and no leaked viewing keys. let report = verify_bundle_without_keys( @@ -283,7 +283,7 @@ pub fn construct_spend_pczt_keyless( Ok((bundle, action_map)) } -/// Validate one keyless spend's note data (plan §4 invariants 1–2): the reconstructed note +/// Validate one keyless spend's note data: the reconstructed note /// must be internally valid, must commit to exactly `cmx`, and its witness must recompute to /// the bundle's anchor. The nullifier's *value* is deliberately NOT checked — that needs the /// FVK — only that it is a canonical encoding. @@ -348,7 +348,7 @@ fn real_spend_wire(spec: &IronwoodKeylessSpendSpec, ak: &[u8; 32]) -> SpendWire } } -/// Rejection-sample an output note's `rseed` for `rho` (plan §4): a random 32-byte seed is +/// Rejection-sample an output note's `rseed` for `rho`: a random 32-byte seed is /// only usable when `RandomSeed::from_bytes` accepts it. fn sample_rseed(rng: &mut R, rho: &Rho) -> RandomSeed { loop { @@ -360,7 +360,7 @@ fn sample_rseed(rng: &mut R, rho: &Rho) -> RandomSeed { } } -/// Rejection-sample the action's value-commitment trapdoor (plan §4): a random 32-byte +/// Rejection-sample the action's value-commitment trapdoor: a random 32-byte /// scalar is only usable when it is a canonical field-element encoding. fn sample_rcv(rng: &mut R) -> ValueCommitTrapdoor { loop { @@ -418,7 +418,7 @@ fn real_output_wire( /// Encrypt an output note: the real `enc_ciphertext` (recoverable by whoever holds the /// recipient's `ivk`) and the ephemeral key it rode in on, plus a placeholder `out_ciphertext` -/// under `ovk = ⊥` — random bytes, replaced by KMS's real one later (plan §2 step 3). +/// under `ovk = ⊥` — random bytes, replaced by KMS's real one later. fn encrypt_output( note: Note, memo: &MemoBytes, @@ -698,7 +698,7 @@ mod tests { } /// Patch every requested output's `out_ciphertext` to the real encryption under `ovk` — - /// what KMS does in the production flow (plan §2 step 3). + /// what KMS does in the production flow. fn patch_out_ciphertexts( bundle: &PcztBundle, output_indices: &[usize], @@ -712,7 +712,7 @@ mod tests { deserialize_pczt(&bytes).unwrap() } - /// The oracle round trip (plan PR 5's tests): a bundle built by the keyless Constructor + /// The oracle round trip: a bundle built by the keyless Constructor /// behaves exactly like one built by the keyed oracle — KMS's `out_ciphertext`s splice in, /// the FROST randomizers install, the keyed verifier and the key-free verifier both pass, /// and the FROST stand-in signatures verify. @@ -917,7 +917,7 @@ mod tests { assert_eq!(report.value_balance, 10_000_000); } - /// Negative tests, one per constructor invariant (plan §4). + /// Negative tests, one per constructor invariant. #[test] fn rejects_bad_requests() { let (specs, anchor, fvk, ak) = fixture(); @@ -1043,7 +1043,7 @@ mod tests { )); } - /// No FVK reaches the constructed PCZT (plan PR 5 review focus): every real spend's `fvk` + /// No FVK reaches the constructed PCZT: every real spend's `fvk` /// is absent, and the wallet FVK's secret-ish half (`nk ‖ rivk`) appears nowhere in the /// serialized bytes — only `ak`, the public placeholder `rk`, does. #[test] diff --git a/packages/wasm-utxo/src/zcash/ironwood_pczt.rs b/packages/wasm-utxo/src/zcash/ironwood_pczt.rs index 6a140cd5073..283ef526c49 100644 --- a/packages/wasm-utxo/src/zcash/ironwood_pczt.rs +++ b/packages/wasm-utxo/src/zcash/ironwood_pczt.rs @@ -236,7 +236,7 @@ pub fn with_spend_rk( /// Set the constructor placeholder `rk` and mark it as requiring signer replacement. /// /// Oracle-only: the keyed Constructor's scrub is the only caller, and it exists only under -/// `test` / `orchard-proving` (plan PR 7 deletes this marker entirely). +/// `test` / `orchard-proving`. #[cfg(any(test, feature = "orchard-proving"))] pub(crate) fn with_placeholder_spend_rk( bytes: &[u8], @@ -405,7 +405,7 @@ fn wire_action( .ok_or(IronwoodPcztError::BadFieldEncoding("actions[action_index]")) } -/// Test-only: replace one real spend's Merkle witness in the wire form (plan PR 4's +/// Test-only: replace one real spend's Merkle witness in the wire form (for the /// `SpendWitness` tamper test). #[cfg(test)] pub(crate) fn with_spend_witness_for_test( @@ -423,7 +423,7 @@ pub(crate) fn with_spend_witness_for_test( }) } -/// Test-only: replace the bundle's committed `value_sum` in the wire form (plan PR 4's +/// Test-only: replace the bundle's committed `value_sum` in the wire form (for the /// `ValueSum` tamper test). #[cfg(test)] pub(crate) fn with_value_sum_for_test( @@ -439,7 +439,7 @@ pub(crate) fn with_value_sum_for_test( } /// Test-only: drop one action output's plaintext `recipient` in the wire form, leaving its -/// value in place (plan PR 4's `MissingPlaintext` tamper test). +/// value in place. #[cfg(test)] pub(crate) fn without_output_recipient_for_test( bytes: &[u8], @@ -491,7 +491,7 @@ pub(crate) fn with_action_rewrite_for_test( /// Test-only: replace one action output's plaintext `recipient` in the wire form (a valid /// 43-byte Orchard address), producing a PCZT whose output note no longer commits to its -/// `cmx`. Exists for the tamper tests (plan PR 4) and the TS tampered-parse fixture. +/// `cmx`. Exists for the tamper tests and the TS tampered-parse fixture. #[cfg(test)] pub(crate) fn with_output_recipient_for_test( bytes: &[u8], @@ -780,7 +780,7 @@ fn wire_to_output( } /// Assemble a bundle from wire-form actions — the keyless spend constructor's assembly -/// path (plan §4): it builds its own actions plus the dummy-spend actions an outputs-only +/// path: it builds its own actions plus the dummy-spend actions an outputs-only /// [`Builder`](orchard::builder::Builder) fabricated, then re-parses the whole set through /// the same validation a serialized PCZT takes ([`wire_to_bundle`], including the /// duplicate-nullifier guard). diff --git a/packages/wasm-utxo/src/zcash/ironwood_verify.rs b/packages/wasm-utxo/src/zcash/ironwood_verify.rs index af40370adaa..3b7074d6a2c 100644 --- a/packages/wasm-utxo/src/zcash/ironwood_verify.rs +++ b/packages/wasm-utxo/src/zcash/ironwood_verify.rs @@ -1,24 +1,24 @@ -//! The key-free verifier for Ironwood (Zcash v6) PCZT bundles (plan §5). +//! The key-free verifier for Ironwood (Zcash v6) PCZT bundles. //! //! A party without viewing keys — the client co-signing, or a build service — cannot check //! nullifier ownership or decrypt anything, but it CAN check that the transaction it is //! asked to sign moves exactly the values it was shown: //! -//! - `K1` every output's plaintext note produces its committed `cmx`; -//! - `K2` every action's `cv_net` commits to its plaintext net value; +//! - `NoteCommitment`: every output's plaintext note produces its committed `cmx`; +//! - `CvNet`: every action's `cv_net` commits to its plaintext net value; //! - every action keeps its full plaintext (note recipient/value/rho/rseed, `rcv`): the //! prover needs it, and it is what lets anyone reading the PSBT see what moves where; -//! - `K3` the bundle's committed `value_sum` equals `Σ(v_spend − v_out)` over exactly the +//! - `ValueSum`: the bundle's committed `value_sum` equals `Σ(v_spend − v_out)` over exactly the //! reported spends and outputs — dummy spends carry value 0, so nothing moves unseen; -//! - `K4` every real spend's rebuilt note commits to a leaf under the bundle's anchor and -//! matches the spend's recorded `cmx`; -//! - `K5` every real spend's `rk` is either the stored wallet `ak` (placeholder) or +//! - `SpendWitness`: every real spend's rebuilt note commits to a leaf under the bundle's anchor +//! and matches the spend's recorded `cmx`; +//! - `Rk`: every real spend's `rk` is either the stored wallet `ak` (placeholder) or //! `ak.randomize(alpha)` (installed), consistent with the caller's expectations; -//! - `K6` no viewing-key material (`fvk`, ZIP-32 derivation) leaks on a real spend; -//! - `K8` the bundle is an Ironwood V3 bundle with the default flags; -//! - `K9` value-0 (dummy) outputs only sit on actions with a real spend; -//! - `K10` every present spend-auth signature verifies against the spend's `rk` over the -//! caller's sighash. +//! - `ViewingKeyPresent`: no viewing-key material (`fvk`, ZIP-32 derivation) leaks on a real spend; +//! - `BundleVersion`: the bundle is an Ironwood V3 bundle with the default flags; +//! - `ZeroValueOutput`: value-0 (dummy) outputs only sit on actions with a real spend; +//! - `SpendSignature`: every present spend-auth signature verifies against the spend's `rk` over +//! the caller's sighash. //! //! Duplicate nullifiers are not re-checked here: `deserialize_pczt` already rejects them. //! @@ -40,12 +40,11 @@ use super::ironwood_build::{ }; use super::ironwood_pczt::PcztSpend; -/// The caller's own expectations for the keyless verification (plan §5). +/// The caller's own expectations for the keyless verification. #[derive(Debug, Clone, Default)] pub struct KeylessVerifyContext { /// The wallet's spend-validating key (`ak`) as stored in the PSBT (proprietary key - /// `0x08`). Required for `K5` when there are real spends; `None` skips `K5` (the key - /// is not written until plan PR 5). + /// `0x08`). Required for `Rk` when there are real spends; `None` skips `Rk`. pub stored_ak: Option<[u8; 32]>, /// The caller's own `ak`: when given, it must equal `stored_ak` (the client verifies /// it is signing for its own wallet). @@ -54,7 +53,7 @@ pub struct KeylessVerifyContext { /// each spend on those actions must match. pub expected_randomizers: Vec<(usize, [u8; 32], [u8; 32])>, /// The ZIP-244 shielded sig digest to verify present spend-auth signatures against - /// (`K10`); `None` skips the check. + /// (`SpendSignature`); `None` skips the check. pub sighash: Option<[u8; 32]>, } @@ -73,8 +72,7 @@ pub struct VerifiedSpend { pub cmx: [u8; 32], /// The witness's leaf position in the commitment tree. pub position: u32, - /// Whether the signer replaced the Constructor's placeholder `rk` (the wire marker; - /// plan PR 7 derives this from `alpha` instead). + /// Whether the signer replaced the Constructor's placeholder `rk` (the wire marker). pub randomizer_installed: bool, /// Whether a spend-auth signature is present. pub signed: bool, @@ -104,7 +102,7 @@ pub struct IronwoodVerificationReport { pub anchor: [u8; 32], } -/// Verify an Ironwood PCZT bundle without any viewing keys (plan §5). +/// Verify an Ironwood PCZT bundle without any viewing keys. /// /// On success the bundle's values are exactly what the plaintext fields say they are, and /// every real spend commits to a leaf under the bundle's anchor: the only transaction a @@ -115,8 +113,8 @@ pub fn verify_bundle_without_keys( bundle: &PcztBundle, ctx: &KeylessVerifyContext, ) -> Result { - // K8: the bundle must be an Ironwood V3 bundle with the default flags — anything else - // changes the flag semantics the accounting below relies on. + // `BundleVersion`: the bundle must be an Ironwood V3 bundle with the default flags — anything + // else changes the flag semantics the accounting below relies on. let ironwood_v3 = BundleVersion::ironwood_v3(); let expected_flags = ironwood_v3 .default_flags() @@ -155,7 +153,7 @@ pub fn verify_bundle_without_keys( // A missing field could otherwise hide a value from the checks below. let (spend_value, output_value, output_recipient) = action_plaintext(action_index, action)?; - // K1: the output's plaintext note must produce its committed `cmx`. + // `NoteCommitment`: the output's plaintext note must produce its committed `cmx`. output.verify_note_commitment(spend).map_err(|e| { action_error( action_index, @@ -164,7 +162,7 @@ pub fn verify_bundle_without_keys( ) })?; - // K2 (F3 fix): `cv_net` must commit to the action's plaintext net value — no FVK is + // `CvNet`: `cv_net` must commit to the action's plaintext net value — no FVK is // needed for this. action .verify_cv_net() @@ -180,10 +178,10 @@ pub fn verify_bundle_without_keys( format!("a dummy spend carries a non-zero value ({spend_value} zatoshi)"), )); } - // K9: a value-0 output may only sit on an action with a real spend — a value-0 - // output on a dummy-spend action would be indistinguishable from a fabricated - // one in the value-based accounting. The dummy spend's paired output is a REAL - // output (the shielding pairing: dummy spend carries the real output). + // `ZeroValueOutput`: a value-0 output may only sit on an action with a real spend — a + // value-0 output on a dummy-spend action would be indistinguishable from a fabricated + // one in the value-based accounting. The dummy spend's paired output is a REAL output + // (the shielding pairing: dummy spend carries the real output). if output_value == 0 { return Err(action_error( action_index, @@ -232,7 +230,7 @@ pub fn verify_bundle_without_keys( } } - // K3: the committed value_sum must equal Σ(v_spend − v_out) over exactly the reported + // `ValueSum`: the committed value_sum must equal Σ(v_spend − v_out) over exactly the reported // spends and outputs. Everything left out of the report is value-0 (dummy spends and // fabricated outputs are checked above), so the reported values are the whole bundle: // nothing moves that the caller is not shown. @@ -263,7 +261,7 @@ pub fn verify_bundle_without_keys( /// The stored spend-validating key: its raw bytes (the placeholder `rk`) and the parsed key. type StoredAk = ([u8; 32], VerificationKey); -/// K5 setup: the stored `ak` — the wallet's public spend-validating key, which the +/// `Rk` setup: the stored `ak` — the wallet's public spend-validating key, which the /// Constructor also writes verbatim as the placeholder `rk` — parsed as a canonical, /// non-identity verification key, and matched against the caller's own when given. fn stored_spend_validating_key( @@ -354,8 +352,8 @@ fn action_plaintext( Ok((spend_value, output_value, output_recipient)) } -/// Run the real-spend-only checks (`K4`, `K5`, `K6`, `K10`) on one action and return its -/// verified data. +/// Run the real-spend-only checks (`SpendWitness`, `Rk`, `ViewingKeyPresent`, `SpendSignature`) on +/// one action and return its verified data. fn verify_real_spend( bundle: &PcztBundle, action_index: usize, @@ -363,7 +361,7 @@ fn verify_real_spend( ctx: &KeylessVerifyContext, stored_ak: Option<&StoredAk>, ) -> Result { - // K4: rebuild the note from plaintext and check it against the witness and the + // `SpendWitness`: rebuild the note from plaintext and check it against the witness and the // recorded `cmx`. let recipient = spend.recipient().ok_or_else(|| { action_error( @@ -436,7 +434,7 @@ fn verify_real_spend( )); } - // K6: no viewing-key material may leak on a real spend. + // `ViewingKeyPresent`: no viewing-key material may leak on a real spend. if spend.fvk().is_some() || spend.zip32_derivation().is_some() { return Err(action_error( action_index, @@ -445,12 +443,11 @@ fn verify_real_spend( )); } - // K5: the `rk` must be the stored wallet `ak` (placeholder) or `ak.randomize(alpha)` + // `Rk`: the `rk` must be the stored wallet `ak` (placeholder) or `ak.randomize(alpha)` // (installed), consistent with the caller's expected randomizers. // - // The reported randomizer state stays marker-based (plan PR 7 derives it from `alpha` - // instead): `replace_ironwood_spend_rk` patches the `rk` without installing `alpha`, - // so alpha-presence is not yet equivalent to "replaced". + // The reported randomizer state is marker-based: `replace_ironwood_spend_rk` patches the `rk` + // without installing `alpha`, so alpha-presence is not yet equivalent to "replaced". let randomizer_installed = crate::zcash::ironwood_pczt::spend_rk_replaced(spend).map_err(|e| { action_error( @@ -516,8 +513,8 @@ fn verify_real_spend( } } - // K10: any present spend-auth signature must verify against the spend's `rk` over the - // caller's sighash. + // `SpendSignature`: any present spend-auth signature must verify against the spend's `rk` over + // the caller's sighash. if let (Some(sighash), Some(sig)) = (ctx.sighash, spend.spend_auth_sig()) { spend.rk().verify(sighash.as_slice(), sig).map_err(|e| { action_error( @@ -669,8 +666,8 @@ mod tests { } } - /// The context the tests verify with: no stored `ak` (the `0x08` key is not written until - /// plan PR 5), so the `Rk` state checks are skipped and only key-free checks run. + /// The context the tests verify with: no stored `ak` (proprietary key `0x08`), so the + /// `Rk` state checks are skipped and only key-free checks run. fn ctx() -> KeylessVerifyContext { KeylessVerifyContext::default() } @@ -682,7 +679,7 @@ mod tests { } /// The wallet's public spend-validating key (`ak`), as the Constructor writes it for the - /// placeholder `rk` — and as plan PR 5 stores it under `0x08`. + /// placeholder `rk` — and as `add_shielded_spends` stores it under `0x08`. fn test_ak() -> [u8; 32] { // `ask.randomize(0)` is `ask` itself as a RedPallas signing key. let ask = test_ask().randomize(&pasta_curves::pallas::Scalar::ZERO); @@ -767,8 +764,8 @@ mod tests { ); } - /// An output whose recipient is withheld would otherwise skip `K1` and drop out of the - /// report while its value still balanced `K3`: funds to a recipient nobody is shown. + /// An output whose recipient is withheld would otherwise skip `NoteCommitment` and drop out of + /// the report while its value still balanced `ValueSum`: funds to a recipient nobody is shown. #[test] fn output_without_its_recipient_fails_missing_plaintext() { let (bytes, _anchor, _recipient) = test_bundle(); @@ -877,7 +874,7 @@ mod tests { let bytes = serialize_pczt(&bundle).unwrap(); // Swap the two real spends' plaintext values (via the action map, so each spec's own // action gets the other's value): each action's `cv_net` no longer commits to its net - // value, while the bundle totals stay equal, so only `K2` can catch it. + // value, while the bundle totals stay equal, so only `CvNet` can catch it. let action_a = action_map.spend_action_index(0).unwrap(); let action_b = action_map.spend_action_index(1).unwrap(); let spend_a = with_spend_value(&bytes, action_a, 40_000_000).unwrap(); @@ -1122,10 +1119,11 @@ mod tests { #[test] fn zero_value_output_on_a_dummy_spend_fails_zero_value_output() { - // A shielding bundle's dummy spend carries a real output; rewrite that output to a - // value-0 one with a consistent note commitment, `cv_net` and committed sum, so the - // verifier reaches `K9` instead of tripping on `K1`–`K3`: the accounting could no - // longer tell it apart from a fabricated dummy output. + // A shielding bundle's dummy spend carries a real output; rewrite that output to a value-0 + // one with a consistent note commitment, `cv_net` and committed sum, so the verifier + // reaches `ZeroValueOutput` instead of tripping on the `NoteCommitment`, `CvNet` or + // `ValueSum` checks: the accounting could no longer tell it apart from a fabricated dummy + // output. let (bytes, shield, dummy_action) = shielding_bundle(); // The output note's rho is the paired SPEND's nullifier (`Rho::from_nf_old`). let rho = Option::::from(Rho::from_bytes( diff --git a/packages/wasm-utxo/src/zcash/mod.rs b/packages/wasm-utxo/src/zcash/mod.rs index 3b9f2593908..1a7fa58f47c 100644 --- a/packages/wasm-utxo/src/zcash/mod.rs +++ b/packages/wasm-utxo/src/zcash/mod.rs @@ -13,12 +13,12 @@ pub mod address; pub mod blake2b; /// orchard PCZT ↔ v6 IronwoodBundle bridge (Constructor / Signer / Extractor roles). pub mod ironwood_build; -/// The key-free Ironwood spend Constructor (plan §4): builds deshield and +/// The key-free Ironwood spend Constructor: builds deshield and /// shielded→shielded bundles without any viewing key. pub mod ironwood_construct; /// Serialization of the orchard PCZT Ironwood bundle (proof-service payload / PSBT carry). pub mod ironwood_pczt; -/// The key-free verifier for Ironwood PCZT bundles (plan §5). +/// The key-free verifier for Ironwood PCZT bundles. pub mod ironwood_verify; pub mod transaction; pub mod unified_address; diff --git a/packages/wasm-utxo/test/types/parsedTypes.ts b/packages/wasm-utxo/test/types/parsedTypes.ts index 34a655104ef..68b9a477e11 100644 --- a/packages/wasm-utxo/test/types/parsedTypes.ts +++ b/packages/wasm-utxo/test/types/parsedTypes.ts @@ -1,5 +1,5 @@ /** - * Compile-time type tests for the generic parsed-input type parameter (plan PR 3). + * Compile-time type tests for the generic parsed-input type parameter. * * Nothing here runs — these are type-level assertions checked by `tsc`; the exported * functions exist only to anchor the annotations. A mismatch fails `npm run build:test`.