From b2ee2ec32f829a2ebd5f2f10f68faa61cd37805f Mon Sep 17 00:00:00 2001 From: Otto Allmendinger Date: Mon, 12 Jan 2026 14:53:31 +0100 Subject: [PATCH] feat(wasm-utxo): add dimension calculation methods Add new methods to Dimensions class for more granular tx size calculations: - `times()` to multiply dimensions by a scalar - `getInputWeight()` and `getInputVSize()` to get input-specific sizes - `getOutputWeight()` and `getOutputVSize()` to get output-specific sizes These methods enable more precise fee estimation and transaction sizing. Issue: BTC-2908 Co-authored-by: llm-git --- .../js/fixedScriptWallet/Dimensions.ts | 37 ++++++ .../wasm/fixed_script_wallet/dimensions.rs | 41 +++++++ packages/wasm-utxo/test/dimensions.ts | 110 ++++++++++++++++++ 3 files changed, 188 insertions(+) diff --git a/packages/wasm-utxo/js/fixedScriptWallet/Dimensions.ts b/packages/wasm-utxo/js/fixedScriptWallet/Dimensions.ts index 4b4e8ff..02c8395 100644 --- a/packages/wasm-utxo/js/fixedScriptWallet/Dimensions.ts +++ b/packages/wasm-utxo/js/fixedScriptWallet/Dimensions.ts @@ -74,6 +74,13 @@ export class Dimensions { return new Dimensions(this._wasm.plus(other._wasm)); } + /** + * Multiply dimensions by a scalar + */ + times(n: number): Dimensions { + return new Dimensions(this._wasm.times(n)); + } + /** * Whether any inputs are segwit (affects overhead calculation) */ @@ -96,4 +103,34 @@ export class Dimensions { getVSize(size: "min" | "max" = "max"): number { return this._wasm.get_vsize(size); } + + /** + * Get input weight only (min or max) + * @param size - "min" or "max", defaults to "max" + */ + getInputWeight(size: "min" | "max" = "max"): number { + return this._wasm.get_input_weight(size); + } + + /** + * Get input virtual size (min or max) + * @param size - "min" or "max", defaults to "max" + */ + getInputVSize(size: "min" | "max" = "max"): number { + return this._wasm.get_input_vsize(size); + } + + /** + * Get output weight + */ + getOutputWeight(): number { + return this._wasm.get_output_weight(); + } + + /** + * Get output virtual size + */ + getOutputVSize(): number { + return this._wasm.get_output_vsize(); + } } diff --git a/packages/wasm-utxo/src/wasm/fixed_script_wallet/dimensions.rs b/packages/wasm-utxo/src/wasm/fixed_script_wallet/dimensions.rs index e06c62a..1b9a27b 100644 --- a/packages/wasm-utxo/src/wasm/fixed_script_wallet/dimensions.rs +++ b/packages/wasm-utxo/src/wasm/fixed_script_wallet/dimensions.rs @@ -456,6 +456,16 @@ impl WasmDimensions { } } + /// Multiply dimensions by a scalar + pub fn times(&self, n: u32) -> WasmDimensions { + WasmDimensions { + input_weight_min: self.input_weight_min * n as usize, + input_weight_max: self.input_weight_max * n as usize, + output_weight: self.output_weight * n as usize, + has_segwit: self.has_segwit, + } + } + /// Whether any inputs are segwit (affects overhead calculation) pub fn has_segwit(&self) -> bool { self.has_segwit @@ -501,4 +511,35 @@ impl WasmDimensions { let weight = self.get_weight(size); weight.div_ceil(4) } + + /// Get input weight only (min or max) + /// + /// # Arguments + /// * `size` - "min" or "max", defaults to "max" + pub fn get_input_weight(&self, size: Option) -> u32 { + let use_min = size.as_deref() == Some("min"); + if use_min { + self.input_weight_min as u32 + } else { + self.input_weight_max as u32 + } + } + + /// Get input virtual size (min or max) + /// + /// # Arguments + /// * `size` - "min" or "max", defaults to "max" + pub fn get_input_vsize(&self, size: Option) -> u32 { + self.get_input_weight(size).div_ceil(4) + } + + /// Get output weight + pub fn get_output_weight(&self) -> u32 { + self.output_weight as u32 + } + + /// Get output virtual size + pub fn get_output_vsize(&self) -> u32 { + (self.output_weight as u32).div_ceil(4) + } } diff --git a/packages/wasm-utxo/test/dimensions.ts b/packages/wasm-utxo/test/dimensions.ts index 81b5b41..f5a591e 100644 --- a/packages/wasm-utxo/test/dimensions.ts +++ b/packages/wasm-utxo/test/dimensions.ts @@ -207,6 +207,116 @@ describe("Dimensions", function () { }); }); + describe("times", function () { + it("should multiply dimensions by a scalar", function () { + const input = Dimensions.fromInput({ chain: 10 }); + const doubled = input.times(2); + + assert.strictEqual(doubled.getInputWeight("min"), input.getInputWeight("min") * 2); + assert.strictEqual(doubled.getInputWeight("max"), input.getInputWeight("max") * 2); + assert.strictEqual(doubled.hasSegwit, input.hasSegwit); + }); + + it("should return zero for times(0)", function () { + const input = Dimensions.fromInput({ chain: 10 }); + const zeroed = input.times(0); + + assert.strictEqual(zeroed.getInputWeight(), 0); + assert.strictEqual(zeroed.getOutputWeight(), 0); + }); + + it("should multiply outputs", function () { + const output = Dimensions.fromOutput(Buffer.alloc(34)); + const tripled = output.times(3); + + assert.strictEqual(tripled.getOutputWeight(), output.getOutputWeight() * 3); + }); + + it("times(1) should return equivalent dimensions", function () { + const dim = Dimensions.fromInput({ chain: 20 }).plus(Dimensions.fromOutput(Buffer.alloc(23))); + const same = dim.times(1); + + assert.strictEqual(same.getInputWeight("min"), dim.getInputWeight("min")); + assert.strictEqual(same.getInputWeight("max"), dim.getInputWeight("max")); + assert.strictEqual(same.getOutputWeight(), dim.getOutputWeight()); + }); + }); + + describe("getInputWeight and getInputVSize", function () { + it("should return input weight only (no overhead)", function () { + const input = Dimensions.fromInput({ chain: 10 }); + const inputWeight = input.getInputWeight(); + + // Input weight should be less than total weight (which includes overhead) + assert.ok(inputWeight < input.getWeight()); + assert.ok(inputWeight > 0); + }); + + it("should return min/max input weights for ECDSA inputs", function () { + const input = Dimensions.fromInput({ chain: 10 }); + + // p2shP2wsh has ECDSA variance + assert.ok(input.getInputWeight("min") < input.getInputWeight("max")); + assert.ok(input.getInputVSize("min") < input.getInputVSize("max")); + }); + + it("should return equal min/max for Schnorr inputs", function () { + const input = Dimensions.fromInput({ chain: 40 }); + + // p2trMusig2 keypath has no variance + assert.strictEqual(input.getInputWeight("min"), input.getInputWeight("max")); + assert.strictEqual(input.getInputVSize("min"), input.getInputVSize("max")); + }); + + it("getInputVSize should be ceiling of weight/4", function () { + const input = Dimensions.fromInput({ chain: 20 }); + + assert.strictEqual(input.getInputVSize("min"), Math.ceil(input.getInputWeight("min") / 4)); + assert.strictEqual(input.getInputVSize("max"), Math.ceil(input.getInputWeight("max") / 4)); + }); + + it("should return zero for output-only dimensions", function () { + const output = Dimensions.fromOutput(Buffer.alloc(34)); + + assert.strictEqual(output.getInputWeight(), 0); + assert.strictEqual(output.getInputVSize(), 0); + }); + }); + + describe("getOutputWeight and getOutputVSize", function () { + it("should return output weight only (no overhead)", function () { + const output = Dimensions.fromOutput(Buffer.alloc(23)); + const outputWeight = output.getOutputWeight(); + + // Output weight = 4 * (8 + 1 + 23) = 128 + assert.strictEqual(outputWeight, 128); + }); + + it("getOutputVSize should be ceiling of weight/4", function () { + const output = Dimensions.fromOutput(Buffer.alloc(23)); + + assert.strictEqual(output.getOutputVSize(), Math.ceil(output.getOutputWeight() / 4)); + // 128 / 4 = 32 + assert.strictEqual(output.getOutputVSize(), 32); + }); + + it("should return zero for input-only dimensions", function () { + const input = Dimensions.fromInput({ chain: 10 }); + + assert.strictEqual(input.getOutputWeight(), 0); + assert.strictEqual(input.getOutputVSize(), 0); + }); + + it("should combine correctly with plus", function () { + const input = Dimensions.fromInput({ chain: 10 }); + const output = Dimensions.fromOutput(Buffer.alloc(34)); + const combined = input.plus(output); + + assert.strictEqual(combined.getInputWeight(), input.getInputWeight()); + assert.strictEqual(combined.getOutputWeight(), output.getOutputWeight()); + }); + }); + describe("integration tests with fixtures", function () { // Zcash has additional transaction overhead (version group, expiry height, etc.) // that we don't account for in Dimensions - skip it for now