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: 8 additions & 8 deletions packages/wasm-utxo/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 4 additions & 1 deletion packages/wasm-utxo/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,10 @@ rstest = "0.26.1"
pastey = "0.1"

[target.'cfg(not(target_arch = "wasm32"))'.dev-dependencies]
zebra-chain = { version = "11", default-features = false }
# 11.2 is the first release with the `Transaction::V6` / Ironwood (`ironwood_shielded_data`)
# variant used as the txid oracle in the v6 codec tests. Native-only dev-dependency, so its
# pre-release zcash_*/orchard cohort stays out of the shipped wasm artifact.
zebra-chain = { version = "11.2", default-features = false }

[build-dependencies]
serde_json = "1.0"
Expand Down
77 changes: 77 additions & 0 deletions packages/wasm-utxo/docs/zcash-v6-transaction.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
# Zcash v6 (Ironwood / NU6.3) transaction format

Reference for the v6 wire codec in [`src/zcash/v6.rs`](../src/zcash/v6.rs) and its
ZIP-244 txid, distilled from the canonical sources so review does not depend on an
external link:

- ZIP-225 (v5 tx format, the base v6 extends) — <https://zips.z.cash/zip-0225>
- ZIP-244 (txid / sighash digest tree) — <https://zips.z.cash/zip-0244>
- Cross-checked against **`zebra-chain` 11.2** (`Transaction::V6` / `ironwood_shielded_data`),
an independent NU6.3 implementation: the `zebra_oracle` tests in `src/zcash/v6.rs` decode
each golden fixture with zebra and assert zebra's `hash()` (ZIP-244 txid) equals ours, plus
matching transparent I/O and Ironwood action counts. The golden transactions in
`test/fixtures/zcash/` (shield / self-send / unshield) originate from a Zebra node on
branch id `37a5165b`.

## Wire layout

The v6 header is **reordered** relative to v4: `consensusBranchId`, `lockTime`, and
`expiryHeight` move to the front, before the transparent I/O. Two shielded slots are
appended after Sapling — a v6-personalized Orchard slot (empty for BitGo flows) and
the new Ironwood slot. All integers little-endian; `varint` = CompactSize.

```
Header: version(4)=0x80000006 versionGroupId(4)=0xD884B698
consensusBranchId(4) lockTime(4) expiryHeight(4)
Transparent: tx_in_count(varint) tx_in[] tx_out_count(varint) tx_out[]
Sapling: nSpendsSapling(varint=0) nOutputsSapling(varint=0)
Orchard v6: nActionsOrchard(varint=0) // body only if > 0
Ironwood: nActionsIronwood(varint) actions[820×n] flags(1) valueBalance(8)
anchor(32) proofsSize(varint) proofs[] spendAuthSigs[64×n] bindingSig(64)
// whole block present only if nActionsIronwood > 0
```

Each Ironwood action is 820 bytes: `cv(32) nullifier(32) rk(32) cmx(32) epk(32)
encCiphertext(580) outCiphertext(80)`. This codec supports empty Sapling/Orchard
slots plus a populated Ironwood bundle; non-empty Sapling/Orchard are rejected with a
clear error rather than mis-parsed. The action count is validated against the
remaining input length before allocating, so a malformed `nActionsIronwood` cannot
trigger an unbounded pre-allocation.

## ZIP-244 txid (five-component tree)

```
txid = BLAKE2b-256("ZcashTxHash_" ‖ consensusBranchId(LE),
header_digest ‖ transparent_digest ‖ sapling_digest
‖ orchard_v6_digest ‖ ironwood_digest)
```

`ironwood_digest` is a three-way split (compact / memos / non-compact per-action byte
ranges) combined with the flag byte and value balance; the anchor, proof, and
signatures are **excluded**. `compute_v6_txid` returns internal byte order; the
`ZcashV6Transaction.getId()` wrapper reverses it to the canonical display form.

## Integration with the build-transaction endpoint (planned)

The indexer's build flow (`@ims-utxo/utxo-core/buildTransaction` →
`createPsbt`/`responseBuilder`) drives wasm-utxo through the `ZcashBitGoPsbt` wrapper:
`createEmpty({ blockHeight })`, `addWalletInput`, `addWalletOutput`/`addOutput`,
`serialize()`, and `psbt.unsignedTxId()`. Reusing that flow for **shielding**
(transparent → Ironwood) needs, in follow-up PRs:

1. **v6 creation** — `ZcashBitGoPsbt.createEmpty` gains a version/era selector so
`createEmptyPsbt` can build a v6 skeleton (`{ blockHeight, txVersion: 6 }`).
2. **v6 serialize + txid through the wrapper** — `serialize()` emits v6 wire and
`unsignedTxId()` version-gates to the ZIP-244 txid. This PR lands the codec and the
`getId()` shape those methods will delegate to (an instance method returning the
display-order id — no static-over-bytes, no manual reversing).
3. **ZIP-244 transparent sighash (T2)** — the transparent inputs the build flow
already handles must sign the v6 sighash (which includes `ironwood_digest`), not
ZIP-243. This is the true next milestone for shielding.
4. **Ironwood bundle attach (T3)** — `set_ironwood_bundle` / `finalize_ironwood_bundle`
apply the server (KMS) proof + actions before final serialize.

The shielded recipient itself is resolved with `ZcashUnifiedAddress` (its Orchard
receiver), and its value becomes `valueBalanceIronwood` rather than a transparent
`TxOut` — an indexer-side change in `buildTransaction` that reuses the existing
unspent-selection / fee / change / response code unchanged.
78 changes: 78 additions & 0 deletions packages/wasm-utxo/js/fixedScriptWallet/ZcashV6Transaction.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { ZcashV6Transaction as WasmZcashV6Transaction } from "../wasm/wasm_utxo.js";

/**
* A parsed Zcash v6 (Ironwood / NU6.3) transaction — for inspection and txid.
*
* The transaction id is an instance method ({@link getId}) returning the canonical
* display-order hex, consistent with the `getId()` convention on the other
* transaction/PSBT wrappers. Callers never pass raw bytes to a txid function or have
* to reverse internal byte order themselves.
*
* @example
* ```typescript
* const tx = ZcashV6Transaction.fromBytes(rawV6Bytes);
* tx.getId(); // canonical (display-order) txid hex
* tx.ironwoodActionCount; // 0 when the Ironwood slot is empty
* ```
*/
export class ZcashV6Transaction {
private constructor(private _wasm: WasmZcashV6Transaction) {}

/**
* Decode a v6 transaction from raw wire bytes.
* @throws If the bytes are not a valid v6 (Ironwood) transaction
*/
static fromBytes(bytes: Uint8Array): ZcashV6Transaction {
return new ZcashV6Transaction(WasmZcashV6Transaction.fromBytes(bytes));
}

/** Serialize back to raw v6 wire bytes. */
toBytes(): Uint8Array {
return this._wasm.toBytes();
}

/** The canonical (display-order) ZIP-244 txid as a lowercase hex string. */
getId(): string {
return this._wasm.getId();
}

/** The ZIP-244 txid in internal (non-reversed) byte order. */
txidBytes(): Uint8Array {
return this._wasm.txidBytes();
}

/** Consensus branch id carried in the v6 header. */
get consensusBranchId(): number {
return this._wasm.consensusBranchId;
}

/** Expiry height. */
get expiryHeight(): number {
return this._wasm.expiryHeight;
}

/** Number of Ironwood actions (0 when the Ironwood slot is empty). */
get ironwoodActionCount(): number {
return this._wasm.ironwoodActionCount;
}

/** Net value crossing the Ironwood pool boundary (0 when there is no bundle). */
get ironwoodValueBalance(): bigint {
return this._wasm.ironwoodValueBalance;
}

/** The Ironwood bundle flag byte, or `undefined` when there is no bundle. */
get ironwoodFlags(): number | undefined {
return this._wasm.ironwoodFlags;
}

/** The Ironwood note-commitment tree anchor (32 bytes), or `undefined`. */
get ironwoodAnchor(): Uint8Array | undefined {
return this._wasm.ironwoodAnchor;
}

/** @internal */
get wasm(): WasmZcashV6Transaction {
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 @@ -49,6 +49,9 @@ export {
// Zcash ZIP-316 Unified Address
export { ZcashUnifiedAddress } from "./ZcashUnifiedAddress.js";

// Zcash v6 (Ironwood / NU6.3) transaction
export { ZcashV6Transaction } from "./ZcashV6Transaction.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 @@ -24,6 +24,7 @@ pub enum WasmUtxoError {
StringError(String),
Parse(ParseTransactionError),
UnifiedAddress(crate::zcash::unified_address::UnifiedAddressError),
ZcashV6(crate::zcash::v6::ZcashV6Error),
}

impl std::error::Error for WasmUtxoError {}
Expand All @@ -34,6 +35,7 @@ impl fmt::Display for WasmUtxoError {
WasmUtxoError::StringError(s) => write!(f, "{}", s),
WasmUtxoError::Parse(e) => write!(f, "{}", e),
WasmUtxoError::UnifiedAddress(e) => write!(f, "{}", e),
WasmUtxoError::ZcashV6(e) => write!(f, "{}", e),
}
}
}
Expand All @@ -44,6 +46,7 @@ impl WasmErrorCode for WasmUtxoError {
WasmUtxoError::StringError(_) => "WasmUtxoError.StringError".to_string(),
WasmUtxoError::Parse(e) => e.code(),
WasmUtxoError::UnifiedAddress(e) => e.code(),
WasmUtxoError::ZcashV6(e) => e.code(),
}
}
}
Expand Down Expand Up @@ -90,6 +93,12 @@ impl From<crate::zcash::unified_address::UnifiedAddressError> for WasmUtxoError
}
}

impl From<crate::zcash::v6::ZcashV6Error> for WasmUtxoError {
fn from(err: crate::zcash::v6::ZcashV6Error) -> Self {
WasmUtxoError::ZcashV6(err)
}
}

impl WasmUtxoError {
pub fn new(s: &str) -> WasmUtxoError {
WasmUtxoError::StringError(s.to_string())
Expand Down
90 changes: 90 additions & 0 deletions packages/wasm-utxo/src/wasm/zcash.rs
Original file line number Diff line number Diff line change
Expand Up @@ -90,3 +90,93 @@ impl ZcashUnifiedAddress {
Ok(self.inner.contains(candidate)?)
}
}

/// A parsed Zcash v6 (Ironwood / NU6.3) transaction — for inspection and txid.
///
/// This wraps the raw v6 wire codec. The transaction id is exposed as an instance
/// method [`ZcashV6Transaction::get_id`] (canonical display-order hex), matching the
/// `getId()` convention used by the other transaction/PSBT wrappers, so callers never
/// pass raw bytes to a txid function or juggle internal vs display byte order.
#[wasm_bindgen]
pub struct ZcashV6Transaction {
inner: crate::zcash::v6::ZcashV6Transaction,
}

#[wasm_bindgen]
impl ZcashV6Transaction {
/// Decode a v6 transaction from raw wire bytes. Throws if the bytes are not a
/// valid v6 (Ironwood) transaction.
#[wasm_bindgen(js_name = fromBytes)]
pub fn from_bytes(bytes: &[u8]) -> Result<ZcashV6Transaction, WasmUtxoError> {
Ok(ZcashV6Transaction {
inner: crate::zcash::v6::ZcashV6Transaction::from_bytes(bytes)?,
})
}

/// Serialize back to raw v6 wire bytes.
#[wasm_bindgen(js_name = toBytes)]
pub fn to_bytes(&self) -> Result<Vec<u8>, WasmUtxoError> {
Ok(self.inner.to_bytes()?)
}

/// The canonical (display-order) ZIP-244 txid as a lowercase hex string.
///
/// `Txid`'s `Display` emits display-order (byte-reversed) hex, matching how a
/// transaction id is printed everywhere else in the codebase.
#[wasm_bindgen(js_name = getId)]
pub fn get_id(&self) -> String {
use miniscript::bitcoin::hashes::Hash;
miniscript::bitcoin::Txid::from_byte_array(self.inner.txid()).to_string()
}

/// The ZIP-244 txid in internal (non-reversed) byte order.
#[wasm_bindgen(js_name = txidBytes)]
pub fn txid_bytes(&self) -> Vec<u8> {
self.inner.txid().to_vec()
}

/// Consensus branch id carried in the v6 header.
#[wasm_bindgen(getter, js_name = consensusBranchId)]
pub fn consensus_branch_id(&self) -> u32 {
self.inner.consensus_branch_id
}

/// Expiry height.
#[wasm_bindgen(getter, js_name = expiryHeight)]
pub fn expiry_height(&self) -> u32 {
self.inner.expiry_height
}

/// Number of Ironwood actions (0 when the Ironwood slot is empty).
#[wasm_bindgen(getter, js_name = ironwoodActionCount)]
pub fn ironwood_action_count(&self) -> usize {
self.inner
.ironwood_bundle
.as_ref()
.map_or(0, |b| b.actions.len())
}

/// Net value crossing the Ironwood pool boundary (0 when there is no bundle).
#[wasm_bindgen(getter, js_name = ironwoodValueBalance)]
pub fn ironwood_value_balance(&self) -> i64 {
self.inner
.ironwood_bundle
.as_ref()
.map_or(0, |b| b.value_balance)
}

/// The Ironwood bundle flag byte, or `undefined` when there is no bundle.
#[wasm_bindgen(getter, js_name = ironwoodFlags)]
pub fn ironwood_flags(&self) -> Option<u8> {
self.inner.ironwood_bundle.as_ref().map(|b| b.flags)
}

/// The Ironwood note-commitment tree anchor (32 bytes), or `undefined`.
#[wasm_bindgen(getter, js_name = ironwoodAnchor)]
pub fn ironwood_anchor(&self) -> Option<Vec<u8>> {
self.inner
.ironwood_bundle
.as_ref()
.map(|b| b.anchor.to_vec())
}
}
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 @@ -12,6 +12,7 @@
pub mod blake2b;
pub mod transaction;
pub mod unified_address;
pub mod v6;

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