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
18 changes: 18 additions & 0 deletions packages/wasm-utxo/js/fixedScriptWallet/ZcashBitGoPsbt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,9 @@ export type ZcashParsedOutput = ParsedOutput & {
isShielded: boolean;
};

/** Zcash transaction versions supported by the fixed-script PSBT API. */
export type ZcashTransactionVersion = 4 | 6;

/**
* Zcash v6 (Ironwood) version group id (0xd884b698). Its presence marks a PSBT as v6 — see
* `ZcashIronwoodBitGoPsbt`.
Expand Down Expand Up @@ -182,6 +185,11 @@ export class ZcashBitGoPsbt extends BitGoPsbt<ZcashParsedOutput> {
return psbt;
}

/** @internal Create from a parsed WASM instance without reparsing the bytes. */
static fromWasm(wasm: WasmBitGoPsbt): ZcashBitGoPsbt {
return new ZcashBitGoPsbt(wasm);
}

/**
* Reconstruct a Zcash PSBT from a network-format transaction (unsigned, half-signed, or fully-signed).
*
Expand Down Expand Up @@ -294,6 +302,16 @@ export class ZcashBitGoPsbt extends BitGoPsbt<ZcashParsedOutput> {
return this.wasm.version_group_id();
}

/**
* Get the Zcash transaction version represented by this PSBT.
*
* The version group ID is the discriminator because the v4 and v6 PSBT encodings do not expose
* the same unsigned transaction bytes to a standard PSBT parser.
*/
getVersion(): ZcashTransactionVersion {
return this.versionGroupId === IRONWOOD_VERSION_GROUP_ID ? 6 : 4;
}

/**
* Get the Zcash expiry height
* @returns The expiry height (0 if not set)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,11 @@ export class ZcashIronwoodBitGoPsbt extends ZcashBitGoPsbt {
return psbt;
}

/** @internal Create from a parsed WASM instance without reparsing the bytes. */
static override fromWasm(wasm: WasmBitGoPsbt): ZcashIronwoodBitGoPsbt {
return new ZcashIronwoodBitGoPsbt(wasm);
}

/**
* Add the shielded output (Constructor role). Stores the orchard PCZT in the PSBT.
*
Expand Down
25 changes: 25 additions & 0 deletions packages/wasm-utxo/js/fixedScriptWallet/ZcashPsbt.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { BitGoPsbt as WasmBitGoPsbt } from "../wasm/wasm_utxo.js";
import { ZcashBitGoPsbt, type ZcashNetworkName } from "./ZcashBitGoPsbt.js";
import { ZcashIronwoodBitGoPsbt } from "./ZcashIronwoodBitGoPsbt.js";

export type ZcashPsbtInstance = ZcashBitGoPsbt | ZcashIronwoodBitGoPsbt;

/** Factory for deserializing either supported Zcash PSBT format. */
export class ZcashPsbt {
private constructor() {}

/**
* Deserialize a Zcash PSBT and return the format-specific implementation.
*
* The version is read from the parsed Zcash metadata, so callers do not need to run a separate
* byte-level detector or choose a concrete parser before deserializing.
*/
static from(bytes: Uint8Array, network: ZcashNetworkName): ZcashPsbtInstance {
const parsed = ZcashBitGoPsbt.fromWasm(WasmBitGoPsbt.from_bytes(bytes, network));
return parsed.getVersion() === 6 ? ZcashIronwoodBitGoPsbt.fromWasm(parsed.wasm) : parsed;
}

static fromBytes(bytes: Uint8Array, network: ZcashNetworkName): ZcashPsbtInstance {
return ZcashPsbt.from(bytes, network);
}
}
3 changes: 2 additions & 1 deletion packages/wasm-utxo/js/fixedScriptWallet/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,10 +44,11 @@ export {
ZcashBitGoPsbt,
type ZcashNetworkName,
type ZcashParsedOutput,
type ZcashTransactionVersion,
type CreateEmptyZcashOptions,
IRONWOOD_VERSION_GROUP_ID,
} from "./ZcashBitGoPsbt.js";

export { ZcashPsbt, type ZcashPsbtInstance } from "./ZcashPsbt.js";
// Zcash v6 (Ironwood / NU6.3) shielding PSBT
export {
ZcashIronwoodBitGoPsbt,
Expand Down
10 changes: 10 additions & 0 deletions packages/wasm-utxo/js/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,16 @@ export { ECPair } from "./ecpair.js";
export { BIP32 } from "./bip32.js";
export { Dimensions } from "./fixedScriptWallet/Dimensions.js";
export { ZcashDimensions } from "./fixedScriptWallet/ZcashDimensions.js";
export {
ZcashBitGoPsbt,
ZcashPsbt,
ZcashIronwoodBitGoPsbt,
type ZcashPsbtInstance,
type ZcashTransactionVersion,
ZcashUnifiedAddress,
ZcashV6Transaction,
ZcashIronwoodWitness,
} from "./fixedScriptWallet/index.js";

export type WasmUtxoVersionInfo = { version: string; gitHash: string };
export function getWasmUtxoVersion(): WasmUtxoVersionInfo {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -271,13 +271,13 @@ impl ZcashBitGoPsbt {
// `psbt\xff` magic, so a failed parse here is not conclusive — fall through to the v4 path
// and let it report the real error.
//
// Delegate to `deserialize_v6` rather than constructing the struct inline: it validates the
// declared version group id, the consensus branch id and the PCZT. Trusting them would let a
// PSBT that carries ZecV6Params but a non-Ironwood version group id through with
// `is_ironwood_v6() == false`, which then routes `serialize()` back down the v4 path.
// Delegate to the pre-shield v6 decoder rather than constructing the struct inline. A PSBT
// can be serialized between building its transparent skeleton and adding the PCZT, so the
// generic entry point must accept both states. The decoder still validates the declared
// version group id and consensus branch id.
if let Ok(psbt) = Psbt::deserialize(bytes) {
if super::propkv::get_zec_v6_params(&psbt).is_some() {
return Self::deserialize_v6(bytes, network);
return Self::deserialize_v6_pre_shield(bytes, network);
}
}

Expand Down Expand Up @@ -344,17 +344,19 @@ impl ZcashBitGoPsbt {
expiry_height = parts.expiry_height;
sapling_fields = parts.sapling_fields;

// Serialize the modified transaction
let mut tx_bytes = Vec::new();
parts
.transaction
.consensus_encode(&mut tx_bytes)
.map_err(|e| {
super::DeserializeError::Network(format!(
"Failed to encode transaction: {}",
e
))
})?;
// Serialize the modified transaction without the segwit marker/flag. The
// transaction is embedded in a PSBT global map and must use the legacy unsigned
// transaction encoding, including for an empty transaction.
let tx_bytes = crate::zcash::transaction::encode_zcash_transaction_parts(
&crate::zcash::transaction::ZcashTransactionParts {
transaction: parts.transaction,
is_overwintered: false,
version_group_id: None,
expiry_height: None,
sapling_fields: Vec::new(),
},
)
.map_err(super::DeserializeError::Network)?;

// Write key
VarInt(key_data.len() as u64)
Expand Down Expand Up @@ -1585,6 +1587,11 @@ impl ZcashBitGoPsbt {
"v6 PSBT is missing its Ironwood PCZT".to_string(),
));
}
if !require_pczt && super::propkv::is_ironwood_extracted(&psbt) {
return Err(super::DeserializeError::Network(
"v6 PSBT is missing its Ironwood PCZT after extraction".to_string(),
));
}
Ok(ZcashBitGoPsbt {
psbt,
network,
Expand Down Expand Up @@ -1636,6 +1643,27 @@ mod tests {
assert!(parts.sapling_fields.is_empty());
}

#[test]
fn test_round_trip_empty_zcash_psbt() {
use crate::fixed_script_wallet::test_utils::get_test_wallet_keys;
use crate::fixed_script_wallet::RootWalletKeys;
use crate::networks::Network;

let wallet_keys = RootWalletKeys::new(get_test_wallet_keys("empty-zcash-round-trip"));
let psbt = ZcashBitGoPsbt::new(
Network::Zcash,
&wallet_keys,
0xc2d6d0b4,
Some(4),
Some(0),
Some(ZCASH_SAPLING_VERSION_GROUP_ID),
Some(0),
);
let bytes = psbt.serialize().unwrap();

ZcashBitGoPsbt::deserialize(&bytes, Network::Zcash).unwrap();
}

#[test]
fn test_round_trip_zcash_psbt() {
use crate::fixed_script_wallet::test_utils::fixtures::{
Expand Down
3 changes: 0 additions & 3 deletions packages/wasm-utxo/src/zcash/transaction.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,6 @@ pub const ZCASH_SAPLING_VERSION_GROUP_ID: u32 = 0x892F2085;
/// Zcash Ironwood version group ID (v6 / NU6.3 transactions)
pub const ZCASH_IRONWOOD_VERSION_GROUP_ID: u32 = 0xD884B698;

/// Transaction version header for v4 transactions (Sapling), overwintered bit set.
pub const ZCASH_V4_VERSION_HEADER: u32 = 0x80000004;

/// Transaction version header for v6 transactions (Ironwood/NU6.3), overwintered bit set.
pub const ZCASH_V6_VERSION_HEADER: u32 = 0x80000006;

Expand Down
54 changes: 54 additions & 0 deletions packages/wasm-utxo/test/fixedScript/zcashPsbt.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import assert from "node:assert";
import { describe, it } from "mocha";

import { ZcashBitGoPsbt, ZcashIronwoodBitGoPsbt, ZcashPsbt } from "../../js/index.js";
import { BitGoPsbt } from "../../js/fixedScriptWallet/index.js";
import { getWalletKeysForSeed } from "../../js/testutils/index.js";

const LEGACY_V4_MAINNET_HEIGHT = 1687104;
const NU6_3_TESTNET_HEIGHT = 4134000;

describe("ZcashPsbt", function () {
const walletKeys = getWalletKeysForSeed("zcash-psbt-factory-test");

it("returns the v4 implementation for a legacy PSBT", function () {
const original = ZcashBitGoPsbt.createEmpty("zcash", walletKeys, {
blockHeight: LEGACY_V4_MAINNET_HEIGHT,
});

const psbt = ZcashPsbt.from(original.serialize(), "zcash");

assert(psbt instanceof ZcashBitGoPsbt);
assert(!(psbt instanceof ZcashIronwoodBitGoPsbt));
assert.strictEqual(psbt.getVersion(), 4);
});

it("returns the v6 implementation for a complete Ironwood PSBT", function () {
const original = ZcashIronwoodBitGoPsbt.createEmpty("zcashTest", walletKeys, {
blockHeight: NU6_3_TESTNET_HEIGHT,
});

const psbt = ZcashPsbt.fromBytes(original.serialize(), "zcashTest");

assert(psbt instanceof ZcashIronwoodBitGoPsbt);
assert.strictEqual(psbt.getVersion(), 6);
});

it("returns the v6 implementation before a shielded output is added", function () {
const original = ZcashIronwoodBitGoPsbt.createEmpty("zcashTest", walletKeys, {
blockHeight: NU6_3_TESTNET_HEIGHT,
});
original.addWalletOutput(walletKeys, { chain: 1, index: 0, value: 100n });

const psbt = ZcashPsbt.from(original.serialize(), "zcashTest");

assert(psbt instanceof ZcashIronwoodBitGoPsbt);
assert.strictEqual(psbt.getVersion(), 6);
});

it("rejects a non-Zcash PSBT", function () {
const bitcoinPsbt = BitGoPsbt.createEmpty("bitcoin", walletKeys);

assert.throws(() => ZcashPsbt.from(bitcoinPsbt.serialize(), "zcash"), /Zcash|branch|PSBT/i);
});
});
Loading