Skip to content

Commit b2fb84c

Browse files
committed
wire FROST threshold signing into Orchard spend auth via PCZT
1 parent 597348d commit b2fb84c

1 file changed

Lines changed: 177 additions & 24 deletions

File tree

src/wallet.rs

Lines changed: 177 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,15 @@ use zcash_protocol::memo::MemoBytes;
2828
use zcash_protocol::value::Zatoshis;
2929
use zcash_transparent::builder::TransparentSigningSet;
3030

31+
// PCZT / FROST signing imports
32+
use blake2b_simd::Hash as Blake2bHash;
33+
// PcztParts/PcztResult are accessed via TxBuilder::build_for_pczt() return type.
34+
use zcash_primitives::transaction::sighash::SignableInput;
35+
use zcash_primitives::transaction::sighash_v5::v5_signature_hash;
36+
use zcash_primitives::transaction::txid::TxIdDigester;
37+
use zcash_primitives::transaction::{Authorization, TransactionData, TxDigests, TxVersion};
38+
use zcash_protocol::value::ZatBalance;
39+
3140
use crate::config::Config;
3241
use crate::db::Db;
3342
use crate::frost_signer::{FrostSigner, SigningMode};
@@ -93,6 +102,16 @@ impl sapling_crypto::prover::OutputProver for NoopOutputProver {
93102
}
94103
}
95104

105+
/// Authorization type for PCZT sighash computation.
106+
/// Combines EffectsOnly from all shielded protocols.
107+
struct PcztEffectsOnly;
108+
109+
impl Authorization for PcztEffectsOnly {
110+
type TransparentAuth = zcash_transparent::bundle::EffectsOnly;
111+
type SaplingAuth = sapling_crypto::bundle::EffectsOnly;
112+
type OrchardAuth = orchard::bundle::EffectsOnly;
113+
}
114+
96115
const MAX_CHECKPOINTS: usize = 100;
97116

98117
type OrchardShardStore = MemoryShardStore<MerkleHashOrchard, BlockHeight>;
@@ -541,14 +560,20 @@ impl AnchorWallet {
541560
}
542561

543562
// Build, prove, and sign
544-
let rng = rand_core::OsRng;
545-
let transparent_signing = TransparentSigningSet::new();
546563
let fee_rule = FeeRule::standard();
547564

548-
// Both signing modes use the single-key path for now.
549-
// FROST threshold signing produces a standalone authorization
550-
// signature over the sighash. Full FROST-in-bundle signing
551-
// requires the PCZT flow (orchard::pczt) to access alpha.
565+
// FROST threshold mode: use PCZT flow to access alpha for
566+
// rerandomized spend authorization signatures.
567+
if self.signing_mode == SigningMode::FrostThreshold {
568+
if let Some(ref frost) = self.frost_signer {
569+
return self
570+
.build_anchor_tx_frost(builder, frost, params, config, &fee_rule, position);
571+
}
572+
}
573+
574+
// Single-key path: standard build with SpendAuthorizingKey.
575+
let rng = rand_core::OsRng;
576+
let transparent_signing = TransparentSigningSet::new();
552577
let sak = SpendAuthorizingKey::from(&self.sk);
553578
let result = builder
554579
.build(
@@ -562,24 +587,6 @@ impl AnchorWallet {
562587
)
563588
.map_err(|e| anyhow::anyhow!("Transaction build failed: {:?}", e))?;
564589

565-
// If FROST mode, also produce a threshold signature as proof of
566-
// multi-party authorization. This is logged and can be verified
567-
// independently against the FROST group public key.
568-
if self.signing_mode == SigningMode::FrostThreshold {
569-
if let Some(ref frost) = self.frost_signer {
570-
let sighash = result.transaction().txid().as_ref().to_vec();
571-
match frost.sign_raw(&sighash) {
572-
Ok(sig) => {
573-
let sig_hex = hex::encode(<[u8; 64]>::from(sig));
574-
tracing::info!("FROST threshold signature: {}", &sig_hex[..32],);
575-
}
576-
Err(e) => {
577-
tracing::error!("FROST signing failed: {}", e);
578-
}
579-
}
580-
}
581-
}
582-
583590
let tx = result.transaction();
584591
let txid = tx.txid().to_string();
585592

@@ -598,6 +605,152 @@ impl AnchorWallet {
598605
Ok((tx_hex, txid, position))
599606
}
600607

608+
/// Build an anchor transaction using FROST threshold signing via the PCZT flow.
609+
///
610+
/// PCZT (Partially Created Zcash Transaction) pipeline:
611+
/// 1. builder.build_for_pczt() -> PcztParts with orchard::pczt::Bundle
612+
/// 2. extract_effects() -> Bundle<EffectsOnly> for sighash computation
613+
/// 3. v5_signature_hash() -> shielded sighash
614+
/// 4. finalize_io(sighash) -> computes bsk, signs dummy spends
615+
/// 5. For each action: read alpha, FROST sign(sighash, alpha), apply_signature()
616+
/// 6. create_proof(ProvingKey) -> Orchard halo2 proof
617+
/// 7. extract() -> Bundle<Unbound>
618+
/// 8. apply_binding_signature(sighash) -> Bundle<Authorized>
619+
/// 9. TransactionData::from_parts() + freeze() -> Transaction
620+
#[allow(clippy::too_many_arguments)]
621+
fn build_anchor_tx_frost<P: Parameters>(
622+
&self,
623+
builder: TxBuilder<'_, P, ()>,
624+
frost: &FrostSigner,
625+
params: &P,
626+
config: &Config,
627+
fee_rule: &FeeRule,
628+
position: Position,
629+
) -> Result<(String, String, Position)> {
630+
let mut rng = rand_core::OsRng;
631+
632+
// Step 1: Build PCZT parts (consumes the builder).
633+
let pczt_result = builder
634+
.build_for_pczt(&mut rng, fee_rule)
635+
.map_err(|e| anyhow::anyhow!("PCZT build failed: {:?}", e))?;
636+
637+
let pczt_parts = pczt_result.pczt_parts;
638+
let mut orchard_pczt = pczt_parts
639+
.orchard
640+
.ok_or_else(|| anyhow::anyhow!("No Orchard bundle in PCZT"))?;
641+
642+
// Step 2: Compute sighash from transaction effects.
643+
let orchard_effects: Option<orchard::Bundle<orchard::bundle::EffectsOnly, ZatBalance>> =
644+
orchard_pczt
645+
.extract_effects()
646+
.map_err(|e| anyhow::anyhow!("extract_effects: {:?}", e))?;
647+
648+
let tx_version =
649+
TxVersion::suggested_for_branch(BranchId::for_height(params, pczt_parts.expiry_height));
650+
let consensus_branch_id = BranchId::for_height(params, pczt_parts.expiry_height);
651+
652+
let tx_data: TransactionData<PcztEffectsOnly> = TransactionData::from_parts(
653+
tx_version,
654+
consensus_branch_id,
655+
0, // lock_time
656+
pczt_parts.expiry_height,
657+
None, // transparent
658+
None, // sprout
659+
None, // sapling
660+
orchard_effects,
661+
);
662+
663+
let txid_parts: TxDigests<Blake2bHash> = tx_data.digest(TxIdDigester);
664+
let shielded_sighash: [u8; 32] =
665+
v5_signature_hash(&tx_data, &SignableInput::Shielded, &txid_parts)
666+
.as_ref()
667+
.try_into()
668+
.expect("sighash is 32 bytes");
669+
670+
tracing::info!("PCZT sighash: {}", &hex::encode(&shielded_sighash)[..16]);
671+
672+
// Step 3: Finalize IO (computes bsk, signs dummy spends).
673+
orchard_pczt
674+
.finalize_io(shielded_sighash, &mut rng)
675+
.map_err(|e| anyhow::anyhow!("finalize_io: {:?}", e))?;
676+
677+
// Step 4: FROST sign each non-dummy action.
678+
for (i, action) in orchard_pczt.actions_mut().iter_mut().enumerate() {
679+
// Skip actions that already have signatures (dummy spends).
680+
if action.spend().spend_auth_sig().is_some() {
681+
continue;
682+
}
683+
684+
let alpha = *action
685+
.spend()
686+
.alpha()
687+
.as_ref()
688+
.ok_or_else(|| anyhow::anyhow!("action {} missing alpha", i))?;
689+
690+
// FROST sign with rerandomization: sign(sighash, alpha)
691+
let frost_sig = frost.sign(&shielded_sighash, alpha)?;
692+
693+
// Convert reddsa::Signature to orchard's internal Signature type.
694+
let sig_bytes: [u8; 64] = frost_sig.into();
695+
let orchard_sig = orchard::primitives::redpallas::Signature::<
696+
orchard::primitives::redpallas::SpendAuth,
697+
>::from(sig_bytes);
698+
699+
action
700+
.apply_signature(shielded_sighash, orchard_sig)
701+
.map_err(|e| anyhow::anyhow!("apply_signature action {}: {:?}", i, e))?;
702+
703+
tracing::info!("FROST signed action {}", i);
704+
}
705+
706+
// Step 5: Create Orchard proof.
707+
let proving_key = orchard::circuit::ProvingKey::build();
708+
orchard_pczt
709+
.create_proof(&proving_key, &mut rng)
710+
.map_err(|e| anyhow::anyhow!("create_proof: {:?}", e))?;
711+
712+
// Step 6: Extract authorized bundle.
713+
let unbound_bundle: orchard::Bundle<orchard::pczt::Unbound, ZatBalance> = orchard_pczt
714+
.extract()
715+
.map_err(|e| anyhow::anyhow!("extract: {:?}", e))?
716+
.ok_or_else(|| anyhow::anyhow!("empty orchard bundle after extract"))?;
717+
718+
let authorized_bundle = unbound_bundle
719+
.apply_binding_signature(shielded_sighash, &mut rng)
720+
.ok_or_else(|| anyhow::anyhow!("binding signature verification failed"))?;
721+
722+
// Step 7: Assemble final Transaction.
723+
let authorized_tx: TransactionData<zcash_primitives::transaction::Authorized> =
724+
TransactionData::from_parts(
725+
tx_version,
726+
consensus_branch_id,
727+
0, // lock_time
728+
pczt_parts.expiry_height,
729+
None, // transparent
730+
None, // sprout
731+
None, // sapling
732+
Some(authorized_bundle),
733+
);
734+
735+
let tx = authorized_tx
736+
.freeze()
737+
.map_err(|e| anyhow::anyhow!("freeze: {:?}", e))?;
738+
739+
let txid = tx.txid().to_string();
740+
let mut raw = Vec::new();
741+
tx.write(&mut raw)
742+
.map_err(|e| anyhow::anyhow!("serialize: {:?}", e))?;
743+
let tx_hex = hex::encode(&raw);
744+
745+
tracing::info!(
746+
"FROST anchor tx built: txid={} ({} zat output)",
747+
txid.get(..16).unwrap_or(&txid),
748+
config.anchor_amount_zat,
749+
);
750+
751+
Ok((tx_hex, txid, position))
752+
}
753+
601754
/// Build a signed payout transaction to an external Orchard address.
602755
/// Returns (raw_tx_hex, txid_hex, spent_position).
603756
pub fn build_payout_tx<P: Parameters>(

0 commit comments

Comments
 (0)