Skip to content

Commit f1b667d

Browse files
OttoAllmendingerllm-git
andcommitted
feat(wasm-utxo): add Zcash support with ZIP-243 sighash
Implement Zcash transaction support including consensus branch ID handling, version group ID, expiry height, and ZIP-243 sighash algorithm for signatures. Add ZcashBitGoPsbt class that handles Zcash-specific transaction fields and simplifies PSBT creation with automatic branch ID determination from block height. Issue: BTC-2659 Co-authored-by: llm-git <[email protected]>
1 parent 649a0a7 commit f1b667d

26 files changed

+3846
-321
lines changed

packages/wasm-utxo/Cargo.lock

Lines changed: 1957 additions & 86 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/wasm-utxo/Cargo.toml

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ all = "warn"
1616
[dependencies]
1717
wasm-bindgen = "0.2"
1818
js-sys = "0.3"
19-
miniscript = { git = "https://github.com/BitGo/rust-miniscript", tag = "miniscript-13.0.0-opdrop-forkid" }
19+
miniscript = { git = "https://github.com/BitGo/rust-miniscript", tag = "miniscript-13.0.0-bitgo.1" }
2020
bech32 = "0.11"
2121
musig2 = { version = "0.3.1", default-features = false, features = ["k256"] }
2222
getrandom = { version = "0.2", features = ["js"] }
@@ -31,6 +31,9 @@ wasm-bindgen-test = "0.3"
3131
rstest = "0.26.1"
3232
pastey = "0.1"
3333

34+
[target.'cfg(not(target_arch = "wasm32"))'.dev-dependencies]
35+
zebra-chain = { version = "3.1", default-features = false }
36+
3437
[profile.release]
3538
# this is required to make webpack happy
3639
# https://github.com/webpack/webpack/issues/15566#issuecomment-2558347645

packages/wasm-utxo/README.md

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,15 @@ This project is under active development.
2020
| Descriptor Wallet: Address Support | ✅ Complete | 🚫 | 🚫 | 🚫 | 🚫 | 🚫 | 🚫 |
2121
| Descriptor Wallet: Transaction Support | ✅ Complete | 🚫 | 🚫 | 🚫 | 🚫 | 🚫 | 🚫 |
2222
| FixedScript Wallet: Address Generation | ✅ Complete | ✅ Complete | ✅ Complete | ✅ Complete | ✅ Complete | ✅ Complete | ✅ Complete |
23-
| FixedScript Wallet: Transaction Support | ✅ Complete | ✅ Complete | ✅ Complete | ⏳ TODO | ⏳ TODO | ✅ Complete | ⏳ TODO |
23+
| FixedScript Wallet: Transaction Support | ✅ Complete | ✅ Complete | ✅ Complete | ⏳ TODO | ⏳ TODO | ✅ Complete | ✅ Complete |
24+
25+
### Zcash Features
26+
27+
Zcash support includes:
28+
- **Network Upgrade Awareness**: Automatic consensus branch ID determination based on block height
29+
- **All Network Upgrades**: Support for Overwinter, Sapling, Blossom, Heartwood, Canopy, Nu5, Nu6, and Nu6_1
30+
- **Height-Based API**: Preferred `createEmpty()` method automatically selects correct consensus rules
31+
- **Parity Testing**: Validated against `zebra-chain` for accuracy across all network upgrades
2432

2533
## Building
2634

packages/wasm-utxo/cli/Cargo.toml

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,5 @@ base64 = "0.21"
1616
serde = { version = "1.0", features = ["derive"] }
1717
serde_json = "1.0"
1818
num-bigint = "0.4"
19-
bitcoin = { git = "https://github.com/BitGo/rust-bitcoin", tag = "bitcoin-0.32.8-forkid" }
2019
colored = "2.1"
2120
ptree = "0.5"

packages/wasm-utxo/cli/src/parse/node.rs

Lines changed: 33 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
/// This contains low-level parsing of PSBT into a node structure suitable for display
2-
use bitcoin::consensus::Decodable;
3-
use bitcoin::hashes::Hash;
4-
use bitcoin::psbt::Psbt;
5-
use bitcoin::{Network, ScriptBuf, Transaction};
2+
use wasm_utxo::bitcoin::consensus::Decodable;
3+
use wasm_utxo::bitcoin::hashes::Hash;
4+
use wasm_utxo::bitcoin::psbt::Psbt;
5+
use wasm_utxo::bitcoin::{Network, ScriptBuf, Transaction};
66
use wasm_utxo::fixed_script_wallet::bitgo_psbt::{
77
p2tr_musig2_input::{Musig2PartialSig, Musig2Participants, Musig2PubNonce},
88
BitGoKeyValue, ProprietaryKeySubtype, BITGO,
@@ -21,8 +21,11 @@ fn script_buf_to_node(label: &str, script_buf: &ScriptBuf) -> Node {
2121

2222
fn bip32_derivations_to_nodes(
2323
bip32_derivation: &std::collections::BTreeMap<
24-
bitcoin::secp256k1::PublicKey,
25-
(bitcoin::bip32::Fingerprint, bitcoin::bip32::DerivationPath),
24+
wasm_utxo::bitcoin::secp256k1::PublicKey,
25+
(
26+
wasm_utxo::bitcoin::bip32::Fingerprint,
27+
wasm_utxo::bitcoin::bip32::DerivationPath,
28+
),
2629
>,
2730
) -> Vec<Node> {
2831
bip32_derivation
@@ -100,7 +103,10 @@ fn musig2_partial_sig_to_node(sig: &Musig2PartialSig) -> Node {
100103
node
101104
}
102105

103-
fn bitgo_proprietary_to_node(prop_key: &bitcoin::psbt::raw::ProprietaryKey, v: &[u8]) -> Node {
106+
fn bitgo_proprietary_to_node(
107+
prop_key: &wasm_utxo::bitcoin::psbt::raw::ProprietaryKey,
108+
v: &[u8],
109+
) -> Node {
104110
// Try to parse as BitGo key-value
105111
let v_vec = v.to_vec();
106112
let bitgo_kv_result = BitGoKeyValue::from_key_value(prop_key, &v_vec);
@@ -159,7 +165,7 @@ fn bitgo_proprietary_to_node(prop_key: &bitcoin::psbt::raw::ProprietaryKey, v: &
159165

160166
fn raw_proprietary_to_node(
161167
label: &str,
162-
prop_key: &bitcoin::psbt::raw::ProprietaryKey,
168+
prop_key: &wasm_utxo::bitcoin::psbt::raw::ProprietaryKey,
163169
v: &[u8],
164170
) -> Node {
165171
let mut prop_node = Node::new(label, Primitive::None);
@@ -177,7 +183,10 @@ fn raw_proprietary_to_node(
177183
}
178184

179185
fn proprietary_to_nodes(
180-
proprietary: &std::collections::BTreeMap<bitcoin::psbt::raw::ProprietaryKey, Vec<u8>>,
186+
proprietary: &std::collections::BTreeMap<
187+
wasm_utxo::bitcoin::psbt::raw::ProprietaryKey,
188+
Vec<u8>,
189+
>,
181190
) -> Vec<Node> {
182191
proprietary
183192
.iter()
@@ -194,8 +203,11 @@ fn proprietary_to_nodes(
194203

195204
fn xpubs_to_nodes(
196205
xpubs: &std::collections::BTreeMap<
197-
bitcoin::bip32::Xpub,
198-
(bitcoin::bip32::Fingerprint, bitcoin::bip32::DerivationPath),
206+
wasm_utxo::bitcoin::bip32::Xpub,
207+
(
208+
wasm_utxo::bitcoin::bip32::Fingerprint,
209+
wasm_utxo::bitcoin::bip32::DerivationPath,
210+
),
199211
>,
200212
) -> Vec<Node> {
201213
xpubs
@@ -215,8 +227,11 @@ fn xpubs_to_nodes(
215227

216228
pub fn xpubs_to_node(
217229
xpubs: &std::collections::BTreeMap<
218-
bitcoin::bip32::Xpub,
219-
(bitcoin::bip32::Fingerprint, bitcoin::bip32::DerivationPath),
230+
wasm_utxo::bitcoin::bip32::Xpub,
231+
(
232+
wasm_utxo::bitcoin::bip32::Fingerprint,
233+
wasm_utxo::bitcoin::bip32::DerivationPath,
234+
),
220235
>,
221236
) -> Node {
222237
let mut xpubs_node = Node::new("xpubs", Primitive::U64(xpubs.len() as u64));
@@ -267,7 +282,7 @@ pub fn psbt_to_node(psbt: &Psbt, network: Network) -> Node {
267282
witness_node.add_child(Node::new(
268283
"address",
269284
Primitive::String(
270-
bitcoin::Address::from_script(&witness_utxo.script_pubkey, network)
285+
wasm_utxo::bitcoin::Address::from_script(&witness_utxo.script_pubkey, network)
271286
.map(|a| a.to_string())
272287
.unwrap_or_else(|_| "<invalid address>".to_string()),
273288
),
@@ -353,7 +368,7 @@ pub fn psbt_to_node(psbt: &Psbt, network: Network) -> Node {
353368
psbt_node
354369
}
355370

356-
pub fn tx_to_node(tx: &Transaction, network: bitcoin::Network) -> Node {
371+
pub fn tx_to_node(tx: &Transaction, network: wasm_utxo::bitcoin::Network) -> Node {
357372
let mut tx_node = Node::new("tx", Primitive::None);
358373

359374
tx_node.add_child(Node::new("version", Primitive::I32(tx.version.0)));
@@ -425,7 +440,9 @@ pub fn tx_to_node(tx: &Transaction, network: bitcoin::Network) -> Node {
425440
Primitive::Buffer(output.script_pubkey.as_bytes().to_vec()),
426441
));
427442

428-
if let Ok(address) = bitcoin::Address::from_script(&output.script_pubkey, network) {
443+
if let Ok(address) =
444+
wasm_utxo::bitcoin::Address::from_script(&output.script_pubkey, network)
445+
{
429446
output_node.add_child(Node::new("address", Primitive::String(address.to_string())));
430447
}
431448

packages/wasm-utxo/cli/src/parse/node_raw.rs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,9 +25,9 @@
2525
///
2626
/// - [BIP-174: PSBT Format](https://github.com/bitcoin/bips/blob/master/bip-0174.mediawiki)
2727
/// - [bitcoin::psbt::raw](https://docs.rs/bitcoin/latest/bitcoin/psbt/raw/index.html)
28-
use bitcoin::consensus::Decodable;
29-
use bitcoin::psbt::raw::{Key, Pair};
30-
use bitcoin::{Network, Transaction, VarInt};
28+
use wasm_utxo::bitcoin::consensus::Decodable;
29+
use wasm_utxo::bitcoin::psbt::raw::{Key, Pair};
30+
use wasm_utxo::bitcoin::{Network, Transaction, VarInt};
3131

3232
pub use crate::node::{Node, Primitive};
3333

packages/wasm-utxo/js/README.md

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -214,3 +214,38 @@ export { BIP32 } from "./bip32";
214214
- Methods need to return new instances of the same type
215215
- Need to encapsulate underlying WASM instance
216216
- Examples: BIP32 keys, RootWalletKeys, BitGoPsbt
217+
218+
### Network-Specific APIs
219+
220+
Some UTXO networks require additional parameters that don't apply to others. The TypeScript wrappers use specialized classes and overloaded signatures to handle these cases while maintaining type safety.
221+
222+
**Example: Zcash-specific PSBT creation**
223+
224+
Zcash transactions require consensus-specific parameters to prevent replay attacks across network upgrades. The `ZcashBitGoPsbt` class provides a height-based API that automatically determines the correct consensus rules:
225+
226+
```typescript
227+
// Non-Zcash networks: simple signature
228+
const btcPsbt = BitGoPsbt.createEmpty("bitcoin", walletKeys, { version: 2 });
229+
230+
// Zcash networks: blockHeight determines consensus rules (preferred)
231+
const zecPsbt = ZcashBitGoPsbt.createEmpty("zcash", walletKeys, {
232+
blockHeight: 1687104, // Automatically uses NU5 consensus rules
233+
version: 5,
234+
versionGroupId: 0x26a7270a, // optional
235+
expiryHeight: 1000000, // optional
236+
});
237+
238+
// Advanced: Explicit consensus branch ID (when needed)
239+
const zecPsbtAdvanced = ZcashBitGoPsbt.createEmptyWithConsensusBranchId("zcash", walletKeys, {
240+
consensusBranchId: 0xc2d6d0b4, // NU5 branch ID
241+
version: 5,
242+
});
243+
```
244+
245+
This pattern ensures:
246+
247+
- Automatic consensus rule selection based on block height (preferred)
248+
- No need to manually look up consensus branch IDs
249+
- Future-proof as new network upgrades activate
250+
- Advanced control available when explicit branch ID is needed
251+
- Full type safety with network-specific options

packages/wasm-utxo/js/fixedScriptWallet/BitGoPsbt.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -105,14 +105,16 @@ export type AddWalletOutputOptions = {
105105
};
106106

107107
export class BitGoPsbt {
108-
private constructor(private wasm: WasmBitGoPsbt) {}
108+
protected constructor(protected wasm: WasmBitGoPsbt) {}
109109

110110
/**
111111
* Create an empty PSBT for the given network with wallet keys
112112
*
113113
* The wallet keys are used to set global xpubs in the PSBT, which identifies
114114
* the keys that will be used for signing.
115115
*
116+
* For Zcash networks, use ZcashBitGoPsbt.createEmpty() instead.
117+
*
116118
* @param network - Network name (utxolib name like "bitcoin" or coin name like "btc")
117119
* @param walletKeys - The wallet's root keys (sets global xpubs in the PSBT)
118120
* @param options - Optional transaction parameters (version, lockTime)
Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
import { BitGoPsbt as WasmBitGoPsbt } from "../wasm/wasm_utxo.js";
2+
import { type WalletKeysArg, RootWalletKeys } from "./RootWalletKeys.js";
3+
import { BitGoPsbt, type CreateEmptyOptions } from "./BitGoPsbt.js";
4+
5+
/** Zcash network names */
6+
export type ZcashNetworkName = "zcash" | "zcashTest" | "zec" | "tzec";
7+
8+
/** Options for creating an empty Zcash PSBT (preferred method using block height) */
9+
export type CreateEmptyZcashOptions = CreateEmptyOptions & {
10+
/** Block height to determine consensus branch ID automatically */
11+
blockHeight: number;
12+
/** Zcash version group ID (defaults to Sapling: 0x892F2085) */
13+
versionGroupId?: number;
14+
/** Zcash transaction expiry height */
15+
expiryHeight?: number;
16+
};
17+
18+
/** Options for creating an empty Zcash PSBT with explicit consensus branch ID (advanced use) */
19+
export type CreateEmptyZcashWithConsensusBranchIdOptions = CreateEmptyOptions & {
20+
/** Zcash consensus branch ID (required, e.g., 0xC2D6D0B4 for NU5, 0x76B809BB for Sapling) */
21+
consensusBranchId: number;
22+
/** Zcash version group ID (defaults to Sapling: 0x892F2085) */
23+
versionGroupId?: number;
24+
/** Zcash transaction expiry height */
25+
expiryHeight?: number;
26+
};
27+
28+
/**
29+
* Zcash-specific PSBT implementation
30+
*
31+
* This class extends BitGoPsbt with Zcash-specific functionality:
32+
* - Required consensus branch ID for sighash computation
33+
* - Version group ID for Zcash transaction format
34+
* - Expiry height for transaction validity
35+
*
36+
* All Zcash-specific getters return non-optional types since they're
37+
* guaranteed to be present for Zcash PSBTs.
38+
*
39+
* @example
40+
* ```typescript
41+
* // Create a new Zcash PSBT
42+
* const psbt = ZcashBitGoPsbt.createEmpty("zcash", walletKeys, {
43+
* consensusBranchId: 0x76B809BB, // Sapling
44+
* });
45+
*
46+
* // Deserialize from bytes
47+
* const psbt = ZcashBitGoPsbt.fromBytes(bytes, "zcash");
48+
* ```
49+
*/
50+
export class ZcashBitGoPsbt extends BitGoPsbt {
51+
/**
52+
* Create an empty Zcash PSBT with consensus branch ID determined from block height
53+
*
54+
* **This is the preferred method for creating Zcash PSBTs.** It automatically determines
55+
* the correct consensus branch ID based on the network and block height using Zcash
56+
* network upgrade activation heights, eliminating the need to manually look up branch IDs.
57+
*
58+
* @param network - Zcash network name ("zcash", "zcashTest", "zec", "tzec")
59+
* @param walletKeys - The wallet's root keys (sets global xpubs in the PSBT)
60+
* @param options - Options including blockHeight to determine consensus rules
61+
* @returns A new ZcashBitGoPsbt instance
62+
* @throws Error if block height is before Overwinter activation
63+
*
64+
* @example
65+
* ```typescript
66+
* // Create PSBT for a specific block height (recommended)
67+
* const psbt = ZcashBitGoPsbt.createEmpty("zcash", walletKeys, {
68+
* blockHeight: 1687104, // Automatically uses Nu5 branch ID
69+
* });
70+
*
71+
* // Create PSBT for current block height
72+
* const currentHeight = await getBlockHeight();
73+
* const psbt = ZcashBitGoPsbt.createEmpty("zcash", walletKeys, {
74+
* blockHeight: currentHeight,
75+
* });
76+
* ```
77+
*/
78+
static override createEmpty(
79+
network: ZcashNetworkName,
80+
walletKeys: WalletKeysArg,
81+
options: CreateEmptyZcashOptions,
82+
): ZcashBitGoPsbt {
83+
const keys = RootWalletKeys.from(walletKeys);
84+
const wasm = WasmBitGoPsbt.create_empty_zcash_at_height(
85+
network,
86+
keys.wasm,
87+
options.blockHeight,
88+
options.version,
89+
options.lockTime,
90+
options.versionGroupId,
91+
options.expiryHeight,
92+
);
93+
return new ZcashBitGoPsbt(wasm);
94+
}
95+
96+
/**
97+
* Create an empty Zcash PSBT with explicit consensus branch ID
98+
*
99+
* **Advanced use only.** This method requires manually specifying the consensus branch ID.
100+
* In most cases, you should use `createEmpty()` instead, which automatically determines
101+
* the correct branch ID from the block height.
102+
*
103+
* @param network - Zcash network name ("zcash", "zcashTest", "zec", "tzec")
104+
* @param walletKeys - The wallet's root keys (sets global xpubs in the PSBT)
105+
* @param options - Zcash-specific options including required consensusBranchId
106+
* @returns A new ZcashBitGoPsbt instance
107+
*
108+
* @example
109+
* ```typescript
110+
* // Only use this if you need explicit control over the branch ID
111+
* const psbt = ZcashBitGoPsbt.createEmptyWithConsensusBranchId("zcash", walletKeys, {
112+
* consensusBranchId: 0xC2D6D0B4, // Nu5 branch ID
113+
* });
114+
* ```
115+
*/
116+
static createEmptyWithConsensusBranchId(
117+
network: ZcashNetworkName,
118+
walletKeys: WalletKeysArg,
119+
options: CreateEmptyZcashWithConsensusBranchIdOptions,
120+
): ZcashBitGoPsbt {
121+
const keys = RootWalletKeys.from(walletKeys);
122+
const wasm = WasmBitGoPsbt.create_empty_zcash(
123+
network,
124+
keys.wasm,
125+
options.consensusBranchId,
126+
options.version,
127+
options.lockTime,
128+
options.versionGroupId,
129+
options.expiryHeight,
130+
);
131+
return new ZcashBitGoPsbt(wasm);
132+
}
133+
134+
/**
135+
* Deserialize a Zcash PSBT from bytes
136+
*
137+
* @param bytes - The PSBT bytes
138+
* @param network - Zcash network name ("zcash", "zcashTest", "zec", "tzec")
139+
* @returns A ZcashBitGoPsbt instance
140+
*/
141+
static override fromBytes(bytes: Uint8Array, network: ZcashNetworkName): ZcashBitGoPsbt {
142+
const wasm = WasmBitGoPsbt.from_bytes(bytes, network);
143+
return new ZcashBitGoPsbt(wasm);
144+
}
145+
146+
// --- Zcash-specific getters ---
147+
148+
/**
149+
* Get the Zcash version group ID
150+
* @returns The version group ID (e.g., 0x892F2085 for Sapling)
151+
*/
152+
get versionGroupId(): number {
153+
return this.wasm.version_group_id();
154+
}
155+
156+
/**
157+
* Get the Zcash expiry height
158+
* @returns The expiry height (0 if not set)
159+
*/
160+
get expiryHeight(): number {
161+
return this.wasm.expiry_height();
162+
}
163+
}

0 commit comments

Comments
 (0)