diff --git a/packages/wasm-utxo/js/fixedScriptWallet/Dimensions.ts b/packages/wasm-utxo/js/fixedScriptWallet/Dimensions.ts index f797192391d..28e69095a54 100644 --- a/packages/wasm-utxo/js/fixedScriptWallet/Dimensions.ts +++ b/packages/wasm-utxo/js/fixedScriptWallet/Dimensions.ts @@ -17,6 +17,17 @@ export type FromInputOptions = { utxolibCompat?: boolean; }; +/** + * Options for output dimension calculation + */ +export type FromOutputOptions = { + /** + * Set when `address` may be a ZIP-316 Unified Address whose Orchard receiver should be sized + * as a shielded output. Forwarded to `toOutputScriptWithCoin`'s `canBeShieldedOutput`. + */ + isShielded?: boolean; +}; + /** * Dimensions class for estimating transaction virtual size. * @@ -72,9 +83,12 @@ export class Dimensions { */ static fromOutput(script: Uint8Array): Dimensions; /** - * Create dimensions for a single output from an address + * Create dimensions for a single output from an address. + * + * Pass `{ isShielded: true }` when `address` may be a ZIP-316 Unified Address whose Orchard + * receiver should be sized as a shielded output */ - static fromOutput(address: string, network: CoinName): Dimensions; + static fromOutput(address: string, network: CoinName, options?: FromOutputOptions): Dimensions; /** * Create dimensions for a single output from script length only */ @@ -86,12 +100,13 @@ export class Dimensions { static fromOutput( params: Uint8Array | string | { length: number } | { scriptType: OutputScriptType }, network?: CoinName, + options?: FromOutputOptions, ): Dimensions { if (typeof params === "string") { if (network === undefined) { throw new Error("network is required when passing an address string"); } - const script = toOutputScriptWithCoin(params, network); + const script = toOutputScriptWithCoin(params, network, options?.isShielded); return new Dimensions(WasmDimensions.from_output_script_length(script.length)); } if (typeof params === "object" && "scriptType" in params) { 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 9a3ab0ee4f3..de42d6f6e60 100644 --- a/packages/wasm-utxo/src/wasm/fixed_script_wallet/mod.rs +++ b/packages/wasm-utxo/src/wasm/fixed_script_wallet/mod.rs @@ -9,6 +9,7 @@ use wasm_bindgen::JsValue; use crate::address::networks::AddressFormat; use crate::error::WasmUtxoError; +use crate::fixed_script_wallet::bitgo_psbt::zcash_psbt::IronwoodOutputRequest; use crate::fixed_script_wallet::bitgo_psbt::ExtractFeePolicy; use crate::fixed_script_wallet::wallet_scripts::{chain_index_path, OutputScriptType}; use crate::fixed_script_wallet::{Chain, Scope, WalletScripts}; @@ -20,6 +21,10 @@ use crate::wasm::replay_protection::WasmReplayProtection; use crate::wasm::try_from_js_value::TryFromJsValue; use crate::wasm::try_into_js_value::TryIntoJsValue; use crate::wasm::wallet_keys::WasmRootWalletKeys; +use crate::zcash::ironwood_build::{ + AnchorBytes, MemoBytes, OrchardAddressBytes, OvkBytes, ANCHOR_SIZE, MEMO_SIZE, + ORCHARD_ADDRESS_SIZE, OVK_SIZE, +}; /// Parse a network from a string that can be either a utxolib name or a coin name fn parse_network(network_str: &str) -> Result { @@ -546,11 +551,6 @@ impl BitGoPsbt { memo: &[u8], unified_address: Option, ) -> Result<(), WasmUtxoError> { - use crate::zcash::ironwood_build::{ - AnchorBytes, MemoBytes, OrchardAddressBytes, OvkBytes, ANCHOR_SIZE, MEMO_SIZE, - ORCHARD_ADDRESS_SIZE, OVK_SIZE, - }; - /// Length-check a boundary byte string, naming the field and its expected size in the error. fn fixed(bytes: &[u8], field: &str) -> Result<[u8; N], WasmUtxoError> { bytes.try_into().map_err(|_| { @@ -593,9 +593,6 @@ impl BitGoPsbt { outputs: JsValue, anchor: &[u8], ) -> Result, WasmUtxoError> { - use crate::fixed_script_wallet::bitgo_psbt::zcash_psbt::IronwoodOutputRequest; - use crate::zcash::ironwood_build::{AnchorBytes, ANCHOR_SIZE}; - let anchor: AnchorBytes = anchor.try_into().map_err(|_| { WasmUtxoError::new(&format!( "anchor must be {ANCHOR_SIZE} bytes, got {}", diff --git a/packages/wasm-utxo/src/wasm/try_from_js_value.rs b/packages/wasm-utxo/src/wasm/try_from_js_value.rs index 3203f06298b..95b997c9d5a 100644 --- a/packages/wasm-utxo/src/wasm/try_from_js_value.rs +++ b/packages/wasm-utxo/src/wasm/try_from_js_value.rs @@ -2,6 +2,7 @@ use std::ops::Deref; use crate::address::utxolib_compat::{CashAddr, UtxolibNetwork}; use crate::error::WasmUtxoError; +use crate::zcash::ironwood_build::{MemoBytes, OrchardAddressBytes, OvkBytes}; use miniscript::bitcoin::psbt::raw; use wasm_bindgen::{JsCast, JsValue}; @@ -298,8 +299,6 @@ impl TryFromJsValue for crate::fixed_script_wallet::bitgo_psbt::HydrationUnspent impl TryFromJsValue for crate::fixed_script_wallet::bitgo_psbt::zcash_psbt::IronwoodOutputRequest { fn try_from_js_value(item: &JsValue) -> Result { - use crate::zcash::ironwood_build::{MemoBytes, OrchardAddressBytes, OvkBytes}; - let recipient_val = js_sys::Reflect::get(item, &"recipient".into()) .map_err(|_| WasmUtxoError::new("Missing 'recipient' field on Ironwood output"))?; if recipient_val.is_undefined() { diff --git a/packages/wasm-utxo/test/dimensions.ts b/packages/wasm-utxo/test/dimensions.ts index ef54d4a74d7..ef36b450e74 100644 --- a/packages/wasm-utxo/test/dimensions.ts +++ b/packages/wasm-utxo/test/dimensions.ts @@ -11,6 +11,7 @@ import { import { mainnetCoinNames } from "./fixedScript/networkSupport.util.js"; import { getDefaultWalletKeys } from "../js/testutils/index.js"; import type { InputScriptType } from "../js/fixedScriptWallet/BitGoPsbt.js"; +import { ZcashUnifiedAddress } from "../js/fixedScriptWallet/ZcashUnifiedAddress.js"; /** * Map fixture psbtInput type to InputScriptType @@ -241,6 +242,88 @@ describe("Dimensions", function () { Dimensions.fromOutput({ length: 34 }).getOutputWeight(), ); }); + + // Zcash still fees transactions with legacy `vsize * feerate`, not ZIP-317's + // per-logical-action accounting. An Orchard/Ironwood shielded output has no scriptPubKey — + // its recipient is a ZIP-316 Unified Address, not a transparent address — but rather than + // modeling the Orchard action's real on-chain byte layout, it's run through the same + // length-based formula as a transparent output, sized from the UA's decoded 43-byte + // receiver. `isShielded: true` forwards to `toOutputScriptWithCoin`'s `canBeShieldedOutput`, + // which is what lets a UA decode at all instead of failing as an invalid transparent address. + describe("Zcash Orchard/Ironwood shielded output", function () { + const ORCHARD_RECEIVER_SIZE = 43; + // 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"); + + it("sizes a shielded output the same as a 43-byte scriptPubKey", function () { + const shieldedOutput = Dimensions.fromOutput(UNIFIED_ADDRESS, "tzec", { isShielded: true }); + + // Output weight = 4 * (8 + 1 + 43) = 208 + assert.strictEqual(shieldedOutput.getOutputWeight(), 208); + assert.strictEqual( + shieldedOutput.getOutputWeight(), + Dimensions.fromOutput({ length: ORCHARD_RECEIVER_SIZE }).getOutputWeight(), + ); + }); + + it("throws for a UA without isShielded (not a valid transparent address)", function () { + assert.throws(() => Dimensions.fromOutput(UNIFIED_ADDRESS, "tzec")); + }); + + it("still decodes an ordinary transparent zcash address with isShielded: true", function () { + // A zcash testnet p2sh address -> ordinary 23-byte scriptPubKey + const transparentAddress = "t288NZMrzYi6oednBEnw8UZvGoqX4Z6NXys"; + const dim = Dimensions.fromOutput(transparentAddress, "tzec", { isShielded: true }); + + assert.strictEqual( + dim.getOutputWeight(), + Dimensions.fromOutput({ length: 23 }).getOutputWeight(), + ); + }); + + it("does not count as segwit", function () { + const shieldedOutput = Dimensions.fromOutput(UNIFIED_ADDRESS, "tzec", { isShielded: true }); + assert.strictEqual(shieldedOutput.hasSegwit, false); + }); + + it("combines with a transparent input the same way any other output would", function () { + const shieldedOutput = Dimensions.fromOutput(UNIFIED_ADDRESS, "tzec", { isShielded: true }); + const transparentInput = Dimensions.fromInput({ chain: 0 }); + const combined = transparentInput.plus(shieldedOutput); + + // Overhead (non-segwit tx structure, 4 * 10 = 40) is only counted once, not once per + // side — combined weight is strictly less than the naive sum of the two standalone + // weights (which would each already include their own copy of the overhead). + assert.strictEqual( + combined.getWeight("max"), + transparentInput.getInputWeight("max") + shieldedOutput.getOutputWeight() + 40, + ); + assert.ok( + combined.getWeight("max") < + transparentInput.getWeight("max") + shieldedOutput.getWeight("max"), + ); + }); + + it("legacy fee for a shielded output clears the ZIP-317 marginal fee for one action", function () { + // ZIP-317 marginal fee per logical action: 5000 zatoshis (0.00005 ZEC). + const ZIP_317_MARGINAL_FEE_ZATOSHIS = 5000; + // A representative legacy fee rate (zatoshis per kvB), same order of magnitude as + // default relay fee rates used elsewhere for legacy fee estimation. + const FEE_RATE_ZAT_PER_KVB = 150_000; + + const shieldedOutput = Dimensions.fromOutput(UNIFIED_ADDRESS, "tzec", { isShielded: true }); + const legacyFee = (shieldedOutput.getOutputVSize() * FEE_RATE_ZAT_PER_KVB) / 1000; + + assert.ok( + legacyFee > ZIP_317_MARGINAL_FEE_ZATOSHIS, + `expected legacy fee (${legacyFee} zats) to exceed the ZIP-317 marginal fee (${ZIP_317_MARGINAL_FEE_ZATOSHIS} zats)`, + ); + }); + }); }); describe("plus", function () {