Skip to content

feat(wasm-utxo): add ZIP-316 unified address parsing#331

Merged
OttoAllmendinger merged 1 commit into
masterfrom
zcash-unified-address
Jul 23, 2026
Merged

feat(wasm-utxo): add ZIP-316 unified address parsing#331
OttoAllmendinger merged 1 commit into
masterfrom
zcash-unified-address

Conversation

@veetragjain

Copy link
Copy Markdown
Contributor

PR2 of 3 (off the blake2b base). Parses a Unified Address into its receiver components via Bech32m + F4Jumble; Ironwood reuses the Orchard receiver (0x03).

API (addresses review feedback — no boolean-flag return trap, single entry point instead of parse/resolve duplicates):

  • ZcashUnifiedAddress.parse(address, network) decodes once; component accessors orchardReceiver / saplingReceiver / transparentScript return typed bytes or undefined; contains(candidate) answers UA/transparent membership.
  • Rust core UnifiedAddress with the same shape; zec-namespaced WASM module (src/wasm/zcash.rs) that also absorbs zcash_branch_id_for_height.

Robustness/consistency fixes from review:

  • UA-vs-transparent candidate detection uses a real Bech32m try-decode, not a string prefix.
  • network handling is consistent (zec/tzec only; regtest dropped rather than half-supported).
  • F4Jumble length is bounded on both ends per ZIP-316 VALID_LENGTH.

Vendors the ZIP-316/F4Jumble encoding reference into docs/. Rust + TS tests validate F4Jumble against the official vector and parsing against an official ZIP-316 vector and a real testnet wallet vector (test/fixtures/zcash/).

@veetragjain
veetragjain marked this pull request as ready for review July 23, 2026 09:41
@veetragjain
veetragjain requested a review from a team as a code owner July 23, 2026 09:41
Base automatically changed from zcash-blake2b to master July 23, 2026 10:21
@veetragjain
veetragjain force-pushed the zcash-unified-address branch 2 times, most recently from ce9fe7e to 4977119 Compare July 23, 2026 10:47

@OttoAllmendinger OttoAllmendinger left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Strong PR — this is the clean redo of the UA surface, and it lands the feedback from both #327 and #330. Not blocking; one item (error signaling) I'd like addressed, the rest are minor.

What it gets right

  • Boolean trap gone. The ironwood flag is retired — Ironwood just reuses Orchard 0x03, documented at the type level. Single parse() + typed accessors (orchardReceiver/saplingReceiver/transparentScript) + contains(); no more parse/resolve duplication, no opaque-bytes return. Exactly the structured shape we wanted.
  • The #327 OOM is fixed. decode_receivers uses Vec::new() (no with_capacity off an untrusted VarInt) and checks cursor.len() < len before split_at. F4Jumble length is bounded both ends.
  • #330 KAT concern addressed on the base. blake2b.rs now carries real known-answer tests — RFC 7693 "abc", the block-aligned 00..7f vector traceable to blake2-kat.json, and a personalized 256-byte block-aligned vector cross-checked against CPython. That's the spec-sourced coverage the block-aligned finalization fix needed.
  • F4Jumble validated against the official vector, which end-to-end also exercises blake2b_var_personal at multiple output lengths (H uses len(left), G uses 64).
  • Nice detail: the j as u16 G-round block index can't overflow because F4JUMBLE_MAX_LEN (2^22+64) caps right at 65536 blocks → max index 65535. Worth a one-line comment so nobody "simplifies" the bound later.

Should fix

  • Error signaling (src/wasm/zcash.rs js_err) — see inline. It throws a bare JS string instead of the crate-standard marked js_sys::Error with .code; every other wasm-utxo surface goes through WasmUtxoError.

Minor (non-blocking)

  • Lenient ZIP-316 parse — see inline (transparent-only / zero-receiver UAs parse).
  • Non-minimal CompactSize — see inline (likely already handled by the fork's rust-bitcoin).
  • transparent_script silently prefers P2PKH over P2SH. Fine because the transparent pool holds one receiver per spec — but that makes the P2SH branch nearly dead. A debug-assert that both aren't present would document the invariant.
  • contains throws (not false) on a foreign-network / malformed candidate. Documented and tested, so a deliberate choice — just flag that membership checks need try/catch; returning false for a cross-network candidate might read more naturally. Your call.
  • Coverage gaps: the Sapling 0x02 path is untested (no fixture with a Sapling receiver), nothing asserts an accessor returns undefined when its receiver is absent (shielded-only / transparent-only UA), and the strictly-ascending-order rejection isn't exercised. All cheap to add.
  • Trivial: ceildiv is just a wrapper over usize::div_ceil — inline it.

Not reimplementing bech32m — it uses the bech32 crate (already a dep, also via rust-bitcoin), and correctly drops to the CheckedHrpstring<Bech32m> primitive because our internal Bech32Codec is segwit-only and can't parse a UA's raw data part. Good call there.


Generated by Claude Code

Comment thread packages/wasm-utxo/src/wasm/zcash.rs Outdated
Comment on lines +9 to +11
fn js_err(e: String) -> JsValue {
JsValue::from_str(&e)
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Error signaling here diverges from the crate convention, and it's worth fixing since we're moving this function anyway.

Everywhere else in the wasm layer, methods return Result<T, WasmUtxoError> and rely on impl From<WasmUtxoError> for JsValue (src/wasm/try_into_js_value.rs). That path throws a real js_sys::Error carrying:

  • .message (from Display)
  • a .code property (WasmUtxoError.StringError, ParseTransactionError.Input/…)
  • a Symbol.for("@bitgo/wasm-utxo/error") marker so callers can identify a BitGo error

js_errJsValue::from_str throws a bare JS string instead. Downstream that means it's not an Error (no stack, instanceof Error === false), and err.message / err.code are undefined. Every other wasm-utxo surface (miniscript, descriptor, dimensions, address) goes through WasmUtxoError; this module silently doesn't.

Two layers:

  1. This file: drop js_err entirely, make these methods -> Result<_, WasmUtxoError>. Since WasmUtxoError: From<String> already exists (src/error.rs), the .map_err(js_err) calls collapse to ?, and consumers get the standard marked Error with .code for free.

  2. src/zcash/unified_address.rs (root cause): it's Result<_, String> throughout — stringly-typed. The rest of the crate uses typed error enums (ParseTransactionError, AddressError, OutputScriptError) with #[derive(strum::IntoStaticStr)] + impl_wasm_error_code!, which is what produces a meaningful .code. Ideal fix is a UnifiedAddressError enum (BadBech32, WrongHrp, BadPadding, ReceiverOrder, TruncatedReceiver, …) + From<UnifiedAddressError> for WasmUtxoError, so a JS caller can branch on err.code === "UnifiedAddressError.WrongHrp" instead of string-matching a message. Minimum acceptable: keep String in core but funnel through WasmUtxoError at the boundary (item 1 alone) — that at least restores the marked-Error contract.

Note this bare-string behavior predates the PR (the old zcash_branch_id_for_height in fixed_script_wallet/mod.rs did the same JsValue::from_str), so it's not a regression — but this is the natural moment to bring it onto WasmUtxoError.


Generated by Claude Code

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed, added a new typed UnifiedAddressError enum.

Comment on lines +255 to +262
.unwrap_or(false)
}

/// A parsed ZIP-316 Unified Address.
///
/// Decode once with [`UnifiedAddress::parse`], then read each component through its
/// named accessor. Absent receivers return `None`.
pub struct UnifiedAddress {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Lenient vs ZIP-316: parse accepts a transparent-only UA and even a zero-receiver UA. ZIP-316 requires at least one shielded receiver (a UA must not be transparent-only). If we only ever feed known-good wallet UAs this is harmless, but a parser that accepts a spec-invalid UA is a latent footgun — either add a guard (≥1 receiver, ≥1 shielded) or document that this is a deliberately lenient parse. Minor; not blocking.


Generated by Claude Code

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It now rejects UA with no shielded receivers

Comment on lines +120 to +128
"zcashTest" | "tzec" => Ok("utest"),
_ => Err(format!(
"unknown Zcash network {:?}: expected \"zec\"/\"zcash\" or \"tzec\"/\"zcashTest\"",
network
)),
}
}

/// A single parsed unified-address receiver.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirm the fork's VarInt::consensus_decode rejects non-minimal CompactSize. Upstream rust-bitcoin returns NonMinimalVarInt since 0.28, so the 13.0.0-based fork should be fine — if so, ignore this. If not, a non-canonically-encoded typecode/length would parse where a strict ZIP-316 reader rejects it (low impact — it's checksum+padding-wrapped and never re-encoded, but it is malleability in what parse accepts).


Generated by Claude Code

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

confirmed

PR2 of 3 (off the blake2b base). Parses a Unified Address into its receiver
components via Bech32m + F4Jumble; Ironwood reuses the Orchard receiver (0x03).

API (addresses review feedback — no boolean-flag return trap, single entry
point instead of parse/resolve duplicates):
- ZcashUnifiedAddress.parse(address, network) decodes once; component accessors
  orchardReceiver / saplingReceiver / transparentScript return typed bytes or
  undefined; contains(candidate) answers UA/transparent membership.
- Rust core UnifiedAddress with the same shape; zec-namespaced WASM module
  (src/wasm/zcash.rs) that also absorbs zcash_branch_id_for_height.

Robustness/consistency fixes from review:
- UA-vs-transparent candidate detection uses a real Bech32m try-decode, not a
  string prefix.
- network handling is consistent (zec/tzec only; regtest dropped rather than
  half-supported).
- F4Jumble length is bounded on both ends per ZIP-316 VALID_LENGTH.

Vendors the ZIP-316/F4Jumble encoding reference into docs/. Rust + TS tests
validate F4Jumble against the official vector and parsing against an official
ZIP-316 vector and a real testnet wallet vector (test/fixtures/zcash/).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@OttoAllmendinger
OttoAllmendinger merged commit f1a64f8 into master Jul 23, 2026
13 checks passed
@OttoAllmendinger
OttoAllmendinger deleted the zcash-unified-address branch July 23, 2026 12:05
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants