Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,15 @@ export type ZcashShieldedSpend = {
recipient: Uint8Array;
/** The nullifier this spend reveals. */
nullifier: Uint8Array;
/**
* The note commitment recomputed from the verified plaintext: the key-free verifier
* checks it commits to a leaf under the bundle's anchor and matches the recorded `cmx`.
*/
cmx: Uint8Array;
/** The witness's leaf position in the commitment tree. */
position: number;
/** The single-receiver Unified Address of the spent note's Orchard receiver. */
address: string;
/** Randomizer state of the spend's `rk` (see {@link IronwoodRandomizerState}). */
randomizer: IronwoodRandomizerState;
/** Whether a spend-auth signature is installed for this spend. */
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,19 @@ pub struct IronwoodSpendInfo {
pub signed: bool,
}

impl From<&crate::zcash::ironwood_verify::VerifiedSpend> for IronwoodSpendInfo {
fn from(spend: &crate::zcash::ironwood_verify::VerifiedSpend) -> Self {
IronwoodSpendInfo {
action_index: spend.action_index,
value: spend.value,
nullifier: spend.nullifier,
recipient: spend.recipient,
rk_replaced: spend.randomizer_installed,
signed: spend.signed,
}
}
}

/// The spend/output/fee accounting view of the stored PCZT: real spends, real outputs, the
/// bundle's value balance, and the transaction-level fee.
///
Expand Down Expand Up @@ -335,17 +348,12 @@ impl ZcashBitGoPsbt {
if self.require_no_shielded_output_ever_added()? {
return Ok((0, 0));
}
let pczt = self.ironwood_pczt()?;
Self::pczt_real_spend_and_output_counts(&pczt)
self.pczt_real_spend_and_output_counts()
}

fn pczt_real_spend_and_output_counts(
pczt: &orchard::pczt::Bundle,
) -> Result<(usize, usize), String> {
Ok((
Self::bundle_real_spends(pczt)?.len(),
Self::bundle_real_outputs(pczt)?.len(),
))
fn pczt_real_spend_and_output_counts(&self) -> Result<(usize, usize), String> {
let report = self.keyless_verify_info()?;
Ok((report.spends.len(), report.outputs.len()))
}

/// Client-managed `ovk`, raw-key form: re-encrypt the Ironwood output's `out_ciphertext` under
Expand Down Expand Up @@ -620,6 +628,36 @@ impl ZcashBitGoPsbt {
Ok(true)
}

/// The keyless verification of the stored PCZT (plan §5): 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
/// present; until then the `Rk` check is skipped and only the key-free checks run.
pub(crate) fn keyless_verify(
&self,
pczt: &orchard::pczt::Bundle,
) -> Result<
crate::zcash::ironwood_verify::IronwoodVerificationReport,
crate::zcash::ironwood_build::IronwoodVerifyError,
> {
let ctx = crate::zcash::ironwood_verify::KeylessVerifyContext {
stored_ak: super::propkv::get_ironwood_spend_validating_key(&self.psbt),
..Default::default()
};
crate::zcash::ironwood_verify::verify_bundle_without_keys(pczt, &ctx)
}

/// The String-flavored keyless verification of the stored PCZT the info accessors use;
/// the Zcash parse calls [`Self::keyless_verify`] to keep the failing check's error code.
fn keyless_verify_info(
&self,
) -> Result<crate::zcash::ironwood_verify::IronwoodVerificationReport, String> {
self.keyless_verify(&self.ironwood_pczt()?)
.map_err(|e| e.to_string())
}

/// The action index, value (zatoshi), and raw 43-byte recipient of every shielded Ironwood
/// output added via [`Self::add_ironwood_output`]/[`Self::add_ironwood_outputs`] — empty if no
/// shielded output is present yet.
Expand Down Expand Up @@ -659,7 +697,12 @@ impl ZcashBitGoPsbt {
if self.require_no_shielded_output_ever_added()? {
return Ok(Vec::new());
}
Self::bundle_real_outputs(&self.ironwood_pczt()?)
let report = self.keyless_verify_info()?;
Ok(report
.outputs
.into_iter()
.map(|output| (output.action_index, output.value, output.recipient))
.collect())
}

/// The action index, value (zatoshi), and nullifier of every real Ironwood spend added via
Expand All @@ -676,67 +719,8 @@ impl ZcashBitGoPsbt {
if self.require_no_shielded_output_ever_added()? {
return Ok(Vec::new());
}
Self::bundle_real_spends(&self.ironwood_pczt()?)
}

/// Every real spend in `pczt` (no `dummy_sk`, see [`Self::bundle_has_real_spends`]); a real
/// spend always carries its plaintext value.
fn bundle_real_spends(pczt: &orchard::pczt::Bundle) -> Result<Vec<IronwoodSpendInfo>, String> {
pczt.actions()
.iter()
.enumerate()
.filter(|(_, action)| action.spend().dummy_sk().is_none())
.map(|(action_index, action)| {
let spend = action.spend();
let value = spend
.value()
.map(|v| v.inner())
.ok_or_else(|| "real spend is missing its plaintext value".to_string())?;
let recipient = spend
.recipient()
.map(|address| address.to_raw_address_bytes())
.ok_or_else(|| "real spend is missing its plaintext recipient".to_string())?;
Ok(IronwoodSpendInfo {
action_index,
value,
nullifier: spend.nullifier().to_bytes(),
recipient,
rk_replaced: crate::zcash::ironwood_pczt::spend_rk_replaced(spend)?,
signed: spend.spend_auth_sig().is_some(),
})
})
.collect()
}

/// Every real output in `pczt`: all but the fabricated zero-valued outputs the builder pairs
/// with surplus real spends (see [`Self::ironwood_shielded_outputs_info`]).
fn bundle_real_outputs(
pczt: &orchard::pczt::Bundle,
) -> Result<
Vec<(
usize,
u64,
crate::zcash::ironwood_build::OrchardAddressBytes,
)>,
String,
> {
let mut outputs = Vec::new();
for (action_index, action) in pczt.actions().iter().enumerate() {
let output = action.output();
let value = output
.value()
.map(|v| v.inner())
.ok_or_else(|| "shielded output is missing its plaintext value".to_string())?;
if value == 0 && action.spend().dummy_sk().is_none() {
continue;
}
let recipient = output
.recipient()
.map(|address| address.to_raw_address_bytes())
.ok_or_else(|| "shielded output is missing its plaintext recipient".to_string())?;
outputs.push((action_index, value, recipient));
}
Ok(outputs)
let report = self.keyless_verify_info()?;
Ok(report.spends.iter().map(IronwoodSpendInfo::from).collect())
}

/// The spend/output/fee accounting for the bundle behind this PSBT — see
Expand All @@ -747,25 +731,24 @@ impl ZcashBitGoPsbt {
if self.require_no_shielded_output_ever_added()? {
return Err("no Ironwood PCZT stored in PSBT".to_string());
}
let pczt = self.ironwood_pczt()?;
let spends = Self::bundle_real_spends(&pczt)?;
let outputs = Self::bundle_real_outputs(&pczt)?;
// The keyless verifier (plan §5) 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`.
let report = self.keyless_verify_info()?;
let spends = report
.spends
.iter()
.map(IronwoodSpendInfo::from)
.collect::<Vec<_>>();
let outputs = report
.outputs
.iter()
.map(|output| (output.action_index, output.value, output.recipient))
.collect::<Vec<_>>();
// The bundle's committed value balance (the wire `value_sum`): the quantity the IO
// Finalizer enforces against the proof-bound `cv_net`s and the ZIP-244 digest commits.
// The plaintext note values below are NOT bound after deserialization — the proofs bind
// only `cv_net`/`cm` — so they are cross-checked against the committed sum before any
// accounting flows out of here.
let value_balance = crate::zcash::ironwood_build::pczt_action_data(&pczt)
.map_err(|e| e.to_string())?
.value_balance;
let spend_total = spends.iter().map(|s| s.value as i128).sum::<i128>();
let output_total = outputs.iter().map(|(_, v, _)| *v as i128).sum::<i128>();
if spend_total - output_total != value_balance as i128 {
return Err(format!(
"plaintext note values are inconsistent with the bundle's committed value_sum: \
spends {spend_total} - outputs {output_total} != value_balance {value_balance}"
));
}
let value_balance = report.value_balance;
let transparent_in = self
.transparent_input_amounts_and_scripts()?
.0
Expand Down Expand Up @@ -1033,10 +1016,7 @@ impl ZcashBitGoPsbt {
///
/// Returns whether a PCZT was present.
pub fn mark_ironwood_extracted(&mut self) -> bool {
if let Ok((spends, outputs)) = self
.ironwood_pczt()
.and_then(|pczt| Self::pczt_real_spend_and_output_counts(&pczt))
{
if let Ok((spends, outputs)) = self.pczt_real_spend_and_output_counts() {
super::propkv::set_ironwood_extracted_counts(
&mut self.psbt,
spends as u32,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,10 @@ use crate::zcash::ironwood_build::OrchardAddressBytes;

/// One shielded (Ironwood) spend, as surfaced by the parse.
///
/// Plaintext values from the PCZT, not yet verified against the bundle's commitments —
/// the key-free verifier (plan PR 4) hardens this.
/// Plaintext values from the PCZT, verified by the key-free verifier (plan §5) 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.
#[derive(Clone, Debug)]
pub struct ZcashShieldedSpend {
/// The spend's action index in the orchard bundle.
Expand All @@ -30,6 +32,12 @@ pub struct ZcashShieldedSpend {
pub recipient: OrchardAddressBytes,
/// The nullifier this spend reveals (committed in the action data).
pub nullifier: [u8; 32],
/// The note commitment recomputed from the verified plaintext.
pub cmx: [u8; 32],
/// The witness's leaf position in the commitment tree.
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).
pub rk_replaced: bool,
Expand Down Expand Up @@ -85,8 +93,12 @@ pub enum ZcashParseTransactionError {
Transparent(ParseTransactionError),
/// The PSBT mixes transparent inputs with shielded spends.
MixedInputKinds,
/// The shielded side could not be read from the PCZT plaintext.
/// 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
/// failing verifier check (`IronwoodVerifyError.<check>`).
Verify(crate::zcash::ironwood_build::IronwoodVerifyError),
}

impl std::fmt::Display for ZcashParseTransactionError {
Expand All @@ -98,6 +110,9 @@ impl std::fmt::Display for ZcashParseTransactionError {
"a PSBT cannot mix transparent inputs with shielded spends"
),
ZcashParseTransactionError::Shielded(error) => write!(f, "Shielded side: {error}"),
ZcashParseTransactionError::Verify(error) => {
write!(f, "Shielded side failed verification: {error}")
}
}
}
}
Expand All @@ -110,6 +125,12 @@ impl From<ParseTransactionError> for ZcashParseTransactionError {
}
}

impl From<crate::zcash::ironwood_build::IronwoodVerifyError> for ZcashParseTransactionError {
fn from(error: crate::zcash::ironwood_build::IronwoodVerifyError) -> Self {
ZcashParseTransactionError::Verify(error)
}
}

impl crate::error::WasmErrorCode for ZcashParseTransactionError {
fn code(&self) -> String {
match self {
Expand All @@ -118,6 +139,9 @@ impl crate::error::WasmErrorCode for ZcashParseTransactionError {
ZcashParseTransactionError::Transparent(error) => {
crate::error::WasmErrorCode::code(error)
}
// A verification failure carries the failing check as its code
// (`IronwoodVerifyError.NoteCommitment`, …) — callers key diagnostics on it.
ZcashParseTransactionError::Verify(error) => crate::error::WasmErrorCode::code(error),
_ => format!("ZcashParseTransactionError.{}", <&'static str>::from(self)),
}
}
Expand Down Expand Up @@ -155,11 +179,9 @@ pub(crate) fn parse_outputs_with_wallet_keys(
paygo_pubkeys: &[miniscript::bitcoin::secp256k1::PublicKey],
) -> Result<Vec<ZcashParsedOutput>, ZcashParseTransactionError> {
let z = zcash_psbt(bitgo_psbt)?;
let shielded_outputs = z
.ironwood_shielded_outputs_info()
.map_err(ZcashParseTransactionError::Shielded)?;
let (shielded_outputs, _spends, _value_balance) = shielded_side(z)?;
let mut outputs = transparent_outputs(bitgo_psbt.parse_outputs(wallet_keys, paygo_pubkeys)?);
outputs.extend(shielded_parsed_outputs(z, &shielded_outputs)?);
outputs.extend(shielded_outputs);
Ok(outputs)
}

Expand Down Expand Up @@ -319,21 +341,44 @@ fn shielded_side(
{
return Ok((Vec::new(), Vec::new(), 0));
}
let info = z
.ironwood_bundle_info()
// The keyless verifier (plan §5) 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
.ironwood_pczt()
.map_err(ZcashParseTransactionError::Shielded)?;
let outputs = shielded_parsed_outputs(z, &info.outputs)?;
let spends = info
let report = z.keyless_verify(&pczt)?;
let outputs = shielded_parsed_outputs(
z,
&report
.outputs
.iter()
.map(|output| (output.action_index, output.value, output.recipient))
.collect::<Vec<_>>(),
)?;
let spends = report
.spends
.into_iter()
.map(|spend| ZcashShieldedSpend {
action_index: spend.action_index,
value: spend.value,
recipient: spend.recipient,
nullifier: spend.nullifier,
rk_replaced: spend.rk_replaced,
signed: spend.signed,
.iter()
.map(|spend| {
// The stored Unified Addresses belong to the bundle's *outputs* (keyed by their
// action index), so a spend's address is always rebuilt from its own receiver.
let address = crate::zcash::unified_address::encode_orchard_receiver(
&spend.recipient,
z.network().to_coin_name(),
)
.map_err(|e| ZcashParseTransactionError::Shielded(e.to_string()))?;
Ok(ZcashShieldedSpend {
action_index: spend.action_index,
value: spend.value,
recipient: spend.recipient,
nullifier: spend.nullifier,
cmx: spend.cmx,
position: spend.position,
address,
rk_replaced: spend.randomizer_installed,
signed: spend.signed,
})
})
.collect();
Ok((outputs, spends, info.value_balance))
.collect::<Result<Vec<_>, ZcashParseTransactionError>>()?;
Ok((outputs, spends, report.value_balance))
}
Loading
Loading