feat(wasm-utxo): add ZIP-316 unified address parsing#331
Conversation
8a72d06 to
01cb896
Compare
ce9fe7e to
4977119
Compare
OttoAllmendinger
left a comment
There was a problem hiding this comment.
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
ironwoodflag is retired — Ironwood just reuses Orchard0x03, documented at the type level. Singleparse()+ typed accessors (orchardReceiver/saplingReceiver/transparentScript) +contains(); no moreparse/resolveduplication, no opaque-bytes return. Exactly the structured shape we wanted. - The #327 OOM is fixed.
decode_receiversusesVec::new()(nowith_capacityoff an untrusted VarInt) and checkscursor.len() < lenbeforesplit_at. F4Jumble length is bounded both ends. - #330 KAT concern addressed on the base.
blake2b.rsnow carries real known-answer tests — RFC 7693"abc", the block-aligned00..7fvector traceable toblake2-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_personalat multiple output lengths (H useslen(left), G uses 64). - Nice detail: the
j as u16G-round block index can't overflow becauseF4JUMBLE_MAX_LEN(2^22+64) capsrightat 65536 blocks → max index 65535. Worth a one-line comment so nobody "simplifies" the bound later.
Should fix
- Error signaling (
src/wasm/zcash.rsjs_err) — see inline. It throws a bare JS string instead of the crate-standard markedjs_sys::Errorwith.code; every other wasm-utxo surface goes throughWasmUtxoError.
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_scriptsilently 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.containsthrows (notfalse) on a foreign-network / malformed candidate. Documented and tested, so a deliberate choice — just flag that membership checks need try/catch; returningfalsefor a cross-network candidate might read more naturally. Your call.- Coverage gaps: the Sapling
0x02path is untested (no fixture with a Sapling receiver), nothing asserts an accessor returnsundefinedwhen its receiver is absent (shielded-only / transparent-only UA), and the strictly-ascending-order rejection isn't exercised. All cheap to add. - Trivial:
ceildivis just a wrapper overusize::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
| fn js_err(e: String) -> JsValue { | ||
| JsValue::from_str(&e) | ||
| } |
There was a problem hiding this comment.
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(fromDisplay)- a
.codeproperty (WasmUtxoError.StringError,ParseTransactionError.Input/…) - a
Symbol.for("@bitgo/wasm-utxo/error")marker so callers can identify a BitGo error
js_err → JsValue::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:
-
This file: drop
js_errentirely, make these methods-> Result<_, WasmUtxoError>. SinceWasmUtxoError: From<String>already exists (src/error.rs), the.map_err(js_err)calls collapse to?, and consumers get the standard markedErrorwith.codefor free. -
src/zcash/unified_address.rs(root cause): it'sResult<_, 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 aUnifiedAddressErrorenum (BadBech32,WrongHrp,BadPadding,ReceiverOrder,TruncatedReceiver, …) +From<UnifiedAddressError> for WasmUtxoError, so a JS caller can branch onerr.code === "UnifiedAddressError.WrongHrp"instead of string-matching a message. Minimum acceptable: keepStringin core but funnel throughWasmUtxoErrorat the boundary (item 1 alone) — that at least restores the marked-Errorcontract.
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
There was a problem hiding this comment.
Addressed, added a new typed UnifiedAddressError enum.
| .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 { |
There was a problem hiding this comment.
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
There was a problem hiding this comment.
It now rejects UA with no shielded receivers
| "zcashTest" | "tzec" => Ok("utest"), | ||
| _ => Err(format!( | ||
| "unknown Zcash network {:?}: expected \"zec\"/\"zcash\" or \"tzec\"/\"zcashTest\"", | ||
| network | ||
| )), | ||
| } | ||
| } | ||
|
|
||
| /// A single parsed unified-address receiver. |
There was a problem hiding this comment.
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
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>
4977119 to
17b4ec7
Compare
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):
Robustness/consistency fixes from review:
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/).