Skip to content

Commit 2e80e26

Browse files
Merge pull request #248 from BitGo/BTC-2768.hydrate-with-rp
feat(wasm-utxo): support P2SH-P2PK replay protection inputs in legacy format hydration
2 parents 0221dd7 + 42c5892 commit 2e80e26

8 files changed

Lines changed: 467 additions & 185 deletions

File tree

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

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -124,11 +124,9 @@ export type ParseOutputsOptions = {
124124
payGoPubkeys?: ECPairArg[];
125125
};
126126

127-
export type HydrationUnspent = {
128-
chain: number;
129-
index: number;
130-
value: bigint;
131-
};
127+
export type HydrationUnspent =
128+
| { chain: number; index: number; value: bigint } // wallet input
129+
| { pubkey: Uint8Array; value: bigint }; // P2SH-P2PK replay protection input
132130

133131
export class BitGoPsbt extends PsbtBase<WasmBitGoPsbt> implements IPsbtWithAddress {
134132
protected constructor(wasm: WasmBitGoPsbt) {

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

Lines changed: 220 additions & 131 deletions
Large diffs are not rendered by default.

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

Lines changed: 114 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,18 @@ pub use psbt_wallet_input::{
120120
};
121121
pub use psbt_wallet_output::ParsedOutput;
122122

123+
/// Describes a single input for `from_half_signed_legacy_transaction`.
124+
pub enum HydrationUnspentInput {
125+
/// A regular wallet input with derivation chain, index, and value.
126+
Wallet(psbt_wallet_input::ScriptIdWithValue),
127+
/// A P2SH-P2PK replay protection input. The caller provides the expected pubkey so it can be
128+
/// validated against the redeemScript embedded in the legacy transaction.
129+
ReplayProtection {
130+
pubkey: miniscript::bitcoin::CompressedPublicKey,
131+
value: u64,
132+
},
133+
}
134+
123135
/// Parsed transaction with wallet information
124136
#[derive(Debug, Clone)]
125137
pub struct ParsedTransaction {
@@ -505,12 +517,12 @@ impl BitGoPsbt {
505517
/// creates a PSBT with proper wallet metadata (bip32Derivation, scripts,
506518
/// witnessUtxo), and inserts the extracted signatures.
507519
///
508-
/// Only supports p2sh, p2shP2wsh, and p2wsh inputs (not taproot).
520+
/// Supports p2sh, p2shP2wsh, p2wsh, and P2SH-P2PK (replay protection) inputs.
509521
pub fn from_half_signed_legacy_transaction(
510522
tx_bytes: &[u8],
511523
network: Network,
512524
wallet_keys: &crate::fixed_script_wallet::RootWalletKeys,
513-
unspents: &[psbt_wallet_input::ScriptIdWithValue],
525+
unspents: &[HydrationUnspentInput],
514526
) -> Result<Self, String> {
515527
use miniscript::bitcoin::consensus::Decodable;
516528
use miniscript::bitcoin::{PublicKey, Transaction};
@@ -531,43 +543,113 @@ impl BitGoPsbt {
531543

532544
let mut psbt = Self::new(network, wallet_keys, Some(version), Some(lock_time));
533545

534-
// Extract signatures before adding inputs (we need the raw tx_in data)
535-
let partial_sigs: Vec<legacy_txformat::LegacyPartialSig> = tx
546+
// Parse each input from the legacy tx
547+
let input_results: Vec<legacy_txformat::LegacyInputResult> = tx
536548
.input
537549
.iter()
538550
.enumerate()
539551
.map(|(i, tx_in)| {
540-
legacy_txformat::unsign_legacy_input(tx_in)
552+
legacy_txformat::parse_legacy_input(tx_in)
541553
.map_err(|e| format!("Input {}: {}", i, e))
542554
})
543555
.collect::<Result<Vec<_>, _>>()?;
544556

545-
// Add wallet inputs (populates bip32Derivation, scripts, witnessUtxo)
546557
for (i, (tx_in, unspent)) in tx.input.iter().zip(unspents.iter()).enumerate() {
547-
let script_id = psbt_wallet_input::ScriptId {
548-
chain: unspent.chain,
549-
index: unspent.index,
550-
};
551-
psbt.add_wallet_input(
552-
tx_in.previous_output.txid,
553-
tx_in.previous_output.vout,
554-
unspent.value,
555-
wallet_keys,
556-
script_id,
557-
psbt_wallet_input::WalletInputOptions {
558-
sign_path: None,
559-
sequence: Some(tx_in.sequence.0),
560-
prev_tx: None, // psbt-lite: no nonWitnessUtxo
561-
},
562-
)
563-
.map_err(|e| format!("Input {}: failed to add wallet input: {}", i, e))?;
558+
match (&input_results[i], unspent) {
559+
(
560+
legacy_txformat::LegacyInputResult::Multisig(sig),
561+
HydrationUnspentInput::Wallet(sv),
562+
) => {
563+
let script_id = psbt_wallet_input::ScriptId {
564+
chain: sv.chain,
565+
index: sv.index,
566+
};
567+
psbt.add_wallet_input(
568+
tx_in.previous_output.txid,
569+
tx_in.previous_output.vout,
570+
sv.value,
571+
wallet_keys,
572+
script_id,
573+
psbt_wallet_input::WalletInputOptions {
574+
sign_path: None,
575+
sequence: Some(tx_in.sequence.0),
576+
prev_tx: None, // psbt-lite: no nonWitnessUtxo
577+
},
578+
)
579+
.map_err(|e| format!("Input {}: failed to add wallet input: {}", i, e))?;
564580

565-
// Insert the extracted partial signature
566-
let sig = &partial_sigs[i];
567-
let pubkey = PublicKey::from(sig.pubkey);
568-
psbt.psbt_mut().inputs[i]
569-
.partial_sigs
570-
.insert(pubkey, sig.sig);
581+
let pubkey = PublicKey::from(sig.pubkey);
582+
psbt.psbt_mut().inputs[i]
583+
.partial_sigs
584+
.insert(pubkey, sig.sig);
585+
}
586+
(
587+
legacy_txformat::LegacyInputResult::ReplayProtection {
588+
pubkey: tx_pubkey,
589+
sig,
590+
},
591+
HydrationUnspentInput::ReplayProtection {
592+
pubkey: expected_pubkey,
593+
value,
594+
},
595+
) => {
596+
if tx_pubkey.to_bytes() != expected_pubkey.to_bytes() {
597+
return Err(format!(
598+
"Input {}: replay protection pubkey mismatch: \
599+
tx has {}, expected {}",
600+
i,
601+
tx_pubkey
602+
.to_bytes()
603+
.iter()
604+
.map(|b| format!("{:02x}", b))
605+
.collect::<String>(),
606+
expected_pubkey
607+
.to_bytes()
608+
.iter()
609+
.map(|b| format!("{:02x}", b))
610+
.collect::<String>(),
611+
));
612+
}
613+
psbt.add_replay_protection_input_at_index(
614+
i,
615+
*tx_pubkey,
616+
tx_in.previous_output.txid,
617+
tx_in.previous_output.vout,
618+
*value,
619+
ReplayProtectionOptions {
620+
sequence: Some(tx_in.sequence.0),
621+
prev_tx: None,
622+
sighash_type: None,
623+
},
624+
)
625+
.map_err(|e| {
626+
format!("Input {}: failed to add replay protection input: {}", i, e)
627+
})?;
628+
629+
if let Some(ecdsa_sig) = sig {
630+
let pk = PublicKey::from(*tx_pubkey);
631+
psbt.psbt_mut().inputs[i]
632+
.partial_sigs
633+
.insert(pk, *ecdsa_sig);
634+
}
635+
}
636+
_ => {
637+
return Err(format!(
638+
"Input {}: mismatch between tx input type and provided unspent type \
639+
(tx has {}, unspent is {})",
640+
i,
641+
match &input_results[i] {
642+
legacy_txformat::LegacyInputResult::Multisig(_) => "multisig",
643+
legacy_txformat::LegacyInputResult::ReplayProtection { .. } =>
644+
"replay protection",
645+
},
646+
match unspent {
647+
HydrationUnspentInput::Wallet(_) => "wallet",
648+
HydrationUnspentInput::ReplayProtection { .. } => "replay protection",
649+
}
650+
));
651+
}
652+
}
571653
}
572654

573655
// Add outputs (plain script+value, no wallet metadata)
@@ -4197,7 +4279,7 @@ mod tests {
41974279

41984280
// Step 2: Build unspents from bip32 derivation paths in the PSBT
41994281
// The derivation path is m/<chain>/<index>
4200-
let unspents: Vec<ScriptIdWithValue> = psbt
4282+
let unspents: Vec<HydrationUnspentInput> = psbt
42014283
.inputs
42024284
.iter()
42034285
.enumerate()
@@ -4219,11 +4301,11 @@ mod tests {
42194301
.ok_or_else(|| format!("Input {} has no witnessUtxo", i))?
42204302
.value
42214303
.to_sat();
4222-
Ok(ScriptIdWithValue {
4304+
Ok(HydrationUnspentInput::Wallet(ScriptIdWithValue {
42234305
chain,
42244306
index,
42254307
value,
4226-
})
4308+
}))
42274309
})
42284310
.collect::<Result<Vec<_>, String>>()?;
42294311

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ pub use checksigverify::{
1313
build_p2tr_ns_script, build_tap_tree_for_output, create_tap_bip32_derivation_for_output,
1414
ScriptP2tr,
1515
};
16-
pub use singlesig::{build_p2pk_script, ScriptP2shP2pk};
16+
pub use singlesig::{build_p2pk_script, parse_p2pk_script, ScriptP2shP2pk};
1717

1818
use crate::address::networks::OutputScriptSupport;
1919
use crate::bitcoin::bip32::{ChildNumber, DerivationPath};

packages/wasm-utxo/src/fixed_script_wallet/wallet_scripts/singlesig.rs

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,19 @@ pub fn build_p2pk_script(key: CompressedPublicKey) -> ScriptBuf {
1212
.into_script()
1313
}
1414

15+
/// Parse a bare P2PK script (`<pubkey> OP_CHECKSIG`) and return the pubkey if valid.
16+
///
17+
/// P2PK format: `0x21 <33-byte compressed pubkey> 0xac`
18+
pub fn parse_p2pk_script(script: &ScriptBuf) -> Option<CompressedPublicKey> {
19+
let b = script.as_bytes();
20+
// 0x21 = push 33 bytes, 0xac = OP_CHECKSIG
21+
if b.len() == 35 && b[0] == 0x21 && b[34] == 0xac {
22+
CompressedPublicKey::from_slice(&b[1..34]).ok()
23+
} else {
24+
None
25+
}
26+
}
27+
1528
#[derive(Debug)]
1629
pub struct ScriptP2shP2pk {
1730
pub redeem_script: ScriptBuf,

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

Lines changed: 40 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -427,34 +427,58 @@ impl BitGoPsbt {
427427
unspents: JsValue,
428428
) -> Result<BitGoPsbt, WasmUtxoError> {
429429
use crate::fixed_script_wallet::bitgo_psbt::psbt_wallet_input::ScriptIdWithValue;
430+
use crate::fixed_script_wallet::bitgo_psbt::HydrationUnspentInput;
430431

431432
let network = parse_network(network)?;
432433
let wallet_keys = wallet_keys.inner();
433434

434-
// Parse the unspents array from JsValue
435+
// Parse the unspents array from JsValue.
436+
// Each element is either:
437+
// { chain: number, index: number, value: bigint } → wallet input
438+
// { value: bigint } → replay protection input
439+
// The presence of `chain` is used to distinguish the two.
435440
let arr = js_sys::Array::from(&unspents);
436441
let mut parsed_unspents = Vec::with_capacity(arr.length() as usize);
437442
for i in 0..arr.length() {
438443
let item = arr.get(i);
439-
let chain = js_sys::Reflect::get(&item, &"chain".into())
440-
.map_err(|_| WasmUtxoError::new("Missing 'chain' field on unspent"))?
441-
.as_f64()
442-
.ok_or_else(|| WasmUtxoError::new("'chain' must be a number"))?
443-
as u32;
444-
let index = js_sys::Reflect::get(&item, &"index".into())
445-
.map_err(|_| WasmUtxoError::new("Missing 'index' field on unspent"))?
446-
.as_f64()
447-
.ok_or_else(|| WasmUtxoError::new("'index' must be a number"))?
448-
as u32;
449444
let value_js = js_sys::Reflect::get(&item, &"value".into())
450445
.map_err(|_| WasmUtxoError::new("Missing 'value' field on unspent"))?;
451446
let value = u64::try_from(js_sys::BigInt::unchecked_from_js(value_js))
452447
.map_err(|_| WasmUtxoError::new("'value' must be a bigint convertible to u64"))?;
453-
parsed_unspents.push(ScriptIdWithValue {
454-
chain,
455-
index,
456-
value,
457-
});
448+
449+
let chain_val =
450+
js_sys::Reflect::get(&item, &"chain".into()).unwrap_or(JsValue::UNDEFINED);
451+
452+
if chain_val.is_undefined() {
453+
// No 'chain' property → replay protection input; require pubkey
454+
let pubkey_val = js_sys::Reflect::get(&item, &"pubkey".into()).map_err(|_| {
455+
WasmUtxoError::new("Missing 'pubkey' on replay protection unspent")
456+
})?;
457+
let pubkey_bytes = js_sys::Uint8Array::new(&pubkey_val).to_vec();
458+
let pubkey = miniscript::bitcoin::CompressedPublicKey::from_slice(&pubkey_bytes)
459+
.map_err(|_| {
460+
WasmUtxoError::new(
461+
"'pubkey' is not a valid compressed public key (33 bytes)",
462+
)
463+
})?;
464+
parsed_unspents.push(HydrationUnspentInput::ReplayProtection { pubkey, value });
465+
} else {
466+
// Has 'chain' → wallet input; also parse 'index'
467+
let chain = chain_val
468+
.as_f64()
469+
.ok_or_else(|| WasmUtxoError::new("'chain' must be a number"))?
470+
as u32;
471+
let index = js_sys::Reflect::get(&item, &"index".into())
472+
.map_err(|_| WasmUtxoError::new("Missing 'index' field on wallet unspent"))?
473+
.as_f64()
474+
.ok_or_else(|| WasmUtxoError::new("'index' must be a number"))?
475+
as u32;
476+
parsed_unspents.push(HydrationUnspentInput::Wallet(ScriptIdWithValue {
477+
chain,
478+
index,
479+
value,
480+
}));
481+
}
458482
}
459483

460484
let psbt =

packages/wasm-utxo/test/fixedScript/fromHalfSignedLegacyTransaction.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import * as utxolib from "@bitgo/utxo-lib";
1515
import { BitGoPsbt, type HydrationUnspent } from "../../js/fixedScriptWallet/BitGoPsbt.js";
1616
import { ZcashBitGoPsbt } from "../../js/fixedScriptWallet/ZcashBitGoPsbt.js";
1717
import { ChainCode } from "../../js/fixedScriptWallet/chains.js";
18+
import { ECPair } from "../../js/ecpair.js";
1819
import { getDefaultWalletKeys, getKeyTriple } from "../../js/testutils/keys.js";
1920
import { getCoinNameForNetwork } from "../networks.js";
2021

@@ -128,4 +129,46 @@ describe("BitGoPsbt.fromHalfSignedLegacyTransaction", function () {
128129
});
129130
}
130131
});
132+
133+
describe("Round-trip with replay protection input", function () {
134+
it("reconstructs PSBT from legacy tx with wallet + replay protection input", function () {
135+
const rootWalletKeys = getDefaultWalletKeys();
136+
const [userXprv] = getKeyTriple("default");
137+
const ecpair = ECPair.fromPublicKey(rootWalletKeys.userKey().publicKey);
138+
139+
const psbt = BitGoPsbt.createEmpty("btc", rootWalletKeys, { version: 2, lockTime: 0 });
140+
psbt.addWalletInput(
141+
{ txid: "00".repeat(32), vout: 0, value: BigInt(10000), sequence: 0xfffffffd },
142+
rootWalletKeys,
143+
{ scriptId: { chain: 0, index: 0 } },
144+
);
145+
psbt.addReplayProtectionInput(
146+
{ txid: "aa".repeat(32), vout: 0, value: BigInt(1000), sequence: 0xfffffffd },
147+
ecpair,
148+
);
149+
psbt.addWalletOutput(rootWalletKeys, { chain: 0, index: 100, value: BigInt(5000) });
150+
// sign() only signs wallet inputs; replay protection input gets 0 sigs
151+
psbt.sign(userXprv);
152+
153+
const txBytes = psbt.getHalfSignedLegacyFormat();
154+
155+
const unspents: HydrationUnspent[] = [
156+
{ chain: 0, index: 0, value: BigInt(10000) }, // wallet
157+
{ pubkey: ecpair.publicKey, value: BigInt(1000) }, // replay protection
158+
];
159+
const reconstructed = BitGoPsbt.fromHalfSignedLegacyTransaction(
160+
txBytes,
161+
"btc",
162+
rootWalletKeys,
163+
unspents,
164+
);
165+
166+
assert.ok(reconstructed.serialize().length > 0, "Reconstructed PSBT serializes");
167+
assert.strictEqual(reconstructed.inputCount(), 2, "Both inputs present");
168+
assert.ok(
169+
reconstructed.verifySignature(0, rootWalletKeys.userKey().neutered().toBase58()),
170+
"Wallet input signature preserved",
171+
);
172+
});
173+
});
131174
});

0 commit comments

Comments
 (0)