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
16 changes: 4 additions & 12 deletions packages/wasm-utxo/js/address.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,19 +8,11 @@ import type { CoinName } from "./coinName.js";
export type AddressFormat = "default" | "cashaddr";

/**
* @param canBeShieldedOutput - When set and `address` is a ZIP-316 unified address carrying an
* Orchard/Ironwood receiver, returns that raw 43-byte receiver instead of a transparent
* scriptPubKey (there is no scriptPubKey for a shielded output). `address` is only even
* attempted as a unified address when this is set. If `address` looks like a unified address for
* this coin's network but is malformed, or has no Orchard/Ironwood receiver (e.g. Sapling-only),
* this throws rather than falling back to the transparent path.
* Convert `address` to its scriptPubKey bytes for `coin`, decoding it as an ordinary transparent
* address.
*/
export function toOutputScriptWithCoin(
address: string,
coin: CoinName,
canBeShieldedOutput?: boolean,
): Uint8Array {
return AddressNamespace.to_output_script_with_coin(address, coin, canBeShieldedOutput);
export function toOutputScriptWithCoin(address: string, coin: CoinName): Uint8Array {
return AddressNamespace.to_output_script_with_coin(address, coin);
}

export function fromOutputScriptWithCoin(
Expand Down
24 changes: 5 additions & 19 deletions packages/wasm-utxo/js/fixedScriptWallet/Dimensions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,17 +17,6 @@ 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 All @@ -37,7 +26,7 @@ export type FromOutputOptions = {
* This is a thin wrapper over the WASM implementation.
*/
export class Dimensions {
private constructor(private _wasm: WasmDimensions) {}
protected constructor(private _wasm: WasmDimensions) {}

/**
* Create empty dimensions (zero weight)
Expand Down Expand Up @@ -83,12 +72,10 @@ export class Dimensions {
*/
static fromOutput(script: Uint8Array): Dimensions;
/**
* 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
* Create dimensions for a single output from an address, decoded as an ordinary transparent
* address for `network`.
*/
static fromOutput(address: string, network: CoinName, options?: FromOutputOptions): Dimensions;
static fromOutput(address: string, network: CoinName): Dimensions;
/**
* Create dimensions for a single output from script length only
*/
Expand All @@ -100,13 +87,12 @@ 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, options?.isShielded);
const script = toOutputScriptWithCoin(params, network);
return new Dimensions(WasmDimensions.from_output_script_length(script.length));
}
if (typeof params === "object" && "scriptType" in params) {
Expand Down
69 changes: 69 additions & 0 deletions packages/wasm-utxo/js/fixedScriptWallet/ZcashDimensions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import { WasmDimensions } from "../wasm/wasm_utxo.js";
import type { CoinName } from "../coinName.js";
import type { OutputScriptType } from "./scriptType.js";
import { toShieldedReceiverWithCoin, toTransparentReceiverWithCoin } from "../zcashAddress.js";
import { Dimensions } from "./Dimensions.js";

/**
* Options for {@link ZcashDimensions.fromOutput}.
*/
export type ZcashFromOutputOptions = {
/**
* Set when `address` may be a ZIP-316 Unified Address whose Orchard receiver should be sized
* as a shielded output. Forwarded to `zcashAddress.toShieldedReceiverWithCoin`; when unset,
* `zcashAddress.toTransparentReceiverWithCoin` is used instead.
*/
isShielded?: boolean;
};

/**
* Zcash-specific dimensions: resolves ZIP-316 Unified Addresses (transparent or Orchard/Ironwood
* shielded receiver) instead of the plain transparent-only decoding {@link Dimensions.fromOutput}
* does for every other coin.
*/
export class ZcashDimensions extends Dimensions {
/**
* Create dimensions for a single output from script bytes
*/
static fromOutput(script: Uint8Array): Dimensions;
/**
* Create dimensions for a single output from a Zcash address for `network`.
*
* Pass `{ isShielded: true }` when `address` may be a ZIP-316 Unified Address whose Orchard
* receiver should be sized as a shielded output; otherwise its transparent receiver is
* resolved (a UA with no transparent receiver throws rather than falling back to Orchard).
*/
static fromOutput(
address: string,
network: CoinName,
options?: ZcashFromOutputOptions,
): Dimensions;
/**
* Create dimensions for a single output from script length only
*/
static fromOutput(params: { length: number }): Dimensions;
/**
* Create dimensions for a single output from script type
*/
static fromOutput(params: { scriptType: OutputScriptType }): Dimensions;
static fromOutput(
params: Uint8Array | string | { length: number } | { scriptType: OutputScriptType },
network?: CoinName,
options?: ZcashFromOutputOptions,
): Dimensions {
if (typeof params === "string") {
if (network === undefined) {
throw new Error("network is required when passing an address string");
}
const receiver = options?.isShielded
? toShieldedReceiverWithCoin(params, network)
: toTransparentReceiverWithCoin(params, network);
return new ZcashDimensions(WasmDimensions.from_output_script_length(receiver.length));
}
if (typeof params === "object" && "scriptType" in params) {
return Dimensions.fromOutput(params);
}
// Both Uint8Array and { length: number } have .length
return Dimensions.fromOutput(params);
}
}
22 changes: 22 additions & 0 deletions packages/wasm-utxo/js/fixedScriptWallet/ZcashUnifiedAddress.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ import type { ZcashNetworkName } from "./ZcashBitGoPsbt.js";
* const ua = ZcashUnifiedAddress.parse(uaString, "zec");
* const ironwood = ua.orchardReceiver; // 43 bytes, or undefined
* const script = ua.transparentScript; // scriptPubKey bytes, or undefined
* ua.hasOrchardReceiver; // true iff orchardReceiver is present
* ua.hasTransparentReceiver; // true iff transparentScript is present
* ua.contains(transparentAddress); // is it one of this UA's receivers?
* ```
*/
Expand Down Expand Up @@ -56,6 +58,26 @@ export class ZcashUnifiedAddress {
return this._wasm.transparentScript;
}

/**
* Whether this Unified Address carries an Orchard/Ironwood receiver.
*
* Equivalent to `orchardReceiver !== undefined`; prefer this when only presence
* matters, since it avoids copying the receiver bytes.
*/
get hasOrchardReceiver(): boolean {
return this._wasm.hasOrchardReceiver;
}

/**
* Whether this Unified Address carries a transparent (P2PKH/P2SH) receiver.
*
* Equivalent to `transparentScript !== undefined`; prefer this when only presence
* matters, since it avoids copying the script bytes.
*/
get hasTransparentReceiver(): boolean {
return this._wasm.hasTransparentReceiver;
}

/**
* Whether `candidate` is a receiver of this unified address.
*
Expand Down
2 changes: 2 additions & 0 deletions packages/wasm-utxo/js/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ void wasm;
// Most exports are namespaced to avoid polluting the top-level namespace
// and to make imports more explicit (e.g., `import { address } from '@bitgo/wasm-utxo'`)
export * as address from "./address.js";
export * as zcashAddress from "./zcashAddress.js";
export * as ast from "./ast/index.js";
export * as bip322 from "./bip322/index.js";
export * as inscriptions from "./inscriptions.js";
Expand All @@ -21,6 +22,7 @@ export * as ecpair from "./ecpair.js";
export { ECPair } from "./ecpair.js";
export { BIP32 } from "./bip32.js";
export { Dimensions } from "./fixedScriptWallet/Dimensions.js";
export { ZcashDimensions } from "./fixedScriptWallet/ZcashDimensions.js";

export type WasmUtxoVersionInfo = { version: string; gitHash: string };
export function getWasmUtxoVersion(): WasmUtxoVersionInfo {
Expand Down
62 changes: 62 additions & 0 deletions packages/wasm-utxo/js/zcashAddress.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import {
zcashHasOrchardReceiver,
zcashHasTransparentReceiver,
zcashToShieldedReceiverWithCoin,
zcashToTransparentReceiverWithCoin,
} from "./wasm/wasm_utxo.js";
import type { CoinName } from "./coinName.js";

/**
* Convert `address` to its scriptPubKey bytes for `coin`'s network. When `address` is a ZIP-316
* unified address, resolves its transparent receiver -- same as any other transparent address --
* rather than rejecting the UA string outright. A UA with no transparent receiver (e.g.
* Orchard-only) throws rather than falling back to a shielded one.
*
* `js/address.ts`'s `toOutputScriptWithCoin` dispatches here for zcash/tzec; call this directly
* only if you already know `coin` is a Zcash coin.
*/
export function toTransparentReceiverWithCoin(address: string, coin: CoinName): Uint8Array {
return zcashToTransparentReceiverWithCoin(address, coin);
}

/**
* Resolve the Orchard/Ironwood receiver of a ZIP-316 unified address for `coin`'s network, as its
* raw 43 bytes (diversifier + `pk_d`) -- there is no scriptPubKey for a shielded output, so this
* returns raw receiver bytes rather than a scriptPubKey.
*
* `address` must be a unified address; an ordinary transparent address is rejected, since it can
* never carry a shielded receiver. A UA that has no Orchard/Ironwood receiver (e.g. Sapling-only)
* throws rather than falling back to the transparent receiver. If `address` merely looks like a
* unified address for this coin's network (right Bech32m HRP) but is malformed, this also throws.
* Use {@link toOutputScriptWithCoin} from `js/address.ts` for the transparent case.
*/
export function toShieldedReceiverWithCoin(address: string, coin: CoinName): Uint8Array {
return zcashToShieldedReceiverWithCoin(address, coin);
}

/**
* Whether `address` is a ZIP-316 unified address for `coin`'s network carrying an
* Orchard/Ironwood receiver.
*
* A plain membership check, not a decoder: never throws. Returns `false` for a malformed or
* wrong-network unified address, an ordinary (non-UA) address, or a UA with no Orchard/Ironwood
* receiver (e.g. Sapling-only). Use {@link toShieldedReceiverWithCoin} when the raw receiver bytes
* are needed.
*/
export function hasOrchardReceiver(address: string, coin: CoinName): boolean {
return zcashHasOrchardReceiver(address, coin);
}

/**
* Whether `address` has a usable transparent receiver for `coin`'s network: either `address` is
* a ZIP-316 unified address carrying a transparent receiver, or `address` is itself an ordinary
* transparent address that decodes for `coin`.
*
* A plain membership check, not a decoder: never throws. Returns `false` for a malformed or
* wrong-network unified address, a UA with no transparent receiver (e.g. Orchard-only), or an
* address that is neither a unified address nor a valid transparent address for `coin`. Use
* {@link toOutputScriptWithCoin} from `js/address.ts` when the scriptPubKey bytes are needed.
*/
export function hasTransparentReceiver(address: string, coin: CoinName): boolean {
return zcashHasTransparentReceiver(address, coin);
}
112 changes: 0 additions & 112 deletions packages/wasm-utxo/src/address/networks.rs
Original file line number Diff line number Diff line change
Expand Up @@ -354,43 +354,6 @@ pub fn to_output_script_with_coin(address: &str, coin: &str) -> Result<ScriptBuf
to_output_script_with_network(address, network)
}

/// Like [`to_output_script_with_coin`], but when `can_be_shielded_output` is set and `address` is
/// a ZIP-316 unified address for `coin`'s network, returns the UA's raw 43-byte Orchard/Ironwood
/// receiver instead of a transparent scriptPubKey — there is no scriptPubKey for a shielded
/// output, so this can't return a `ScriptBuf` uniformly and returns raw bytes instead.
///
/// `address` is only even attempted as a UA when `can_be_shielded_output` is set: a transparent
/// address is never itself a valid UA (a UA HRP can't collide with a transparent address's own
/// encoding), so this flag exists purely to make "the caller is prepared to receive an Ironwood
/// receiver instead of a scriptPubKey" explicit rather than inferred from the address string.
///
/// If `address` merely *looks* like a UA for this network (right Bech32m HRP) but is malformed,
/// or is well-formed but has no Orchard/Ironwood receiver (e.g. Sapling-only), this errors rather
/// than silently falling back to the transparent path — a UA that can't yield the shielded
/// receiver the caller asked for is a caller bug, not an alternate valid address.
pub fn to_output_script_or_shielded_receiver_with_coin(
address: &str,
coin: &str,
can_be_shielded_output: bool,
) -> Result<Vec<u8>> {
if can_be_shielded_output
&& crate::zcash::unified_address::looks_like_unified_for_network(address, coin)
{
let ua = crate::zcash::unified_address::UnifiedAddress::parse(address, coin)
.map_err(|e| AddressError::InvalidAddress(e.to_string()))?;
return match ua
.orchard_receiver()
.map_err(|e| AddressError::InvalidAddress(e.to_string()))?
{
Some(receiver) => Ok(receiver),
None => Err(AddressError::InvalidAddress(format!(
"unified address has no Orchard/Ironwood receiver: {address}"
))),
};
}
to_output_script_with_coin(address, coin).map(|script| script.to_bytes().to_vec())
}

/// Convert an output script to an address string using a BitGo coin name.
/// The coin name is first converted to a Network using `Network::from_coin_name()`.
pub fn from_output_script_with_coin(script: &Script, coin: &str) -> Result<String> {
Expand Down Expand Up @@ -483,81 +446,6 @@ mod tests {
assert!(result.is_err());
}

mod shielded_output {
use super::*;

fn ua_fixtures() -> serde_json::Value {
let s = crate::fixed_script_wallet::test_utils::fixtures::load_fixture(
"zcash/unified_address.json",
)
.expect("load unified_address.json");
serde_json::from_str(&s).expect("parse unified_address.json")
}

fn fx(v: &serde_json::Value, group: &str, key: &str) -> String {
v[group][key]
.as_str()
.unwrap_or_else(|| panic!("missing fixture field {}.{}", group, key))
.to_string()
}

#[test]
fn returns_the_orchard_receiver_for_a_unified_address_when_set() {
let f = ua_fixtures();
let ua = fx(&f, "zip316Mainnet", "unified");
let expected = hex::decode(fx(&f, "zip316Mainnet", "orchardReceiverHex")).unwrap();

let receiver =
to_output_script_or_shielded_receiver_with_coin(&ua, "zec", true).unwrap();
assert_eq!(receiver, expected);
assert_eq!(receiver.len(), 43);
}

#[test]
fn never_even_attempts_ua_parsing_when_unset() {
// A unified address is never itself a valid transparent address, so without the flag
// this must fail exactly like it did before this feature existed — not succeed by
// accidentally matching some transparent codec.
let ua = fx(&ua_fixtures(), "zip316Mainnet", "unified");
assert!(to_output_script_or_shielded_receiver_with_coin(&ua, "zec", false).is_err());
}

#[test]
fn falls_through_to_the_transparent_path_for_an_ordinary_address_even_when_set() {
let f = ua_fixtures();
let addr = fx(&f, "testnetWallet", "transparentAddress");
let expected_hash = hex::decode(fx(&f, "testnetWallet", "transparentPubkeyHashHex"))
.unwrap()
.try_into()
.unwrap();
let expected =
ScriptBuf::new_p2pkh(&PubkeyHash::from_byte_array(expected_hash)).to_bytes();

let script =
to_output_script_or_shielded_receiver_with_coin(&addr, "tzec", true).unwrap();
assert_eq!(script, expected);
}

#[test]
fn errors_on_a_wrong_network_unified_address_rather_than_succeeding() {
// The mainnet UA's HRP ("u") doesn't match testnet's ("utest"), so
// `looks_like_unified_for_network` itself returns false — this never reaches
// `UnifiedAddress::parse`'s own (separately tested, in unified_address.rs)
// `WrongHrp` check; it falls through to the transparent path instead, which fails for
// an unrelated reason (a UA string never decodes as a transparent address). Assert on
// that specific failure rather than a bare `is_err()`, so this pins down which path
// actually rejected it — a bare `is_err()` would still pass even if the network check
// were silently removed entirely.
let ua = fx(&ua_fixtures(), "zip316Mainnet", "unified");
let err =
to_output_script_or_shielded_receiver_with_coin(&ua, "tzec", true).unwrap_err();
assert!(
err.to_string().contains("Could not decode address"),
"expected the transparent-decode fallback to fail; got: {err}"
);
}
}

#[test]
fn test_base58_bitcoin_cash() {
// Bitcoin Cash should prefer base58 format for encoding
Expand Down
Loading
Loading