Skip to content

Commit f33a678

Browse files
authored
feat(wasm-utxo): expose v6 (Ironwood) PSBT flow via wasm + TS
2 parents 49c0e1d + 89d9d27 commit f33a678

10 files changed

Lines changed: 963 additions & 11 deletions

File tree

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

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,25 @@
1-
import { BitGoPsbt as WasmBitGoPsbt, zcash_branch_id_for_height } from "../wasm/wasm_utxo.js";
1+
import {
2+
BitGoPsbt as WasmBitGoPsbt,
3+
zcash_branch_id_for_height,
4+
zcash_ironwood_version_group_id,
5+
} from "../wasm/wasm_utxo.js";
26
import { type WalletKeysArg, RootWalletKeys } from "./RootWalletKeys.js";
37
import { BitGoPsbt, type CreateEmptyOptions, type HydrationUnspent } from "./BitGoPsbt.js";
48
import { ZcashTransaction, type ITransaction } from "../transaction.js";
59

610
/** Zcash network names */
711
export type ZcashNetworkName = "zcash" | "zcashTest" | "zec" | "tzec";
812

13+
/**
14+
* Zcash v6 (Ironwood) version group id (0xd884b698). Its presence marks a PSBT as v6 — see
15+
* `ZcashIronwoodBitGoPsbt`.
16+
*
17+
* Read from the wasm layer rather than hard-coded, so it cannot drift from
18+
* `ZCASH_IRONWOOD_VERSION_GROUP_ID` in `src/zcash/transaction.rs`: a divergence would make the
19+
* v4/v6 discrimination in {@link ZcashBitGoPsbt.fromBytes} silently classify v6 bytes as v4.
20+
*/
21+
export const IRONWOOD_VERSION_GROUP_ID: number = zcash_ironwood_version_group_id();
22+
923
/** Options for creating an empty Zcash PSBT (preferred method using block height) */
1024
export type CreateEmptyZcashOptions = CreateEmptyOptions & {
1125
/** Block height to determine consensus branch ID automatically */
@@ -137,11 +151,17 @@ export class ZcashBitGoPsbt extends BitGoPsbt {
137151
*
138152
* @param bytes - The PSBT bytes
139153
* @param network - Zcash network name ("zcash", "zcashTest", "zec", "tzec")
154+
* @throws Error if the deserialized PSBT is a v6 (Ironwood) PSBT — use
155+
* {@link ZcashIronwoodBitGoPsbt.fromBytes} instead
140156
* @returns A ZcashBitGoPsbt instance
141157
*/
142158
static override fromBytes(bytes: Uint8Array, network: ZcashNetworkName): ZcashBitGoPsbt {
143159
const wasm = WasmBitGoPsbt.from_bytes(bytes, network);
144-
return new ZcashBitGoPsbt(wasm);
160+
const psbt = new ZcashBitGoPsbt(wasm);
161+
if (psbt.versionGroupId === IRONWOOD_VERSION_GROUP_ID) {
162+
throw new Error("this is a v6 (Ironwood) PSBT: use ZcashIronwoodBitGoPsbt.fromBytes instead");
163+
}
164+
return psbt;
145165
}
146166

147167
/**
Lines changed: 252 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,252 @@
1+
import { BitGoPsbt as WasmBitGoPsbt } from "../wasm/wasm_utxo.js";
2+
import { type WalletKeysArg, RootWalletKeys } from "./RootWalletKeys.js";
3+
import {
4+
IRONWOOD_VERSION_GROUP_ID,
5+
ZcashBitGoPsbt,
6+
type ZcashNetworkName,
7+
} from "./ZcashBitGoPsbt.js";
8+
9+
/**
10+
* Options for creating an empty Zcash v6 (Ironwood) shielding PSBT.
11+
*
12+
* Deliberately narrower than `CreateEmptyZcashOptions`: `version` and `versionGroupId` are fixed by
13+
* the v6 format, so accepting them would only let a caller ask for something that is then ignored.
14+
*/
15+
export type CreateEmptyIronwoodOptions = {
16+
/** Block height to determine the consensus branch ID automatically (at/after NU6.3 activation) */
17+
blockHeight: number;
18+
/** Lock time (default: 0) */
19+
lockTime?: number;
20+
/** Zcash transaction expiry height */
21+
expiryHeight?: number;
22+
};
23+
24+
/**
25+
* Options for creating an empty Zcash v6 (Ironwood) shielding PSBT with an explicit consensus
26+
* branch ID.
27+
*
28+
* Like {@link CreateEmptyIronwoodOptions}, this omits `version` and `versionGroupId` — both are
29+
* fixed by the v6 format. The consensus branch ID is *not* fixed: it tracks the active network
30+
* upgrade, so it stays a caller-supplied parameter exactly as it is for v4.
31+
*/
32+
export type CreateEmptyIronwoodWithConsensusBranchIdOptions = {
33+
/** Zcash consensus branch ID (e.g. the NU6.3 branch ID) */
34+
consensusBranchId: number;
35+
/** Lock time (default: 0) */
36+
lockTime?: number;
37+
/** Zcash transaction expiry height */
38+
expiryHeight?: number;
39+
};
40+
41+
/**
42+
* A fresh ZIP-302 "no memo" memo field: the `0xf6` marker byte followed by 511 zero bytes. The
43+
* default for {@link ZcashIronwoodBitGoPsbt.addShieldedOutput}'s `memo` option.
44+
*
45+
* Not the same as an all-zeros memo, which decodes as a *text* memo holding the empty string and is
46+
* rendered as such by wallets. Exported so callers can pass it explicitly, and so the distinction is
47+
* visible rather than buried in a default.
48+
*
49+
* A function rather than a shared constant: a module-level `Uint8Array` is mutable, so one caller
50+
* writing into it would silently change the default memo for every later output.
51+
*/
52+
export function zip302NoMemo(): Uint8Array {
53+
const memo = new Uint8Array(512);
54+
memo[0] = 0xf6;
55+
return memo;
56+
}
57+
58+
/**
59+
* A Zcash **v6 (Ironwood / NU6.3)** shielding PSBT.
60+
*
61+
* Distinct from the generic `ZcashBitGoPsbt` (v4/Sapling-shaped transactions): a v6 PSBT carries
62+
* its shielded side as an orchard PCZT in the proprietary map rather than Sapling fields, and its
63+
* lifecycle mirrors the microservice build → sign → combine flow rather than the v4 ZIP-243
64+
* signing path.
65+
*
66+
* Method names carry no `ironwood` prefix — the class name already says it. They follow the base
67+
* `BitGoPsbt` vocabulary (`createEmpty`, `fromBytes`, `addOutput`-style adders, `getId`) so the v6
68+
* surface reads the same as the v4 one; the wasm bindings keep their `ironwood_v6_*` names because
69+
* they live on the single flat `BitGoPsbt` struct, where the prefix is what disambiguates them.
70+
*
71+
* @example
72+
* ```typescript
73+
* const psbt = ZcashIronwoodBitGoPsbt.createEmpty("zcash", walletKeys, { blockHeight });
74+
* psbt.addWalletInput(...);
75+
* psbt.addWalletOutput(...);
76+
* psbt.addShieldedOutput(recipient, amount, { anchor });
77+
* const sighash = psbt.transparentSighash(0);
78+
* // ... sign sighash externally, then:
79+
* psbt.addTransparentSignature(0, pubkey, sig);
80+
* const tx = psbt.combineProof(proof);
81+
* ```
82+
*/
83+
export class ZcashIronwoodBitGoPsbt extends ZcashBitGoPsbt {
84+
/**
85+
* Create an empty Zcash **v6 (Ironwood)** shielding PSBT, with the consensus branch ID
86+
* determined from block height.
87+
*
88+
* Add transparent inputs/outputs with the usual `addWalletInput` / `addWalletOutput`, the
89+
* shielded output with {@link addShieldedOutput}, then sign the transparent inputs over
90+
* {@link transparentSighash} and finish with {@link combineProof}.
91+
*
92+
* @param network - Zcash network name ("zcash", "zcashTest", "zec", "tzec")
93+
* @param walletKeys - The wallet's root keys (sets global xpubs in the PSBT)
94+
* @param options - Options including blockHeight (at/after NU6.3 activation)
95+
*/
96+
static override createEmpty(
97+
network: ZcashNetworkName,
98+
walletKeys: WalletKeysArg,
99+
options: CreateEmptyIronwoodOptions,
100+
): ZcashIronwoodBitGoPsbt {
101+
const keys = RootWalletKeys.from(walletKeys);
102+
const wasm = WasmBitGoPsbt.create_empty_zcash_v6_at_height(
103+
network,
104+
keys.wasm,
105+
options.blockHeight,
106+
options.lockTime,
107+
options.expiryHeight,
108+
);
109+
return new ZcashIronwoodBitGoPsbt(wasm);
110+
}
111+
112+
/**
113+
* Create an empty Zcash **v6 (Ironwood)** shielding PSBT with an explicit consensus branch ID.
114+
*
115+
* **Advanced use only.** Prefer {@link createEmpty}, which derives the branch ID from a block
116+
* height and rejects heights before NU6.3 activation. Reach for this only when you already know
117+
* the branch ID — regtest, a future upgrade, or replaying a known-good value.
118+
*
119+
* Only the *version group ID* is fixed by the v6 format; the consensus branch ID tracks the active
120+
* network upgrade and remains caller-supplied, exactly as for v4.
121+
*
122+
* @param network - Zcash network name ("zcash", "zcashTest", "zec", "tzec")
123+
* @param walletKeys - The wallet's root keys (sets global xpubs in the PSBT)
124+
* @param options - Options including the required consensusBranchId
125+
*/
126+
static override createEmptyWithConsensusBranchId(
127+
network: ZcashNetworkName,
128+
walletKeys: WalletKeysArg,
129+
options: CreateEmptyIronwoodWithConsensusBranchIdOptions,
130+
): ZcashIronwoodBitGoPsbt {
131+
const keys = RootWalletKeys.from(walletKeys);
132+
const wasm = WasmBitGoPsbt.create_empty_zcash_v6(
133+
network,
134+
keys.wasm,
135+
options.consensusBranchId,
136+
options.lockTime,
137+
options.expiryHeight,
138+
);
139+
return new ZcashIronwoodBitGoPsbt(wasm);
140+
}
141+
142+
/**
143+
* Not applicable to v6, and not implementable: a broadcast v6 transaction carries the shielded
144+
* side as a proof plus a binding signature, while every v6 operation here needs the PCZT — which
145+
* holds witness data (`rseed`, `rcv`, `alpha`, the note plaintext) that is deliberately *not*
146+
* recoverable from the transaction. Decoding the transparent skeleton alone would yield a PSBT
147+
* that `getId`, `transparentSighash`, `combineProof`, and even `serialize`/{@link fromBytes} all
148+
* reject. Inherited only because JS statics are inherited.
149+
*/
150+
static override fromNetworkFormat(): never {
151+
throw new Error(
152+
"not supported for v6 (Ironwood): the PCZT witness data cannot be recovered from a broadcast " +
153+
"transaction; rebuild the PSBT with createEmpty",
154+
);
155+
}
156+
157+
/**
158+
* Not applicable to v6: the legacy half-signed format is a v4-era p2ms encoding with no way to
159+
* carry the PCZT, and no v6 transaction has ever been produced in it. Inherited only because JS
160+
* statics are inherited.
161+
*/
162+
static override fromHalfSignedLegacyTransaction(): never {
163+
throw new Error(
164+
"not supported for v6 (Ironwood): the legacy half-signed format is v4-only; " +
165+
"rebuild the PSBT with createEmpty",
166+
);
167+
}
168+
169+
/**
170+
* Deserialize a v6 (Ironwood) Zcash PSBT from bytes.
171+
*
172+
* @param bytes - The PSBT bytes
173+
* @param network - Zcash network name ("zcash", "zcashTest", "zec", "tzec")
174+
* @throws Error if the deserialized PSBT is not a v6 (Ironwood) PSBT
175+
* @returns A ZcashIronwoodBitGoPsbt instance
176+
*/
177+
static override fromBytes(bytes: Uint8Array, network: ZcashNetworkName): ZcashIronwoodBitGoPsbt {
178+
const wasm = WasmBitGoPsbt.from_bytes(bytes, network);
179+
const psbt = new ZcashIronwoodBitGoPsbt(wasm);
180+
if (psbt.versionGroupId !== IRONWOOD_VERSION_GROUP_ID) {
181+
throw new Error(
182+
"not a v6 (Ironwood) PSBT: use ZcashBitGoPsbt.fromBytes for v4/Sapling-shaped PSBTs",
183+
);
184+
}
185+
return psbt;
186+
}
187+
188+
/**
189+
* Add the shielded output (Constructor role). Stores the orchard PCZT in the PSBT.
190+
*
191+
* Named for the shielded/transparent split rather than the note type, leaving room for a
192+
* transparent-output variant alongside the inherited `addOutput` / `addWalletOutput`.
193+
*
194+
* @param recipient - 43-byte raw Orchard/Ironwood address
195+
* @param amount - note value in zatoshi
196+
* @param options.anchor - 32-byte Ironwood note-commitment-tree root
197+
* @param options.memo - optional 512-byte memo (defaults to the ZIP-302 "no memo" encoding)
198+
* @param options.ovk - optional 32-byte outgoing viewing key (omit for a keyless build)
199+
*/
200+
addShieldedOutput(
201+
recipient: Uint8Array,
202+
amount: bigint,
203+
options: { anchor: Uint8Array; memo?: Uint8Array; ovk?: Uint8Array },
204+
): void {
205+
const memo = options.memo ?? zip302NoMemo();
206+
this.wasm.add_ironwood_output(recipient, amount, options.ovk, options.anchor, memo);
207+
}
208+
209+
/**
210+
* The canonical (display-order) ZIP-244 v6 txid as a lowercase hex string, matching
211+
* `ITransaction.getId()`. Defined once the transparent inputs/outputs and the shielded output are
212+
* in place; unchanged by signing or proving.
213+
*/
214+
getId(): string {
215+
return this.wasm.ironwood_v6_txid();
216+
}
217+
218+
/**
219+
* The ZIP-244 per-input transparent sighash (32 bytes) the key controlling transparent input
220+
* `index` must sign.
221+
*/
222+
transparentSighash(index: number): Uint8Array {
223+
return this.wasm.ironwood_v6_transparent_sighash(index);
224+
}
225+
226+
/**
227+
* Ingest a transparent-input signature returned by the client/HSM, after verifying it against
228+
* {@link transparentSighash} for that input.
229+
*
230+
* @param index - transparent input index
231+
* @param pubkey - the signing public key
232+
* @param sig - DER ECDSA signature with the trailing SIGHASH_ALL byte (as in a scriptSig)
233+
*/
234+
addTransparentSignature(index: number, pubkey: Uint8Array, sig: Uint8Array): void {
235+
this.wasm.add_ironwood_v6_signature(index, pubkey, sig);
236+
}
237+
238+
/**
239+
* Transaction Extractor role: given the external prover's `proof` bytes, finalize the
240+
* transparent inputs, apply the shielded binding signature, and return the broadcast-ready v6
241+
* transaction bytes. Requires every transparent input to be signed via
242+
* {@link addTransparentSignature}.
243+
*
244+
* Terminal: the wasm binding drops the stored PCZT on success, so any later v6 call on this PSBT
245+
* throws — including after a `serialize`/{@link fromBytes} round-trip, since the PCZT is gone
246+
* from the bytes too. Build a fresh PSBT instead. On failure nothing is dropped, so a call that
247+
* errors (bad proof, unsigned input) leaves the PSBT retryable.
248+
*/
249+
combineProof(proof: Uint8Array): Uint8Array {
250+
return this.wasm.combine_ironwood_proof(proof);
251+
}
252+
}

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

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,8 +44,17 @@ export {
4444
ZcashBitGoPsbt,
4545
type ZcashNetworkName,
4646
type CreateEmptyZcashOptions,
47+
IRONWOOD_VERSION_GROUP_ID,
4748
} from "./ZcashBitGoPsbt.js";
4849

50+
// Zcash v6 (Ironwood / NU6.3) shielding PSBT
51+
export {
52+
ZcashIronwoodBitGoPsbt,
53+
zip302NoMemo,
54+
type CreateEmptyIronwoodOptions,
55+
type CreateEmptyIronwoodWithConsensusBranchIdOptions,
56+
} from "./ZcashIronwoodBitGoPsbt.js";
57+
4958
// Zcash ZIP-316 Unified Address
5059
export { ZcashUnifiedAddress } from "./ZcashUnifiedAddress.js";
5160

packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/mod.rs

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -497,6 +497,33 @@ impl BitGoPsbt {
497497
))
498498
}
499499

500+
/// Create an empty Zcash **v6 (Ironwood)** shielding PSBT with an explicit consensus branch id.
501+
/// Delegates to [`ZcashBitGoPsbt::new_v6`].
502+
///
503+
/// The version group id is fixed by the v6 format, but the consensus branch id is not — it
504+
/// tracks the active network upgrade, so it stays a caller-supplied parameter here just as it is
505+
/// for v4 in [`Self::new_zcash`]. Prefer [`Self::new_zcash_v6_at_height`], which derives it and
506+
/// checks the height is at/after NU6.3 activation; this is the escape hatch for callers that
507+
/// already know the branch id (regtest, a future upgrade, replaying a known-good value).
508+
pub fn new_zcash_v6(
509+
network: Network,
510+
wallet_keys: &crate::fixed_script_wallet::RootWalletKeys,
511+
consensus_branch_id: u32,
512+
lock_time: Option<u32>,
513+
expiry_height: Option<u32>,
514+
) -> Self {
515+
BitGoPsbt::Zcash(
516+
ZcashBitGoPsbt::new_v6(
517+
network,
518+
wallet_keys,
519+
consensus_branch_id,
520+
lock_time,
521+
expiry_height,
522+
),
523+
network,
524+
)
525+
}
526+
500527
/// Create an empty Zcash **v6 (Ironwood)** shielding PSBT with the consensus branch id resolved
501528
/// from block height. Delegates to [`ZcashBitGoPsbt::new_v6_at_height`].
502529
///
@@ -1374,8 +1401,20 @@ impl BitGoPsbt {
13741401
/// For all other coins the bitcoin PSBT deserializer is used.
13751402
///
13761403
/// Copies per input: partial_sigs, tap_key_sig, tap_script_sigs, proprietary.
1404+
///
1405+
/// Not supported for Zcash v6 (Ironwood) PSBTs: `deserialize_stripped` exists to accept an HSM
1406+
/// response that has dropped everything but the signatures, but v6 bytes route to
1407+
/// `deserialize_v6`, which requires the consensus branch id and the PCZT — so a stripped v6
1408+
/// response would fail with a "missing its Ironwood PCZT" error describing a corrupt PSBT
1409+
/// rather than an unsupported path. v6 ingests signatures via
1410+
/// [`ZcashBitGoPsbt::add_v6_transparent_signature`] instead.
13771411
pub fn combine_inputs(&mut self, other_bytes: &[u8]) -> Result<(), String> {
13781412
let raw: Psbt = match self {
1413+
BitGoPsbt::Zcash(z, _) if z.is_ironwood_v6() => {
1414+
return Err("combine_inputs is not supported for v6 (Ironwood) PSBTs; \
1415+
use add_v6_transparent_signature to ingest transparent signatures"
1416+
.to_string());
1417+
}
13791418
BitGoPsbt::Zcash(_, network) => {
13801419
ZcashBitGoPsbt::deserialize_stripped(other_bytes, *network)
13811420
.map(|z| z.psbt)

packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/propkv.rs

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -322,6 +322,21 @@ pub fn get_ironwood_pczt(psbt: &miniscript::bitcoin::psbt::Psbt) -> Option<Vec<u
322322
get_zec_v6(psbt, ZecV6KeySubtype::IronwoodPczt)
323323
}
324324

325+
/// Remove the serialized Ironwood (v6) PCZT bundle, returning whether one was present.
326+
///
327+
/// Used to make extraction terminal: once `combine_ironwood_proof` has produced the broadcast-ready
328+
/// transaction, dropping the PCZT means any further Ironwood operation on that PSBT — including
329+
/// after a `serialize`/`deserialize` round-trip — fails loudly instead of silently re-running
330+
/// against state that has already been spent.
331+
pub fn take_ironwood_pczt(psbt: &mut miniscript::bitcoin::psbt::Psbt) -> bool {
332+
let key = miniscript::bitcoin::psbt::raw::ProprietaryKey {
333+
prefix: BITGO_ZEC_V6.to_vec(),
334+
subtype: ZecV6KeySubtype::IronwoodPczt as u8,
335+
key: vec![],
336+
};
337+
psbt.proprietary.remove(&key).is_some()
338+
}
339+
325340
/// Store the Zcash v6 (Ironwood) header params — `version_group_id` and `expiry_height` — under the
326341
/// `BITGO_ZEC_V6` namespace. `version_group_id`'s presence marks the PSBT as v6; `expiry_height` is
327342
/// always written too (even when 0, a valid "no expiry" value, so it can be told apart from absent).

0 commit comments

Comments
 (0)