Skip to content

feat(wasm-utxo): expose v6 (Ironwood) PSBT flow via wasm + TS - #343

Merged
veetragjain merged 1 commit into
masterfrom
veetragjain/cshld-1295-wasm-utxo-expose-v6-ironwood-methods-via-wasm-bindgen-ts
Jul 31, 2026
Merged

feat(wasm-utxo): expose v6 (Ironwood) PSBT flow via wasm + TS#343
veetragjain merged 1 commit into
masterfrom
veetragjain/cshld-1295-wasm-utxo-expose-v6-ironwood-methods-via-wasm-bindgen-ts

Conversation

@veetragjain

Copy link
Copy Markdown
Contributor

Surface the dedicated ZcashBitGoPsbt v6 methods through the wasm bindings and the TypeScript ZcashBitGoPsbt wrapper.

wasm (src/wasm/fixed_script_wallet): add create_empty_zcash_v6_at_height, add_ironwood_output, ironwood_v6_txid, ironwood_v6_transparent_sighash, add_ironwood_v6_signature, and combine_ironwood_proof, plus private zcash()/zcash_mut() helpers to reach the Zcash arm. Byte-length validation for recipient (43) / anchor (32) / memo (512) / ovk (32) happens at the boundary; randomness uses OsRng (getrandom js).

zcash_psbt: make the internal serialize()/deserialize() v6-aware so the standard from_bytes/serialize path round-trips a v6 PSBT (plain PSBT carrying the transparent skeleton + PCZT + ZecV6Params) without the v4 tx-replacement dance.

TS (ZcashBitGoPsbt.ts): add createIronwood, addIronwoodOutput, ironwoodTxid, ironwoodTransparentSighash, addIronwoodSignature, and combineIronwoodProof.

Test (test/fixedScript/zcashIronwoodPsbt.ts): drive the bindings from TypeScript — build a shield PSBT, assert the Ironwood version group id, compute a 32-byte v6 txid + per-input sighash that survive a serialize round-trip, and check the signature/recipient validation error paths. (Full sign→combine runtime behavior is covered by the native Rust test in PR4.) A small getrandom shim lets the bundler-target wasm source randomness under the Node/mocha harness; production bundler/browser and nodejs-target builds don't need it.

@linear-code

linear-code Bot commented Jul 28, 2026

Copy link
Copy Markdown

CSHLD-1295

return ZcashTransaction.fromWasm(this.wasm.extract_zcash_transaction(maxFeeRate));
}

// --- Zcash v6 (Ironwood / NU6.3) shielding ---

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.

maybe we want to create a separate ZcashIronwoodBitGoPsbt.ts instead?

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.

+1 — I'd do this in two steps:

Now (this PR): TS-level split. New ZcashIronwoodBitGoPsbt.ts extends ZcashBitGoPsbt, move the six v6 methods (addIronwoodOutput / ironwoodTxid / ironwoodTransparentSighash / addIronwoodSignature / combineIronwoodProof) onto it, createIronwood returns it, and fromBytes detects the ZecV6Params marker and returns the subclass. This gives the type-safety win with no wasm change — the underlying wasm object is the same flat BitGoPsbt. The one thing to get right is the fromBytes polymorphism (deserialize needs to return the subclass when the v6 marker is present).

Follow-up: wasm-level split. A genuinely separate WasmZcashIronwoodPsbt struct. Cheaper than I first thought — the generic PSBT ops come free via impl_wasm_psbt_ops!(WasmZcashIronwoodPsbt, psbt) (src/wasm/psbt.rs:782); only the wallet-construction surface (add_wallet_input/add_wallet_output ± _at_index, serialize) is hand-written on BitGoPsbt today and would need delegation — or a second macro arm to generate it. That step removes the zcash()/zcash_mut() runtime guards in favor of compile-time separation, so it's worth doing, just not blocking this PR.

Doing the TS split first keeps the public API shape the follow-up will want (a distinct Ironwood type) without holding up the stack.


Generated by Claude Code

@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.

Review

Overview

Surfaces the v6 flow through wasm-bindgen and the ZcashBitGoPsbt TS wrapper (createIronwood, addIronwoodOutput, ironwoodTxid, ironwoodTransparentSighash, addIronwoodSignature, combineIronwoodProof), plus v6-aware serialize/deserialize that auto-detects a v6 PSBT by the ZecV6Params proprietary key. Convention compliance is good: Uint8Array throughout (recipient/anchor/memo/ovk/sig/txid/proof), bigint for amount, number for non-monetary blockHeight/index/lockTime/expiryHeight, camelCase names, and length-validation at the wasm boundary.

Type separation (ZcashIronwoodBitGoPsbt)

Discussed in the ZcashBitGoPsbt.ts thread — agreed plan is a TS-level split in this PR (subclass returned by createIronwood, with fromBytes returning it when the ZecV6Params marker is present), and a wasm-level split as a follow-up (separate WasmZcashIronwoodPsbt, generic ops via impl_wasm_psbt_ops!, dropping the zcash()/zcash_mut() runtime guards). The TS split is the priority here because it gives compile-time separation — right now the six v6 methods live on ZcashBitGoPsbt and only work when the PSBT is actually v6, failing at runtime otherwise.

Issues

1. (API consistency) ironwoodTxid() returns bytes in display (reverse) order.
It returns a reversed Uint8Array — neither the internal-order txid nor a display hex string, an easy-to-misuse middle ground; the rest of the codebase exposes ids as getId(): string. Consider returning a hex string (display) to match getId, or internal-order bytes and letting the caller reverse+encode. Whatever you pick, document the order — a bare Uint8Array gives no hint it's reversed.

2. (Minor) combine_ironwood_proof clones the whole PSBT and leaves the JS object usable afterward.
The Rust combine_ironwood_proof consumes self, so the wasm binding takes &self and self.psbt.clone()s (PCZT + proof). Functionally fine, but the JS object stays "live" after combine, so a second combineIronwoodProof silently re-runs on stale state. Consider documenting it as terminal, or invalidating on the JS side.

Test coverage

Good smoke coverage (vgid, 32-byte txid/sighash + serialize round-trip, bad-sig reject, bad-recipient-length reject). One gap worth a line: combineIronwoodProof and the happy path of addIronwoodSignature are never exercised from TS — that wasm-bindgen glue is only covered by the native #342 test. A minimal TS combine (even against a placeholder proof) would exercise the binding end-to-end.

Note on the #338 finding (unshield digest)

This top layer is shield-onlyaddIronwoodOutput adds the shielded output and the transparent side is always the funding inputs; there's no shielded-input → transparent-output path exposed anywhere in the stack. So compute_v6_sig_digest's inputs.is_empty() fallback (my #338 finding #1) is unreachable in the current product surface — every real v6 tx here has ≥1 transparent input. I'd downgrade that finding to "latent, not currently reachable" — still worth fixing for API honesty, but not a shipping blocker.

Praise

Clean length-validation at the wasm boundary (recipient 43 / anchor 32 / memo 512 / ovk 32), the well-documented getrandom shim scoped to the mocha/Node-ESM harness (with a clear note production builds don't need it), and the plain-PSBT auto-detection via the marker key that keeps serialize/fromBytes transparent to callers.


Generated by Claude Code

Base automatically changed from veetragjain/cshld-1294-wasm-utxo-wire-v6-ironwood-buildsigncombine-into to master July 30, 2026 13:22
@veetragjain
veetragjain force-pushed the veetragjain/cshld-1295-wasm-utxo-expose-v6-ironwood-methods-via-wasm-bindgen-ts branch 5 times, most recently from f151c56 to 178d76a Compare July 30, 2026 14:51
@veetragjain
veetragjain marked this pull request as ready for review July 30, 2026 14:56
@veetragjain
veetragjain requested review from a team as code owners July 30, 2026 14:56
Comment on lines +511 to +520
let recipient: [u8; 43] = recipient
.try_into()
.map_err(|_| WasmUtxoError::new("recipient must be 43 bytes"))?;
let anchor: [u8; 32] = anchor
.try_into()
.map_err(|_| WasmUtxoError::new("anchor must be 32 bytes"))?;
let memo: [u8; 512] = memo
.try_into()
.map_err(|_| WasmUtxoError::new("memo must be 512 bytes"))?;
let ovk: Option<[u8; 32]> = match ovk {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

nit: should we store these lens in a descriptive variables?

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.

these are not runtime variables, but arguably we could extract a few type aliases

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.

Done

@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.

following up with more notes, to be continued

* @param walletKeys - The wallet's root keys (sets global xpubs in the PSBT)
* @param options - Options including blockHeight (at/after NU6.3 activation)
*/
static createIronwood(

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.

shouldn't this be ZcashIronwoodBitGoPsbt.create?

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.

Agree — create. Worth applying the same de-prefixing across the whole surface (see the txid / addOutput threads): once the class name carries "Ironwood", every member repeating it is redundant.


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.

Done

* transparent inputs/outputs and the Ironwood output are in place; unchanged by signing or
* proving.
*/
ironwoodTxid(): string {

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 - shoudn't this just the standard txid()?

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.

One correction on the target name: the codebase convention is getId(), not txid(). ITransaction and transactionBase both expose the display-order id as getId(): string, and the sibling ZcashV6Transaction already returns this exact value as getId() (src/wasm/zcash.rs:126, #[wasm_bindgen(js_name = getId)]). So the consistent rename is ironwoodTxid()getId(). No collision — the base PSBT classes define no getId.


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.

Changed it to getId()

* @param options.memo - optional 512-byte memo (defaults to the ZIP-302 "no memo" encoding)
* @param options.ovk - optional 32-byte outgoing viewing key (omit for a keyless build)
*/
addIronwoodOutput(

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.

we don't need to repeat Ironwood in the method name if we already have it in the classname

addShieldedOutput maybe if we want to keep the option for transparent outputs?

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.

+1 on addShieldedOutput — it also reads well against the base's addWalletOutput (transparent), making the shielded/transparent distinction explicit. Same de-prefixing for the rest of the surface: transparentSighash, addTransparentSignature, combineProof.


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.

Done

Comment on lines +511 to +520
let recipient: [u8; 43] = recipient
.try_into()
.map_err(|_| WasmUtxoError::new("recipient must be 43 bytes"))?;
let anchor: [u8; 32] = anchor
.try_into()
.map_err(|_| WasmUtxoError::new("anchor must be 32 bytes"))?;
let memo: [u8; 512] = memo
.try_into()
.map_err(|_| WasmUtxoError::new("memo must be 512 bytes"))?;
let ovk: Option<[u8; 32]> = match ovk {

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.

these are not runtime variables, but arguably we could extract a few type aliases

Comment on lines +4 to +25
* Mocha setup: make CSPRNG bytes available to the bundler-target wasm under Node ESM.
*
* The bundler-target build sources randomness through getrandom, which — on detecting Node via
* `process` — calls `module.require("crypto")`. `module` is undefined under Node ESM (this test
* harness), so provide a working `require`. Production consumers (real bundlers, or the
* nodejs-target build the microservice uses) don't need this.
*
* This lives in a `--require`d setup file rather than at the top of a spec because ESM hoists
* `import` declarations above every statement in a module body: a shim written inline in a spec runs
* only *after* the wasm module it is meant to support has been imported and evaluated. It happens to
* work there because getrandom resolves its backend lazily on first use, but that is a fragile thing
* to depend on. Loading it here runs it before any spec module is evaluated.
*
* Defined as a **self-removing** accessor rather than a plain global. A global `module` is how
* libraries detect CommonJS (`typeof module !== "undefined"`), and leaving one in place for the whole
* run would tell that lie to every dependency in the suite. getrandom reads `module.require` exactly
* once and caches the resulting `crypto` object, so deleting the property on first read narrows the
* window to that single access. If something else consumes it first, getrandom then fails loudly
* rather than any test silently changing behavior.
*
* The proper fix is a custom getrandom backend in Rust that goes straight to
* `globalThis.crypto.getRandomValues`, removing the Node-detection path — then this file can go.

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.

wait what? there must be a better way. how does it work for our BTC randomness needs?

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.

BTC musig2 already answers this. generate_musig2_nonces with no session_id auto-generates one via getrandom::getrandom() (mod.rs:1253) — the same getrandom 0.2.16 backend the orchard builder's OsRng uses (the only other copy, 0.3.4, is native-only via tempfile). And test/fixedScript/musig2Nonces.ts exercises exactly that path on master — assert.doesNotThrow(() => generateMusig2Nonces(userKey)) — with no require shim in .mocharc. So wasm CSPRNG already works in this tsx/ESM harness.

That makes the shim's premise suspect: if musig2's auto-gen path sources randomness fine here, the Ironwood path (OsRng → same getrandom) should too. Before landing the shim, worth confirming the Ironwood test actually fails without it — and if it's the same getrandom path, dropping the shim is the better outcome. If there is a real difference (e.g. rand's OsRng reaching the glue differently than a bare getrandom() call), a one-line note would justify keeping it.


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.

Removed the shim but the Ironwood test does fail without the shim on Node 18 and so does musig2Nonces.ts

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.

We don't use node18 anywhere, I initially thought we used it in bitgojs ci tests, but we only test on node20 and above, so removing the shim should not be a probelm.

@veetragjain
veetragjain force-pushed the veetragjain/cshld-1295-wasm-utxo-expose-v6-ironwood-methods-via-wasm-bindgen-ts branch from 178d76a to 8d95cd4 Compare July 31, 2026 09:02
Comment thread packages/wasm-utxo/js/fixedScriptWallet/ZcashIronwoodBitGoPsbt.ts
@veetragjain
veetragjain force-pushed the veetragjain/cshld-1295-wasm-utxo-expose-v6-ironwood-methods-via-wasm-bindgen-ts branch from 8d95cd4 to 89d9d27 Compare July 31, 2026 09:23

@manas-at-bitgo manas-at-bitgo left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

lgtm

@veetragjain
veetragjain merged commit f33a678 into master Jul 31, 2026
13 checks passed
@veetragjain
veetragjain deleted the veetragjain/cshld-1295-wasm-utxo-expose-v6-ironwood-methods-via-wasm-bindgen-ts branch July 31, 2026 10:45
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.

4 participants