From 02778b53049c5a9bae0e18b5f9eb06ffa8915891 Mon Sep 17 00:00:00 2001 From: Veetrag Jain Date: Wed, 14 Oct 2026 21:17:37 +0530 Subject: [PATCH] feat(wasm-utxo): add ZIP-317 fee estimation for zec Ticket: CSHLD-1959 --- .../js/fixedScriptWallet/Zip317Dimensions.ts | 200 ++++++++++ packages/wasm-utxo/js/index.ts | 5 + packages/wasm-utxo/src/error.rs | 8 + .../fixed_script_wallet/bitgo_psbt/propkv.rs | 27 ++ .../bitgo_psbt/zcash_psbt.rs | 367 ++++++++++++++++++ .../src/wasm/fixed_script_wallet/mod.rs | 1 + .../fixed_script_wallet/zip317_dimensions.rs | 274 +++++++++++++ packages/wasm-utxo/src/zcash/mod.rs | 1 + packages/wasm-utxo/src/zcash/v6.rs | 13 + packages/wasm-utxo/src/zcash/zip317.rs | 263 +++++++++++++ .../test/fixedScript/zcashIronwoodPsbt.ts | 29 ++ packages/wasm-utxo/test/zip317Dimensions.ts | 221 +++++++++++ 12 files changed, 1409 insertions(+) create mode 100644 packages/wasm-utxo/js/fixedScriptWallet/Zip317Dimensions.ts create mode 100644 packages/wasm-utxo/src/wasm/fixed_script_wallet/zip317_dimensions.rs create mode 100644 packages/wasm-utxo/src/zcash/zip317.rs create mode 100644 packages/wasm-utxo/test/zip317Dimensions.ts diff --git a/packages/wasm-utxo/js/fixedScriptWallet/Zip317Dimensions.ts b/packages/wasm-utxo/js/fixedScriptWallet/Zip317Dimensions.ts new file mode 100644 index 00000000000..b1ec5ed5a01 --- /dev/null +++ b/packages/wasm-utxo/js/fixedScriptWallet/Zip317Dimensions.ts @@ -0,0 +1,200 @@ +import { WasmZip317Dimensions } from "../wasm/wasm_utxo.js"; +import type { BitGoPsbt } from "./BitGoPsbt.js"; +import type { CoinName } from "../coinName.js"; +import { + hasOrchardReceiver, + toShieldedReceiverWithCoin, + toTransparentReceiverWithCoin, +} from "../zcashAddress.js"; + +/** The transparent input script types a Zcash wallet can spend. */ +export const Zip317InputScriptType = ["p2sh", "p2shP2pk"] as const; +export type Zip317InputScriptType = (typeof Zip317InputScriptType)[number]; + +/** + * Options for {@link Zip317Dimensions.fromOutput}. + */ +export type Zip317FromOutputOptions = { + /** + * Set when `address` may be a ZIP-316 Unified Address whose Orchard/Ironwood receiver should be + * paid as a shielded output (one Ironwood output). Resolved like `ZcashDimensions.fromOutput` + * (via `zcashAddress.toShieldedReceiverWithCoin`): a Unified Address without such a receiver, + * or a malformed one, throws; an ordinary transparent address is sized as a transparent + * output. When unset, the address's transparent receiver is sized as a transparent output. + */ + isShielded?: boolean; +}; + +/** + * ZIP-317 dimensions for Zcash conventional fee estimation. + * + * Tracks what ZIP-317 prices — the transparent inputs' and outputs' serialized sizes and the + * Ironwood spends/outputs — rather than bitcoin weight. This is independent of `Dimensions` / + * `ZcashDimensions`: callers pick the estimator they want (`vsize × feeRate` from those, or + * {@link Zip317Dimensions.getConventionalFee} from this). + * + * Ironwood spends and outputs are tracked separately because bundles are unpadded: the action + * count is `max(spends, outputs)`, not their sum. + * + * This is a thin wrapper over the WASM implementation. + */ +export class Zip317Dimensions { + private constructor(private _wasm: WasmZip317Dimensions) {} + + /** + * Create empty dimensions + */ + static empty(): Zip317Dimensions { + return new Zip317Dimensions(WasmZip317Dimensions.empty()); + } + + /** + * Create dimensions from a Zcash BitGoPsbt (any version) + * + * Transparent inputs are sized with the scriptSig they will finalize to (the real one once + * finalized), transparent outputs exactly, and the Ironwood side from the real (non-dummy) + * spends and outputs of the stored PCZT. After `combineProof` has dropped the PCZT, the counts + * recorded at extraction are used, so a finalized PSBT still reports its actions and fee. + * + * @throws WasmUtxoError `Zip317Error.NotZcashPsbt` for a non-Zcash PSBT, or + * `Zip317Error.IronwoodPczt` if the stored PCZT cannot be read + */ + static fromPsbt(psbt: BitGoPsbt): Zip317Dimensions { + return new Zip317Dimensions(WasmZip317Dimensions.from_psbt(psbt.wasm)); + } + + /** + * Create dimensions for a single transparent input + * + * @param params - Either `{ chain }` (Zcash wallets only have the P2SH chains 0 and 1) or + * `{ scriptType }` + */ + static fromInput( + params: { chain: number } | { scriptType: Zip317InputScriptType }, + ): Zip317Dimensions { + if ("scriptType" in params) { + return new Zip317Dimensions(WasmZip317Dimensions.from_input_script_type(params.scriptType)); + } + return new Zip317Dimensions(WasmZip317Dimensions.from_input(params.chain)); + } + + /** + * Create dimensions for a single transparent output from script bytes + */ + static fromOutput(script: Uint8Array): Zip317Dimensions; + /** + * Create dimensions for a single output from a Zcash address for `network`. + * + * With `{ isShielded: true }`, a Unified Address is one Ironwood output (it must carry an + * Orchard/Ironwood receiver, else this throws) and a transparent address is a transparent + * output. Otherwise the address's transparent receiver is sized. + */ + static fromOutput( + address: string, + network: CoinName, + options?: Zip317FromOutputOptions, + ): Zip317Dimensions; + /** + * Create dimensions for a single transparent output from script length only + */ + static fromOutput(params: { length: number }): Zip317Dimensions; + static fromOutput( + params: Uint8Array | string | { length: number }, + network?: CoinName, + options?: Zip317FromOutputOptions, + ): Zip317Dimensions { + if (typeof params === "string") { + if (network === undefined) { + throw new Error("network is required when passing an address string"); + } + if (options?.isShielded && hasOrchardReceiver(params, network)) { + return Zip317Dimensions.fromIronwoodOutputs(1); + } + // For `isShielded`, this throws for a Unified Address without an Orchard/Ironwood receiver + // (or a malformed one) and returns a transparent address's scriptPubKey. + const script = options?.isShielded + ? toShieldedReceiverWithCoin(params, network) + : toTransparentReceiverWithCoin(params, network); + return new Zip317Dimensions(WasmZip317Dimensions.from_output_script_length(script.length)); + } + // Both Uint8Array and { length: number } have .length + return new Zip317Dimensions(WasmZip317Dimensions.from_output_script_length(params.length)); + } + + /** + * Create dimensions for `count` Ironwood spends (shielded notes being spent) + */ + static fromIronwoodSpends(count: number): Zip317Dimensions { + return new Zip317Dimensions(WasmZip317Dimensions.from_ironwood_spends(count)); + } + + /** + * Create dimensions for `count` Ironwood (shielded) outputs + */ + static fromIronwoodOutputs(count: number): Zip317Dimensions { + return new Zip317Dimensions(WasmZip317Dimensions.from_ironwood_outputs(count)); + } + + /** + * Combine with another Zip317Dimensions instance + */ + plus(other: Zip317Dimensions): Zip317Dimensions { + return new Zip317Dimensions(this._wasm.plus(other._wasm)); + } + + /** + * Multiply dimensions by a scalar + */ + times(n: number): Zip317Dimensions { + return new Zip317Dimensions(this._wasm.times(n)); + } + + /** + * The sum of the transparent inputs' estimated final serialized sizes, in bytes + */ + get txInTotalSize(): number { + return this._wasm.get_tx_in_total_size(); + } + + /** + * The sum of the transparent outputs' serialized sizes, in bytes + */ + get txOutTotalSize(): number { + return this._wasm.get_tx_out_total_size(); + } + + /** + * The number of Ironwood spends + */ + get ironwoodSpends(): number { + return this._wasm.get_ironwood_spends(); + } + + /** + * The number of Ironwood outputs + */ + get ironwoodOutputs(): number { + return this._wasm.get_ironwood_outputs(); + } + + /** + * The number of Ironwood actions: `max(spends, outputs)` + */ + get ironwoodActions(): number { + return this._wasm.get_ironwood_actions(); + } + + /** + * The ZIP-317 `logical_actions` + */ + getLogicalActions(): number { + return this._wasm.get_logical_actions(); + } + + /** + * The ZIP-317 conventional fee in zatoshi: `5_000 × max(2, logical_actions)` + */ + getConventionalFee(): bigint { + return this._wasm.get_conventional_fee(); + } +} diff --git a/packages/wasm-utxo/js/index.ts b/packages/wasm-utxo/js/index.ts index e7dcfdab916..23cd94fc2ea 100644 --- a/packages/wasm-utxo/js/index.ts +++ b/packages/wasm-utxo/js/index.ts @@ -23,6 +23,11 @@ export { ECPair } from "./ecpair.js"; export { BIP32 } from "./bip32.js"; export { Dimensions } from "./fixedScriptWallet/Dimensions.js"; export { ZcashDimensions } from "./fixedScriptWallet/ZcashDimensions.js"; +export { + Zip317Dimensions, + Zip317InputScriptType, + type Zip317FromOutputOptions, +} from "./fixedScriptWallet/Zip317Dimensions.js"; export { ZcashBitGoPsbt, ZcashPsbt, diff --git a/packages/wasm-utxo/src/error.rs b/packages/wasm-utxo/src/error.rs index da476d1b21b..acb297144b6 100644 --- a/packages/wasm-utxo/src/error.rs +++ b/packages/wasm-utxo/src/error.rs @@ -27,6 +27,7 @@ pub enum WasmUtxoError { UnifiedAddress(crate::zcash::unified_address::UnifiedAddressError), ZcashV6(crate::zcash::v6::ZcashV6Error), Ironwood(crate::zcash::ironwood_build::IronwoodBuildError), + Zip317(crate::zcash::zip317::Zip317Error), VerifyV6Signature(VerifyV6SignatureError), } @@ -40,6 +41,7 @@ impl fmt::Display for WasmUtxoError { WasmUtxoError::UnifiedAddress(e) => write!(f, "{}", e), WasmUtxoError::ZcashV6(e) => write!(f, "{}", e), WasmUtxoError::Ironwood(e) => write!(f, "{}", e), + WasmUtxoError::Zip317(e) => write!(f, "{}", e), WasmUtxoError::VerifyV6Signature(e) => write!(f, "{}", e), } } @@ -53,6 +55,7 @@ impl WasmErrorCode for WasmUtxoError { WasmUtxoError::UnifiedAddress(e) => e.code(), WasmUtxoError::ZcashV6(e) => e.code(), WasmUtxoError::Ironwood(e) => e.code(), + WasmUtxoError::Zip317(e) => e.code(), WasmUtxoError::VerifyV6Signature(e) => e.code(), } } @@ -111,6 +114,11 @@ impl From for WasmUtxoError { WasmUtxoError::Ironwood(err) } } +impl From for WasmUtxoError { + fn from(err: crate::zcash::zip317::Zip317Error) -> Self { + WasmUtxoError::Zip317(err) + } +} impl From for WasmUtxoError { fn from(err: VerifyV6SignatureError) -> Self { WasmUtxoError::VerifyV6Signature(err) diff --git a/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/propkv.rs b/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/propkv.rs index a9b7676e59c..bd0df661b96 100644 --- a/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/propkv.rs +++ b/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/propkv.rs @@ -286,6 +286,10 @@ pub enum ZecV6KeySubtype { /// from the scriptPubKey) — kept so output parsing can hand back the exact UA the caller /// passed, not one reconstructed as a bare transparent address. TransparentUnifiedAddress = 0x06, + /// The real (non-dummy) Ironwood spend and output counts of the PCZT dropped by + /// [`take_ironwood_pczt`], as two little-endian `u32`s. Written alongside `IronwoodExtracted` + /// so ZIP-317 fee estimation still sees the bundle's actions once the PCZT itself is gone. + IronwoodExtractedCounts = 0x07, } fn set_zec_v6( @@ -386,6 +390,29 @@ pub fn is_ironwood_extracted(psbt: &miniscript::bitcoin::psbt::Psbt) -> bool { get_zec_v6(psbt, ZecV6KeySubtype::IronwoodExtracted).is_some() } +/// Record the real Ironwood spend/output counts of a PCZT about to be dropped by +/// [`take_ironwood_pczt`] (see [`ZecV6KeySubtype::IronwoodExtractedCounts`]). +pub fn set_ironwood_extracted_counts( + psbt: &mut miniscript::bitcoin::psbt::Psbt, + spends: u32, + outputs: u32, +) { + let mut value = spends.to_le_bytes().to_vec(); + value.extend_from_slice(&outputs.to_le_bytes()); + set_zec_v6(psbt, ZecV6KeySubtype::IronwoodExtractedCounts, value); +} + +/// The `(spends, outputs)` recorded by [`set_ironwood_extracted_counts`], or `None` if absent or +/// malformed. +pub fn get_ironwood_extracted_counts(psbt: &miniscript::bitcoin::psbt::Psbt) -> Option<(u32, u32)> { + let value = get_zec_v6(psbt, ZecV6KeySubtype::IronwoodExtractedCounts)?; + let (spends, outputs) = value.as_slice().split_at_checked(4)?; + Some(( + u32::from_le_bytes(spends.try_into().ok()?), + u32::from_le_bytes(outputs.try_into().ok()?), + )) +} + /// Store the Zcash v6 (Ironwood) header params — `version_group_id` and `expiry_height` — under the /// `BITGO_ZEC_V6` namespace. `version_group_id`'s presence marks the PSBT as v6; `expiry_height` is /// always written too (even when 0, a valid "no expiry" value, so it can be told apart from absent). diff --git a/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/zcash_psbt.rs b/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/zcash_psbt.rs index 528647fdf01..eb2a73b1c54 100644 --- a/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/zcash_psbt.rs +++ b/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/zcash_psbt.rs @@ -1167,6 +1167,40 @@ impl ZcashBitGoPsbt { super::propkv::get_ironwood_pczt(&self.psbt) } + /// The number of real (non-dummy) Ironwood spends and outputs in the stored PCZT; `(0, 0)` + /// when no shielded spend or output was ever added. + /// + /// The Constructor builds `BundleType::UNPADDED` bundles, so the action count is + /// `max(spends, outputs)`. Once the PCZT has been extracted (see + /// [`Self::mark_ironwood_extracted`]) the counts recorded at extraction are returned, so a + /// finalized PSBT still reports its actions. + pub fn ironwood_real_spend_and_output_counts(&self) -> Result<(usize, usize), String> { + if !super::propkv::has_ironwood_pczt(&self.psbt) + && super::propkv::is_ironwood_extracted(&self.psbt) + { + let (spends, outputs) = super::propkv::get_ironwood_extracted_counts(&self.psbt) + .ok_or_else(|| { + "this PSBT's Ironwood PCZT was extracted without recording its action counts" + .to_string() + })?; + return Ok((spends as usize, outputs as usize)); + } + if self.require_no_shielded_output_ever_added()? { + return Ok((0, 0)); + } + let pczt = self.ironwood_pczt()?; + Self::pczt_real_spend_and_output_counts(&pczt) + } + + fn pczt_real_spend_and_output_counts( + pczt: &orchard::pczt::Bundle, + ) -> Result<(usize, usize), String> { + Ok(( + Self::bundle_real_spends(pczt)?.len(), + Self::bundle_real_outputs(pczt)?.len(), + )) + } + /// Client-managed `ovk`, raw-key form: re-encrypt the Ironwood output's `out_ciphertext` under /// an `ovk` derived as the ECDH agreement of `bitgo_pubkey` and `user_privkey`. The `ovk` never /// leaves this call — it is not returned and not persisted anywhere. @@ -1799,6 +1833,53 @@ impl ZcashBitGoPsbt { Ok(()) } + /// The ZIP-317 transparent terms `(tx_in_total_size, tx_out_total_size)`: the estimated + /// **final** serialized sizes of the transparent inputs (each carrying the scriptSig the + /// PSBT's scripts will produce, see [`estimated_script_sig_len`]) and the exact serialized + /// sizes of the transparent outputs. + pub fn zip317_transparent_sizes(&self) -> (u64, u64) { + let tx_in_total_size = self + .psbt + .inputs + .iter() + .map(|input| { + crate::zcash::zip317::transparent_input_size(estimated_script_sig_len(input)) + }) + .sum(); + let tx_out_total_size = self + .psbt + .unsigned_tx + .output + .iter() + .map(|output| crate::zcash::zip317::transparent_output_size(output.script_pubkey.len())) + .sum(); + (tx_in_total_size, tx_out_total_size) + } + + /// The ZIP-317 logical-action counts for this PSBT — see [`crate::zcash::zip317`]. + /// + /// The transparent terms come from [`Self::zip317_transparent_sizes`]. The shielded term is + /// the Ironwood action count, `max(real spends, real outputs)` of the stored PCZT (zero when + /// none is stored); v6 transactions have no Sapling or JoinSplit components. + pub fn zip317_action_counts(&self) -> Result { + let (tx_in_total_size, tx_out_total_size) = self.zip317_transparent_sizes(); + let (spends, outputs) = self.ironwood_real_spend_and_output_counts()?; + Ok(crate::zcash::zip317::Zip317ActionCounts { + tx_in_total_size, + tx_out_total_size, + n_actions_ironwood: spends.max(outputs) as u64, + ..Default::default() + }) + } + + /// The ZIP-317 conventional fee (zatoshi) for this PSBT's shape. Opt-in: nothing in the + /// transparent-only fee path calls it. + pub fn zip317_conventional_fee(&self) -> Result { + Ok(crate::zcash::zip317::conventional_fee( + &self.zip317_action_counts()?, + )) + } + /// The spent-output value (zatoshi, as i64) and scriptPubKey of every transparent input, in /// input order — the amounts/scripts ZIP-244 commits to. fn transparent_input_amounts_and_scripts( @@ -2313,8 +2394,21 @@ impl ZcashBitGoPsbt { /// instead of silently re-running against already-extracted state — and because the PCZT is /// gone from the bytes too, that holds across a `serialize`/`deserialize` round-trip. /// + /// The PCZT's real spend/output counts are recorded first, so ZIP-317 fee estimation + /// ([`Self::zip317_action_counts`]) keeps working on the extracted PSBT. + /// /// Returns whether a PCZT was present. pub fn mark_ironwood_extracted(&mut self) -> bool { + if let Ok((spends, outputs)) = self + .ironwood_pczt() + .and_then(|pczt| Self::pczt_real_spend_and_output_counts(&pczt)) + { + super::propkv::set_ironwood_extracted_counts( + &mut self.psbt, + spends as u32, + outputs as u32, + ); + } super::propkv::take_ironwood_pczt(&mut self.psbt) } @@ -5639,6 +5733,105 @@ mod ironwood_v6_tests { assert!(err.contains("does not conserve value"), "unexpected: {err}"); } + #[test] + fn zip317_counts_and_fee() { + let (z, _keys) = build_unshield_psbt("ironwood_v6_zip317"); + + // The fixture has NO transparent input (spend + transparent output only): the input + // term is 0. The single transparent output (5-byte script): 8 + 1 + 5 = 14. One + // Ironwood action from the stored PCZT. + let counts = z.zip317_action_counts().unwrap(); + assert_eq!(counts.tx_in_total_size, 0); + assert_eq!(counts.tx_out_total_size, 14); + assert_eq!(counts.n_actions_ironwood, 1); + assert_eq!(z.ironwood_real_spend_and_output_counts().unwrap(), (1, 0)); + // logical_actions = max(0, ceil(14/34) = 1) + 1 = 2 ⇒ 10_000 zatoshi. + assert_eq!(z.zip317_conventional_fee().unwrap(), 10_000); + + // A PSBT with no stored PCZT has no Ironwood term. + let mut fresh = { + let psbt = BitGoPsbt::new_zcash_v6_at_height( + Network::ZcashTestnet, + &root_wallet_keys("ironwood_v6_zip317_fresh"), + NetworkUpgrade::Nu6_3.testnet_activation_height(), + None, + None, + ) + .unwrap(); + match psbt { + BitGoPsbt::Zcash(z, _) => z, + _ => panic!("expected Zcash PSBT"), + } + }; + fresh + .add_transparent_output( + miniscript::bitcoin::ScriptBuf::from(vec![0x76u8, 0xa9, 0x14, 0x88, 0xac]), + 99_999_000, + None, + ) + .unwrap(); + let fresh_counts = fresh.zip317_action_counts().unwrap(); + assert_eq!(fresh_counts.n_actions_ironwood, 0); + assert_eq!( + fresh.ironwood_real_spend_and_output_counts().unwrap(), + (0, 0) + ); + } + + /// Extraction drops the PCZT, but the ZIP-317 counts (and fee) must survive it: they are + /// recorded by `mark_ironwood_extracted`. + #[test] + fn zip317_counts_survive_extraction() { + let mut shield = build_shield_psbt_multi("zip317_extract_shield", &[test_recipient(); 2]); + let (mut unshield, _keys) = build_unshield_psbt("zip317_extract_unshield"); + for z in [&mut shield, &mut unshield] { + let counts = z.zip317_action_counts().unwrap(); + let spends_and_outputs = z.ironwood_real_spend_and_output_counts().unwrap(); + assert!(z.mark_ironwood_extracted()); + assert!(z.raw_ironwood_pczt_bytes().is_none()); + assert_eq!( + z.ironwood_real_spend_and_output_counts().unwrap(), + spends_and_outputs + ); + assert_eq!(z.zip317_action_counts().unwrap(), counts); + assert_eq!( + z.zip317_conventional_fee().unwrap(), + crate::zcash::zip317::conventional_fee(&counts) + ); + } + assert_eq!( + shield.ironwood_real_spend_and_output_counts().unwrap(), + (0, 2) + ); + assert_eq!( + unshield.ironwood_real_spend_and_output_counts().unwrap(), + (1, 0) + ); + + // A PCZT dropped without recording counts errors instead of reporting no actions. + let mut legacy = build_shield_psbt("zip317_extract_legacy"); + assert!(super::super::propkv::take_ironwood_pczt(&mut legacy.psbt)); + let err = legacy.zip317_action_counts().unwrap_err(); + assert!(err.contains("without recording"), "unexpected: {err}"); + } + + /// `max(real spends, real outputs)` is the stored bundle's action count: the Constructor + /// builds UNPADDED bundles, pairing each real side with a dummy where the other is missing. + #[test] + fn zip317_ironwood_term_matches_the_stored_action_count() { + let shield = build_shield_psbt_multi("zip317_shield_multi", &[test_recipient(); 3]); + let (unshield, _keys) = build_unshield_psbt("zip317_unshield"); + for (z, expected) in [(shield, (0, 3)), (unshield, (1, 0))] { + let (spends, outputs) = z.ironwood_real_spend_and_output_counts().unwrap(); + assert_eq!((spends, outputs), expected); + let pczt = crate::zcash::ironwood_pczt::deserialize_pczt( + &z.raw_ironwood_pczt_bytes().unwrap(), + ) + .unwrap(); + assert_eq!(spends.max(outputs), pczt.actions().len()); + } + } + #[test] fn ironwood_bundle_info_rejects_a_tampered_plaintext_value() { // The spend's plaintext value is not bound by the proofs (only `cv_net`/`cm` are), so a @@ -6103,4 +6296,178 @@ mod ironwood_v6_tests { } assert_eq!(z.v6_sig_digest().unwrap(), sighash, "digest unchanged"); } + + /// Existing wallets: a transparent-only v6 PSBT (no Ironwood bundle) must keep the + /// bitcoin-weight virtual size and must not see the shielded fee path. + #[test] + fn transparent_only_v6_keeps_the_bitcoin_weight_virtual_size() { + let wallet_keys = RootWalletKeys::new(get_test_wallet_keys("v6_transparent_vsize")); + let mut psbt = BitGoPsbt::new_zcash_v6_at_height( + Network::ZcashTestnet, + &wallet_keys, + NetworkUpgrade::Nu6_3.testnet_activation_height(), + None, + None, + ) + .unwrap(); + psbt.add_wallet_input( + Txid::from_byte_array([0x45u8; 32]), + 0, + 200_000_000, + &wallet_keys, + ScriptId { chain: 0, index: 0 }, + WalletInputOptions::default(), + ) + .unwrap(); + psbt.add_wallet_output(0, 1, 199_900_000, &wallet_keys) + .unwrap(); + let weight_vsize = match &psbt { + BitGoPsbt::Zcash(z, _) => { + assert!(z.is_ironwood_v6()); + assert!(z.raw_ironwood_pczt_bytes().is_none()); + z.psbt.unsigned_tx.weight().to_vbytes_ceil() + } + _ => panic!("expected Zcash PSBT"), + }; + let parsed = psbt + .parse_transaction_with_wallet_keys( + &wallet_keys, + &crate::fixed_script_wallet::ReplayProtection::new(vec![]), + &[], + ) + .unwrap(); + assert_eq!(parsed.virtual_size as u64, weight_vsize); + } + + /// The shielding flow (transparent inputs → Ironwood output) carries a PCZT, but its actions + /// pair dummy spends with the real outputs: it must keep the bitcoin-weight virtual size the + /// indexer's fee selection and fee-rate checks already use. + #[test] + fn shielding_v6_keeps_the_bitcoin_weight_virtual_size() { + let seed = "v6_shield_vsize"; + let z = build_shield_psbt(seed); + assert!(z.raw_ironwood_pczt_bytes().is_some()); + let weight_vsize = z.psbt.unsigned_tx.weight().to_vbytes_ceil(); + + let parsed = BitGoPsbt::Zcash(z, Network::ZcashTestnet) + .parse_transaction_with_wallet_keys( + &RootWalletKeys::new(get_test_wallet_keys(seed)), + &crate::fixed_script_wallet::ReplayProtection::new(vec![]), + &[], + ) + .unwrap(); + assert_eq!(parsed.virtual_size as u64, weight_vsize); + } + + /// The ZIP-317 input-size estimate for a P2SH 2-of-3 wallet input must cover the real + /// finalized scriptSig (`OP_0 `), never undercount it. + #[test] + fn estimated_script_sig_len_covers_a_finalized_multisig_input() { + let seed = "v6_script_sig_estimate"; + let wallet_keys = RootWalletKeys::new(get_test_wallet_keys(seed)); + let mut psbt = BitGoPsbt::new_zcash_v6_at_height( + Network::ZcashTestnet, + &wallet_keys, + NetworkUpgrade::Nu6_3.testnet_activation_height(), + None, + None, + ) + .unwrap(); + psbt.add_wallet_input( + Txid::from_byte_array([0x46u8; 32]), + 0, + 200_000_000, + &wallet_keys, + ScriptId { chain: 0, index: 0 }, + WalletInputOptions::default(), + ) + .unwrap(); + let BitGoPsbt::Zcash(mut z, _) = psbt else { + panic!("expected Zcash PSBT"); + }; + let estimate = estimated_script_sig_len(&z.psbt.inputs[0]); + + let secp = Secp256k1::new(); + let msg = Message::from_digest(z.v6_transparent_sighash(0).unwrap()); + for sk in signing_secret_keys(seed, 0, 0).iter().take(2) { + let pk = crate::bitcoin::secp256k1::PublicKey::from_secret_key(&secp, sk); + let pubkey = PublicKey::from(CompressedPublicKey(pk)); + let mut der = secp.sign_ecdsa(&msg, sk).serialize_der().to_vec(); + der.push(0x01); + z.add_v6_transparent_signature(0, pubkey, &der).unwrap(); + } + let counts = z.zip317_action_counts().unwrap(); + let tx = z.finalized_transparent_tx().unwrap(); + let actual = tx.input[0].script_sig.len(); + assert!( + estimate >= actual && estimate - actual <= 4, + "estimate {estimate} must cover and tightly bound the real scriptSig {actual}" + ); + + // The ZIP-317 input term is the whole serialized input — outpoint (36), scriptSig with + // its CompactSize, sequence (4) — and must cover the real finalized input too. + let actual_input = crate::bitcoin::consensus::serialize(&tx.input[0]).len() as u64; + assert!( + counts.tx_in_total_size >= actual_input && counts.tx_in_total_size - actual_input <= 4, + "ZIP-317 input size {} must cover and tightly bound the real input {actual_input}", + counts.tx_in_total_size + ); + } + + /// A finalized input is sized from its real scriptSig, even though the finalizer cleared + /// its redeem script (which would otherwise fall through to the P2PKH estimate). + #[test] + fn estimated_script_sig_len_uses_a_finalized_script_sig() { + let script_sig = miniscript::bitcoin::ScriptBuf::from(vec![0u8; 253]); + let input = crate::bitcoin::psbt::Input { + final_script_sig: Some(script_sig.clone()), + ..Default::default() + }; + assert_eq!(estimated_script_sig_len(&input), 253); + } + + /// A P2SH-P2PK replay-protection input's scriptSig is ` `, not the 2-of-3 + /// multisig form. + #[test] + fn estimated_script_sig_len_sizes_a_replay_protection_input() { + let secp = Secp256k1::new(); + let sk = SecretKey::from_slice(&[0x11u8; 32]).unwrap(); + let pubkey = PublicKey::from(CompressedPublicKey( + crate::bitcoin::secp256k1::PublicKey::from_secret_key(&secp, &sk), + )); + let redeem = miniscript::bitcoin::script::Builder::new() + .push_key(&pubkey) + .push_opcode(miniscript::bitcoin::opcodes::all::OP_CHECKSIG) + .into_script(); + let input = crate::bitcoin::psbt::Input { + redeem_script: Some(redeem.clone()), + ..Default::default() + }; + // push(73-byte max sig) + push(35-byte redeem script) + assert_eq!(estimated_script_sig_len(&input), 74 + 1 + redeem.len()); + } +} + +/// The estimated length of the FINAL scriptSig a transparent input will carry, for ZIP-317's +/// size-based input accounting — exact once the input is finalized. +/// +/// BitGo wallet inputs are P2SH 2-of-3 multisig: the scriptSig is +/// `OP_0 `, so it is sized from the actual redeem script. A P2SH-P2PK +/// replay-protection input (redeem script ` OP_CHECKSIG`) carries ` ` +/// instead. See [`crate::zcash::zip317`] for the signature-size convention. +fn estimated_script_sig_len(input: &crate::bitcoin::psbt::Input) -> usize { + use crate::zcash::zip317; + // A finalized input carries its real scriptSig (and the finalizer clears `redeem_script`). + if let Some(script_sig) = &input.final_script_sig { + return script_sig.len(); + } + match &input.redeem_script { + Some(redeem) + if super::BitGoPsbt::extract_pubkey_from_p2pk_redeem_script(redeem).is_ok() => + { + zip317::p2sh_p2pk_script_sig_len(redeem.len()) + } + Some(redeem) => zip317::p2sh_multisig_script_sig_len(redeem.len()), + None => zip317::p2pkh_script_sig_len(), + } } diff --git a/packages/wasm-utxo/src/wasm/fixed_script_wallet/mod.rs b/packages/wasm-utxo/src/wasm/fixed_script_wallet/mod.rs index 8ba82f127fb..2676b1fc3fe 100644 --- a/packages/wasm-utxo/src/wasm/fixed_script_wallet/mod.rs +++ b/packages/wasm-utxo/src/wasm/fixed_script_wallet/mod.rs @@ -1,4 +1,5 @@ mod dimensions; +mod zip317_dimensions; pub use dimensions::WasmDimensions; diff --git a/packages/wasm-utxo/src/wasm/fixed_script_wallet/zip317_dimensions.rs b/packages/wasm-utxo/src/wasm/fixed_script_wallet/zip317_dimensions.rs new file mode 100644 index 00000000000..46513c3f1bc --- /dev/null +++ b/packages/wasm-utxo/src/wasm/fixed_script_wallet/zip317_dimensions.rs @@ -0,0 +1,274 @@ +//! ZIP-317 dimensions for Zcash conventional fee estimation. +//! +//! The ZIP-317 counterpart of [`super::dimensions::WasmDimensions`]: it tracks the quantities +//! ZIP-317 prices — the transparent inputs' and outputs' serialized sizes and the Ironwood +//! spends/outputs — instead of bitcoin weight. It is opt-in and independent of `WasmDimensions`: +//! callers choose which estimator to use. + +use crate::error::WasmUtxoError; +use crate::fixed_script_wallet::bitgo_psbt::BitGoPsbt as InnerBitGoPsbt; +use crate::fixed_script_wallet::wallet_scripts::OutputScriptType; +use crate::fixed_script_wallet::Chain; +use crate::zcash::zip317::{self, Zip317ActionCounts, Zip317Error}; +use wasm_bindgen::prelude::*; + +use super::BitGoPsbt; + +/// ZIP-317 dimensions: composable with [`Self::plus`] / [`Self::times`], priced with +/// [`Self::get_conventional_fee`]. +/// +/// Ironwood spends and outputs are tracked separately because the Constructor builds +/// unpadded bundles: the action count is `max(spends, outputs)`, not their sum. +#[wasm_bindgen] +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct WasmZip317Dimensions { + tx_in_total_size: u32, + tx_out_total_size: u32, + ironwood_spends: u32, + ironwood_outputs: u32, +} + +impl WasmZip317Dimensions { + fn counts(&self) -> Zip317ActionCounts { + Zip317ActionCounts { + tx_in_total_size: self.tx_in_total_size as u64, + tx_out_total_size: self.tx_out_total_size as u64, + n_actions_ironwood: self.get_ironwood_actions() as u64, + ..Default::default() + } + } + + fn from_input_script_sig_len(script_sig_len: usize) -> WasmZip317Dimensions { + WasmZip317Dimensions { + tx_in_total_size: saturating_u32(zip317::transparent_input_size(script_sig_len)), + ..Default::default() + } + } +} + +#[wasm_bindgen] +impl WasmZip317Dimensions { + /// Create empty dimensions. + pub fn empty() -> WasmZip317Dimensions { + WasmZip317Dimensions::default() + } + + /// Create dimensions from a Zcash BitGoPsbt (any version). + /// + /// Transparent inputs are sized with the scriptSig their scripts will finalize to (the real + /// one once finalized), transparent outputs exactly, and the Ironwood side from the real + /// (non-dummy) spends and outputs of the stored PCZT — or, once the PCZT has been extracted + /// by `combine_ironwood_proof`, from the counts recorded at extraction. + pub fn from_psbt(psbt: &BitGoPsbt) -> Result { + let InnerBitGoPsbt::Zcash(z, _) = &psbt.psbt else { + return Err(Zip317Error::NotZcashPsbt.into()); + }; + let (tx_in_total_size, tx_out_total_size) = z.zip317_transparent_sizes(); + let (spends, outputs) = z + .ironwood_real_spend_and_output_counts() + .map_err(Zip317Error::IronwoodPczt)?; + Ok(WasmZip317Dimensions { + tx_in_total_size: saturating_u32(tx_in_total_size), + tx_out_total_size: saturating_u32(tx_out_total_size), + ironwood_spends: saturating_u32(spends as u64), + ironwood_outputs: saturating_u32(outputs as u64), + }) + } + + /// Create dimensions for a single transparent wallet input from its chain code. + /// + /// Zcash wallets only have P2SH chains (0, 1); any other chain is rejected. + pub fn from_input(chain: u32) -> Result { + let chain_enum = + Chain::try_from(chain).map_err(|_| Zip317Error::UnsupportedChain(chain))?; + match chain_enum.script_type { + OutputScriptType::P2sh => Ok(Self::from_input_script_sig_len( + zip317::p2sh_multisig_script_sig_len(zip317::P2SH_2OF3_REDEEM_SCRIPT_LEN), + )), + _ => Err(Zip317Error::UnsupportedChain(chain).into()), + } + } + + /// Create dimensions for a single transparent input from its script type. + /// + /// # Arguments + /// * `script_type` - One of: "p2sh" (2-of-3 wallet input), "p2shP2pk" (replay protection) + pub fn from_input_script_type( + script_type: &str, + ) -> Result { + let script_sig_len = match script_type { + "p2sh" => zip317::p2sh_multisig_script_sig_len(zip317::P2SH_2OF3_REDEEM_SCRIPT_LEN), + "p2shP2pk" => zip317::p2sh_p2pk_script_sig_len(zip317::P2SH_P2PK_REDEEM_SCRIPT_LEN), + _ => { + return Err(Zip317Error::UnsupportedInputScriptType(script_type.to_string()).into()) + } + }; + Ok(Self::from_input_script_sig_len(script_sig_len)) + } + + /// Create dimensions for a single transparent output from its scriptPubKey length. + pub fn from_output_script_length(length: u32) -> WasmZip317Dimensions { + WasmZip317Dimensions { + tx_out_total_size: saturating_u32(zip317::transparent_output_size(length as usize)), + ..Default::default() + } + } + + /// Create dimensions for `count` Ironwood spends (shielded notes being spent). + pub fn from_ironwood_spends(count: u32) -> WasmZip317Dimensions { + WasmZip317Dimensions { + ironwood_spends: count, + ..Default::default() + } + } + + /// Create dimensions for `count` Ironwood (shielded) outputs. + pub fn from_ironwood_outputs(count: u32) -> WasmZip317Dimensions { + WasmZip317Dimensions { + ironwood_outputs: count, + ..Default::default() + } + } + + /// Combine with another instance. + pub fn plus(&self, other: &WasmZip317Dimensions) -> WasmZip317Dimensions { + WasmZip317Dimensions { + tx_in_total_size: self.tx_in_total_size.saturating_add(other.tx_in_total_size), + tx_out_total_size: self + .tx_out_total_size + .saturating_add(other.tx_out_total_size), + ironwood_spends: self.ironwood_spends.saturating_add(other.ironwood_spends), + ironwood_outputs: self.ironwood_outputs.saturating_add(other.ironwood_outputs), + } + } + + /// Multiply by a scalar. + pub fn times(&self, n: u32) -> WasmZip317Dimensions { + WasmZip317Dimensions { + tx_in_total_size: self.tx_in_total_size.saturating_mul(n), + tx_out_total_size: self.tx_out_total_size.saturating_mul(n), + ironwood_spends: self.ironwood_spends.saturating_mul(n), + ironwood_outputs: self.ironwood_outputs.saturating_mul(n), + } + } + + /// The sum of the transparent inputs' estimated final serialized sizes, in bytes. + pub fn get_tx_in_total_size(&self) -> u32 { + self.tx_in_total_size + } + + /// The sum of the transparent outputs' serialized sizes, in bytes. + pub fn get_tx_out_total_size(&self) -> u32 { + self.tx_out_total_size + } + + /// The number of Ironwood spends. + pub fn get_ironwood_spends(&self) -> u32 { + self.ironwood_spends + } + + /// The number of Ironwood outputs. + pub fn get_ironwood_outputs(&self) -> u32 { + self.ironwood_outputs + } + + /// The number of Ironwood actions: `max(spends, outputs)` (unpadded bundle). + pub fn get_ironwood_actions(&self) -> u32 { + self.ironwood_spends.max(self.ironwood_outputs) + } + + /// The ZIP-317 `logical_actions`. + pub fn get_logical_actions(&self) -> u32 { + saturating_u32(zip317::logical_actions(&self.counts())) + } + + /// The ZIP-317 conventional fee in zatoshi (as a `bigint`): + /// `5_000 × max(2, logical_actions)`. + pub fn get_conventional_fee(&self) -> u64 { + zip317::conventional_fee(&self.counts()) + } +} + +/// Clamp to `u32`: sizes and counts this large are not real transactions, and saturating keeps +/// the fee estimate an overestimate instead of silently wrapping. +fn saturating_u32(value: u64) -> u32 { + u32::try_from(value).unwrap_or(u32::MAX) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn wallet_input_matches_the_p2sh_script_type() { + assert_eq!( + WasmZip317Dimensions::from_input(0).unwrap(), + WasmZip317Dimensions::from_input_script_type("p2sh").unwrap() + ); + assert_eq!( + WasmZip317Dimensions::from_input(0) + .unwrap() + .get_tx_in_total_size(), + 299 + ); + } + + #[test] + fn rejects_inputs_zcash_wallets_cannot_have() { + assert!(matches!( + WasmZip317Dimensions::from_input(20), + Err(WasmUtxoError::Zip317(Zip317Error::UnsupportedChain(20))) + )); + assert!(matches!( + WasmZip317Dimensions::from_input(7), + Err(WasmUtxoError::Zip317(Zip317Error::UnsupportedChain(7))) + )); + assert!(matches!( + WasmZip317Dimensions::from_input_script_type("p2wsh"), + Err(WasmUtxoError::Zip317( + Zip317Error::UnsupportedInputScriptType(_) + )) + )); + } + + #[test] + fn ironwood_actions_are_the_max_of_spends_and_outputs() { + let d = WasmZip317Dimensions::from_ironwood_spends(2) + .plus(&WasmZip317Dimensions::from_ironwood_outputs(3)); + assert_eq!(d.get_ironwood_actions(), 3); + assert_eq!(d.get_logical_actions(), 3); + assert_eq!(d.get_conventional_fee(), 15_000); + } + + #[test] + fn shielding_one_input_to_one_output_hits_the_grace_floor() { + // 299-byte P2SH input (ceil(299/150) = 2) + one Ironwood output: 2 + 1 = 3 actions. + let d = WasmZip317Dimensions::from_input(0) + .unwrap() + .plus(&WasmZip317Dimensions::from_ironwood_outputs(1)); + assert_eq!(d.get_logical_actions(), 3); + assert_eq!(d.get_conventional_fee(), 15_000); + // Nothing at all still pays for two actions. + assert_eq!(WasmZip317Dimensions::empty().get_conventional_fee(), 10_000); + } + + #[test] + fn times_scales_every_term() { + let d = WasmZip317Dimensions::from_input(0) + .unwrap() + .plus(&WasmZip317Dimensions::from_output_script_length(23)) + .plus(&WasmZip317Dimensions::from_ironwood_outputs(1)) + .times(3); + assert_eq!(d.get_tx_in_total_size(), 3 * 299); + assert_eq!(d.get_tx_out_total_size(), 3 * 32); + assert_eq!(d.get_ironwood_outputs(), 3); + } + + #[test] + fn plus_and_times_saturate_instead_of_wrapping() { + let d = WasmZip317Dimensions::from_input(0).unwrap().times(u32::MAX); + assert_eq!(d.get_tx_in_total_size(), u32::MAX); + assert_eq!(d.plus(&d).get_tx_in_total_size(), u32::MAX); + assert!(d.get_conventional_fee() > WasmZip317Dimensions::empty().get_conventional_fee()); + } +} diff --git a/packages/wasm-utxo/src/zcash/mod.rs b/packages/wasm-utxo/src/zcash/mod.rs index 7ca40761bd2..3808b09cf95 100644 --- a/packages/wasm-utxo/src/zcash/mod.rs +++ b/packages/wasm-utxo/src/zcash/mod.rs @@ -18,6 +18,7 @@ pub mod ironwood_pczt; pub mod transaction; pub mod unified_address; pub mod v6; +pub mod zip317; /// Zcash network upgrade identifiers #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] diff --git a/packages/wasm-utxo/src/zcash/v6.rs b/packages/wasm-utxo/src/zcash/v6.rs index c901b56f702..4cf07760fa0 100644 --- a/packages/wasm-utxo/src/zcash/v6.rs +++ b/packages/wasm-utxo/src/zcash/v6.rs @@ -737,6 +737,19 @@ pub fn decode_v6_transaction(bytes: &[u8]) -> Result usize { + if value < 253 { + 1 + } else if value <= u16::MAX as usize { + 3 + } else if value <= u32::MAX as usize { + 5 + } else { + 9 + } +} + /// Encode a v6 (Ironwood) transaction back to its raw wire bytes. pub fn encode_v6_transaction(tx: &ZcashV6Transaction) -> Result, ZcashV6Error> { if tx.sapling_value_balance != 0 { diff --git a/packages/wasm-utxo/src/zcash/zip317.rs b/packages/wasm-utxo/src/zcash/zip317.rs new file mode 100644 index 00000000000..7480d734f06 --- /dev/null +++ b/packages/wasm-utxo/src/zcash/zip317.rs @@ -0,0 +1,263 @@ +//! ZIP-317 conventional fee estimation. +//! +//! Formula per [ZIP-317 §Fee calculation]: the transparent terms are **size**-based (bytes over +//! the standard P2PKH input/output sizes), the Sapling term is `max(spends, outputs)`, JoinSplit +//! pairs count double, and each shielded-bundle action counts once. +//! +//! The Ironwood term is settled by zebra's NU6.3 implementation +//! (`zebra-chain::transaction::unmined::zip317`): *Ironwood actions have the same structure and +//! cost as Orchard actions, so they are counted the same way* — they enter `logical_actions` as +//! their own additive term. A v6 transaction carries either an Orchard or an Ironwood bundle, +//! so in practice exactly one of the two terms is non-zero. +//! +//! [ZIP-317 §Fee calculation]: https://zips.z.cash/zip-0317#fee-calculation + +/// The marginal fee per logical action, in zatoshi (ZIP-317 §Fee calculation). +pub const MARGINAL_FEE: u64 = 5_000; +/// The minimum number of actions a transaction pays for (ZIP-317 §Fee calculation). +pub const GRACE_ACTIONS: u64 = 2; +/// The standard size of a P2PKH input for the transparent logical-action divisor (ZIP-317). +pub const P2PKH_STANDARD_INPUT_SIZE: u64 = 150; +/// The standard size of a P2PKH output for the transparent logical-action divisor (ZIP-317). +pub const P2PKH_STANDARD_OUTPUT_SIZE: u64 = 34; + +/// The ZIP-317 count inputs. The transparent sides are **serialized sizes**, not item counts — +/// a naive input/output count misprices them (ZIP-317 §Fee calculation). +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub struct Zip317ActionCounts { + /// The sum of the estimated final serialized sizes of the transaction's transparent inputs. + pub tx_in_total_size: u64, + /// The sum of the serialized sizes of the transaction's transparent outputs. + pub tx_out_total_size: u64, + /// The number of Sprout JoinSplit descriptions. + pub n_join_split: u64, + /// The number of Sapling spends (per anchor). + pub n_spends_sapling: u64, + /// The number of Sapling outputs. + pub n_outputs_sapling: u64, + /// The number of Orchard actions. + pub n_actions_orchard: u64, + /// The number of Ironwood actions (v6 only; same footing as Orchard actions). + pub n_actions_ironwood: u64, +} + +impl Zip317ActionCounts { + /// Counts for a transaction with only an Ironwood bundle of `n_actions` actions. + pub fn ironwood_only(n_actions: u64) -> Self { + Self { + n_actions_ironwood: n_actions, + ..Self::default() + } + } +} + +/// The ZIP-317 `logical_actions` for these counts: `max(ceil(tx_in_total_size / 150), +/// ceil(tx_out_total_size / 34)) + 2 × nJoinSplit + max(nSpendsSapling, nOutputsSapling) +/// + nActionsOrchard + nActionsIronwood`. +pub fn logical_actions(c: &Zip317ActionCounts) -> u64 { + let tx_in_logical_actions = c.tx_in_total_size.div_ceil(P2PKH_STANDARD_INPUT_SIZE); + let tx_out_logical_actions = c.tx_out_total_size.div_ceil(P2PKH_STANDARD_OUTPUT_SIZE); + + tx_in_logical_actions.max(tx_out_logical_actions) + + 2 * c.n_join_split + + c.n_spends_sapling.max(c.n_outputs_sapling) + + c.n_actions_orchard + + c.n_actions_ironwood +} + +/// The ZIP-317 conventional fee in zatoshi: `MARGINAL_FEE × max(GRACE_ACTIONS, logical_actions)`. +pub fn conventional_fee(c: &Zip317ActionCounts) -> u64 { + MARGINAL_FEE * GRACE_ACTIONS.max(logical_actions(c)) +} + +// ============================================================================ +// Transparent serialized sizes +// ============================================================================ + +/// Each signature push in a finalized scriptSig: the maximum DER-encoded ECDSA signature (72) +/// plus its sighash byte, matching the `dimensions` estimator's max convention. +const MAX_SIG_WITH_SIGHASH: usize = 73; +const COMPRESSED_PUBKEY_LEN: usize = 33; + +/// The redeem script of a BitGo 2-of-3 multisig wallet input over compressed keys: +/// `OP_2 <33> <33> <33> OP_3 OP_CHECKMULTISIG`. +pub const P2SH_2OF3_REDEEM_SCRIPT_LEN: usize = 105; +/// The redeem script of a P2SH-P2PK replay-protection input: `<33> OP_CHECKSIG`. +pub const P2SH_P2PK_REDEEM_SCRIPT_LEN: usize = 35; + +/// The length of a minimal data push of `len` bytes: opcode(s) + data. +fn push_len(len: usize) -> usize { + let opcode = match len { + 0..=75 => 1, + 76..=255 => 2, + 256..=65535 => 3, + _ => 5, + }; + opcode + len +} + +/// A finalized P2SH 2-of-3 multisig scriptSig: `OP_0 `. +pub fn p2sh_multisig_script_sig_len(redeem_script_len: usize) -> usize { + 1 + 2 * push_len(MAX_SIG_WITH_SIGHASH) + push_len(redeem_script_len) +} + +/// A finalized P2SH-P2PK scriptSig: ` `. +pub fn p2sh_p2pk_script_sig_len(redeem_script_len: usize) -> usize { + push_len(MAX_SIG_WITH_SIGHASH) + push_len(redeem_script_len) +} + +/// A finalized P2PKH scriptSig: ` `. +pub fn p2pkh_script_sig_len() -> usize { + push_len(MAX_SIG_WITH_SIGHASH) + push_len(COMPRESSED_PUBKEY_LEN) +} + +/// The serialized size of a transparent input: outpoint (txid 32 + vout 4) + +/// CompactSize(scriptSig) + scriptSig + sequence (4). +pub fn transparent_input_size(script_sig_len: usize) -> u64 { + (36 + super::v6::compact_size_len(script_sig_len) + script_sig_len + 4) as u64 +} + +/// The serialized size of a transparent output: value (8) + CompactSize(scriptPubKey) + +/// scriptPubKey. +pub fn transparent_output_size(script_pubkey_len: usize) -> u64 { + (8 + super::v6::compact_size_len(script_pubkey_len) + script_pubkey_len) as u64 +} + +// ============================================================================ +// Errors +// ============================================================================ + +/// Failure modes of building ZIP-317 dimensions. +#[derive(Debug, strum::IntoStaticStr)] +pub enum Zip317Error { + /// The PSBT is not a Zcash PSBT. + NotZcashPsbt, + /// The input script type is not one a Zcash wallet input can have (no segwit or taproot). + UnsupportedInputScriptType(String), + /// The chain code is invalid, or its script type is not available on Zcash. + UnsupportedChain(u32), + /// The stored Ironwood PCZT could not be read (or was already extracted). + IronwoodPczt(String), +} + +impl core::fmt::Display for Zip317Error { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + match self { + Self::NotZcashPsbt => write!(f, "zip317: not a Zcash PSBT"), + Self::UnsupportedInputScriptType(t) => { + write!(f, "zip317: input script type {t} is not supported on Zcash") + } + Self::UnsupportedChain(c) => { + write!(f, "zip317: chain {c} is invalid or not supported on Zcash") + } + Self::IronwoodPczt(e) => write!(f, "zip317: cannot read the Ironwood PCZT: {e}"), + } + } +} + +impl std::error::Error for Zip317Error {} + +crate::impl_wasm_error_code!(Zip317Error); + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn grace_actions_floor_prices_an_empty_transaction_at_two_actions() { + // No inputs, no outputs, no shielded sides: still billed for GRACE_ACTIONS. + let counts = Zip317ActionCounts::default(); + assert_eq!(logical_actions(&counts), 0); + assert_eq!(conventional_fee(&counts), MARGINAL_FEE * 2); + } + + #[test] + fn transparent_terms_are_size_based_not_count_based() { + // The divisor is on total bytes: exactly 150 input bytes (or 34 output bytes) is one + // logical action, and 151 is two. + let one_action = Zip317ActionCounts { + tx_in_total_size: 150, + ..Zip317ActionCounts::default() + }; + assert_eq!(logical_actions(&one_action), 1); + + let one_action_outputs = Zip317ActionCounts { + tx_out_total_size: 34, + ..Zip317ActionCounts::default() + }; + assert_eq!(logical_actions(&one_action_outputs), 1); + + let two_actions = Zip317ActionCounts { + tx_in_total_size: 151, + ..Zip317ActionCounts::default() + }; + assert_eq!(logical_actions(&two_actions), 2); + } + + #[test] + fn transparent_sides_take_the_max_of_the_two_divisions() { + // 3 input-actions vs 1 output-action ⇒ 3. + let counts = Zip317ActionCounts { + tx_in_total_size: 3 * P2PKH_STANDARD_INPUT_SIZE, + tx_out_total_size: P2PKH_STANDARD_OUTPUT_SIZE, + ..Zip317ActionCounts::default() + }; + assert_eq!(logical_actions(&counts), 3); + } + + #[test] + fn ironwood_actions_are_an_additive_term_on_the_same_footings_as_orchard() { + // Two Ironwood actions over an otherwise empty transparent side: logical_actions = 2, + // billed at 2 × MARGINAL_FEE (above the grace floor). + let counts = Zip317ActionCounts::ironwood_only(2); + assert_eq!(logical_actions(&counts), 2); + assert_eq!(conventional_fee(&counts), 2 * MARGINAL_FEE); + + // Orchard actions count identically — a v6 tx carries one bundle or the other. + let orchard = Zip317ActionCounts { + n_actions_orchard: 2, + ..Zip317ActionCounts::default() + }; + assert_eq!(logical_actions(&orchard), logical_actions(&counts)); + } + + #[test] + fn single_shielded_output_hits_the_grace_floor() { + // A 1-action shielding transaction: logical_actions = 1, but ZIP-317 bills two. + let counts = Zip317ActionCounts::ironwood_only(1); + assert_eq!(logical_actions(&counts), 1); + assert_eq!(conventional_fee(&counts), 2 * MARGINAL_FEE); + } + + #[test] + fn redeem_script_lengths_match_the_wallet_scripts() { + // OP_2 + 3 × push(33) + OP_3 + OP_CHECKMULTISIG; push(33) + OP_CHECKSIG. + assert_eq!(P2SH_2OF3_REDEEM_SCRIPT_LEN, 1 + 3 * (1 + 33) + 1 + 1); + assert_eq!(P2SH_P2PK_REDEEM_SCRIPT_LEN, 1 + 33 + 1); + // OP_0 + 2 × push(73) + OP_PUSHDATA1 push(105) = 1 + 148 + 107 = 256, which takes the + // 3-byte CompactSize: 36 + 3 + 256 + 4 = 299. + assert_eq!( + p2sh_multisig_script_sig_len(P2SH_2OF3_REDEEM_SCRIPT_LEN), + 256 + ); + assert_eq!( + transparent_input_size(p2sh_multisig_script_sig_len(P2SH_2OF3_REDEEM_SCRIPT_LEN)), + 299 + ); + // A P2SH output script (23 bytes): 8 + 1 + 23 = 32. + assert_eq!(transparent_output_size(23), 32); + } + + #[test] + fn mixed_multi_action_bundle() { + // 2 Ironwood actions + 1 transparent input of 170 bytes (ceil(170/150) = 2): + // logical_actions = 2 + 2 = 4 ⇒ 20_000 zatoshi. + let counts = Zip317ActionCounts { + tx_in_total_size: 170, + n_actions_ironwood: 2, + ..Zip317ActionCounts::default() + }; + assert_eq!(logical_actions(&counts), 4); + assert_eq!(conventional_fee(&counts), 4 * MARGINAL_FEE); + } +} diff --git a/packages/wasm-utxo/test/fixedScript/zcashIronwoodPsbt.ts b/packages/wasm-utxo/test/fixedScript/zcashIronwoodPsbt.ts index a80432c0824..b9c05fdf5bf 100644 --- a/packages/wasm-utxo/test/fixedScript/zcashIronwoodPsbt.ts +++ b/packages/wasm-utxo/test/fixedScript/zcashIronwoodPsbt.ts @@ -14,6 +14,7 @@ import { type ZcashParsedOutput, } from "../../js/fixedScriptWallet/ZcashBitGoPsbt.js"; import type { ParsedTransaction } from "../../js/fixedScriptWallet/BitGoPsbt.js"; +import { Dimensions } from "../../js/fixedScriptWallet/Dimensions.js"; import { getKeyTriple, getWalletKeysForSeed } from "../../js/testutils/index.js"; import { ZcashUnifiedAddress } from "../../js/fixedScriptWallet/ZcashUnifiedAddress.js"; @@ -193,6 +194,34 @@ describe("ZcashIronwoodBitGoPsbt v6 (Ironwood)", function () { assert.strictEqual(parsed.spendAmount, 100_000_000n); }); + // The shielding flow is funded by transparent inputs, so it keeps the transparent-skeleton + // fee estimate (`Dimensions.fromPsbt` and `virtualSize`) the indexer's fee-rate checks use: + // adding the shielded output does not change either. + it("sizes a shielding PSBT from its transparent skeleton alone", function () { + const psbt = ZcashIronwoodBitGoPsbt.createEmpty("zcashTest", walletKeys, { + blockHeight: NU6_3_TESTNET_HEIGHT, + }); + psbt.addWalletInput({ txid: "11".repeat(32), vout: 0, value: 200_000_000n }, walletKeys, { + scriptId: SCRIPT_ID, + signPath: { signer: "user", cosigner: "bitgo" }, + }); + psbt.addWalletOutput(walletKeys, { chain: 1, index: 0, value: 99_900_000n }); + const parseOptions = { replayProtection: { publicKeys: [] } }; + const transparentVSize = Dimensions.fromPsbt(psbt).getVSize("max"); + const transparentVirtualSize = psbt.parseTransactionWithWalletKeys( + walletKeys, + parseOptions, + ).virtualSize; + + psbt.addShieldedOutput(RECIPIENT, 100_000_000n, { anchor: new Uint8Array(32) }); + + assert.strictEqual(Dimensions.fromPsbt(psbt).getVSize("max"), transparentVSize); + assert.strictEqual( + psbt.parseTransactionWithWalletKeys(walletKeys, parseOptions).virtualSize, + transparentVirtualSize, + ); + }); + it("omits the shielded entry for a v6 PSBT with no shielded output yet", function () { const psbt = ZcashIronwoodBitGoPsbt.createEmpty("zcashTest", walletKeys, { blockHeight: NU6_3_TESTNET_HEIGHT, diff --git a/packages/wasm-utxo/test/zip317Dimensions.ts b/packages/wasm-utxo/test/zip317Dimensions.ts new file mode 100644 index 00000000000..127746710ed --- /dev/null +++ b/packages/wasm-utxo/test/zip317Dimensions.ts @@ -0,0 +1,221 @@ +import assert from "node:assert"; +import { describe, it } from "mocha"; + +import { + fixedScriptWallet, + isWasmUtxoError, + ZcashDimensions, + Zip317Dimensions, +} from "../js/index.js"; +import { ZcashBitGoPsbt } from "../js/fixedScriptWallet/ZcashBitGoPsbt.js"; +import { ZcashIronwoodBitGoPsbt } from "../js/fixedScriptWallet/ZcashIronwoodBitGoPsbt.js"; +import { ZcashUnifiedAddress } from "../js/fixedScriptWallet/ZcashUnifiedAddress.js"; +import { getDefaultWalletKeys, getKeyTriple, getWalletKeysForSeed } from "../js/testutils/index.js"; + +// NU6.3 (Ironwood) testnet activation height. +const NU6_3_TESTNET_HEIGHT = 4134000; +// A mainnet NU5 height, for a v4 (pre-Ironwood) PSBT. +const NU5_MAINNET_HEIGHT = 2_726_400; + +// A valid raw Orchard/Ironwood receiver (43 bytes), encoded as a single-receiver UA. +const RECEIVER = Buffer.from( + "4559029c0b5dbf941c5ad181a5fe8f45b34630f29d0c8dd8dc1cc3573386f416cb324133156d723df5e62d", + "hex", +); +const UNIFIED_ADDRESS = ZcashUnifiedAddress.encodeOrchardReceiver(RECEIVER, "zcashTest"); +// A zcash testnet p2sh address -> ordinary 23-byte scriptPubKey. +const TRANSPARENT_ADDRESS = "t288NZMrzYi6oednBEnw8UZvGoqX4Z6NXys"; + +// A BitGo P2SH 2-of-3 input: outpoint (36) + CompactSize (3) + `OP_0 ` +// (1 + 2 × 74 + 107 = 256) + sequence (4). +const P2SH_INPUT_SIZE = 299; +// A P2SH output: value (8) + CompactSize (1) + 23-byte script. +const P2SH_OUTPUT_SIZE = 32; + +function assertThrowsCode(fn: () => unknown, code: string): void { + assert.throws(fn, (e: unknown) => isWasmUtxoError(e) && e.code === code); +} + +describe("Zip317Dimensions", function () { + describe("conventional fee", function () { + it("prices ZIP-317's grace floor at two actions", function () { + assert.strictEqual(Zip317Dimensions.empty().getLogicalActions(), 0); + assert.strictEqual(Zip317Dimensions.empty().getConventionalFee(), 10_000n); + }); + + it("prices the transparent terms by size, not count", function () { + // 3 inputs: ceil(3 × 299 / 150) = 6 logical actions, more than one per input. + const dims = Zip317Dimensions.fromInput({ chain: 0 }).times(3); + assert.strictEqual(dims.txInTotalSize, 3 * P2SH_INPUT_SIZE); + assert.strictEqual(dims.getLogicalActions(), 6); + assert.strictEqual(dims.getConventionalFee(), 30_000n); + }); + + it("counts Ironwood actions as max(spends, outputs)", function () { + const dims = Zip317Dimensions.fromIronwoodSpends(2).plus( + Zip317Dimensions.fromIronwoodOutputs(3), + ); + assert.strictEqual(dims.ironwoodActions, 3); + assert.strictEqual(dims.getConventionalFee(), 15_000n); + }); + }); + + describe("fromInput", function () { + it("sizes a wallet input with its finalized 2-of-3 scriptSig", function () { + const fromChain = Zip317Dimensions.fromInput({ chain: 0 }); + assert.strictEqual(fromChain.txInTotalSize, P2SH_INPUT_SIZE); + assert.strictEqual( + Zip317Dimensions.fromInput({ scriptType: "p2sh" }).txInTotalSize, + P2SH_INPUT_SIZE, + ); + assert.strictEqual(Zip317Dimensions.fromInput({ chain: 1 }).txInTotalSize, P2SH_INPUT_SIZE); + }); + + it("sizes a replay-protection input as ", function () { + // 36 + 1 + (74 + 36) + 4 + assert.strictEqual(Zip317Dimensions.fromInput({ scriptType: "p2shP2pk" }).txInTotalSize, 151); + }); + + it("rejects inputs a Zcash wallet cannot have", function () { + assertThrowsCode( + () => Zip317Dimensions.fromInput({ chain: 20 }), + "Zip317Error.UnsupportedChain", + ); + assertThrowsCode( + () => Zip317Dimensions.fromInput({ scriptType: "p2wsh" as never }), + "Zip317Error.UnsupportedInputScriptType", + ); + }); + }); + + describe("fromOutput", function () { + it("sizes a transparent output from its script", function () { + assert.strictEqual(Zip317Dimensions.fromOutput({ length: 23 }).txOutTotalSize, 32); + assert.strictEqual( + Zip317Dimensions.fromOutput(TRANSPARENT_ADDRESS, "tzec").txOutTotalSize, + P2SH_OUTPUT_SIZE, + ); + }); + + it("sizes a shielded Unified Address as one Ironwood output", function () { + const dims = Zip317Dimensions.fromOutput(UNIFIED_ADDRESS, "tzec", { isShielded: true }); + assert.strictEqual(dims.ironwoodOutputs, 1); + assert.strictEqual(dims.txOutTotalSize, 0); + }); + + it("sizes a transparent address as transparent even with isShielded", function () { + const dims = Zip317Dimensions.fromOutput(TRANSPARENT_ADDRESS, "tzec", { isShielded: true }); + assert.strictEqual(dims.ironwoodOutputs, 0); + assert.strictEqual(dims.txOutTotalSize, P2SH_OUTPUT_SIZE); + }); + + it("rejects a malformed Unified Address with isShielded, like ZcashDimensions", function () { + const last = UNIFIED_ADDRESS.slice(-1); + const corrupted = UNIFIED_ADDRESS.slice(0, -1) + (last === "q" ? "p" : "q"); + assert.throws(() => ZcashDimensions.fromOutput(corrupted, "tzec", { isShielded: true })); + assert.throws(() => Zip317Dimensions.fromOutput(corrupted, "tzec", { isShielded: true })); + }); + }); + + describe("fromPsbt", function () { + const walletKeys = getWalletKeysForSeed("zip317-dimensions"); + + function buildTransparentPart(): ZcashIronwoodBitGoPsbt { + const psbt = ZcashIronwoodBitGoPsbt.createEmpty("zcashTest", walletKeys, { + blockHeight: NU6_3_TESTNET_HEIGHT, + }); + psbt.addWalletInput({ txid: "11".repeat(32), vout: 0, value: 200_000_000n }, walletKeys, { + scriptId: { chain: 0, index: 0 }, + signPath: { signer: "user", cosigner: "bitgo" }, + }); + psbt.addWalletOutput(walletKeys, { chain: 1, index: 0, value: 99_900_000n }); + return psbt; + } + + it("matches the composed dimensions for a shielding PSBT", function () { + const psbt = buildTransparentPart(); + psbt.addShieldedOutput(RECEIVER, 100_000_000n, { anchor: new Uint8Array(32) }); + + const fromPsbt = Zip317Dimensions.fromPsbt(psbt); + const composed = Zip317Dimensions.fromInput({ chain: 0 }) + .plus(Zip317Dimensions.fromOutput({ length: 23 })) + .plus(Zip317Dimensions.fromIronwoodOutputs(1)); + assert.strictEqual(fromPsbt.txInTotalSize, composed.txInTotalSize); + assert.strictEqual(fromPsbt.txOutTotalSize, composed.txOutTotalSize); + assert.strictEqual(fromPsbt.ironwoodSpends, 0, "the shielding bundle's spends are dummies"); + assert.strictEqual(fromPsbt.ironwoodOutputs, 1); + // max(ceil(299/150), ceil(32/34)) + 1 = 3 ⇒ 15_000 zatoshi. + assert.strictEqual(fromPsbt.getLogicalActions(), 3); + assert.strictEqual(fromPsbt.getConventionalFee(), composed.getConventionalFee()); + assert.strictEqual(fromPsbt.getConventionalFee(), 15_000n); + }); + + it("has no Ironwood term for a transparent-only v6 PSBT", function () { + const fromPsbt = Zip317Dimensions.fromPsbt(buildTransparentPart()); + assert.strictEqual(fromPsbt.ironwoodActions, 0); + assert.strictEqual(fromPsbt.txInTotalSize, P2SH_INPUT_SIZE); + }); + + it("still sees the actions and fee after combineProof drops the PCZT", function () { + const seed = "zip317-dimensions-combine"; + const keys = getWalletKeysForSeed(seed); + const [userKey, , bitgoKey] = getKeyTriple(seed); + const psbt = ZcashIronwoodBitGoPsbt.createEmpty("zcashTest", keys, { + blockHeight: NU6_3_TESTNET_HEIGHT, + }); + psbt.addWalletInput({ txid: "22".repeat(32), vout: 0, value: 200_000_000n }, keys, { + scriptId: { chain: 0, index: 0 }, + signPath: { signer: "user", cosigner: "bitgo" }, + }); + psbt.addWalletOutput(keys, { chain: 1, index: 0, value: 99_900_000n }); + psbt.addShieldedOutput(RECEIVER, 100_000_000n, { anchor: new Uint8Array(32) }); + psbt.sign(userKey, keys); + psbt.sign(bitgoKey, keys); + const before = Zip317Dimensions.fromPsbt(psbt); + + // A placeholder proof of the real single-action size stands in for the external prover. + assert.ok(psbt.combineProof(new Uint8Array(4992)).length > 0); + assert.strictEqual(psbt.getPczt(), undefined, "the PCZT is gone"); + + const after = Zip317Dimensions.fromPsbt(psbt); + assert.strictEqual(after.ironwoodSpends, 0); + assert.strictEqual(after.ironwoodOutputs, 1); + assert.strictEqual(after.txInTotalSize, before.txInTotalSize); + assert.strictEqual(after.txOutTotalSize, before.txOutTotalSize); + assert.strictEqual(after.getConventionalFee(), before.getConventionalFee()); + assert.strictEqual(after.getConventionalFee(), 15_000n); + }); + + it("sizes a finalized v4 PSBT's inputs from their real scriptSig", function () { + const seed = "zip317-dimensions-v4"; + const keys = getWalletKeysForSeed(seed); + const [userKey, , bitgoKey] = getKeyTriple(seed); + const psbt = ZcashBitGoPsbt.createEmpty("zcash", keys, { blockHeight: NU5_MAINNET_HEIGHT }); + psbt.addWalletInput({ txid: "11".repeat(32), vout: 0, value: 200_000_000n }, keys, { + scriptId: { chain: 0, index: 0 }, + signPath: { signer: "user", cosigner: "bitgo" }, + }); + psbt.addWalletOutput(keys, { chain: 1, index: 0, value: 199_900_000n }); + const estimated = Zip317Dimensions.fromPsbt(psbt); + assert.strictEqual(estimated.ironwoodActions, 0); + assert.strictEqual(estimated.txInTotalSize, P2SH_INPUT_SIZE); + + psbt.sign(userKey); + psbt.sign(bitgoKey); + psbt.finalizeAllInputs(); + const finalized = Zip317Dimensions.fromPsbt(psbt); + // The real input (DER signatures are at most 73 bytes with sighash) never exceeds the + // estimate, and is not mis-sized as a P2PKH input once the finalizer clears the redeem script. + assert.ok(finalized.txInTotalSize <= P2SH_INPUT_SIZE, `${finalized.txInTotalSize}`); + assert.ok(finalized.txInTotalSize >= P2SH_INPUT_SIZE - 4, `${finalized.txInTotalSize}`); + assert.strictEqual(finalized.txOutTotalSize, estimated.txOutTotalSize); + assert.strictEqual(finalized.getConventionalFee(), estimated.getConventionalFee()); + }); + + it("rejects a non-Zcash PSBT", function () { + const rootWalletKeys = getDefaultWalletKeys(); + const psbt = fixedScriptWallet.BitGoPsbt.createEmpty("bitcoin", rootWalletKeys); + assertThrowsCode(() => Zip317Dimensions.fromPsbt(psbt), "Zip317Error.NotZcashPsbt"); + }); + }); +});