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
122 changes: 122 additions & 0 deletions packages/wasm-utxo/docs/zip-0316-unified-address.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
# ZIP-316 Unified Address encoding (vendored reference)

This is a self-contained reference for the exact encoding that
[`src/zcash/unified_address.rs`](../src/zcash/unified_address.rs) parses. It is
distilled from the canonical sources so review does not depend on an external link:

- ZIP-316 "Unified Addresses and Unified Viewing Keys" — <https://zips.z.cash/zip-0316>
- F4Jumble reference implementation — the `f4jumble` crate (Zcash Foundation) and
<https://github.com/zcash-hackworks/zcash-test-vectors/blob/master/f4jumble.py>
- Unified-address test vectors — `zcash_address` crate
(`kind/unified/address/test_vectors.rs`) and
<https://github.com/zcash/zcash-test-vectors/blob/master/zcash_test_vectors/unified_address.py>

We implement **decoding only** (parse a UA → receivers). Encoding is not needed.

---

## 1. Overall structure

A Unified Address is a Bech32m string whose data part is an **F4Jumbled** byte
sequence. Decoding reverses the pipeline:

```
UA string
── Bech32m decode ─────────────▶ jumbled bytes (HRP checked against network)
── F4Jumble⁻¹ ────────────────▶ padded bytes
── strip 16-byte HRP padding ─▶ receivers blob
── parse TLV records ─────────▶ [ (typecode, data), … ]
```

### Human-readable parts (HRP)

| Network | HRP |
| ---------------------------- | ------- |
| Mainnet (`zec`/`zcash`) | `u` |
| Testnet (`tzec`/`zcashTest`) | `utest` |

(Regtest `uregtest` exists in ZIP-316 but is intentionally unsupported here — there
is no corresponding transparent base58check codec in this crate, so supporting it
for UA parsing but not for transparent-address comparison would be inconsistent.)

### Receiver TLV records

The un-jumbled, un-padded blob is a sequence of receivers, each:

```
CompactSize(typecode) ‖ CompactSize(length) ‖ data[length]
```

Receivers MUST appear in **strictly ascending typecode order**.

| Typecode | Receiver | `data` |
| -------- | ------------------- | --------------------------------------------- |
| `0x00` | P2PKH (transparent) | 20-byte pubkey hash |
| `0x01` | P2SH (transparent) | 20-byte script hash |
| `0x02` | Sapling | 43 bytes (11-byte diversifier + 32-byte pk_d) |
| `0x03` | Orchard | 43 bytes (11-byte diversifier + 32-byte pk_d) |

**Ironwood (NU6.3) reuses the Orchard receiver (`0x03`)** — no new typecode. An
Orchard unified receiver routes to the Ironwood pool once NU6.3 rules are active.

A transparent receiver here is reduced to its scriptPubKey: P2PKH →
`OP_DUP OP_HASH160 <20> OP_EQUALVERIFY OP_CHECKSIG`, P2SH → `OP_HASH160 <20> OP_EQUAL`.

### Padding

After the last receiver, 16 bytes of padding are appended: the HRP as ASCII,
zero-extended to 16 bytes. On decode we strip the last 16 bytes and verify they
equal `HRP ‖ 0x00…`.

---

## 2. F4Jumble

F4Jumble is a length-preserving, unkeyed **4-round Feistel** network over two
unequal halves, giving cascading (avalanche) behavior so a single altered character
changes the whole decoded output. Valid message length is `48 ..= 4_194_368` bytes.

### Split

```
ℓ = len(message)
left_len = min(64, ℓ / 2) # 64 = BLAKE2b output size (OUTBYTES)
left = message[..left_len]
right = message[left_len..]
```

### Round functions

Both use BLAKE2b with a 16-byte personalization.

```
H(i): hash = BLAKE2b(personal = b"UA_F4Jumble_H" ‖ [i, 0, 0],
out_len = len(left)) over `right`
left ^= hash

G(i): for j in 0 .. ceil(len(right) / 64):
hash = BLAKE2b(personal = b"UA_F4Jumble_G" ‖ [i, j_lo, j_hi],
out_len = 64) over `left`
right[j*64 ..] ^= hash
```

`j_lo`/`j_hi` are the little-endian bytes of the `u16` block index `j`.
Note the output length is folded into BLAKE2b's parameter block, so it is part of
the domain — not a truncation (see `blake2b_var_personal`).

### Round order

```
apply F4Jumble : G(0) H(0) G(1) H(1)
apply F4Jumble⁻¹ : H(1) G(1) H(0) G(0) # what we implement
```

---

## 3. Test vectors used

- **F4Jumble⁻¹** is checked against the 48-byte vector from the `f4jumble` crate
(`test_vectors.rs`, vector 0).
- **UA parsing** is checked against an official ZIP-316 mainnet vector (P2PKH +
Orchard receivers) and a real testnet wallet vector (UA ↔ transparent address ↔
raw Ironwood receiver), both in `test/fixtures/zcash/unified_address.json`.
75 changes: 75 additions & 0 deletions packages/wasm-utxo/js/fixedScriptWallet/ZcashUnifiedAddress.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import { ZcashUnifiedAddress as WasmZcashUnifiedAddress } from "../wasm/wasm_utxo.js";
import type { ZcashNetworkName } from "./ZcashBitGoPsbt.js";

/**
* A parsed ZIP-316 Unified Address.
*
* Decode once with {@link ZcashUnifiedAddress.parse}, then read each component
* through its accessor (returns `undefined` when the receiver is absent). Membership
* of another address is answered by {@link contains}.
*
* Ironwood reuses the Orchard receiver, so {@link orchardReceiver} is the shielded
* receiver used to construct an Ironwood output note.
*
* @example
* ```typescript
* const ua = ZcashUnifiedAddress.parse(uaString, "zec");
* const ironwood = ua.orchardReceiver; // 43 bytes, or undefined
* const script = ua.transparentScript; // scriptPubKey bytes, or undefined
* ua.contains(transparentAddress); // is it one of this UA's receivers?
* ```
*/
export class ZcashUnifiedAddress {
private constructor(private _wasm: WasmZcashUnifiedAddress) {}

/**
* Parse a Unified Address.
*
* @param address - The Bech32m unified address string
* @param network - Zcash network name ("zcash", "zcashTest", "zec", "tzec")
* @throws If the address is malformed or on the wrong network
*/
static parse(address: string, network: ZcashNetworkName): ZcashUnifiedAddress {
return new ZcashUnifiedAddress(WasmZcashUnifiedAddress.parse(address, network));
}

/**
* The Orchard (a.k.a. Ironwood) receiver — 43 raw bytes (11-byte diversifier +
* 32-byte pk_d) — or `undefined` if the UA has no Orchard receiver.
*/
get orchardReceiver(): Uint8Array | undefined {
return this._wasm.orchardReceiver;
}

/**
* The Sapling receiver — 43 raw bytes (diversifier + pk_d) — or `undefined`.
*/
get saplingReceiver(): Uint8Array | undefined {
return this._wasm.saplingReceiver;
}

/**
* The transparent receiver as scriptPubKey bytes (P2PKH or P2SH), ready to use as
* a `TxOut.scriptPubKey`, or `undefined` if the UA has no transparent receiver.
*/
get transparentScript(): Uint8Array | undefined {
return this._wasm.transparentScript;
}

/**
* Whether `candidate` is a receiver of this unified address.
*
* @param candidate - Another Unified Address (matches if all of its receivers are
* contained here) or a transparent Zcash address (matches this UA's transparent
* receiver). Must be on the same network as this UA.
* @throws If `candidate` is malformed or on the wrong network
*/
contains(candidate: string): boolean {
return this._wasm.contains(candidate);
}

/** @internal */
get wasm(): WasmZcashUnifiedAddress {
return this._wasm;
}
}
3 changes: 3 additions & 0 deletions packages/wasm-utxo/js/fixedScriptWallet/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,9 @@ export {
type CreateEmptyZcashOptions,
} from "./ZcashBitGoPsbt.js";

// Zcash ZIP-316 Unified Address
export { ZcashUnifiedAddress } from "./ZcashUnifiedAddress.js";

import type { ScriptType } from "./scriptType.js";

/**
Expand Down
9 changes: 9 additions & 0 deletions packages/wasm-utxo/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ macro_rules! impl_wasm_error_code {
pub enum WasmUtxoError {
StringError(String),
Parse(ParseTransactionError),
UnifiedAddress(crate::zcash::unified_address::UnifiedAddressError),
}

impl std::error::Error for WasmUtxoError {}
Expand All @@ -32,6 +33,7 @@ impl fmt::Display for WasmUtxoError {
match self {
WasmUtxoError::StringError(s) => write!(f, "{}", s),
WasmUtxoError::Parse(e) => write!(f, "{}", e),
WasmUtxoError::UnifiedAddress(e) => write!(f, "{}", e),
}
}
}
Expand All @@ -41,6 +43,7 @@ impl WasmErrorCode for WasmUtxoError {
match self {
WasmUtxoError::StringError(_) => "WasmUtxoError.StringError".to_string(),
WasmUtxoError::Parse(e) => e.code(),
WasmUtxoError::UnifiedAddress(e) => e.code(),
}
}
}
Expand Down Expand Up @@ -81,6 +84,12 @@ impl From<ParseTransactionError> for WasmUtxoError {
}
}

impl From<crate::zcash::unified_address::UnifiedAddressError> for WasmUtxoError {
fn from(err: crate::zcash::unified_address::UnifiedAddressError) -> Self {
WasmUtxoError::UnifiedAddress(err)
}
}

impl WasmUtxoError {
pub fn new(s: &str) -> WasmUtxoError {
WasmUtxoError::StringError(s.to_string())
Expand Down
21 changes: 2 additions & 19 deletions packages/wasm-utxo/src/wasm/fixed_script_wallet/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2001,22 +2001,5 @@ impl BitGoPsbt {

impl_wasm_psbt_ops!(BitGoPsbt, psbt);

/// Return the Zcash consensus branch ID active at `height` on `network`.
///
/// `network`: "zcash" / "zec" for mainnet, "zcashTest" / "tzec" for testnet.
/// Returns `None` if `height` is before Overwinter activation.
/// Throws if `network` is not a recognised Zcash network name.
#[wasm_bindgen]
pub fn zcash_branch_id_for_height(network: &str, height: u32) -> Result<Option<u32>, JsValue> {
let is_mainnet = match network {
"zcash" | "zec" => true,
"zcashTest" | "tzec" => false,
_ => {
return Err(JsValue::from_str(&format!(
"unknown Zcash network {:?}: expected \"zcash\", \"zec\", \"zcashTest\", or \"tzec\"",
network
)))
}
};
Ok(crate::zcash::branch_id_for_height(height, is_mainnet))
}
// Zcash-scoped bindings (`zcash_branch_id_for_height`, `ZcashUnifiedAddress`, …)
// live in `crate::wasm::zcash`.
1 change: 1 addition & 0 deletions packages/wasm-utxo/src/wasm/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ mod try_from_js_value;
mod try_into_js_value;
mod utxolib_compat;
mod wallet_keys;
mod zcash;

pub use address::AddressNamespace;
pub use bip32::WasmBIP32;
Expand Down
92 changes: 92 additions & 0 deletions packages/wasm-utxo/src/wasm/zcash.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
//! Zcash-scoped WASM bindings.
//!
//! Groups the `zcash_*` free functions and the [`ZcashUnifiedAddress`] wrapper so
//! Zcash surface lives in one zec-namespaced module rather than spread through the
//! generic fixed-script-wallet bindings.

use crate::error::WasmUtxoError;
use wasm_bindgen::prelude::*;

/// Return the Zcash consensus branch ID active at `height` on `network`.
///
/// `network`: "zcash" / "zec" for mainnet, "zcashTest" / "tzec" for testnet.
/// Returns `None` if `height` is before Overwinter activation.
/// Throws if `network` is not a recognised Zcash network name.
///
/// Errors are thrown as the crate-standard [`WasmUtxoError`] (a marked `js_sys::Error`
/// with `.message` and `.code`), not a bare string.
#[wasm_bindgen]
pub fn zcash_branch_id_for_height(
network: &str,
height: u32,
) -> Result<Option<u32>, WasmUtxoError> {
let is_mainnet = match network {
"zcash" | "zec" => true,
"zcashTest" | "tzec" => false,
_ => {
return Err(WasmUtxoError::from(format!(
"unknown Zcash network {network:?}: expected \"zcash\", \"zec\", \"zcashTest\", or \"tzec\""
)))
}
};
Ok(crate::zcash::branch_id_for_height(height, is_mainnet))
}

/// A parsed ZIP-316 Unified Address.
///
/// Decode once with [`ZcashUnifiedAddress::parse`], then read each component through
/// its accessor (returns `undefined` when absent). Ironwood reuses the Orchard
/// receiver, so `orchardReceiver` is the shielded receiver for Ironwood output notes.
#[wasm_bindgen]
pub struct ZcashUnifiedAddress {
inner: crate::zcash::unified_address::UnifiedAddress,
orchard: Option<Vec<u8>>,
sapling: Option<Vec<u8>>,
transparent: Option<Vec<u8>>,
}

#[wasm_bindgen]
impl ZcashUnifiedAddress {
/// Parse a Unified Address for `network` ("zcash"/"zec" or "zcashTest"/"tzec").
///
/// All receiver components are resolved and validated eagerly, so the accessors
/// below are infallible. Throws if the address is malformed or on the wrong network.
#[wasm_bindgen]
pub fn parse(address: &str, network: &str) -> Result<ZcashUnifiedAddress, WasmUtxoError> {
let inner = crate::zcash::unified_address::UnifiedAddress::parse(address, network)?;
let orchard = inner.orchard_receiver()?;
let sapling = inner.sapling_receiver()?;
let transparent = inner.transparent_script()?;
Ok(ZcashUnifiedAddress {
inner,
orchard,
sapling,
transparent,
})
}

/// The Orchard/Ironwood receiver's raw 43 bytes (diversifier + pk_d), or `undefined`.
#[wasm_bindgen(getter, js_name = orchardReceiver)]
pub fn orchard_receiver(&self) -> Option<Vec<u8>> {
self.orchard.clone()
}

/// The Sapling receiver's raw 43 bytes (diversifier + pk_d), or `undefined`.
#[wasm_bindgen(getter, js_name = saplingReceiver)]
pub fn sapling_receiver(&self) -> Option<Vec<u8>> {
self.sapling.clone()
}

/// The transparent receiver as scriptPubKey bytes (P2PKH/P2SH), or `undefined`.
#[wasm_bindgen(getter, js_name = transparentScript)]
pub fn transparent_script(&self) -> Option<Vec<u8>> {
self.transparent.clone()
}

/// Whether `candidate` (another Unified Address, or a transparent Zcash address
/// on the same network) is a receiver of this Unified Address.
#[wasm_bindgen]
pub fn contains(&self, candidate: &str) -> Result<bool, WasmUtxoError> {
Ok(self.inner.contains(candidate)?)
}
}
1 change: 1 addition & 0 deletions packages/wasm-utxo/src/zcash/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@

pub mod blake2b;
pub mod transaction;
pub mod unified_address;

/// Zcash network upgrade identifiers
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
Expand Down
Loading
Loading