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
20 changes: 20 additions & 0 deletions packages/wasm-utxo/js/fixedScriptWallet/ZcashV6Transaction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,26 @@ export class ZcashV6Transaction {
return this._wasm.ironwoodAnchor;
}

/**
* The ZIP-244 per-input transparent sighash (32 bytes) for transparent input `index` —
* for a transaction inspected directly from its raw bytes rather than one built via a
* PSBT (e.g. independently verifying an already-broadcast transaction's signatures).
*
* `inputAmounts`/`inputScriptPubkeys` are the spent outputs' values (zatoshi) and
* scriptPubKeys for *every* transparent input of this transaction, in input order.
*/
transparentSighash(
index: number,
inputAmounts: bigint[],
inputScriptPubkeys: Uint8Array[],
): Uint8Array {
return this._wasm.transparentSighash(
index,
BigInt64Array.from(inputAmounts),
inputScriptPubkeys,
);
}

/** @internal */
get wasm(): WasmZcashV6Transaction {
return this._wasm;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1033,19 +1033,14 @@ impl ZcashBitGoPsbt {
.inputs
.get(index)
.ok_or_else(|| format!("input {index} out of range"))?;
let script_code = input
.witness_script
.as_ref()
.or(input.redeem_script.as_ref())
.ok_or_else(|| format!("input {index}: no redeem/witness script"))?;
crate::zcash::v6::compute_v6_transparent_sighash(
&tx,
index,
script_code.as_script(),
&amounts,
&scripts,
)
.map_err(|e| e.to_string())
// Not used in the digest itself (ZIP-244 §S.2g.iii commits the spent scriptPubKey, not
// the redeem/witness script — see `compute_v6_transparent_sighash`), but its presence
// confirms the input is actually spendable before we hand back a sighash to sign.
if input.witness_script.is_none() && input.redeem_script.is_none() {
return Err(format!("input {index}: no redeem/witness script"));
}
crate::zcash::v6::compute_v6_transparent_sighash(&tx, index, &amounts, &scripts)
.map_err(|e| e.to_string())
}

/// Ingest a transparent-input signature returned by the client/HSM into `partial_sigs`, after
Expand Down Expand Up @@ -2689,7 +2684,6 @@ mod ironwood_v6_tests {
let codec_sighash = compute_v6_transparent_sighash(
&tx,
0,
prevout_script.as_script(),
&[prevout_value],
std::slice::from_ref(&prevout_script),
)
Expand Down
30 changes: 30 additions & 0 deletions packages/wasm-utxo/src/wasm/zcash.rs
Original file line number Diff line number Diff line change
Expand Up @@ -189,4 +189,34 @@ impl ZcashV6Transaction {
.as_ref()
.map(|b| b.anchor.to_vec())
}

/// The ZIP-244 per-input transparent sighash (32 bytes) for transparent input `index` of
/// this transaction — the free-standing counterpart to
/// `ZcashIronwoodBitGoPsbt.transparentSighash`, for a transaction inspected directly from
/// its raw bytes rather than one built via this codebase's PSBT flow (e.g. independently
/// verifying an already-broadcast transaction's signatures).
///
/// `input_amounts`/`input_script_pubkeys` are the spent outputs' values (zatoshi) and
/// scriptPubKeys for *every* transparent input of this transaction, in input order — the
/// same data `add-input`/`addWalletInput` would have carried in a PSBT's `witness_utxo`.
#[wasm_bindgen(js_name = transparentSighash)]
pub fn transparent_sighash(
&self,
index: usize,
input_amounts: Vec<i64>,
input_script_pubkeys: Vec<js_sys::Uint8Array>,
) -> Result<Vec<u8>, WasmUtxoError> {
let scripts: Vec<miniscript::bitcoin::ScriptBuf> = input_script_pubkeys
.iter()
.map(|u| miniscript::bitcoin::ScriptBuf::from(u.to_vec()))
.collect();
crate::zcash::v6::compute_v6_transparent_sighash(
&self.inner,
index,
&input_amounts,
&scripts,
)
.map(|h| h.to_vec())
.map_err(|e| WasmUtxoError::new(&e.to_string()))
}
}
137 changes: 112 additions & 25 deletions packages/wasm-utxo/src/zcash/v6.rs
Original file line number Diff line number Diff line change
Expand Up @@ -524,13 +524,18 @@ pub fn compute_v6_sig_digest(
/// ZIP-244 v6 **transparent** per-input signature hash (SIGHASH_ALL) — the message the key
/// controlling transparent input `input_index` signs.
///
/// `script_code` is the script being signed for that input (its prevout scriptPubKey for P2PKH,
/// or the redeem/witness script for P2SH/P2WSH). `input_amounts` / `input_script_pubkeys` are the
/// spent outputs' values and scriptPubKeys for every input, in input order.
/// Per ZIP-244 §S.2g.iii (as implemented by `zcash_primitives::transaction::sighash_v5::
/// transparent_sig_digest`, shared by v6), the per-input field committed here is the spent
/// output's **scriptPubKey** — not the redeem/witness script ("scriptCode") used to actually
/// execute the input's scriptSig. Those two coincide for P2PKH (which is all the "golden"
/// production fixture exercises), but differ for P2SH/P2WSH, where using the redeem/witness
/// script here instead produces a sighash real consensus rules reject. `input_amounts` /
/// `input_script_pubkeys` are the spent outputs' values and scriptPubKeys for every input, in
/// input order; `input_script_pubkeys[input_index]` is also the value used for this input's own
/// per-input field.
pub fn compute_v6_transparent_sighash(
tx: &ZcashV6Transaction,
input_index: usize,
script_code: &miniscript::bitcoin::Script,
input_amounts: &[i64],
input_script_pubkeys: &[miniscript::bitcoin::ScriptBuf],
) -> Result<[u8; 32], ZcashV6Error> {
Expand All @@ -542,14 +547,17 @@ pub fn compute_v6_transparent_sighash(
let amount = *input_amounts
.get(input_index)
.ok_or(ZcashV6Error::UnexpectedEof)?;
let script_pubkey = input_script_pubkeys
.get(input_index)
.ok_or(ZcashV6Error::UnexpectedEof)?;

// S.2g: prevout ‖ value(8, signed LE) ‖ scriptCode(length-prefixed) ‖ nSequence(4, LE).
// S.2g: prevout ‖ value(8, signed LE) ‖ scriptPubKey(length-prefixed) ‖ nSequence(4, LE).
let mut txin_data = Vec::new();
txin.previous_output
.consensus_encode(&mut txin_data)
.expect("vec write is infallible");
txin_data.extend_from_slice(&amount.to_le_bytes());
script_code
script_pubkey
.consensus_encode(&mut txin_data)
.expect("vec write is infallible");
txin.sequence
Expand Down Expand Up @@ -1070,37 +1078,25 @@ mod tests {
let tx = sample_tx_with_inputs();
let amounts = [12_345i64];
let scripts = [ScriptBuf::from(vec![0x76u8, 0xa9, 0x14])];
let script_code = ScriptBuf::from(vec![0x76u8, 0xa9, 0x14, 0x88, 0xac]);

let shielded = compute_v6_sig_digest(&tx, &amounts, &scripts);
let transparent =
compute_v6_transparent_sighash(&tx, 0, script_code.as_script(), &amounts, &scripts)
.unwrap();
let transparent = compute_v6_transparent_sighash(&tx, 0, &amounts, &scripts).unwrap();
// The per-input transparent sighash populates the txin component, so it must differ from
// the shielded (empty-txin) digest over the same tx.
assert_ne!(shielded, transparent);
// Deterministic.
assert_eq!(
transparent,
compute_v6_transparent_sighash(&tx, 0, script_code.as_script(), &amounts, &scripts)
.unwrap()
compute_v6_transparent_sighash(&tx, 0, &amounts, &scripts).unwrap()
);
// A different script_code changes the digest.
let other_code = ScriptBuf::from(vec![0x51u8]);
// A different spent scriptPubKey changes the digest.
let other_scripts = [ScriptBuf::from(vec![0x51u8])];
assert_ne!(
transparent,
compute_v6_transparent_sighash(&tx, 0, other_code.as_script(), &amounts, &scripts)
.unwrap()
compute_v6_transparent_sighash(&tx, 0, &amounts, &other_scripts).unwrap()
);
// Out-of-range input index is an error, not a panic.
assert!(compute_v6_transparent_sighash(
&tx,
9,
script_code.as_script(),
&amounts,
&scripts
)
.is_err());
assert!(compute_v6_transparent_sighash(&tx, 9, &amounts, &scripts).is_err());
}

/// Golden oracle for [`compute_v6_transparent_sighash`]: verify the **real** ECDSA signature
Expand Down Expand Up @@ -1152,7 +1148,6 @@ mod tests {
let sighash = compute_v6_transparent_sighash(
&tx,
0,
prevout_script.as_script(),
&[prevout_value],
std::slice::from_ref(&prevout_script),
)
Expand All @@ -1167,6 +1162,98 @@ mod tests {
.expect("the tx's real signature verifies against compute_v6_transparent_sighash");
}

/// Regression test for the bug where `compute_v6_transparent_sighash` hashed the redeem
/// script (scriptCode) into the ZIP-244 §S.2g.iii per-input field instead of the spent

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can you explain a bit on scriptPubKey and redeem script?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

scriptPubKey is something that utxo is locked to, when spending it you need to reveal the redeem script that hashes to the scriptPubKey that the utxo is locked to.

/// output's scriptPubKey. That distinction is invisible for a P2PKH input (its scriptCode
/// *is* its scriptPubKey — see [`golden_transparent_sighash_verifies_real_signature`]
/// above), but for a P2SH input they differ, and hashing the wrong one produces a sighash
/// real consensus rules reject.
///
/// The fixture is a real transaction — spending a 2-of-3 P2SH multisig transparent input —
/// that was built with this codebase's CLI, submitted to a live Zcash testnet (NU6.3)
/// `zebrad` node via `sendrawtransaction`, and **accepted into its mempool**: real
/// consensus-rule validation of the transparent scriptSig, not merely self-consistency
/// against this codebase's own sighash.
#[test]
fn golden_multisig_transparent_sighash_verifies_real_signature() {
use miniscript::bitcoin::script::Instruction;
use miniscript::bitcoin::secp256k1::{ecdsa::Signature, Message, PublicKey, Secp256k1};

let raw = hex::decode(load_zcash_fixture("v6_shield_multisig_rawtx.hex").trim()).unwrap();
let tx = decode_v6_transaction(&raw).unwrap();
assert_eq!(tx.transparent.input.len(), 1);
assert_eq!(tx.transparent.output.len(), 0);

// Spent output (a synthetic 2-of-3 P2SH multisig address funded on Zcash testnet, then
// spent by this tx); ZIP-244 commits to both.
let prevout_value: i64 = 2_000_000;
let prevout_script =
ScriptBuf::from(hex::decode("a914ed68766fe37d9e2325758ed209ac78db505425a987").unwrap());
let redeem_script = ScriptBuf::from(
hex::decode(
"5221023b4221b042fa25af6609d7e65d322fcb64c497b79ffc8f1891ea6b23d4e7d84a\
2102feaf8248a2f8dcc34f2e2f520201801bb88d20ab549baf47b48bc9f2f4dfcc93\
21030b82f01fd53e7dabe2d904938d64294e3352e9e836240af6ba2cfb9df8f837da53ae",
)
.unwrap(),
);
let pubkeys: Vec<Vec<u8>> = redeem_script
.instructions()
.map(|i| i.expect("valid redeem script"))
.filter_map(|i| match i {
Instruction::PushBytes(pb) => Some(pb.as_bytes().to_vec()),
Instruction::Op(_) => None,
})
.collect();
assert_eq!(pubkeys.len(), 3, "2-of-3 redeem script has 3 pubkeys");

// scriptSig = OP_0 <sig1> <sig2> <redeemScript>; the two signatures correspond to
// pubkeys[0] and pubkeys[2] (the redeem script's first and third keys).
let pushes: Vec<Vec<u8>> = tx.transparent.input[0]
.script_sig
.instructions()
.map(|i| i.expect("valid scriptSig"))
.filter_map(|i| match i {
Instruction::PushBytes(pb) if !pb.as_bytes().is_empty() => {
Some(pb.as_bytes().to_vec())
}
_ => None,
})
.collect();
assert_eq!(
pushes.len(),
3,
"OP_0 dummy, 2 sigs, redeem script (dummy excluded above)"
);
let sig_pubkey_pairs = [(&pushes[0], &pubkeys[0]), (&pushes[1], &pubkeys[2])];

let sighash = compute_v6_transparent_sighash(
&tx,
0,
&[prevout_value],
std::slice::from_ref(&prevout_script),
)
.unwrap();

let secp = Secp256k1::verification_only();
let msg = Message::from_digest(sighash);
for (sig_bytes, pubkey_bytes) in sig_pubkey_pairs {
assert_eq!(
*sig_bytes.last().unwrap(),
SIGHASH_ALL,
"signature uses SIGHASH_ALL"
);
let mut sig =
Signature::from_der(&sig_bytes[..sig_bytes.len() - 1]).expect("DER signature");
sig.normalize_s();
let pk = PublicKey::from_slice(pubkey_bytes).expect("valid pubkey");
secp.verify_ecdsa(&msg, &sig, &pk).expect(
"the real mempool-accepted multisig tx's signature verifies against \
Comment thread
Ranjna-G marked this conversation as resolved.
compute_v6_transparent_sighash",
);
}
}

#[test]
fn digest_is_deterministic() {
let b = sample_bundle();
Expand Down
75 changes: 75 additions & 0 deletions packages/wasm-utxo/test/fixedScript/zcashV6Transaction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import * as assert from "assert";
import * as fs from "fs";
import * as path from "path";
import { fileURLToPath } from "url";
import { ecc, script } from "@bitgo/utxo-lib";
import { ZcashV6Transaction } from "../../js/fixedScriptWallet/ZcashV6Transaction.js";

const __dirname = path.dirname(fileURLToPath(import.meta.url));
Expand Down Expand Up @@ -45,4 +46,78 @@ describe("ZcashV6Transaction", function () {
it("throws on non-v6 bytes", function () {
assert.throws(() => ZcashV6Transaction.fromBytes(Buffer.from("00010203", "hex")));
});

/**
* Golden regression test: `transparentSighash` must hash the spent output's scriptPubKey
* into the ZIP-244 per-input digest, not the redeem/witness script ("scriptCode"). Those
* coincide for a P2PKH input (see the Rust-side `golden_transparent_sighash_verifies_real_signature`
* test), which is why a prior bug that hashed the redeem script there went uncaught until it
* was exercised against a real P2SH multisig input.
*
* The fixture is a real transaction — spending a 2-of-3 P2SH multisig transparent input into
* an Ironwood shielded output — that was built with this codebase's CLI, submitted to a live
* Zcash testnet (NU6.3) `zebrad` node via `sendrawtransaction`, and accepted into its
* mempool: real consensus-rule validation of the transparent scriptSig, not merely
* self-consistency against this codebase's own sighash.
*/
it("verifies a real mempool-accepted multisig tx's signatures against transparentSighash", function () {
const tx = ZcashV6Transaction.fromBytes(
Buffer.from(readFixture("v6_shield_multisig_rawtx.hex"), "hex"),
);

// Spent output (a synthetic 2-of-3 P2SH multisig address funded on Zcash testnet, then
// spent by this tx); ZIP-244 commits to both.
const prevoutValue = 2_000_000n;
const prevoutScript = Buffer.from("a914ed68766fe37d9e2325758ed209ac78db505425a987", "hex");
const redeemScript = Buffer.from(
"5221023b4221b042fa25af6609d7e65d322fcb64c497b79ffc8f1891ea6b23d4e7d84a" +
"2102feaf8248a2f8dcc34f2e2f520201801bb88d20ab549baf47b48bc9f2f4dfcc93" +
"21030b82f01fd53e7dabe2d904938d64294e3352e9e836240af6ba2cfb9df8f837da53ae",
"hex",
);
// scriptSig = OP_0 <sig1> <sig2> <redeemScript>, as decoded from the fixture's raw bytes.
const scriptSig = Buffer.from(
"0047304402204da2cef266325268af039a5ad4992babbb7d6813ac98f4351741c89e0186e41" +
"10220441b74b62b346ed9a3e1e5b07c850d2c7acfce22b7140f2a9d02bddf221f29c50148" +
"3045022100ee66c425f9fae3e32ae866534db4cae9b8daf5fb570a3638bf426cab1893e2a" +
"8022033af0f5ccaf869703e5af54920a771a392c722588ff5660c342d5c06df726e4c014c" +
"695221023b4221b042fa25af6609d7e65d322fcb64c497b79ffc8f1891ea6b23d4e7d84a2" +
"102feaf8248a2f8dcc34f2e2f520201801bb88d20ab549baf47b48bc9f2f4dfcc9321030b" +
"82f01fd53e7dabe2d904938d64294e3352e9e836240af6ba2cfb9df8f837da53ae",
"hex",
);

const pubkeys = (script.decompile(redeemScript) ?? []).filter((el): el is Buffer =>
Buffer.isBuffer(el),
);
assert.strictEqual(pubkeys.length, 3, "2-of-3 redeem script has 3 pubkeys");

// The signatures correspond to pubkeys[0] and pubkeys[2] (the redeem script's first and
// third keys).
const scriptSigChunks = (script.decompile(scriptSig) ?? []).filter(
(el): el is Buffer => Buffer.isBuffer(el) && el.length > 0,
);
assert.strictEqual(
scriptSigChunks.length,
3,
"OP_0 dummy (excluded above), 2 sigs, redeem script",
);
const sigPubkeyPairs: [Buffer, Buffer][] = [
[scriptSigChunks[0], pubkeys[0]],
[scriptSigChunks[1], pubkeys[2]],
];

const sighash = tx.transparentSighash(0, [prevoutValue], [prevoutScript]);
assert.strictEqual(sighash.length, 32);

for (const [derSigWithHashType, pubkey] of sigPubkeyPairs) {
const { signature, hashType } = script.signature.decode(derSigWithHashType);
assert.strictEqual(hashType, 0x01, "signature uses SIGHASH_ALL");
assert.strictEqual(
ecc.verify(sighash, pubkey, signature),
true,
"the real mempool-accepted multisig tx's signature verifies against transparentSighash",
);
}
});
});
Loading
Loading