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 @@ -21,6 +21,7 @@ import {
type ZcashShieldedSpend,
type ZcashShieldedSpendRequest,
} from "./ZcashIronwoodTypes.js";
import { type ZcashIronwoodWitness } from "./ZcashIronwoodWitness.js";
import { Zip317Dimensions } from "./Zip317Dimensions.js";

/**
Expand Down Expand Up @@ -160,7 +161,10 @@ 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. Before signing, the client checks the bundle
* {@link combineProof}) follow, in that order, with the anchor refresh
* ({@link updateAnchorAndWitnesses}) allowed at any point before the proof request — a
* build whose approval sat long enough re-witnesses every spend under a fresh anchor
* without re-signing. Before signing, the client checks the bundle
* against its own spend-validating key and randomizers with {@link verifyShieldedBundle}.
*
* For custodial MPC shielded wallets — no fixed-script xpub triple at all — the constructor
Expand All @@ -184,7 +188,7 @@ type IronwoodParsedBase = ParsedTransaction<ZcashIronwoodParsedOutput, ZcashPars
* - **Randomizers next** — every real spend's randomized `rk` before any sighash or
* signature (`RandomizerPending`);
* - **Frozen once signed** — ciphertexts and randomizers rejected after any signature
* (`AlreadySigned`);
* (`AlreadySigned`); the anchor is not sighash-committed, so its refresh stays open;
* - **Signed before proven** — the proof is requested only once every real spend is signed
* (`SpendSignaturePending`).
*
Expand Down Expand Up @@ -606,6 +610,41 @@ export class ZcashIronwoodBitGoPsbt extends ZcashBitGoPsbt<
this.addOutCiphertexts([{ actionIndex, outCiphertext }]);
}

/**
* Refresh the bundle's anchor and every real spend's Merkle witness — the re-witness step a
* build whose approval sat too long runs before proving: the anchor is read once, fresh
* witnesses are built for every spent note ({@link ZcashIronwoodWitness.build} against the
* new anchor), and both land in one call. Allowed before **and after** signing: the v6
* sighash does not commit the anchor (it sits in the authorizing digest, bound by the
* proof), so the spend-auth signatures stay valid and no re-sign is needed. A refresh does
* invalidate any proof request already handed out — call {@link getProofRequest} after the
* last refresh, or {@link combineProof} gets a proof over the old anchor.
*
* `entries` carries exactly one `{ actionIndex, witness }` per real spend
* (`IronwoodPsbtError.WitnessMissing` otherwise — the anchor is bundle-level, so a spend
* left on its old witness would make the bundle internally inconsistent). Every witness is
* re-validated against the new anchor over the spend's own note commitment: a stale
* witness for the old anchor throws (`IronwoodBuildError.WitnessAnchorMismatch`), and
* nothing is written unless every entry validates (**atomic** — one bad entry stores
* nothing).
*
* @param anchor - the new Ironwood note-commitment-tree root (32 bytes)
* @param entries - one `{ actionIndex, witness }` per real spend; the action indices are
* the `spendActionIndices` {@link addShieldedSpends} returned
*/
updateAnchorAndWitnesses(
anchor: Uint8Array,
entries: Array<{ actionIndex: number; witness: ZcashIronwoodWitness }>,
): void {
this.wasm.update_ironwood_anchor_and_witnesses(
anchor,
entries.map((entry) => ({
actionIndex: entry.actionIndex,
witness: { position: entry.witness.position, authPath: entry.witness.authPath },
})),
);
}

/**
* Install the signer's randomized `rk`s: one `{ actionIndex, alpha, rk }` entry per real
* spend, with `rk = ak.randomize(alpha)`. **Only `rk` is stored** — the PSBT never carries
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,13 @@ pub enum IronwoodPsbtError {
/// The real spends' action indices without an `alpha`.
action_indices: Vec<usize>,
},
/// The anchor update named no witness for these real spends: the anchor is bundle-level,
/// so changing it invalidates every real spend's witness — one re-witnessed path per real
/// spend must be supplied.
WitnessMissing {
/// The real spends' action indices without a witness.
action_indices: Vec<usize>,
},
/// The proof is not the canonical size for the bundle's action count.
ProofLength {
/// `orchard::Proof::expected_proof_size` for the bundle's actions.
Expand Down Expand Up @@ -170,6 +177,11 @@ impl std::fmt::Display for IronwoodPsbtError {
"no alpha was supplied for real spends {action_indices:?}; the proof request \
needs one per real spend"
),
IronwoodPsbtError::WitnessMissing { action_indices } => write!(
f,
"no witness was supplied for real spends {action_indices:?}; changing the \
anchor re-witnesses every real spend"
),
IronwoodPsbtError::ProofLength { expected, actual } => write!(
f,
"the proof is {actual} bytes; a bundle of this many actions needs {expected}"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -422,8 +422,10 @@ impl ZcashBitGoPsbt {
/// [`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.
/// real spend's `spend_auth_sig` must be installed first. Signing freezes the action data,
/// so the proof is always made over the final action data; the anchor is not action data
/// and can still be refreshed ([`Self::update_anchor_and_witnesses`]) — request the proof
/// after the last refresh.
///
/// The PSBT never stores `alpha` (with `rk` it would reveal the wallet's `ak`), so the
/// caller supplies exactly one `(action_index, alpha)` per real spend: each index must
Expand Down Expand Up @@ -611,6 +613,98 @@ impl ZcashBitGoPsbt {
Ok(())
}

/// Refresh the bundle's anchor and every real spend's Merkle witness — the re-witness
/// step a build whose approval sat too long runs before proving: the anchor is read once,
/// fresh witnesses are fetched for every spent note, and both land in one call. Allowed
/// before *and after* signing: the v6 sighash does not commit the anchor (it sits in the
/// authorizing digest, bound by the proof), so the spend-auth and transparent signatures
/// stay valid across a refresh, and the binding signature is only made at
/// [`Self::combine_ironwood_proof`]. A refresh does invalidate any proof request already
/// handed out — the proof must be requested ([`Self::proof_request`]) after the last
/// refresh, or the combined transaction carries a proof over the old anchor.
///
/// `anchor` is the new note-commitment-tree root (32 bytes); `entries` carries exactly
/// one `(action_index, witness)` per real spend — each index must exist
/// ([`IronwoodPsbtError::UnknownAction`]), name a real spend
/// ([`IronwoodPsbtError::DummyAction`]) and appear once
/// ([`IronwoodPsbtError::DuplicateAction`]), and every real spend must be covered
/// ([`IronwoodPsbtError::WitnessMissing`]: the anchor is bundle-level, so a spend left
/// on its old witness would make the bundle internally inconsistent). Every supplied
/// witness is validated against the *new* anchor over its spend's own note commitment —
/// a stale witness for the old anchor is rejected
/// ([`IronwoodBuildError::WitnessAnchorMismatch`] under [`IronwoodPsbtError::Build`]) —
/// so the call can never leave the bundle anchoring nothing. **Atomic** — every entry is
/// validated before anything is written, so one bad entry stores nothing. Dummy spends
/// keep their builder-fabricated witnesses: the circuit checks the anchor only for real
/// spends, so dummies need no re-witnessing.
///
/// Only spend bundles take an anchor refresh ([`IronwoodPsbtError::NotSpendBundle`]),
/// and the batch must not be empty ([`IronwoodPsbtError::NothingPending`]).
///
/// [`IronwoodBuildError::WitnessAnchorMismatch`]:
/// crate::zcash::ironwood_build::IronwoodBuildError::WitnessAnchorMismatch
pub fn update_anchor_and_witnesses(
&mut self,
anchor: &crate::zcash::ironwood_build::AnchorBytes,
entries: &[(usize, crate::zcash::ironwood_build::IronwoodWitness)],
) -> Result<(), IronwoodPsbtError> {
use crate::zcash::ironwood_build::build_ironwood_witness;

let (bytes, pczt) = self.stored_ironwood_pczt()?;
if !Self::bundle_has_real_spends(&pczt) {
return Err(IronwoodPsbtError::NotSpendBundle);
}
if entries.is_empty() {
return Err(IronwoodPsbtError::NothingPending);
}
Self::require_distinct_actions(entries.iter().map(|&(action_index, _)| action_index))?;
let missing: Vec<usize> = pczt
.actions()
.iter()
.enumerate()
.filter(|&(_, action)| action.spend().dummy_sk().is_none())
.map(|(action_index, _)| action_index)
.filter(|action_index| !entries.iter().any(|&(index, _)| index == *action_index))
.collect();
if !missing.is_empty() {
return Err(IronwoodPsbtError::WitnessMissing {
action_indices: missing,
});
}
// The pre-update bundle must verify: the refresh is written only over a consistent
// bundle, so a tampered stored PCZT fails here instead of being re-anchored. The
// verified report carries each real spend's recomputed note commitment — the leaf
// its new witness must anchor; an entry naming no verified spend names an
// out-of-range action ([`IronwoodPsbtError::UnknownAction`]) or a dummy one
// ([`IronwoodPsbtError::DummyAction`]).
let report = self
.keyless_verify(&pczt)
.map_err(IronwoodPsbtError::Verify)?;
for &(action_index, witness) in entries {
let cmx = report
.spends
.iter()
.find(|spend| spend.action_index == action_index)
.map(|spend| spend.cmx)
.ok_or_else(|| {
if action_index < pczt.actions().len() {
IronwoodPsbtError::DummyAction
} else {
IronwoodPsbtError::UnknownAction
}
})?;
build_ironwood_witness(&cmx, witness.position, &witness.auth_path, anchor)
.map_err(IronwoodPsbtError::Build)?;
}

// Validate-then-write: the PSBT is replaced only on full success.
let patched =
crate::zcash::ironwood_pczt::with_anchor_and_witnesses(&bytes, *anchor, entries)
.map_err(IronwoodPsbtError::Pczt)?;
super::propkv::set_ironwood_pczt(&mut self.psbt, patched);
Ok(())
}

/// The orchestration view of the shielded workflow: the input kind, what is still pending
/// at each stage, and whether the PSBT is ready for its proof. A PSBT without a PCZT is a
/// transparent one with nothing pending and nothing to prove; errors only when the stored
Expand Down
Loading
Loading