Skip to content

Commit 44a9040

Browse files
authored
fix(wasm-utxo): make isShielded nullable and preserve Unified Address on shielded outputs
2 parents a10e4e5 + a6f6432 commit 44a9040

15 files changed

Lines changed: 553 additions & 44 deletions

File tree

packages/wasm-utxo/cli/src/psbt/add_shielded_output.rs

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ pub fn handle_add_shielded_output_command(
1515
anchor: String,
1616
ovk: Option<String>,
1717
memo: Option<String>,
18+
unified_address: Option<String>,
1819
) -> Result<()> {
1920
let raw_bytes = read_input_bytes(&path, "PSBT")?;
2021
let bytes = decode_input(&raw_bytes)?;
@@ -45,9 +46,17 @@ pub fn handle_add_shielded_output_command(
4546
None => [0u8; 512],
4647
};
4748

48-
psbt.add_ironwood_output(&recipient, value, ovk, &anchor, &memo, OsRng)
49-
.map_err(|e| anyhow!(e))
50-
.context("failed to add shielded output")?;
49+
psbt.add_ironwood_output(
50+
&recipient,
51+
value,
52+
ovk,
53+
&anchor,
54+
&memo,
55+
unified_address.as_deref(),
56+
OsRng,
57+
)
58+
.map_err(|e| anyhow!(e))
59+
.context("failed to add shielded output")?;
5160

5261
println!("{}", hex::encode(psbt.serialize().map_err(|e| anyhow!(e))?));
5362
Ok(())

packages/wasm-utxo/cli/src/psbt/mod.rs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,11 @@ pub enum PsbtCommand {
133133
/// Memo field, hex-encoded (512 bytes; default: all-zero)
134134
#[arg(long)]
135135
memo: Option<String>,
136+
/// Full Unified Address this output was addressed to, if known. Its Orchard receiver must
137+
/// match --recipient. Stored verbatim so it survives a serialize/deserialize round-trip
138+
/// instead of being reconstructed as a single-receiver UA when the PSBT is later parsed.
139+
#[arg(long)]
140+
unified_address: Option<String>,
136141
},
137142
/// Sign one transparent input of a v6 PSBT with a single private key, over the ZIP-244
138143
/// transparent sighash. Call once per required signature (2-of-3). Prints the updated PSBT
@@ -244,6 +249,7 @@ pub fn handle_command(command: PsbtCommand) -> Result<()> {
244249
anchor,
245250
ovk,
246251
memo,
252+
unified_address,
247253
} => add_shielded_output::handle_add_shielded_output_command(
248254
path,
249255
network.into(),
@@ -252,6 +258,7 @@ pub fn handle_command(command: PsbtCommand) -> Result<()> {
252258
anchor,
253259
ovk,
254260
memo,
261+
unified_address,
255262
),
256263
PsbtCommand::SignV6Input {
257264
path,

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

Lines changed: 10 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -53,21 +53,11 @@ export type ParsedOutput = {
5353
paygo: boolean;
5454
/** Full BIP32 derivation path from the wallet xpub (e.g. "0/1"). Null for external outputs. */
5555
derivationPath: string | null;
56-
/**
57-
* True for a shielded (Orchard/Ironwood) output. Such an output has no `unsigned_tx` entry of
58-
* its own — it lives in the PSBT's proprietary-map PCZT, read from its plaintext (not
59-
* encrypted/decrypted) recipient field. `address` is a single-receiver ZIP-316 unified address
60-
* (`u1...`/`utest1...`) encoding that receiver — a real, usable Zcash address, though not
61-
* necessarily byte-identical to whatever multi-receiver UA the sender originally pasted in (a
62-
* UA with a transparent/Sapling receiver too would round-trip to a different string carrying
63-
* only the Orchard one). `script` holds the same receiver as raw 43 bytes.
64-
*/
65-
isShielded: boolean;
6656
};
6757

68-
export type ParsedTransaction = {
58+
export type ParsedTransaction<TOutput extends ParsedOutput = ParsedOutput> = {
6959
inputs: ParsedInput[];
70-
outputs: ParsedOutput[];
60+
outputs: TOutput[];
7161
spendAmount: bigint;
7262
minerFee: bigint;
7363
virtualSize: number;
@@ -145,7 +135,10 @@ export type HydrationUnspent =
145135
| { chain: number; index: number; value: bigint } // wallet input
146136
| { pubkey: Uint8Array; value: bigint }; // P2SH-P2PK replay protection input
147137

148-
export class BitGoPsbt extends PsbtBase<WasmBitGoPsbt> implements IPsbtWithAddress {
138+
export class BitGoPsbt<TOutput extends ParsedOutput = ParsedOutput>
139+
extends PsbtBase<WasmBitGoPsbt>
140+
implements IPsbtWithAddress
141+
{
149142
protected constructor(wasm: WasmBitGoPsbt) {
150143
super(wasm);
151144
}
@@ -626,15 +619,15 @@ export class BitGoPsbt extends PsbtBase<WasmBitGoPsbt> implements IPsbtWithAddre
626619
parseTransactionWithWalletKeys(
627620
walletKeys: WalletKeysArg,
628621
options: ParseTransactionOptions,
629-
): ParsedTransaction {
622+
): ParsedTransaction<TOutput> {
630623
const keys = RootWalletKeys.from(walletKeys);
631624
const rp = ReplayProtection.from(options.replayProtection, this._wasm.network());
632625
const pubkeys = options.payGoPubkeys?.map((arg) => ECPair.from(arg).wasm);
633626
return this._wasm.parse_transaction_with_wallet_keys(
634627
keys.wasm,
635628
rp.wasm,
636629
pubkeys,
637-
) as ParsedTransaction;
630+
) as ParsedTransaction<TOutput>;
638631
}
639632

640633
/**
@@ -650,13 +643,10 @@ export class BitGoPsbt extends PsbtBase<WasmBitGoPsbt> implements IPsbtWithAddre
650643
* @returns Array of parsed outputs
651644
* @note This method does NOT validate wallet inputs. It only parses outputs.
652645
*/
653-
parseOutputsWithWalletKeys(
654-
walletKeys: WalletKeysArg,
655-
options?: ParseOutputsOptions,
656-
): ParsedOutput[] {
646+
parseOutputsWithWalletKeys(walletKeys: WalletKeysArg, options?: ParseOutputsOptions): TOutput[] {
657647
const keys = RootWalletKeys.from(walletKeys);
658648
const pubkeys = options?.payGoPubkeys?.map((arg) => ECPair.from(arg).wasm);
659-
return this._wasm.parse_outputs_with_wallet_keys(keys.wasm, pubkeys) as ParsedOutput[];
649+
return this._wasm.parse_outputs_with_wallet_keys(keys.wasm, pubkeys) as TOutput[];
660650
}
661651

662652
/**

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

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,30 @@ import {
44
zcash_ironwood_version_group_id,
55
} from "../wasm/wasm_utxo.js";
66
import { type WalletKeysArg, RootWalletKeys } from "./RootWalletKeys.js";
7-
import { BitGoPsbt, type CreateEmptyOptions, type HydrationUnspent } from "./BitGoPsbt.js";
7+
import {
8+
BitGoPsbt,
9+
type CreateEmptyOptions,
10+
type HydrationUnspent,
11+
type ParsedOutput,
12+
} from "./BitGoPsbt.js";
813
import { ZcashTransaction, type ITransaction } from "../transaction.js";
914

1015
/** Zcash network names */
1116
export type ZcashNetworkName = "zcash" | "zcashTest" | "zec" | "tzec";
1217

18+
export type ZcashParsedOutput = ParsedOutput & {
19+
/**
20+
* True for a shielded (Orchard/Ironwood) output. Such an output has no `unsigned_tx` entry of
21+
* its own — it lives in the PSBT's proprietary-map PCZT, read from its plaintext (not
22+
* encrypted/decrypted) recipient field. `address` is a single-receiver ZIP-316 unified address
23+
* (`u1...`/`utest1...`) encoding that receiver — a real, usable Zcash address, though not
24+
* necessarily byte-identical to whatever multi-receiver UA the sender originally pasted in (a
25+
* UA with a transparent/Sapling receiver too would round-trip to a different string carrying
26+
* only the Orchard one). `script` holds the same receiver as raw 43 bytes.
27+
*/
28+
isShielded: boolean;
29+
};
30+
1331
/**
1432
* Zcash v6 (Ironwood) version group id (0xd884b698). Its presence marks a PSBT as v6 — see
1533
* `ZcashIronwoodBitGoPsbt`.
@@ -62,7 +80,7 @@ export type CreateEmptyZcashWithConsensusBranchIdOptions = CreateEmptyOptions &
6280
* const psbt = ZcashBitGoPsbt.fromBytes(bytes, "zcash");
6381
* ```
6482
*/
65-
export class ZcashBitGoPsbt extends BitGoPsbt {
83+
export class ZcashBitGoPsbt extends BitGoPsbt<ZcashParsedOutput> {
6684
/**
6785
* Create an empty Zcash PSBT with consensus branch ID determined from block height
6886
*

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

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -198,14 +198,27 @@ export class ZcashIronwoodBitGoPsbt extends ZcashBitGoPsbt {
198198
* @param options.anchor - 32-byte Ironwood note-commitment-tree root
199199
* @param options.memo - optional 512-byte memo (defaults to the ZIP-302 "no memo" encoding)
200200
* @param options.ovk - optional 32-byte outgoing viewing key (omit for a keyless build)
201+
* @param options.unifiedAddress - optional full Unified Address string this output was addressed
202+
* to. Its Orchard receiver must equal `recipient`. The PCZT itself only carries the raw 43-byte
203+
* receiver — a lossy encoding for a multi-receiver UA, since any transparent/Sapling receiver
204+
* can't be recovered from it — so passing this stores the original UA verbatim, letting a later
205+
* `parseOutputsWithWalletKeys`/`parseTransactionWithWalletKeys` (even after a
206+
* serialize/deserialize round-trip) return it in full instead of a re-encoded single-receiver UA.
201207
*/
202208
addShieldedOutput(
203209
recipient: Uint8Array,
204210
amount: bigint,
205-
options: { anchor: Uint8Array; memo?: Uint8Array; ovk?: Uint8Array },
211+
options: { anchor: Uint8Array; memo?: Uint8Array; ovk?: Uint8Array; unifiedAddress?: string },
206212
): void {
207213
const memo = options.memo ?? zip302NoMemo();
208-
this.wasm.add_ironwood_output(recipient, amount, options.ovk, options.anchor, memo);
214+
this.wasm.add_ironwood_output(
215+
recipient,
216+
amount,
217+
options.ovk,
218+
options.anchor,
219+
memo,
220+
options.unifiedAddress,
221+
);
209222
}
210223

211224
/**

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ export { BitGoKeySubtype, type PsbtKvKey } from "./BitGoKeySubtype.js";
4343
export {
4444
ZcashBitGoPsbt,
4545
type ZcashNetworkName,
46+
type ZcashParsedOutput,
4647
type CreateEmptyZcashOptions,
4748
IRONWOOD_VERSION_GROUP_ID,
4849
} from "./ZcashBitGoPsbt.js";

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

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2755,11 +2755,17 @@ impl BitGoPsbt {
27552755
else {
27562756
return Ok(None);
27572757
};
2758-
let address = crate::zcash::unified_address::encode_orchard_receiver(
2759-
&recipient,
2760-
self.network().to_coin_name(),
2761-
)
2762-
.map_err(|e| ParseTransactionError::ShieldedOutput(e.to_string()))?;
2758+
// Prefer the caller's original Unified Address (if `add_ironwood_output` was given one):
2759+
// it may carry a transparent/Sapling receiver alongside the Orchard one, which a
2760+
// single-receiver reconstruction from `recipient` alone cannot recover.
2761+
let address = match propkv::get_ironwood_unified_address(&z.psbt) {
2762+
Some(ua) => ua,
2763+
None => crate::zcash::unified_address::encode_orchard_receiver(
2764+
&recipient,
2765+
self.network().to_coin_name(),
2766+
)
2767+
.map_err(|e| ParseTransactionError::ShieldedOutput(e.to_string()))?,
2768+
};
27632769
Ok(Some((
27642770
ParsedOutput {
27652771
address: Some(address),
@@ -2771,7 +2777,7 @@ impl BitGoPsbt {
27712777
script_id: None,
27722778
paygo: false,
27732779
derivation_path: None,
2774-
is_shielded: true,
2780+
is_shielded: Some(true),
27752781
},
27762782
amount,
27772783
)))

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

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -271,6 +271,13 @@ pub enum ZecV6KeySubtype {
271271
/// happened, not just "no shielded output was ever added"). Persists even though the PCZT
272272
/// itself is gone, so a later read can tell the two "no PCZT" states apart.
273273
IronwoodExtracted = 0x04,
274+
/// The full ZIP-316 Unified Address string (UTF-8) the shielded output was addressed to, if
275+
/// the caller supplied one to [`crate::fixed_script_wallet::bitgo_psbt::zcash_psbt`]'s
276+
/// `add_ironwood_output`. The PCZT itself only carries the raw 43-byte Orchard receiver, which
277+
/// is lossy for a multi-receiver UA (transparent/Sapling receivers can't be recovered from it);
278+
/// storing the original string here lets output parsing return the exact UA the caller passed,
279+
/// receivers and all, after a serialize/deserialize round-trip.
280+
UnifiedAddress = 0x05,
274281
}
275282

276283
fn set_zec_v6(
@@ -292,6 +299,15 @@ fn get_zec_v6(psbt: &miniscript::bitcoin::psbt::Psbt, subtype: ZecV6KeySubtype)
292299
.map(|(_, v)| v.clone())
293300
}
294301

302+
fn remove_zec_v6(psbt: &mut miniscript::bitcoin::psbt::Psbt, subtype: ZecV6KeySubtype) {
303+
let key = ProprietaryKey {
304+
prefix: BITGO_ZEC_V6.to_vec(),
305+
subtype: subtype as u8,
306+
key: vec![],
307+
};
308+
psbt.proprietary.remove(&key);
309+
}
310+
295311
fn set_zec_v6_u32(
296312
psbt: &mut miniscript::bitcoin::psbt::Psbt,
297313
subtype: ZecV6KeySubtype,
@@ -377,6 +393,36 @@ pub fn get_zec_v6_params(psbt: &miniscript::bitcoin::psbt::Psbt) -> Option<(u32,
377393
Some((vgid, expiry))
378394
}
379395

396+
/// Store the full Unified Address string the Ironwood shielded output was addressed to, so it
397+
/// survives a serialize/deserialize round-trip verbatim (receivers and all) instead of being
398+
/// rebuilt from just the raw Orchard receiver. Overwrites any existing value.
399+
pub fn set_ironwood_unified_address(psbt: &mut miniscript::bitcoin::psbt::Psbt, ua: &str) {
400+
set_zec_v6(
401+
psbt,
402+
ZecV6KeySubtype::UnifiedAddress,
403+
ua.as_bytes().to_vec(),
404+
);
405+
}
406+
407+
/// Fetch the Unified Address string stored by [`set_ironwood_unified_address`], if present and
408+
/// valid UTF-8.
409+
pub fn get_ironwood_unified_address(psbt: &miniscript::bitcoin::psbt::Psbt) -> Option<String> {
410+
let bytes = get_zec_v6(psbt, ZecV6KeySubtype::UnifiedAddress)?;
411+
String::from_utf8(bytes).ok()
412+
}
413+
414+
/// Remove the Unified Address string set by [`set_ironwood_unified_address`], if present.
415+
///
416+
/// Callers that build a shielded output without a `unified_address` must call this rather than
417+
/// simply not calling [`set_ironwood_unified_address`]: `add_ironwood_output` can be called again
418+
/// on a PSBT whose PCZT was previously extracted (see `take_ironwood_pczt`/
419+
/// `mark_ironwood_extracted`, which drop only the PCZT key, not this one), and without an explicit
420+
/// removal a UA stored for an earlier shielded output would otherwise survive and be silently
421+
/// misattributed to the new one.
422+
pub fn remove_ironwood_unified_address(psbt: &mut miniscript::bitcoin::psbt::Psbt) {
423+
remove_zec_v6(psbt, ZecV6KeySubtype::UnifiedAddress);
424+
}
425+
380426
#[cfg(test)]
381427
mod tests {
382428
use super::*;

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

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,9 +16,10 @@ pub struct ParsedOutput {
1616
/// `None` for outputs that do not belong to this wallet.
1717
pub derivation_path: Option<DerivationPath>,
1818
/// Whether this output is a shielded (Orchard/Ironwood) output rather than a transparent one.
19-
/// Always `false` for outputs parsed from `tx_output`/`psbt_output` — set by the caller when
20-
/// synthesizing a `ParsedOutput` for the shielded side of a v6 (Ironwood) transaction.
21-
pub is_shielded: bool,
19+
/// `None` for coins that don't support shielded outputs. Always `Some(false)` for outputs
20+
/// parsed from `tx_output`/`psbt_output` on a Zcash PSBT — set to `Some(true)` by the caller
21+
/// when synthesizing a `ParsedOutput` for the shielded side of a v6 (Ironwood) transaction.
22+
pub is_shielded: Option<bool>,
2223
}
2324

2425
impl ParsedOutput {
@@ -63,7 +64,7 @@ impl ParsedOutput {
6364
script_id,
6465
paygo,
6566
derivation_path,
66-
is_shielded: false,
67+
is_shielded: matches!(network, Network::Zcash | Network::ZcashTestnet).then_some(false),
6768
})
6869
}
6970

0 commit comments

Comments
 (0)