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
21 changes: 18 additions & 3 deletions packages/wasm-utxo/js/fixedScriptWallet/Dimensions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down Expand Up @@ -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
*/
Expand All @@ -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) {
Expand Down
13 changes: 5 additions & 8 deletions packages/wasm-utxo/src/wasm/fixed_script_wallet/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand All @@ -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<crate::networks::Network, WasmUtxoError> {
Expand Down Expand Up @@ -546,11 +551,6 @@ impl BitGoPsbt {
memo: &[u8],
unified_address: Option<String>,
) -> 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<const N: usize>(bytes: &[u8], field: &str) -> Result<[u8; N], WasmUtxoError> {
bytes.try_into().map_err(|_| {
Expand Down Expand Up @@ -593,9 +593,6 @@ impl BitGoPsbt {
outputs: JsValue,
anchor: &[u8],
) -> Result<Vec<u32>, 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 {}",
Expand Down
3 changes: 1 addition & 2 deletions packages/wasm-utxo/src/wasm/try_from_js_value.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};

Expand Down Expand Up @@ -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<Self, WasmUtxoError> {
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() {
Expand Down
83 changes: 83 additions & 0 deletions packages/wasm-utxo/test/dimensions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 () {
Expand Down
Loading