Skip to content

feat(zcash): add support for v6 transaction format#327

Draft
veetragjain wants to merge 8 commits into
zcash-v6-transactionfrom
veetragjain/cshld-1059-add-support-for-v6-transaction-format-in-wasm-utxo
Draft

feat(zcash): add support for v6 transaction format#327
veetragjain wants to merge 8 commits into
zcash-v6-transactionfrom
veetragjain/cshld-1059-add-support-for-v6-transaction-format-in-wasm-utxo

Conversation

@veetragjain

@veetragjain veetragjain commented Jul 22, 2026

Copy link
Copy Markdown
Contributor

This branch adds the foundational Zcash v6 (Ironwood / NU6.3) transaction support to wasm-utxo, implemented as a standalone, hand-rolled codec (no librustzcash dependency) so the existing v4 transparent path is completely untouched and non-breaking. It introduces the v6 wire format parse/encode (IronwoodAction/IronwoodBundle/ZcashV6Transaction), the ZIP-244 five-component txid computation, the shared ironwood_digest utility, a crate-local BLAKE2b-personalization helper, and ZIP-316 Unified Address parsing(Bech32m + F4Jumble)

veetragjain and others added 7 commits July 21, 2026 18:15
Groundwork for Zcash v6 (Ironwood/NU6.3) transaction support:
- ZCASH_IRONWOOD_VERSION_GROUP_ID (0xD884B698), v4/v6 version headers
- crate-local blake2b_256_personal for ZIP-244 hash trees (the fork's
  helper is pub(crate) and unreachable here); includes the block-aligned
  finalization fix

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Implements the standalone v6/NU6.3 wire codec (S1). The v6 header is
reordered vs v4 (consensusBranchId/lockTime/expiryHeight move to the
front) so it is a separate codec, leaving the v4 path untouched:
- IronwoodAction (820B), IronwoodBundle, ZcashV6Transaction types
- decode_v6_transaction / encode_v6_transaction with round-trip tests
- empty Sapling + empty Orchard v6 slots supported; non-empty rejected
- v4 decoder now guards against v6 bytes instead of mis-parsing them

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Implements the shared ironwood_digest (S3) used by both the v6 txid (S2)
and the shielded sighash. Three-way BLAKE2b split over per-action byte
ranges (compact/memos/noncompact) combined with flags + valueBalance;
anchor, proof, and signatures are excluded per ZIP-244. Adds an
orchard_v6_empty_digest helper for the empty Orchard slot.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Implements the five-component ZIP-244 hash tree (S2): header, transparent
(txid form), sapling, orchard-v6, and ironwood digests combined under the
ZcashTxHash_<branchId> personalization. Adds zcash_compute_v6_txid WASM
export. v4 txid (sha256d) path is unchanged.

Validated against a real testnet shielding transaction captured from the
Ironwood reference (txid b5af4de4...): the parser, encoder (byte-identical
re-encode), and txid all match the golden vector.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Ironwood reuses the existing Orchard receiver (typecode 0x03), so no new
typecode is needed. Implements ZIP-316 UA decoding: Bech32m + F4Jumble
inverse + typecode scanning.
- blake2b: add variable-length blake2b_var_personal (F4Jumble needs it)
- parse_unified_address: ironwood=true -> Orchard/Ironwood receiver (43B);
  ironwood=false -> transparent receiver as P2PKH/P2SH scriptPubKey
- WASM exports zcash_parse_unified_address and zcash_compute_v6_txid;
  TS wrappers ZcashBitGoPsbt.parseUnifiedAddress / computeV6Txid
- F4Jumble validated against the ZIP-316 vector; UA parse validated against
  official ZIP-316 unified-address test vectors; TS integration tests

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Two more real testnet transactions captured from the Ironwood reference:
- Ironwood->Ironwood self-send (aadfe41f...): no transparent I/O, exercises
  the empty transparent-digest branch of the txid tree
- Transparent->Ironwood 1 ZEC shield (667e8ed3...)

Both parse, re-encode byte-identically, and reproduce their canonical txid.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…elpers

- resolve_unified_address_component(address, network, resolveShieldedComponent):
  intent-named form returning the shielded (Ironwood/Orchard, 43B) or the
  transparent (scriptPubKey) component
- is_address_component_of(unified, candidate, network): true when candidate
  (another UA, or a transparent base58check address) is contained in the UA;
  reuses the ZCASH/ZCASH_TEST codecs to reduce a transparent address to its
  (typecode, hash) receiver form
- WASM exports + ZcashBitGoPsbt.resolveUnifiedAddressComponent / isAddressComponentOf

Validated against the real testnet wallet vector (wallet-data): the UA
resolves to the wallet's Ironwood raw receiver and transparent PKH, and the
wallet's transparent address is recognized as a component.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@linear-code

linear-code Bot commented Jul 22, 2026

Copy link
Copy Markdown

CSHLD-1059

Follow the repo fixture convention: test data lives in test/fixtures, not
inline or under src/.
- move the 6 v6 golden .hex files to test/fixtures/zcash/, loaded in Rust
  via load_fixture (the CARGO_MANIFEST_DIR helper other fs-based tests use)
  instead of include_str!
- add test/fixtures/zcash/unified_address.json holding the ZIP-316 mainnet
  vector and the testnet wallet vector; both Rust and TS load from it
  (removes the duplicated hardcoded consts and the test->src coupling)
- extend TS computeV6Txid coverage to all three golden txids; add Rust and
  TS negative cases (malformed candidate, missing receiver)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@veetragjain
veetragjain marked this pull request as ready for review July 22, 2026 12:46
@veetragjain
veetragjain requested a review from a team as a code owner July 22, 2026 12:46
@OttoAllmendinger
OttoAllmendinger marked this pull request as draft July 22, 2026 12:48

Copy link
Copy Markdown
Contributor

Suggested split into multiple PRs

The code layers naturally by dependency, and this split also isolates each of the two main review findings into a different PR.

Dependency shape

PR1  blake2b helper  (shared primitive)
      ├── PR2  ZIP-316 Unified Address parsing
      └── PR3  v6 codec + ZIP-244 txid

blake2b.rs is the only thing both features share. UA parsing and the v6 codec don't touch each other, so PR2 and PR3 are independent siblings — reviewable/mergeable in parallel once PR1 lands.

PR 1 — BLAKE2b-256 personalization helper (base)

  • src/zcash/blake2b.rs
  • src/zcash/mod.rspub mod blake2b;
  • Cargo.toml, Cargo.lock (the blake2 = "0.10" direct dep)

~90 lines, self-contained, ships with its own unit tests. Small and easy to approve; unblocks the other two.

PR 2 — ZIP-316 Unified Address parsing (off PR1)

  • src/zcash/unified_address.rs + pub mod unified_address;
  • mod.rs bindings: zcash_parse_unified_address, zcash_resolve_unified_address_component, zcash_is_address_component_of
  • ZcashBitGoPsbt.ts: parseUnifiedAddress, resolveUnifiedAddressComponent, isAddressComponentOf
  • Fixture: unified_address.json; the UA tests from zcashIronwood.ts
  • Good place to fold in the API-design feedback (boolean-flag / duplicate-method redesign) while the surface is still draft.

PR 3 — v6 (Ironwood) codec + ZIP-244 txid (off PR1)

  • src/zcash/v6.rs + pub mod v6;
  • src/zcash/transaction.rs (v6/Ironwood version-group IDs + the v6-detection guard)
  • mod.rs binding: zcash_compute_v6_txid; ZcashBitGoPsbt.ts: computeV6Txid
  • Fixtures: v6_*_rawtx.hex / v6_*_txid.hex; the txid tests from zcashIronwood.ts
  • Good place to fold in the robustness fix (unbounded Vec::with_capacity on the untrusted Ironwood action count).

One thing to watch

test/fixedScript/zcashIronwood.ts mixes UA and v6-txid tests, so it has to be split across PR2/PR3 (e.g. zcashUnifiedAddress.ts + zcashIronwood.ts). mod.rs, ZcashBitGoPsbt.ts, and zcash/mod.rs each get clean, non-overlapping additions per PR — no conflicts if PR1 merges first.

Base branch for all three can stay zcash-v6-transaction (the current integration branch), unless stacking onto master is preferred.


Generated by Claude Code

Copy link
Copy Markdown
Contributor

Code review

Reviewed the full diff (v6 codec, unified-address parsing, blake2b helper, WASM bindings, TS wrapper, tests, fixtures).

Strengths

  • Excellent test coverage. Three real captured testnet transactions decode → re-encode byte-identically → reproduce their canonical txids. That validates the whole ZIP-244 hash tree (header/transparent/sapling/orchard/ironwood digests), F4Jumble⁻¹ (against the official ZIP-316 vector and real UAs), and round-trip stability. The right way to validate a hand-rolled codec.
  • Clean separation of concerns: empty Sapling/Orchard slots and non-empty bundles are rejected with clear errors rather than mis-parsed.
  • Good doc comments citing the exact ZIP-244/316 layouts; personalization constants asserted to be 16 bytes.
  • Follows the Uint8Array convention at the WASM/TS boundary (CONVENTIONS §1) — no hex/base64 variants.

Issues

1. (Robustness) Unbounded Vec::with_capacity from an attacker-controlled action count — v6.rs

let n = usize::try_from(n_actions_ironwood).map_err(...)?;
let mut actions = Vec::with_capacity(n);   // n is an uncapped CompactSize (up to 2^64)
for _ in 0..n { let a = take_bytes(&mut r, IRONWOOD_ACTION_SIZE)?; ... }

n_actions_ironwood comes from a bare VarInt::consensus_decode, which is not capped (the MAX_VEC_SIZE guard lives in Decodable for Vec<T>, not in VarInt). A malformed tx declaring nActionsIronwood = 0xFFFFFFFF... triggers a huge pre-allocation before the per-iteration byte-availability check → OOM / WASM abort. decode_v6_transaction is reachable from the public zcash_compute_v6_txid binding with arbitrary bytes.
Fix: validate remaining input before reserving (e.g. r.len() >= n * IRONWOOD_ACTION_SIZE), or drop/cap the capacity hint. (The later spend_auth_sigs with_capacity(n) is fine — n is already bounded by having consumed n×820 bytes.)

2. (CONVENTIONS §3 — boolean trap + duplicate API) UA accessor design

  • Boolean flag selecting return semantics. parseUnifiedAddress(addr, net, ironwood: boolean) returns either a 43-byte Orchard receiver or a 25-byte transparent scriptPubKey depending on true/false. parse(addr, net, true) is unreadable at the call site, and one return type carries two unrelated meanings. §3 favors as const unions over magic flags — e.g. a ReceiverKind = ["shielded", "transparent"] as const.
  • Inconsistent naming. The sibling method names the same flag resolveShieldedComponent, this one names it ironwood.
  • Redundant surface. resolve_unified_address_component is literally parse_unified_address under a renamed bool. Exposing both as public WASM + TS methods is API bloat — collapse to one.

3. (Minor) Network handling

  • hrp_for_network accepts zcashRegtest/uregtest, but transparent_address_to_receiver only handles zec/tzec, so is_address_component_of on regtest with a transparent candidate errors despite the container decoding fine. Support it consistently or document the limitation.
  • is_address_component_of distinguishes UA vs transparent candidate via candidate.starts_with("u1"/"utest1"). Works for Zcash (transparent addrs start with t), but a try-UA-decode-then-fallback would be less fragile.

4. (Minor) Footguns

  • computeV6Txid returns internal byte order; every caller must .reverse() for display. Documented and tested, but consider returning display order (or naming it ...Internal) to avoid silent misuse.
  • f4jumble_inv enforces the ZIP-316 minimum length but not the maximum. Not security-critical (bech32-bounded input), but cheap to add.
  • debug_assert! on BLAKE2b out_len compiles out in release; current call sites can't violate it, so fine — just noting it isn't a real guard.

Verdict

Solid, well-tested implementation with strong golden-vector validation. #1 is the one I'd fix before merge (untrusted pre-allocation); #2 is a real conventions violation worth resolving while the API is still draft. #3#4 are polish.


Generated by Claude Code

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.

additions should go to zec-namespaced file, maybe take some zcash scoped funcs here with you as well

Comment on lines +1 to +13
//! ZIP-316 Unified Address (UA) parsing.
//!
//! A UA is a Bech32m string whose payload is F4Jumble-encoded (ZIP-316 §F4Jumble).
//! The un-jumbled payload is a sequence of receivers — each a
//! `CompactSize(typecode) ‖ CompactSize(length) ‖ data` record — followed by a
//! 16-byte padding equal to the human-readable part (HRP), zero-extended.
//!
//! Ironwood reuses the existing **Orchard** receiver (typecode `0x03`): an Orchard
//! unified receiver routes to the Ironwood pool once NU6.3 rules are active, so no
//! new typecode is required.
//!
//! Parsing here only needs Bech32m + F4Jumble⁻¹ + typecode scanning — no `orchard`
//! crate, no ZK machinery.

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.

copy the spec to the repo

* @returns The 32-byte txid in internal byte order (reverse for display)
* @throws If the bytes are not a valid v6 transaction
*/
static computeV6Txid(txBytes: Uint8Array): Uint8Array {

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.

why not a method on a psbt? why take txBytes?

* @returns The receiver bytes
* @throws If the address is malformed or lacks a receiver of the requested type
*/
static parseUnifiedAddress(

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.

Same "opaque bytes" concern as the computeV6Txid thread above, on the return side: these return a bare Uint8Array whose meaning depends on the boolean arg — an Orchard receiver (43 bytes) when true, a transparent scriptPubKey (25 bytes) when false. The caller has to remember which shape they asked for.

Prefer a structured return: decode once into a typed UnifiedAddress with named accessors (.orchardReceiver, .transparentScript), or a discriminated union { kind: "shielded" | "transparent"; ... }. That also removes the boolean flag (CONVENTIONS §3) and collapses parseUnifiedAddress/resolveUnifiedAddressComponent, which are currently the same function under a renamed bool.


Generated by Claude Code

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