Skip to content

Commit d22fb64

Browse files
OttoAllmendingerllm-git
andcommitted
feat(wasm-utxo): add Zcash transaction extraction and PSBT reconstruction
Add new methods to better handle Zcash-specific transaction operations: 1. Add BitGoPsbt::new_like() to create a new PSBT with the same network parameters as an existing PSBT (including Zcash parameters) 2. Add BitGoPsbt::extract_tx() to extract finalized transactions with proper network-specific serialization 3. Fix ZcashBitGoPsbt implementation to allow efficient transaction extraction without cloning 4. Update PSBT reconstruction tests to work with Zcash PSBTs Issue: BTC-2659 Co-authored-by: llm-git <llm-git@ttll.de>
1 parent 083dc37 commit d22fb64

File tree

3 files changed

+208
-62
lines changed

3 files changed

+208
-62
lines changed

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

Lines changed: 116 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -454,6 +454,52 @@ impl BitGoPsbt {
454454
))
455455
}
456456

457+
/// Create a new empty PSBT with the same network parameters as an existing PSBT
458+
///
459+
/// This is useful for reconstructing PSBTs - it copies:
460+
/// - Network
461+
/// - Transaction version and locktime
462+
/// - For Zcash: consensus_branch_id, version_group_id, expiry_height
463+
///
464+
/// # Arguments
465+
/// * `template` - The existing PSBT to copy network parameters from
466+
/// * `wallet_keys` - The wallet's root keys for the new PSBT
467+
///
468+
/// # Returns
469+
/// * `Ok(Self)` - A new empty PSBT with the same network parameters
470+
/// * `Err(String)` - If the template is a Zcash PSBT missing the consensus branch ID
471+
pub fn new_like(
472+
template: &BitGoPsbt,
473+
wallet_keys: &crate::fixed_script_wallet::RootWalletKeys,
474+
) -> Result<Self, String> {
475+
let network = template.network();
476+
let version = template.psbt().unsigned_tx.version.0;
477+
let lock_time = template.psbt().unsigned_tx.lock_time.to_consensus_u32();
478+
479+
match template {
480+
BitGoPsbt::Zcash(zcash_psbt, _) => {
481+
// For Zcash, extract all required parameters from the template
482+
let consensus_branch_id = propkv::get_zec_consensus_branch_id(&zcash_psbt.psbt)
483+
.ok_or("Template PSBT missing ZecConsensusBranchId")?;
484+
Ok(Self::new_zcash(
485+
network,
486+
wallet_keys,
487+
consensus_branch_id,
488+
Some(version),
489+
Some(lock_time),
490+
zcash_psbt.version_group_id,
491+
zcash_psbt.expiry_height,
492+
))
493+
}
494+
_ => Ok(Self::new(
495+
network,
496+
wallet_keys,
497+
Some(version),
498+
Some(lock_time),
499+
)),
500+
}
501+
}
502+
457503
fn new_internal(
458504
network: Network,
459505
wallet_keys: &crate::fixed_script_wallet::RootWalletKeys,
@@ -1075,6 +1121,37 @@ impl BitGoPsbt {
10751121
}
10761122
}
10771123

1124+
/// Extract the finalized transaction bytes with network-appropriate serialization
1125+
///
1126+
/// This method extracts the fully-signed transaction from a finalized PSBT,
1127+
/// serializing it with the correct format for the network:
1128+
/// - For Zcash: includes version_group_id, expiry_height, and sapling fields
1129+
/// - For other networks: uses standard Bitcoin transaction serialization
1130+
///
1131+
/// This method consumes the PSBT since the underlying `extract_tx()` requires ownership.
1132+
///
1133+
/// # Requirements
1134+
/// All inputs must be finalized before calling this method.
1135+
///
1136+
/// # Returns
1137+
/// * `Ok(Vec<u8>)` - The serialized transaction bytes
1138+
/// * `Err(String)` - If transaction extraction fails
1139+
pub fn extract_tx(self) -> Result<Vec<u8>, String> {
1140+
use miniscript::bitcoin::consensus::serialize;
1141+
1142+
match self {
1143+
BitGoPsbt::Zcash(zcash_psbt, _) => zcash_psbt
1144+
.extract_tx()
1145+
.map_err(|e| format!("Failed to extract transaction: {}", e)),
1146+
BitGoPsbt::BitcoinLike(psbt, _) | BitGoPsbt::Dash(DashBitGoPsbt { psbt, .. }, _) => {
1147+
let tx = psbt
1148+
.extract_tx()
1149+
.map_err(|e| format!("Failed to extract transaction: {}", e))?;
1150+
Ok(serialize(&tx))
1151+
}
1152+
}
1153+
}
1154+
10781155
pub fn into_psbt(self) -> Psbt {
10791156
match self {
10801157
BitGoPsbt::BitcoinLike(psbt, _network) => psbt,
@@ -3212,27 +3289,29 @@ mod tests {
32123289
format,
32133290
)
32143291
.expect("Failed to load fixture");
3215-
let bitgo_psbt = fixture
3292+
let mut bitgo_psbt = fixture
32163293
.to_bitgo_psbt(network)
32173294
.expect("Failed to convert to BitGo PSBT");
32183295
let fixture_extracted_transaction = fixture
32193296
.extracted_transaction
32203297
.expect("Failed to extract transaction");
32213298

3222-
// // Use BitGoPsbt::finalize() which handles MuSig2 inputs
3299+
// Finalize and extract using the network-aware extract_tx() method
32233300
let secp = crate::bitcoin::secp256k1::Secp256k1::new();
3224-
let finalized_psbt = bitgo_psbt.finalize(&secp).expect("Failed to finalize PSBT");
3225-
let extracted_transaction = finalized_psbt
3301+
bitgo_psbt
3302+
.finalize_mut(&secp)
3303+
.expect("Failed to finalize PSBT");
3304+
3305+
let extracted_tx_bytes = bitgo_psbt
32263306
.extract_tx()
32273307
.expect("Failed to extract transaction");
3228-
use miniscript::bitcoin::consensus::serialize;
3229-
let extracted_transaction_hex = hex::encode(serialize(&extracted_transaction));
3308+
let extracted_transaction_hex = hex::encode(extracted_tx_bytes);
3309+
32303310
assert_eq!(
32313311
extracted_transaction_hex, fixture_extracted_transaction,
32323312
"Extracted transaction should match"
32333313
);
3234-
// Zcash fixtures were created with legacy Bitcoin sighash; implementation uses ZIP-243
3235-
}, ignore: [Zcash]);
3314+
});
32363315

32373316
#[test]
32383317
fn test_add_paygo_attestation() {
@@ -3418,32 +3497,37 @@ mod tests {
34183497
format,
34193498
)
34203499
.expect("Failed to load fixture");
3421-
3500+
34223501
let bitgo_psbt = fixture
34233502
.to_bitgo_psbt(network)
34243503
.expect("Failed to convert to BitGo PSBT");
3425-
3504+
34263505
// Get wallet keys from fixture
34273506
let wallet_xprv = fixture
34283507
.get_wallet_xprvs()
34293508
.expect("Failed to get wallet keys");
34303509
let wallet_keys = wallet_xprv.to_root_wallet_keys();
3431-
3510+
34323511
// Create replay protection with the replay protection script from fixture
34333512
let replay_protection = crate::fixed_script_wallet::ReplayProtection::new(vec![
3434-
miniscript::bitcoin::ScriptBuf::from_hex("a91420b37094d82a513451ff0ccd9db23aba05bc5ef387")
3435-
.expect("Failed to parse replay protection output script"),
3513+
miniscript::bitcoin::ScriptBuf::from_hex(
3514+
"a91420b37094d82a513451ff0ccd9db23aba05bc5ef387",
3515+
)
3516+
.expect("Failed to parse replay protection output script"),
34363517
]);
3437-
3518+
34383519
// Parse the transaction (no PayGo verification in tests)
34393520
let parsed = bitgo_psbt
34403521
.parse_transaction_with_wallet_keys(&wallet_keys, &replay_protection, &[])
34413522
.expect("Failed to parse transaction");
3442-
3523+
34433524
// Basic validations
34443525
assert!(!parsed.inputs.is_empty(), "Should have at least one input");
3445-
assert!(!parsed.outputs.is_empty(), "Should have at least one output");
3446-
3526+
assert!(
3527+
!parsed.outputs.is_empty(),
3528+
"Should have at least one output"
3529+
);
3530+
34473531
// Verify at least one replay protection input exists
34483532
let replay_protection_inputs = parsed
34493533
.inputs
@@ -3454,18 +3538,15 @@ mod tests {
34543538
replay_protection_inputs > 0,
34553539
"Should have at least one replay protection input"
34563540
);
3457-
3541+
34583542
// Verify at least one wallet input exists
34593543
let wallet_inputs = parsed
34603544
.inputs
34613545
.iter()
34623546
.filter(|i| i.script_id.is_some())
34633547
.count();
3464-
assert!(
3465-
wallet_inputs > 0,
3466-
"Should have at least one wallet input"
3467-
);
3468-
3548+
assert!(wallet_inputs > 0, "Should have at least one wallet input");
3549+
34693550
// Count internal (wallet) and external outputs
34703551
let internal_outputs = parsed
34713552
.outputs
@@ -3477,13 +3558,13 @@ mod tests {
34773558
.iter()
34783559
.filter(|o| o.script_id.is_none())
34793560
.count();
3480-
3561+
34813562
assert_eq!(
34823563
internal_outputs + external_outputs,
34833564
parsed.outputs.len(),
34843565
"All outputs should be either internal or external"
34853566
);
3486-
3567+
34873568
// Verify spend amount only includes external outputs
34883569
let calculated_spend_amount: u64 = parsed
34893570
.outputs
@@ -3495,23 +3576,23 @@ mod tests {
34953576
parsed.spend_amount, calculated_spend_amount,
34963577
"Spend amount should equal sum of external output values"
34973578
);
3498-
3579+
34993580
// Verify total values
35003581
let total_input_value: u64 = parsed.inputs.iter().map(|i| i.value).sum();
35013582
let total_output_value: u64 = parsed.outputs.iter().map(|o| o.value).sum();
3502-
3583+
35033584
assert_eq!(
35043585
parsed.miner_fee,
35053586
total_input_value - total_output_value,
35063587
"Miner fee should equal inputs minus outputs"
35073588
);
3508-
3589+
35093590
// Verify virtual size is reasonable
35103591
assert!(
35113592
parsed.virtual_size > 0,
35123593
"Virtual size should be greater than 0"
35133594
);
3514-
3595+
35153596
// Verify outputs (fixtures now have 3 external outputs)
35163597
assert_eq!(
35173598
external_outputs, 3,
@@ -3526,7 +3607,7 @@ mod tests {
35263607
parsed.spend_amount > 0,
35273608
"Spend amount should be greater than 0 when there are external outputs"
35283609
);
3529-
}, ignore: []);
3610+
});
35303611

35313612
#[test]
35323613
fn test_serialize_bitcoin_psbt() {
@@ -3636,19 +3717,9 @@ mod tests {
36363717
.parse_outputs(&other_wallet_keys, &[])
36373718
.expect("Failed to parse outputs with other wallet keys");
36383719

3639-
// Create empty PSBT with same version and locktime as original
3640-
let original_version = original_psbt.psbt().unsigned_tx.version.0 as i32;
3641-
let original_locktime = original_psbt
3642-
.psbt()
3643-
.unsigned_tx
3644-
.lock_time
3645-
.to_consensus_u32();
3646-
let mut reconstructed = BitGoPsbt::new(
3647-
network,
3648-
&wallet_keys,
3649-
Some(original_version),
3650-
Some(original_locktime),
3651-
);
3720+
// Create empty PSBT with same network parameters as original (handles Zcash automatically)
3721+
let mut reconstructed = BitGoPsbt::new_like(&original_psbt, &wallet_keys)
3722+
.expect("Failed to create PSBT from template");
36523723

36533724
// Track which inputs are wallet inputs vs replay protection
36543725
let mut wallet_input_indices = Vec::new();
@@ -3958,15 +4029,14 @@ mod tests {
39584029
let reconstructed_bytes = reconstructed
39594030
.serialize()
39604031
.expect("Failed to serialize reconstructed");
3961-
assert_equal_psbt(&original_bytes, &reconstructed_bytes);
4032+
assert_equal_psbt(&original_bytes, &reconstructed_bytes, network);
39624033
}
39634034

39644035
// Note: Only testing PsbtLite format for now because full PSBT format
39654036
// uses non_witness_utxo instead of witness_utxo for non-segwit inputs
3966-
// Zcash: Transaction decoding fails because Zcash tx format differs from Bitcoin
39674037
crate::test_psbt_fixtures!(test_psbt_reconstruction, network, format, {
39684038
test_psbt_reconstruction_for_network(network, format);
3969-
}, ignore: [Zcash]);
4039+
});
39704040

39714041
#[test]
39724042
fn test_dogecoin_single_input_single_output_large_amount() {

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

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,10 +61,12 @@ impl ZcashBitGoPsbt {
6161
self.serialize_as_zcash_transaction(&self.psbt.unsigned_tx)
6262
}
6363

64-
/// Extract the finalized Zcash transaction bytes from the PSBT
64+
/// Extract the finalized Zcash transaction bytes from the PSBT (borrowing)
6565
///
6666
/// This extracts the fully-signed transaction with Zcash-specific fields.
6767
/// Must be called after all inputs have been finalized.
68+
///
69+
/// Note: This clones the inner PSBT. Use `extract_tx()` to avoid the clone.
6870
pub fn extract_zcash_transaction(&self) -> Result<Vec<u8>, super::DeserializeError> {
6971
use miniscript::bitcoin::psbt::ExtractTxError;
7072

@@ -84,6 +86,46 @@ impl ZcashBitGoPsbt {
8486
self.serialize_as_zcash_transaction(&tx)
8587
}
8688

89+
/// Extract the finalized Zcash transaction bytes from the PSBT (consuming)
90+
///
91+
/// This extracts the fully-signed transaction with Zcash-specific fields.
92+
/// Must be called after all inputs have been finalized.
93+
///
94+
/// This method consumes the PSBT to avoid cloning.
95+
pub fn extract_tx(self) -> Result<Vec<u8>, super::DeserializeError> {
96+
use miniscript::bitcoin::psbt::ExtractTxError;
97+
98+
// Capture Zcash-specific fields before consuming psbt
99+
let version_group_id = self
100+
.version_group_id
101+
.unwrap_or(ZCASH_SAPLING_VERSION_GROUP_ID);
102+
let expiry_height = self.expiry_height.unwrap_or(0);
103+
let sapling_fields = self.sapling_fields;
104+
105+
let tx = self.psbt.extract_tx().map_err(|e| match e {
106+
ExtractTxError::AbsurdFeeRate { .. } => {
107+
super::DeserializeError::Network(format!("Absurd fee rate: {}", e))
108+
}
109+
ExtractTxError::MissingInputValue { .. } => {
110+
super::DeserializeError::Network(format!("Missing input value: {}", e))
111+
}
112+
ExtractTxError::SendingTooMuch { .. } => {
113+
super::DeserializeError::Network(format!("Sending too much: {}", e))
114+
}
115+
_ => super::DeserializeError::Network(format!("Failed to extract transaction: {}", e)),
116+
})?;
117+
118+
let parts = crate::zcash::transaction::ZcashTransactionParts {
119+
transaction: tx,
120+
is_overwintered: true,
121+
version_group_id: Some(version_group_id),
122+
expiry_height: Some(expiry_height),
123+
sapling_fields,
124+
};
125+
crate::zcash::transaction::encode_zcash_transaction_parts(&parts)
126+
.map_err(super::DeserializeError::Network)
127+
}
128+
87129
/// Compute the transaction ID for the unsigned Zcash transaction
88130
///
89131
/// The txid is the double SHA256 of the full Zcash transaction bytes.

0 commit comments

Comments
 (0)