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 @@ -16,6 +16,7 @@ import {
} from "./BitGoPsbt.js";
import {
type IronwoodShieldedStatus,
type IronwoodVerificationReport,
type ZcashShieldedOutputRequest,
type ZcashShieldedSpend,
type ZcashShieldedSpendRequest,
Expand Down Expand Up @@ -111,7 +112,8 @@ type IronwoodParsedBase = ParsedTransaction<ZcashIronwoodParsedOutput, ZcashPars
* FROST round
* ({@link addSpendRandomizers} → {@link spendSighash} → FROST →
* {@link addSpendSignatures}), and the proof round ({@link getProofRequest} → prover →
* {@link combineProof}) follow, in that order.
* {@link combineProof}) follow, in that order. Before signing, the client checks the bundle
* against its own spend-validating key and randomizers with {@link verifyShieldedBundle}.
*
* **Sign first, prove last.** Spend signatures are made over {@link spendSighash}, which
* commits `rk` but neither the proof nor the randomizers `alpha`; the alphas reach the prover
Expand Down Expand Up @@ -561,6 +563,52 @@ export class ZcashIronwoodBitGoPsbt extends ZcashBitGoPsbt<
return this.wasm.ironwood_shielded_status() as IronwoodShieldedStatus;
}

/**
* The client's key-free verification of the stored shielded bundle, driven by the caller's
* own expectations. The PSBT is never trusted for who a spend belongs to: the
* spend-validating key and the signer's randomized keys come in from the caller, never
* from the PSBT.
*
* Runs the key-free verifier's checks — every output's plaintext note commits to its
* recorded `cmx`, every action's net value commits to its `cv`, the bundle's value balance
* equals the shown totals, every real spend's note commits to a leaf under the bundle's
* anchor, no viewing-key material or stray randomizer rides the bundle, and every present
* spend-auth signature verifies against its spend's `rk`. Once it passes, the only
* transaction the client can sign over this bundle that verifies on-chain is one that
* moves exactly the values shown; what the client *cannot* check (note ownership,
* nullifier validity, a fresh anchor) can only make the transaction invalid, never
* misdirected.
*
* @param options.spendValidatingKey - the client's own spend-validating key (32 bytes,
* optional). When given, every randomized real spend must carry
* `rk == ak.randomize(alpha)` for the matching `randomizers` entry: the client checks it
* is signing for its own wallet (`IronwoodVerifyError.Rk` otherwise).
* @param options.randomizers - the signer's randomizers, one
* `{ actionIndex, alpha, rk }` per randomized real spend (optional). Each entry must
* land on a real spend; a spend whose installed `rk` does not match its entry is
* rejected.
*
* When every real spend's randomized `rk` is installed, the ZIP-244 sig digest is computed
* here and any present spend-auth signature is verified over it
* (`IronwoodVerifyError.SpendSignature`); a mid-flow bundle (placeholder `rk`s still
* pending) verifies without the signature check instead.
*
* @returns the verified view of the bundle: {@link IronwoodVerificationReport}.
* @throws Error with a typed `err.code`: `IronwoodVerifyError.<Check>` when any check
* fails, `IronwoodPsbtError.NotSpendBundle` when the PSBT carries no shielded bundle, and
* `IronwoodPsbtError.OutCiphertextPending` when randomized `rk`s are installed while a
* real output's `out_ciphertext` is still pending.
*/
verifyShieldedBundle(options?: {
spendValidatingKey?: Uint8Array;
randomizers?: Array<{ actionIndex: number; alpha: Uint8Array; rk: Uint8Array }>;
}): IronwoodVerificationReport {
return this.wasm.verify_ironwood_shielded_bundle(
options?.spendValidatingKey,
options?.randomizers ?? [],
) as IronwoodVerificationReport;
}

/**
* The ZIP-317 conventional fee (zatoshi) for this PSBT's shape: the transparent terms plus
* the Ironwood action count (`max(real spends, real outputs)`) of the stored PCZT — shorthand
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,8 @@ pub enum IronwoodPsbtError {
/// silently overwrite the first batch, whose value the transparent side is funding.
AlreadyAdded,
/// 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`.
/// receiver, or its Orchard receiver is not the output's `recipient`; or a verified
/// spend's Orchard receiver could not be encoded as a Unified Address.
BadUnifiedAddress(String),
/// The PSBT carries no Ironwood PCZT, or its bundle is not a spend bundle (no real
/// spends), and the operation needs one.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ pub struct IronwoodSpendInfo {
pub nullifier: [u8; 32],
/// Raw 43-byte Orchard recipient of the spent note.
pub recipient: crate::zcash::ironwood_build::OrchardAddressBytes,
/// Whether the signer replaced the Constructor's placeholder `rk` (the wire marker).
/// Whether the signer replaced the Constructor's placeholder `rk` with a randomized one.
pub rk_replaced: bool,
/// Whether a spend-auth signature is installed.
pub signed: bool,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
//! adders call for their Zcash arms, and in `decode_v6` at deserialize time.

use super::error::IronwoodPsbtError;
use super::parse::ZcashShieldedSpend;
use super::{IronwoodOutputRequest, ZcashBitGoPsbt};

impl ZcashBitGoPsbt {
Expand Down Expand Up @@ -623,6 +624,67 @@ impl ZcashBitGoPsbt {
ready_for_proof,
})
}

/// The client's key-free verification of the stored shielded bundle, driven by the
/// caller's own expectations — the PSBT is never trusted for who a spend belongs to.
///
/// This runs [`verify_bundle_without_keys`] over the stored PCZT with
/// the caller's spend-validating key `ak` and its signer's `(action, alpha, rk)`
/// triples; nothing is read back from the PSBT for the `Rk` check. When every real
/// spend's randomized `rk` is installed, the ZIP-244 sig digest is computed here and any
/// present spend-auth signature is verified over it; a mid-flow bundle (placeholder
/// `rk`s still pending) verifies without the signature check instead. On success the
/// returned report carries the verified plaintext values: the only transaction the
/// client can sign over this bundle that verifies on-chain is one that moves exactly the
/// values shown.
///
/// A PSBT without a stored PCZT fails with [`IronwoodPsbtError::NotSpendBundle`]. A
/// shielding bundle (dummy spends only) verifies with no spends and its shielded outputs
/// in the report.
///
/// [`verify_bundle_without_keys`]: crate::zcash::ironwood_verify::verify_bundle_without_keys
pub fn verify_shielded_bundle(
&self,
ak: Option<&[u8; 32]>,
randomizers: &[(usize, [u8; 32], [u8; 32])],
) -> Result<IronwoodVerifiedBundle, IronwoodPsbtError> {
use crate::zcash::ironwood_verify::{verify_bundle_without_keys, KeylessVerifyContext};

let (_, pczt) = self.stored_ironwood_pczt()?;
// Only real spends carry spend-auth signatures to check, and the digest is only
// well-defined once every real spend's randomized `rk` is installed (and every real
// output's `out_ciphertext` is set); `spend_sighash` enforces both.
let sighash = if Self::bundle_has_real_spends(&pczt)
&& Self::pending_randomizers_in(&pczt).is_empty()
{
Some(self.spend_sighash()?)
} else {
None
};
let report = verify_bundle_without_keys(
&pczt,
&KeylessVerifyContext {
expected_ak: ak.copied(),
expected_randomizers: randomizers.to_vec(),
sighash,
},
)
.map_err(IronwoodPsbtError::Verify)?;
let spends = report
.spends
.iter()
.map(|spend| {
ZcashShieldedSpend::from_verified(spend, self.network().to_coin_name())
.map_err(|e| IronwoodPsbtError::BadUnifiedAddress(e.to_string()))
})
.collect::<Result<Vec<_>, IronwoodPsbtError>>()?;
Ok(IronwoodVerifiedBundle {
spends,
outputs: report.outputs,
value_balance: report.value_balance,
anchor: report.anchor,
})
}
}

/// Which input kind a v6 PSBT carries.
Expand Down Expand Up @@ -652,6 +714,21 @@ pub struct IronwoodShieldedStatus {
pub ready_for_proof: bool,
}

/// The client-facing verified view of the stored shielded bundle, from
/// [`ZcashBitGoPsbt::verify_shielded_bundle`] — the key-free verifier's report with each
/// spend's Unified Address filled in.
#[derive(Clone, Debug)]
pub struct IronwoodVerifiedBundle {
/// The bundle's real spends, as verified, in the parse's [`ZcashShieldedSpend`] shape.
pub spends: Vec<ZcashShieldedSpend>,
/// The bundle's real outputs, as verified.
pub outputs: Vec<crate::zcash::ironwood_verify::VerifiedOutput>,
/// The bundle's value balance (Σv_spend − Σv_out).
pub value_balance: i64,
/// The anchor the spends' witnesses commit to.
pub anchor: [u8; 32],
}

/// 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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,8 +38,7 @@ 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` (read from the wire
/// marker).
/// Whether the signer replaced the Constructor's placeholder `rk` with a randomized one.
pub rk_replaced: bool,
/// Whether a spend-auth signature is installed.
pub signed: bool,
Expand Down Expand Up @@ -360,25 +359,35 @@ fn shielded_side(
.spends
.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,
})
ZcashShieldedSpend::from_verified(spend, z.network().to_coin_name())
.map_err(|e| ZcashParseTransactionError::Shielded(e.to_string()))
})
.collect::<Result<Vec<_>, ZcashParseTransactionError>>()?;
Ok((outputs, spends, report.value_balance))
}

impl ZcashShieldedSpend {
/// A verified spend in the parse shape, with the single-receiver Unified Address of its
/// Orchard receiver on `coin_name`'s network.
///
/// 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.
pub(crate) fn from_verified(
spend: &crate::zcash::ironwood_verify::VerifiedSpend,
coin_name: &str,
) -> Result<Self, crate::zcash::unified_address::UnifiedAddressError> {
let address =
crate::zcash::unified_address::encode_orchard_receiver(&spend.recipient, coin_name)?;
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,
})
}
}
Loading
Loading