Skip to content

Commit eba18e5

Browse files
authored
Merge pull request #338 from BitGo/veetragjain/cshld-1291-wasm-utxo-add-zip-244-v6-signature-digests-shielded
feat(wasm-utxo): add ZIP-244 v6 signature digests (shielded + transparent)
2 parents fc311bb + 92c080f commit eba18e5

1 file changed

Lines changed: 359 additions & 1 deletion

File tree

  • packages/wasm-utxo/src/zcash

packages/wasm-utxo/src/zcash/v6.rs

Lines changed: 359 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -124,8 +124,19 @@ pub const ZTXID_OUTPUTS_PERSONAL: &[u8; 16] = b"ZTxIdOutputsHash";
124124
/// ZIP-244 personalization for the sapling digest.
125125
pub const ZTXID_SAPLING_PERSONAL: &[u8; 16] = b"ZTxIdSaplingHash";
126126
/// ZIP-244 outer txid personalization prefix; the 4-byte consensus branch id
127-
/// (little-endian) is appended to form the full 16-byte personalization.
127+
/// (little-endian) is appended to form the full 16-byte personalization. Shared by the
128+
/// txid digest and the signature (SIGHASH) digest.
128129
pub const ZCASH_TXID_PERSONAL_PREFIX: &[u8; 12] = b"ZcashTxHash_";
130+
/// ZIP-244 §S.2b personalization for the transparent input-amounts sig sub-digest.
131+
pub const ZTXID_AMOUNTS_SIG_PERSONAL: &[u8; 16] = b"ZTxTrAmountsHash";
132+
/// ZIP-244 §S.2c personalization for the transparent scriptPubKeys sig sub-digest.
133+
pub const ZTXID_SCRIPTS_SIG_PERSONAL: &[u8; 16] = b"ZTxTrScriptsHash";
134+
/// ZIP-244 §S.2g personalization for the per-input (txin) sig sub-digest. Hashed over the
135+
/// empty string for a shielded signature; over the signed input's fields for a transparent one.
136+
pub const ZTXID_TXIN_SIG_PERSONAL: &[u8; 16] = b"Zcash___TxInHash";
137+
/// The `SIGHASH_ALL` hash type byte. Shielded signatures always use `SIGHASH_ALL`, and the
138+
/// BitGo transparent flows sign with `SIGHASH_ALL` as well.
139+
const SIGHASH_ALL: u8 = 0x01;
129140

130141
/// Boundary between the compact note plaintext and the memo inside `encCiphertext`.
131142
const ENC_COMPACT_END: usize = 52;
@@ -388,6 +399,169 @@ pub fn compute_v6_txid_from_bytes(bytes: &[u8]) -> Result<[u8; 32], ZcashV6Error
388399
Ok(compute_v6_txid(&tx))
389400
}
390401

402+
/// ZIP-244 §S.2 `transparent_sig_digest`, parameterized by the per-input (`txin`) sub-digest.
403+
///
404+
/// The shielded and per-input transparent signature digests differ *only* in the `txin`
405+
/// component: empty for a shielded signature, populated for the transparent input being signed.
406+
/// Every other sub-digest (prevouts / amounts / scriptPubKeys / sequences / outputs) is identical
407+
/// under `SIGHASH_ALL`, so both callers share this builder. `input_amounts` and
408+
/// `input_script_pubkeys` are the spent outputs' values and scriptPubKeys, one per transparent
409+
/// input in input order.
410+
fn transparent_sig_digest_with_txin(
411+
tx: &ZcashV6Transaction,
412+
input_amounts: &[i64],
413+
input_script_pubkeys: &[miniscript::bitcoin::ScriptBuf],
414+
txin_sig_hash: [u8; 32],
415+
) -> [u8; 32] {
416+
let inputs = &tx.transparent.input;
417+
// Per ZIP-244, the sig transparent digest collapses to the txid one only when there are
418+
// neither transparent inputs nor outputs (e.g. a fully-shielded tx). With outputs but no
419+
// inputs (unshield: shielded spend -> transparent output), the full S.2 structure still
420+
// applies, since hash_type/amounts/scripts/txin differ from the txid digest whenever
421+
// outputs is non-empty.
422+
if inputs.is_empty() && tx.transparent.output.is_empty() {
423+
return transparent_txid_digest(tx);
424+
}
425+
426+
debug_assert_eq!(
427+
input_amounts.len(),
428+
inputs.len(),
429+
"input_amounts must have one entry per transparent input"
430+
);
431+
debug_assert_eq!(
432+
input_script_pubkeys.len(),
433+
inputs.len(),
434+
"input_script_pubkeys must have one entry per transparent input"
435+
);
436+
437+
// S.2a prevouts / S.2d sequences / S.2e outputs: identical to the txid sub-digests under
438+
// SIGHASH_ALL (plain concatenation, no CompactSize count).
439+
let mut prevouts = Vec::new();
440+
let mut sequences = Vec::new();
441+
for txin in inputs {
442+
txin.previous_output
443+
.consensus_encode(&mut prevouts)
444+
.expect("vec write is infallible");
445+
txin.sequence
446+
.consensus_encode(&mut sequences)
447+
.expect("vec write is infallible");
448+
}
449+
let mut outputs_data = Vec::new();
450+
for txout in &tx.transparent.output {
451+
txout
452+
.consensus_encode(&mut outputs_data)
453+
.expect("vec write is infallible");
454+
}
455+
let prevouts_hash = blake2b_256_personal(&prevouts, ZTXID_PREVOUTS_PERSONAL);
456+
let sequence_hash = blake2b_256_personal(&sequences, ZTXID_SEQUENCE_PERSONAL);
457+
let outputs_hash = blake2b_256_personal(&outputs_data, ZTXID_OUTPUTS_PERSONAL);
458+
459+
// S.2b amounts / S.2c scriptPubKeys: `zcash_encoding::Array` encoding — each element written
460+
// back-to-back with NO outer count. Amounts are 8-byte signed LE; scriptPubKeys use their
461+
// standard (individually length-prefixed) consensus encoding.
462+
let mut amounts_data = Vec::with_capacity(input_amounts.len() * 8);
463+
for amount in input_amounts {
464+
amounts_data.extend_from_slice(&amount.to_le_bytes());
465+
}
466+
let mut scripts_data = Vec::new();
467+
for script in input_script_pubkeys {
468+
script
469+
.consensus_encode(&mut scripts_data)
470+
.expect("vec write is infallible");
471+
}
472+
let amounts_hash = blake2b_256_personal(&amounts_data, ZTXID_AMOUNTS_SIG_PERSONAL);
473+
let scripts_hash = blake2b_256_personal(&scripts_data, ZTXID_SCRIPTS_SIG_PERSONAL);
474+
475+
let mut data = Vec::with_capacity(1 + 32 * 6);
476+
data.push(SIGHASH_ALL);
477+
data.extend_from_slice(&prevouts_hash);
478+
data.extend_from_slice(&amounts_hash);
479+
data.extend_from_slice(&scripts_hash);
480+
data.extend_from_slice(&sequence_hash);
481+
data.extend_from_slice(&outputs_hash);
482+
data.extend_from_slice(&txin_sig_hash);
483+
blake2b_256_personal(&data, ZTXID_TRANSPARENT_PERSONAL)
484+
}
485+
486+
/// Combine the five ZIP-244 component digests into the outer signature (SIGHASH) digest, using
487+
/// the same `ZcashTxHash_`+branch-id personalization as the txid.
488+
fn v6_sig_digest_from_transparent(tx: &ZcashV6Transaction, transparent: [u8; 32]) -> [u8; 32] {
489+
let header = header_digest(tx);
490+
let sapling = blake2b_256_personal(&[], ZTXID_SAPLING_PERSONAL);
491+
let orchard = orchard_v6_empty_digest();
492+
let ironwood = ironwood_digest(tx.ironwood_bundle.as_ref());
493+
494+
let mut data = Vec::with_capacity(32 * 5);
495+
data.extend_from_slice(&header);
496+
data.extend_from_slice(&transparent);
497+
data.extend_from_slice(&sapling);
498+
data.extend_from_slice(&orchard);
499+
data.extend_from_slice(&ironwood);
500+
501+
let mut personal = [0u8; 16];
502+
personal[..12].copy_from_slice(ZCASH_TXID_PERSONAL_PREFIX);
503+
personal[12..].copy_from_slice(&tx.consensus_branch_id.to_le_bytes());
504+
blake2b_256_personal(&data, &personal)
505+
}
506+
507+
/// ZIP-244 v6 **shielded** signature hash (SIGHASH_ALL) — the message the Ironwood binding
508+
/// signature (and any shielded spend-auth signature) signs.
509+
///
510+
/// `input_amounts` / `input_script_pubkeys` describe the spent outputs, one per transparent input
511+
/// in input order. Result is a 32-byte digest in internal order (not a txid; not reversed).
512+
pub fn compute_v6_sig_digest(
513+
tx: &ZcashV6Transaction,
514+
input_amounts: &[i64],
515+
input_script_pubkeys: &[miniscript::bitcoin::ScriptBuf],
516+
) -> [u8; 32] {
517+
// Shielded signature: the per-input (txin) sub-digest is the empty personalized hash.
518+
let txin_sig_hash = blake2b_256_personal(&[], ZTXID_TXIN_SIG_PERSONAL);
519+
let transparent =
520+
transparent_sig_digest_with_txin(tx, input_amounts, input_script_pubkeys, txin_sig_hash);
521+
v6_sig_digest_from_transparent(tx, transparent)
522+
}
523+
524+
/// ZIP-244 v6 **transparent** per-input signature hash (SIGHASH_ALL) — the message the key
525+
/// controlling transparent input `input_index` signs.
526+
///
527+
/// `script_code` is the script being signed for that input (its prevout scriptPubKey for P2PKH,
528+
/// or the redeem/witness script for P2SH/P2WSH). `input_amounts` / `input_script_pubkeys` are the
529+
/// spent outputs' values and scriptPubKeys for every input, in input order.
530+
pub fn compute_v6_transparent_sighash(
531+
tx: &ZcashV6Transaction,
532+
input_index: usize,
533+
script_code: &miniscript::bitcoin::Script,
534+
input_amounts: &[i64],
535+
input_script_pubkeys: &[miniscript::bitcoin::ScriptBuf],
536+
) -> Result<[u8; 32], ZcashV6Error> {
537+
let txin = tx
538+
.transparent
539+
.input
540+
.get(input_index)
541+
.ok_or(ZcashV6Error::UnexpectedEof)?;
542+
let amount = *input_amounts
543+
.get(input_index)
544+
.ok_or(ZcashV6Error::UnexpectedEof)?;
545+
546+
// S.2g: prevout ‖ value(8, signed LE) ‖ scriptCode(length-prefixed) ‖ nSequence(4, LE).
547+
let mut txin_data = Vec::new();
548+
txin.previous_output
549+
.consensus_encode(&mut txin_data)
550+
.expect("vec write is infallible");
551+
txin_data.extend_from_slice(&amount.to_le_bytes());
552+
script_code
553+
.consensus_encode(&mut txin_data)
554+
.expect("vec write is infallible");
555+
txin.sequence
556+
.consensus_encode(&mut txin_data)
557+
.expect("vec write is infallible");
558+
let txin_sig_hash = blake2b_256_personal(&txin_data, ZTXID_TXIN_SIG_PERSONAL);
559+
560+
let transparent =
561+
transparent_sig_digest_with_txin(tx, input_amounts, input_script_pubkeys, txin_sig_hash);
562+
Ok(v6_sig_digest_from_transparent(tx, transparent))
563+
}
564+
391565
/// A fully parsed Zcash v6 (Ironwood) transaction.
392566
#[derive(Debug, Clone, PartialEq, Eq)]
393567
pub struct ZcashV6Transaction {
@@ -809,6 +983,190 @@ mod tests {
809983
}
810984
}
811985

986+
fn sample_tx_with_inputs() -> ZcashV6Transaction {
987+
ZcashV6Transaction {
988+
version_group_id: ZCASH_IRONWOOD_VERSION_GROUP_ID,
989+
consensus_branch_id: 0x37a5165b,
990+
transparent: sample_transparent(true),
991+
expiry_height: 0,
992+
sapling_value_balance: 0,
993+
ironwood_bundle: Some(sample_bundle()),
994+
}
995+
}
996+
997+
// ---- v6 signature-digest regression tests ----
998+
// (The full independent oracle — zebra recomputing these digests and verifying the binding
999+
// signature over them — runs in the ironwood_build build→prove→combine test, where a real
1000+
// bundle with known input amounts/scripts exists.)
1001+
1002+
#[test]
1003+
fn sig_digest_is_deterministic() {
1004+
let tx = sample_tx_with_inputs();
1005+
let amounts = [12_345i64];
1006+
let scripts = [ScriptBuf::from(vec![0x76u8, 0xa9, 0x14])];
1007+
assert_eq!(
1008+
compute_v6_sig_digest(&tx, &amounts, &scripts),
1009+
compute_v6_sig_digest(&tx, &amounts, &scripts)
1010+
);
1011+
}
1012+
1013+
#[test]
1014+
fn shielded_sig_digest_commits_to_amounts_and_scripts() {
1015+
let tx = sample_tx_with_inputs();
1016+
let base = compute_v6_sig_digest(&tx, &[12_345], &[ScriptBuf::from(vec![0x76u8, 0xa9])]);
1017+
// Differs from the txid (which does not commit to input amounts/scripts).
1018+
assert_ne!(base, compute_v6_txid(&tx));
1019+
assert_ne!(
1020+
base,
1021+
compute_v6_sig_digest(&tx, &[99_999], &[ScriptBuf::from(vec![0x76u8, 0xa9])])
1022+
);
1023+
assert_ne!(
1024+
base,
1025+
compute_v6_sig_digest(&tx, &[12_345], &[ScriptBuf::from(vec![0x51u8])])
1026+
);
1027+
}
1028+
1029+
#[test]
1030+
fn shielded_sig_digest_without_inputs_equals_txid() {
1031+
let tx = ZcashV6Transaction {
1032+
version_group_id: ZCASH_IRONWOOD_VERSION_GROUP_ID,
1033+
consensus_branch_id: 0x37a5165b,
1034+
transparent: sample_transparent(false),
1035+
expiry_height: 0,
1036+
sapling_value_balance: 0,
1037+
ironwood_bundle: Some(sample_bundle()),
1038+
};
1039+
assert_eq!(compute_v6_sig_digest(&tx, &[], &[]), compute_v6_txid(&tx));
1040+
}
1041+
1042+
#[test]
1043+
fn shielded_sig_digest_unshield_case_differs_from_txid() {
1044+
// Unshield: transparent output present, no transparent inputs. The sig transparent
1045+
// digest must NOT collapse to the txid digest here, since the txid digest omits
1046+
// hash_type/amounts/scripts/txin while the sig digest includes them.
1047+
let (_, output) = {
1048+
let sample = sample_transparent(true);
1049+
(sample.input, sample.output)
1050+
};
1051+
let transparent = Transaction {
1052+
version: miniscript::bitcoin::transaction::Version::non_standard(6),
1053+
input: vec![],
1054+
output,
1055+
lock_time: miniscript::bitcoin::locktime::absolute::LockTime::from_consensus(0),
1056+
};
1057+
let tx = ZcashV6Transaction {
1058+
version_group_id: ZCASH_IRONWOOD_VERSION_GROUP_ID,
1059+
consensus_branch_id: 0x37a5165b,
1060+
transparent,
1061+
expiry_height: 0,
1062+
sapling_value_balance: 0,
1063+
ironwood_bundle: Some(sample_bundle()),
1064+
};
1065+
assert_ne!(compute_v6_sig_digest(&tx, &[], &[]), compute_v6_txid(&tx));
1066+
}
1067+
1068+
#[test]
1069+
fn transparent_sighash_differs_from_shielded_and_varies_by_input() {
1070+
let tx = sample_tx_with_inputs();
1071+
let amounts = [12_345i64];
1072+
let scripts = [ScriptBuf::from(vec![0x76u8, 0xa9, 0x14])];
1073+
let script_code = ScriptBuf::from(vec![0x76u8, 0xa9, 0x14, 0x88, 0xac]);
1074+
1075+
let shielded = compute_v6_sig_digest(&tx, &amounts, &scripts);
1076+
let transparent =
1077+
compute_v6_transparent_sighash(&tx, 0, script_code.as_script(), &amounts, &scripts)
1078+
.unwrap();
1079+
// The per-input transparent sighash populates the txin component, so it must differ from
1080+
// the shielded (empty-txin) digest over the same tx.
1081+
assert_ne!(shielded, transparent);
1082+
// Deterministic.
1083+
assert_eq!(
1084+
transparent,
1085+
compute_v6_transparent_sighash(&tx, 0, script_code.as_script(), &amounts, &scripts)
1086+
.unwrap()
1087+
);
1088+
// A different script_code changes the digest.
1089+
let other_code = ScriptBuf::from(vec![0x51u8]);
1090+
assert_ne!(
1091+
transparent,
1092+
compute_v6_transparent_sighash(&tx, 0, other_code.as_script(), &amounts, &scripts)
1093+
.unwrap()
1094+
);
1095+
// Out-of-range input index is an error, not a panic.
1096+
assert!(compute_v6_transparent_sighash(
1097+
&tx,
1098+
9,
1099+
script_code.as_script(),
1100+
&amounts,
1101+
&scripts
1102+
)
1103+
.is_err());
1104+
}
1105+
1106+
/// Golden oracle for [`compute_v6_transparent_sighash`]: verify the **real** ECDSA signature
1107+
/// carried in a signed transparent→Ironwood testnet tx against the sighash we compute.
1108+
///
1109+
/// The tx is the `v6_shield1zec` fixture (one P2PKH transparent input → 1 ZEC Ironwood note).
1110+
/// The spent output's value and scriptPubKey are not on the wire but are committed by ZIP-244;
1111+
/// they come from the sandbox reference that produced this tx (prevout
1112+
/// `058886a9…:0`, 313_990_000 zat, standard P2PKH). If the transaction's own signature verifies
1113+
/// against our digest, the digest is byte-correct — an independent check, since the signature
1114+
/// was produced by an external signer, not by this code.
1115+
#[test]
1116+
fn golden_transparent_sighash_verifies_real_signature() {
1117+
use miniscript::bitcoin::script::Instruction;
1118+
use miniscript::bitcoin::secp256k1::{ecdsa::Signature, Message, PublicKey, Secp256k1};
1119+
1120+
let raw = hex::decode(load_zcash_fixture("v6_shield1zec_rawtx.hex").trim()).unwrap();
1121+
let tx = decode_v6_transaction(&raw).unwrap();
1122+
assert_eq!(tx.transparent.input.len(), 1);
1123+
assert_eq!(tx.transparent.output.len(), 1);
1124+
1125+
// Spent output (from the reference that built this tx); ZIP-244 commits to both.
1126+
let prevout_value: i64 = 313_990_000;
1127+
let prevout_script = ScriptBuf::from(
1128+
hex::decode("76a9147c6b843a25873c036aff575516e3802bcc47f63488ac").unwrap(),
1129+
);
1130+
1131+
// P2PKH scriptSig = <DER sig ‖ SIGHASH_ALL> <pubkey>. The scriptCode signed for a P2PKH
1132+
// input is its scriptPubKey.
1133+
let pushes: Vec<Vec<u8>> = tx.transparent.input[0]
1134+
.script_sig
1135+
.instructions()
1136+
.map(|i| i.expect("valid scriptSig"))
1137+
.filter_map(|i| match i {
1138+
Instruction::PushBytes(pb) => Some(pb.as_bytes().to_vec()),
1139+
Instruction::Op(_) => None,
1140+
})
1141+
.collect();
1142+
assert_eq!(pushes.len(), 2, "P2PKH scriptSig has sig + pubkey");
1143+
let sig_bytes = &pushes[0];
1144+
let pubkey_bytes = &pushes[1];
1145+
assert_eq!(
1146+
*sig_bytes.last().unwrap(),
1147+
SIGHASH_ALL,
1148+
"signature uses SIGHASH_ALL"
1149+
);
1150+
let der = &sig_bytes[..sig_bytes.len() - 1];
1151+
1152+
let sighash = compute_v6_transparent_sighash(
1153+
&tx,
1154+
0,
1155+
prevout_script.as_script(),
1156+
&[prevout_value],
1157+
std::slice::from_ref(&prevout_script),
1158+
)
1159+
.unwrap();
1160+
1161+
let secp = Secp256k1::verification_only();
1162+
let msg = Message::from_digest(sighash);
1163+
let mut sig = Signature::from_der(der).expect("DER signature");
1164+
sig.normalize_s();
1165+
let pk = PublicKey::from_slice(pubkey_bytes).expect("valid pubkey");
1166+
secp.verify_ecdsa(&msg, &sig, &pk)
1167+
.expect("the tx's real signature verifies against compute_v6_transparent_sighash");
1168+
}
1169+
8121170
#[test]
8131171
fn digest_is_deterministic() {
8141172
let b = sample_bundle();

0 commit comments

Comments
 (0)