Skip to content

Commit 6c16e60

Browse files
OttoAllmendingerllm-git
andcommitted
feat(wasm-utxo): implement ReplayProtection class
Add a dedicated ReplayProtection class to handle replay protection inputs in transactions. The implementation supports creating replay protection from public keys, output scripts, or addresses, providing a cleaner API than the previous approach using plain objects. Issue: BTC-2786 Co-authored-by: llm-git <[email protected]>
1 parent 457f8d9 commit 6c16e60

File tree

12 files changed

+294
-155
lines changed

12 files changed

+294
-155
lines changed

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

Lines changed: 10 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,13 @@
11
import { BitGoPsbt as WasmBitGoPsbt } from "../wasm/wasm_utxo.js";
22
import { type WalletKeysArg, RootWalletKeys } from "./RootWalletKeys.js";
3+
import { type ReplayProtectionArg, ReplayProtection } from "./ReplayProtection.js";
34
import { type BIP32Arg, BIP32 } from "../bip32.js";
45
import { type ECPairArg, ECPair } from "../ecpair.js";
56
import type { UtxolibName } from "../utxolibCompat.js";
67
import type { CoinName } from "../coinName.js";
78

89
export type NetworkName = UtxolibName | CoinName;
910

10-
type ReplayProtection =
11-
| {
12-
outputScripts: Uint8Array[];
13-
}
14-
| {
15-
addresses: string[];
16-
};
17-
1811
export type ScriptId = { chain: number; index: number };
1912

2013
export type InputScriptType =
@@ -79,13 +72,11 @@ export class BitGoPsbt {
7972
*/
8073
parseTransactionWithWalletKeys(
8174
walletKeys: WalletKeysArg,
82-
replayProtection: ReplayProtection,
75+
replayProtection: ReplayProtectionArg,
8376
): ParsedTransaction {
8477
const keys = RootWalletKeys.from(walletKeys);
85-
return this.wasm.parse_transaction_with_wallet_keys(
86-
keys.wasm,
87-
replayProtection,
88-
) as ParsedTransaction;
78+
const rp = ReplayProtection.from(replayProtection, this.wasm.network());
79+
return this.wasm.parse_transaction_with_wallet_keys(keys.wasm, rp.wasm) as ParsedTransaction;
8980
}
9081

9182
/**
@@ -171,8 +162,12 @@ export class BitGoPsbt {
171162
* @returns true if the input is a replay protection input and has a valid signature, false if no valid signature
172163
* @throws Error if the input is not a replay protection input, index is out of bounds, or scripts are invalid
173164
*/
174-
verifyReplayProtectionSignature(inputIndex: number, replayProtection: ReplayProtection): boolean {
175-
return this.wasm.verify_replay_protection_signature(inputIndex, replayProtection);
165+
verifyReplayProtectionSignature(
166+
inputIndex: number,
167+
replayProtection: ReplayProtectionArg,
168+
): boolean {
169+
const rp = ReplayProtection.from(replayProtection, this.wasm.network());
170+
return this.wasm.verify_replay_protection_signature(inputIndex, rp.wasm);
176171
}
177172

178173
/**
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
import { WasmReplayProtection } from "../wasm/wasm_utxo.js";
2+
import { type ECPairArg, ECPair } from "../ecpair.js";
3+
4+
/**
5+
* ReplayProtectionArg represents the various forms that replay protection can take
6+
* before being converted to a WasmReplayProtection instance
7+
*/
8+
export type ReplayProtectionArg =
9+
| ReplayProtection
10+
| WasmReplayProtection
11+
| {
12+
publicKeys: ECPairArg[];
13+
}
14+
| {
15+
/** @deprecated - use publicKeys instead */
16+
outputScripts: Uint8Array[];
17+
}
18+
| {
19+
/** @deprecated - use publicKeys instead */
20+
addresses: string[];
21+
};
22+
23+
/**
24+
* ReplayProtection wrapper class for PSBT replay protection inputs
25+
*/
26+
export class ReplayProtection {
27+
private constructor(private _wasm: WasmReplayProtection) {}
28+
29+
/**
30+
* Create a ReplayProtection instance from a WasmReplayProtection instance (internal use)
31+
* @internal
32+
*/
33+
static fromWasm(wasm: WasmReplayProtection): ReplayProtection {
34+
return new ReplayProtection(wasm);
35+
}
36+
37+
/**
38+
* Convert ReplayProtectionArg to ReplayProtection instance
39+
* @param arg - The replay protection in various formats
40+
* @param network - Optional network string (required for addresses variant)
41+
* @returns ReplayProtection instance
42+
*/
43+
static from(arg: ReplayProtectionArg, network?: string): ReplayProtection {
44+
// Short-circuit if already a ReplayProtection instance
45+
if (arg instanceof ReplayProtection) {
46+
return arg;
47+
}
48+
// If it's a WasmReplayProtection instance, wrap it
49+
if (arg instanceof WasmReplayProtection) {
50+
return new ReplayProtection(arg);
51+
}
52+
53+
// Handle object variants
54+
if ("publicKeys" in arg) {
55+
// Convert ECPairArg to public key bytes
56+
const publicKeyBytes = arg.publicKeys.map((key) => ECPair.from(key).publicKey);
57+
const wasm = WasmReplayProtection.from_public_keys(publicKeyBytes);
58+
return new ReplayProtection(wasm);
59+
}
60+
61+
if ("outputScripts" in arg) {
62+
const wasm = WasmReplayProtection.from_output_scripts(arg.outputScripts);
63+
return new ReplayProtection(wasm);
64+
}
65+
66+
if ("addresses" in arg) {
67+
if (!network) {
68+
throw new Error("Network is required when using addresses variant");
69+
}
70+
const wasm = WasmReplayProtection.from_addresses(arg.addresses, network);
71+
return new ReplayProtection(wasm);
72+
}
73+
74+
throw new Error("Invalid ReplayProtectionArg type");
75+
}
76+
77+
/**
78+
* Create from public keys (derives P2SH-P2PK output scripts)
79+
* @param publicKeys - Array of ECPair instances or arguments
80+
* @returns ReplayProtection instance
81+
*/
82+
static fromPublicKeys(publicKeys: ECPairArg[]): ReplayProtection {
83+
return ReplayProtection.from({ publicKeys });
84+
}
85+
86+
/**
87+
* Create from output scripts
88+
* @param outputScripts - Array of output script buffers
89+
* @returns ReplayProtection instance
90+
*/
91+
static fromOutputScripts(outputScripts: Uint8Array[]): ReplayProtection {
92+
return ReplayProtection.from({ outputScripts });
93+
}
94+
95+
/**
96+
* Create from addresses
97+
* @param addresses - Array of address strings
98+
* @param network - Network string (e.g., "bitcoin", "testnet", "btc", "tbtc")
99+
* @returns ReplayProtection instance
100+
*/
101+
static fromAddresses(addresses: string[], network: string): ReplayProtection {
102+
return ReplayProtection.from({ addresses }, network);
103+
}
104+
105+
/**
106+
* Get the underlying WASM instance (internal use only)
107+
* @internal
108+
*/
109+
get wasm(): WasmReplayProtection {
110+
return this._wasm;
111+
}
112+
}

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
export { RootWalletKeys, type WalletKeysArg, type IWalletKeys } from "./RootWalletKeys.js";
2+
export { ReplayProtection, type ReplayProtectionArg } from "./ReplayProtection.js";
23
export { outputScript, address } from "./address.js";
34
export {
45
BitGoPsbt,

packages/wasm-utxo/js/index.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,20 +4,25 @@ import * as wasm from "./wasm/wasm_utxo.js";
44
// and forgets to include it in the bundle
55
void wasm;
66

7+
// Most exports are namespaced to avoid polluting the top-level namespace
8+
// and to make imports more explicit (e.g., `import { address } from '@bitgo/wasm-utxo'`)
79
export * as address from "./address.js";
810
export * as ast from "./ast/index.js";
911
export * as utxolibCompat from "./utxolibCompat.js";
1012
export * as fixedScriptWallet from "./fixedScriptWallet/index.js";
1113
export * as bip32 from "./bip32.js";
1214
export * as ecpair from "./ecpair.js";
1315

16+
// Only the most commonly used classes and types are exported at the top level for convenience
1417
export { ECPair } from "./ecpair.js";
1518
export { BIP32 } from "./bip32.js";
1619

1720
export type { CoinName } from "./coinName.js";
1821
export type { Triple } from "./triple.js";
1922
export type { AddressFormat } from "./address.js";
2023

24+
// TODO: the exports below should be namespaced under `descriptor` in the future
25+
2126
export type DescriptorPkType = "derivable" | "definite" | "string";
2227

2328
export type ScriptContext = "tap" | "segwitv0" | "legacy";

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

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -478,7 +478,7 @@ impl BitGoPsbt {
478478
fn parse_inputs(
479479
&self,
480480
wallet_keys: &crate::fixed_script_wallet::RootWalletKeys,
481-
replay_protection: &psbt_wallet_input::ReplayProtection,
481+
replay_protection: &crate::fixed_script_wallet::ReplayProtection,
482482
) -> Result<Vec<ParsedInput>, ParseTransactionError> {
483483
let psbt = self.psbt();
484484
let network = self.network();
@@ -669,7 +669,7 @@ impl BitGoPsbt {
669669
&self,
670670
secp: &secp256k1::Secp256k1<C>,
671671
input_index: usize,
672-
replay_protection: &psbt_wallet_input::ReplayProtection,
672+
replay_protection: &crate::fixed_script_wallet::ReplayProtection,
673673
) -> Result<bool, String> {
674674
use miniscript::bitcoin::{hashes::Hash, sighash::SighashCache};
675675

@@ -912,7 +912,7 @@ impl BitGoPsbt {
912912
pub fn parse_transaction_with_wallet_keys(
913913
&self,
914914
wallet_keys: &crate::fixed_script_wallet::RootWalletKeys,
915-
replay_protection: &psbt_wallet_input::ReplayProtection,
915+
replay_protection: &crate::fixed_script_wallet::ReplayProtection,
916916
) -> Result<ParsedTransaction, ParseTransactionError> {
917917
let psbt = self.psbt();
918918

@@ -1324,7 +1324,7 @@ mod tests {
13241324

13251325
// Create replay protection with this output script
13261326
let replay_protection =
1327-
psbt_wallet_input::ReplayProtection::new(vec![output_script.clone()]);
1327+
crate::fixed_script_wallet::ReplayProtection::new(vec![output_script.clone()]);
13281328

13291329
// Verify the signature exists and is valid
13301330
let has_valid_signature = bitgo_psbt.verify_replay_protection_signature(
@@ -1603,7 +1603,7 @@ mod tests {
16031603
let wallet_keys = wallet_xprv.to_root_wallet_keys();
16041604

16051605
// Create replay protection with the replay protection script from fixture
1606-
let replay_protection = psbt_wallet_input::ReplayProtection::new(vec![
1606+
let replay_protection = crate::fixed_script_wallet::ReplayProtection::new(vec![
16071607
miniscript::bitcoin::ScriptBuf::from_hex("a91420b37094d82a513451ff0ccd9db23aba05bc5ef387")
16081608
.expect("Failed to parse replay protection output script"),
16091609
]);

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

Lines changed: 1 addition & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -4,26 +4,9 @@ use miniscript::bitcoin::secp256k1::{self, PublicKey};
44
use miniscript::bitcoin::{OutPoint, ScriptBuf, TapLeafHash, XOnlyPublicKey};
55

66
use crate::bitcoin::bip32::KeySource;
7-
use crate::fixed_script_wallet::{Chain, RootWalletKeys, WalletScripts};
7+
use crate::fixed_script_wallet::{Chain, ReplayProtection, RootWalletKeys, WalletScripts};
88
use crate::Network;
99

10-
#[derive(Debug, Clone)]
11-
pub struct ReplayProtection {
12-
pub permitted_output_scripts: Vec<ScriptBuf>,
13-
}
14-
15-
impl ReplayProtection {
16-
pub fn new(permitted_output_scripts: Vec<ScriptBuf>) -> Self {
17-
Self {
18-
permitted_output_scripts,
19-
}
20-
}
21-
22-
pub fn is_replay_protection_input(&self, output_script: &ScriptBuf) -> bool {
23-
self.permitted_output_scripts.contains(output_script)
24-
}
25-
}
26-
2710
pub type Bip32DerivationMap = std::collections::BTreeMap<PublicKey, KeySource>;
2811

2912
/// Check if a fingerprint matches any xpub in the wallet
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
11
/// This module contains code for the BitGo Fixed Script Wallets.
22
/// These are not based on descriptors.
33
pub mod bitgo_psbt;
4+
pub mod replay_protection;
45
mod wallet_keys;
56
pub mod wallet_scripts;
67

78
#[cfg(test)]
89
pub mod test_utils;
910

11+
pub use replay_protection::*;
1012
pub use wallet_keys::*;
1113
pub use wallet_scripts::*;
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
use miniscript::bitcoin::{CompressedPublicKey, ScriptBuf};
2+
3+
use crate::fixed_script_wallet::wallet_scripts::ScriptP2shP2pk;
4+
5+
#[derive(Debug, Clone)]
6+
pub struct ReplayProtection {
7+
pub permitted_output_scripts: Vec<ScriptBuf>,
8+
}
9+
10+
impl ReplayProtection {
11+
pub fn new(permitted_output_scripts: Vec<ScriptBuf>) -> Self {
12+
Self {
13+
permitted_output_scripts,
14+
}
15+
}
16+
17+
/// Create from public keys by deriving P2SH-P2PK output scripts
18+
/// This is useful for replay protection inputs where we know the public keys
19+
/// but want to automatically create the corresponding output scripts
20+
pub fn from_public_keys(public_keys: Vec<CompressedPublicKey>) -> Self {
21+
let output_scripts = public_keys
22+
.into_iter()
23+
.map(|key| {
24+
let script = ScriptP2shP2pk::new(key);
25+
script.output_script()
26+
})
27+
.collect();
28+
Self {
29+
permitted_output_scripts: output_scripts,
30+
}
31+
}
32+
33+
pub fn is_replay_protection_input(&self, output_script: &ScriptBuf) -> bool {
34+
self.permitted_output_scripts.contains(output_script)
35+
}
36+
}

0 commit comments

Comments
 (0)