Skip to content
Draft
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
86 changes: 86 additions & 0 deletions packages/wasm-utxo/js/fixedScriptWallet/ZcashBitGoPsbt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -292,4 +292,90 @@ export class ZcashBitGoPsbt extends BitGoPsbt {
override extractTransaction(maxFeeRate?: number): ZcashTransaction {
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


/**
* Create an empty Zcash **v6 (Ironwood)** shielding PSBT, with the consensus branch ID
* determined from block height.
*
* Add transparent inputs/outputs with the usual `addWalletInput` / `addWalletOutput`, the
* shielded output with {@link addIronwoodOutput}, then sign the transparent inputs over
* {@link ironwoodTransparentSighash} and finish with {@link combineIronwoodProof}.
*
* @param network - Zcash network name ("zcash", "zcashTest", "zec", "tzec")
* @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(
network: ZcashNetworkName,
walletKeys: WalletKeysArg,
options: { blockHeight: number; lockTime?: number; expiryHeight?: number },
): ZcashBitGoPsbt {
const keys = RootWalletKeys.from(walletKeys);
const wasm = WasmBitGoPsbt.create_empty_zcash_v6_at_height(
network,
keys.wasm,
options.blockHeight,
options.lockTime,
options.expiryHeight,
);
return new ZcashBitGoPsbt(wasm);
}

/**
* Add the shielded Ironwood output (Constructor role). Stores the orchard PCZT in the PSBT.
*
* @param recipient - 43-byte raw Orchard/Ironwood address
* @param amount - note value in zatoshi
* @param options.anchor - 32-byte Ironwood note-commitment-tree root
* @param options.memo - optional 512-byte memo (defaults to all zeros)
* @param options.ovk - optional 32-byte outgoing viewing key (omit for a keyless build)
*/
addIronwoodOutput(
recipient: Uint8Array,
amount: bigint,
options: { anchor: Uint8Array; memo?: Uint8Array; ovk?: Uint8Array },
): void {
const memo = options.memo ?? new Uint8Array(512);
this.wasm.add_ironwood_output(recipient, amount, options.ovk, options.anchor, memo);
}

/**
* The ZIP-244 v6 txid in display (reverse) byte order. Defined once the transparent
* inputs/outputs and the Ironwood output are in place; unchanged by signing or proving.
*/
ironwoodTxid(): Uint8Array {
return this.wasm.ironwood_v6_txid();
}

/**
* The ZIP-244 per-input transparent sighash (32 bytes) the key controlling transparent input
* `index` must sign.
*/
ironwoodTransparentSighash(index: number): Uint8Array {
return this.wasm.ironwood_v6_transparent_sighash(index);
}

/**
* Ingest a transparent-input signature returned by the client/HSM, after verifying it against
* {@link ironwoodTransparentSighash} for that input.
*
* @param index - transparent input index
* @param pubkey - the signing public key
* @param sig - DER ECDSA signature with the trailing SIGHASH_ALL byte (as in a scriptSig)
*/
addIronwoodSignature(index: number, pubkey: Uint8Array, sig: Uint8Array): void {
this.wasm.add_ironwood_v6_signature(index, pubkey, sig);
}

/**
* Transaction Extractor role: given the external prover's `proof` bytes, finalize the
* transparent inputs, apply the shielded binding signature, and return the broadcast-ready v6
* transaction bytes. Requires every transparent input to be signed via
* {@link addIronwoodSignature}.
*/
combineIronwoodProof(proof: Uint8Array): Uint8Array {
return this.wasm.combine_ironwood_proof(proof);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -240,6 +240,22 @@ impl ZcashBitGoPsbt {
network: crate::Network,
require_branch_id: bool,
) -> Result<Self, super::DeserializeError> {
// A v6 (Ironwood) PSBT is a plain PSBT carrying the transparent skeleton, so it parses
// directly; the presence of the ZecV6Params proprietary key distinguishes it from the v4
// path (whose embedded overwintered tx a plain PSBT decoder cannot parse).
if let Ok(psbt) = Psbt::deserialize(bytes) {
if let Some((version_group_id, expiry_height)) = super::propkv::get_zec_v6_params(&psbt)
{
return Ok(ZcashBitGoPsbt {
psbt,
network,
version_group_id: Some(version_group_id),
expiry_height: Some(expiry_height),
sapling_fields: Vec::new(),
});
}
}

let mut r = bytes;

// Read magic bytes
Expand Down Expand Up @@ -407,6 +423,12 @@ impl ZcashBitGoPsbt {

/// Serialize the Zcash PSBT back to bytes, including Zcash-specific fields
pub fn serialize(&self) -> Result<Vec<u8>, super::DeserializeError> {
// A v6 (Ironwood) PSBT keeps its transparent skeleton in the PSBT's own tx and its shielded
// state in the proprietary map, so it serializes as a plain PSBT.
if self.is_ironwood_v6() {
return Ok(self.serialize_v6());
}

// First serialize as standard Bitcoin PSBT
let bitcoin_psbt_bytes = self.psbt.serialize();

Expand Down
140 changes: 140 additions & 0 deletions packages/wasm-utxo/src/wasm/fixed_script_wallet/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -307,6 +307,30 @@ pub struct BitGoPsbt {
pub(crate) first_rounds: HashMap<(usize, String), musig2::FirstRound>,
}

impl BitGoPsbt {
/// Borrow the inner `ZcashBitGoPsbt`, erroring if this is not a Zcash PSBT.
fn zcash(
&self,
) -> Result<&crate::fixed_script_wallet::bitgo_psbt::ZcashBitGoPsbt, WasmUtxoError> {
use crate::fixed_script_wallet::bitgo_psbt::BitGoPsbt as InnerBitGoPsbt;
match &self.psbt {
InnerBitGoPsbt::Zcash(z, _) => Ok(z),
_ => Err(WasmUtxoError::new("not a Zcash PSBT")),
}
}

/// Mutably borrow the inner `ZcashBitGoPsbt`, erroring if this is not a Zcash PSBT.
fn zcash_mut(
&mut self,
) -> Result<&mut crate::fixed_script_wallet::bitgo_psbt::ZcashBitGoPsbt, WasmUtxoError> {
use crate::fixed_script_wallet::bitgo_psbt::BitGoPsbt as InnerBitGoPsbt;
match &mut self.psbt {
InnerBitGoPsbt::Zcash(z, _) => Ok(z),
_ => Err(WasmUtxoError::new("not a Zcash PSBT")),
}
}
}

#[wasm_bindgen]
impl BitGoPsbt {
/// Deserialize a PSBT from bytes with network-specific logic
Expand Down Expand Up @@ -439,6 +463,122 @@ impl BitGoPsbt {
})
}

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

/// Create an empty Zcash **v6 (Ironwood)** shielding PSBT, with the consensus branch id
/// resolved from `block_height`. Transparent inputs/outputs are added with the usual
/// `add_wallet_input` / `add_wallet_output`; the shielded output and the v6 sign/combine
/// steps use the dedicated `add_ironwood_output` / `ironwood_v6_*` / `combine_ironwood_proof`
/// methods.
pub fn create_empty_zcash_v6_at_height(
network: &str,
wallet_keys: &WasmRootWalletKeys,
block_height: u32,
lock_time: Option<u32>,
expiry_height: Option<u32>,
) -> Result<BitGoPsbt, WasmUtxoError> {
let network = parse_network(network)?;
let wallet_keys = wallet_keys.inner();
let psbt = crate::fixed_script_wallet::bitgo_psbt::BitGoPsbt::new_zcash_v6_at_height(
network,
wallet_keys,
block_height,
lock_time,
expiry_height,
)
.map_err(|e| WasmUtxoError::new(&e))?;
Ok(BitGoPsbt {
psbt,
first_rounds: HashMap::new(),
})
}

/// Constructor: add the shielded Ironwood output as an orchard PCZT stored in the PSBT.
///
/// * `recipient` - 43-byte raw Orchard/Ironwood address
/// * `amount` - note value in zatoshi
/// * `ovk` - optional 32-byte outgoing viewing key (`None` for a keyless build)
/// * `anchor` - 32-byte Ironwood note-commitment-tree root
/// * `memo` - 512-byte memo field
pub fn add_ironwood_output(
&mut self,
recipient: &[u8],
amount: u64,
ovk: Option<Vec<u8>>,
anchor: &[u8],
memo: &[u8],
) -> Result<(), WasmUtxoError> {
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 {
Some(v) => Some(
v.as_slice()
.try_into()
.map_err(|_| WasmUtxoError::new("ovk must be 32 bytes"))?,
),
None => None,
};
self.zcash_mut()?
.add_ironwood_output(&recipient, amount, ovk, &anchor, &memo, rand::rngs::OsRng)
.map_err(|e| WasmUtxoError::new(&e))
}

/// The ZIP-244 v6 txid in display (reverse) byte order.
pub fn ironwood_v6_txid(&self) -> Result<Vec<u8>, WasmUtxoError> {
let mut txid = self
.zcash()?
.v6_txid()
.map_err(|e| WasmUtxoError::new(&e))?;
txid.reverse();
Ok(txid.to_vec())
}

/// The ZIP-244 per-input transparent sighash (32 bytes) that the key controlling transparent
/// input `index` must sign.
pub fn ironwood_v6_transparent_sighash(&self, index: usize) -> Result<Vec<u8>, WasmUtxoError> {
self.zcash()?
.v6_transparent_sighash(index)
.map(|h| h.to_vec())
.map_err(|e| WasmUtxoError::new(&e))
}

/// Ingest a transparent-input signature (`sig`: DER ECDSA with the trailing SIGHASH_ALL byte)
/// after verifying it against `ironwood_v6_transparent_sighash(index)`.
pub fn add_ironwood_v6_signature(
&mut self,
index: usize,
pubkey: &[u8],
sig: &[u8],
) -> Result<(), WasmUtxoError> {
let pk = miniscript::bitcoin::PublicKey::from_slice(pubkey)
.map_err(|e| WasmUtxoError::new(&format!("invalid pubkey: {e}")))?;
self.zcash_mut()?
.add_v6_transparent_signature(index, pk, sig)
.map_err(|e| WasmUtxoError::new(&e))
}

/// Transaction Extractor: given the external prover's `proof` bytes, finalize the transparent
/// inputs, apply the shielded binding signature, and return the broadcast-ready v6 transaction
/// bytes. Requires the transparent inputs to be signed (via `add_ironwood_v6_signature`).
pub fn combine_ironwood_proof(&self, proof: &[u8]) -> Result<Vec<u8>, WasmUtxoError> {
use crate::fixed_script_wallet::bitgo_psbt::BitGoPsbt as InnerBitGoPsbt;
match self.psbt.clone() {
InnerBitGoPsbt::Zcash(z, _) => z
.combine_ironwood_proof(proof.to_vec(), rand::rngs::OsRng)
.map_err(|e| WasmUtxoError::new(&e)),
_ => Err(WasmUtxoError::new(
"combine_ironwood_proof: not a Zcash PSBT",
)),
}
}

/// Convert a half-signed legacy transaction to a psbt-lite.
///
/// # Arguments
Expand Down
78 changes: 78 additions & 0 deletions packages/wasm-utxo/test/fixedScript/zcashIronwoodPsbt.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import assert from "node:assert";
import { createRequire } from "node:module";
import { describe, it } from "mocha";

// The bundler-target wasm sources CSPRNG bytes through getrandom, which — on detecting Node via
// `process` — calls `module.require("crypto")`. `module` is undefined under Node ESM (the mocha
// harness), so provide a working `require`. Production consumers (real bundlers, or the
// nodejs-target build the microservice uses) don't need this. Runs before the first wasm
// randomness call.
(globalThis as unknown as { module?: { require: NodeRequire } }).module ??= {
require: createRequire(import.meta.url),
};

import { ZcashBitGoPsbt } from "../../js/fixedScriptWallet/ZcashBitGoPsbt.js";
import { getWalletKeysForSeed } from "../../js/testutils/index.js";

// NU6.3 (Ironwood) testnet activation height and version group id.
const NU6_3_TESTNET_HEIGHT = 4134000;
const IRONWOOD_VERSION_GROUP_ID = 0xd884b698;

// A valid raw Orchard/Ironwood receiver (43 bytes), derived in Rust from
// SpendingKey([7u8; 32]) → FullViewingKey → address_at(0, External).
const RECIPIENT = Buffer.from(
"4559029c0b5dbf941c5ad181a5fe8f45b34630f29d0c8dd8dc1cc3573386f416cb324133156d723df5e62d",
"hex",
);

describe("ZcashBitGoPsbt v6 (Ironwood)", function () {
function buildShieldPsbt(): ZcashBitGoPsbt {
const walletKeys = getWalletKeysForSeed("ironwood-ts");
const psbt = ZcashBitGoPsbt.createIronwood("zcashTest", walletKeys, {
blockHeight: NU6_3_TESTNET_HEIGHT,
});
// One 2-of-3 P2SH transparent input (2 ZEC) and a transparent change output.
psbt.addWalletInput({ txid: "11".repeat(32), vout: 0, value: 200_000_000n }, walletKeys, {
scriptId: { chain: 0, index: 0 },
signPath: { signer: "user", cosigner: "bitgo" },
});
psbt.addWalletOutput(walletKeys, { chain: 1, index: 0, value: 99_900_000n });
// Shielded Ironwood output (1 ZEC). Any valid Pallas base element is a usable build-time
// anchor (validity against a real tree is a prover concern), so all-zeros works.
psbt.addIronwoodOutput(RECIPIENT, 100_000_000n, { anchor: new Uint8Array(32) });
return psbt;
}

it("creates an Ironwood PSBT with the Ironwood version group id", function () {
const psbt = buildShieldPsbt();
assert.strictEqual(psbt.versionGroupId, IRONWOOD_VERSION_GROUP_ID);
});

it("computes a 32-byte v6 txid and per-input sighash that survive a serialize round-trip", function () {
const psbt = buildShieldPsbt();
const txid = psbt.ironwoodTxid();
assert.strictEqual(txid.length, 32);
const sighash = psbt.ironwoodTransparentSighash(0);
assert.strictEqual(sighash.length, 32);

// serialize → fromBytes preserves the v6 state (transparent skeleton + PCZT + params).
const bytes = psbt.serialize();
const round = ZcashBitGoPsbt.fromBytes(bytes, "zcashTest");
assert.strictEqual(round.versionGroupId, IRONWOOD_VERSION_GROUP_ID);
assert.deepStrictEqual(round.ironwoodTxid(), txid);
assert.deepStrictEqual(round.ironwoodTransparentSighash(0), sighash);
});

it("rejects a signature that does not verify against the v6 sighash", function () {
const psbt = buildShieldPsbt();
assert.throws(() => psbt.addIronwoodSignature(0, new Uint8Array(33), new Uint8Array(72)));
});

it("rejects Ironwood operations on a non-Ironwood output shape", function () {
const psbt = buildShieldPsbt();
// A bad recipient length is rejected before touching the orchard builder.
assert.throws(() =>
psbt.addIronwoodOutput(new Uint8Array(10), 1n, { anchor: new Uint8Array(32) }),
);
});
});