Skip to content

Commit 322a745

Browse files
OttoAllmendingerllm-git
andcommitted
feat(wasm-utxo): add combineMusig2Nonces method
Implements new combineMusig2Nonces method to merge MuSig2 nonces from different PSBTs. This enables proper nonce exchange between cosigners during the MuSig2 signing workflow. The method copies nonces and partial signatures between PSBTs, validates network compatibility, and handles input count matching. Includes comprehensive tests and updated documentation with examples showing the complete MuSig2 signing workflow. Issue: BTC-2786 Co-authored-by: llm-git <[email protected]>
1 parent a4b730d commit 322a745

File tree

4 files changed

+174
-12
lines changed

4 files changed

+174
-12
lines changed

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

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -266,7 +266,8 @@ export class BitGoPsbt {
266266
* // Send PSBT to counterparty
267267
*
268268
* // Phase 2: After receiving counterparty PSBT with their nonces
269-
* psbt.combine(counterpartyPsbtBytes);
269+
* const counterpartyPsbt = BitGoPsbt.fromBytes(counterpartyPsbtBytes, network);
270+
* psbt.combineMusig2Nonces(counterpartyPsbt);
270271
* // Sign MuSig2 key path inputs
271272
* const parsed = psbt.parseTransactionWithWalletKeys(walletKeys, replayProtection);
272273
* for (let i = 0; i < parsed.inputs.length; i++) {
@@ -281,6 +282,29 @@ export class BitGoPsbt {
281282
this.wasm.generate_musig2_nonces(wasmKey.wasm, sessionId);
282283
}
283284

285+
/**
286+
* Combine/merge data from another PSBT into this one
287+
*
288+
* This method copies MuSig2 nonces and signatures (proprietary key-value pairs) from the
289+
* source PSBT to this PSBT. This is useful for merging PSBTs during the nonce exchange
290+
* and signature collection phases.
291+
*
292+
* @param sourcePsbt - The source PSBT containing data to merge
293+
* @throws Error if networks don't match
294+
*
295+
* @example
296+
* ```typescript
297+
* // After receiving counterparty's PSBT with their nonces
298+
* const counterpartyPsbt = BitGoPsbt.fromBytes(counterpartyPsbtBytes, network);
299+
* psbt.combineMusig2Nonces(counterpartyPsbt);
300+
* // Now can sign with all nonces present
301+
* psbt.sign(0, userXpriv);
302+
* ```
303+
*/
304+
combineMusig2Nonces(sourcePsbt: BitGoPsbt): void {
305+
this.wasm.combine_musig2_nonces(sourcePsbt.wasm);
306+
}
307+
284308
/**
285309
* Finalize all inputs in the PSBT
286310
*

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

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -204,6 +204,71 @@ impl BitGoPsbt {
204204
}
205205
}
206206

207+
/// Combine/merge data from another PSBT into this one
208+
///
209+
/// This method copies MuSig2 nonces and signatures (proprietary key-value pairs) from the
210+
/// source PSBT to this PSBT. This is useful for merging PSBTs during the nonce exchange
211+
/// and signature collection phases.
212+
///
213+
/// # Arguments
214+
/// * `source_psbt` - The source PSBT containing data to merge
215+
///
216+
/// # Returns
217+
/// Ok(()) if data was successfully merged
218+
///
219+
/// # Errors
220+
/// Returns error if networks don't match
221+
pub fn combine_musig2_nonces(&mut self, source_psbt: &BitGoPsbt) -> Result<(), String> {
222+
// Check network match
223+
if self.network() != source_psbt.network() {
224+
return Err(format!(
225+
"Network mismatch: destination is {}, source is {}",
226+
self.network(),
227+
source_psbt.network()
228+
));
229+
}
230+
231+
let source = source_psbt.psbt();
232+
let dest = self.psbt_mut();
233+
234+
// Check that both PSBTs have the same number of inputs
235+
if source.inputs.len() != dest.inputs.len() {
236+
return Err(format!(
237+
"PSBT input count mismatch: source has {} inputs, destination has {}",
238+
source.inputs.len(),
239+
dest.inputs.len()
240+
));
241+
}
242+
243+
// Copy MuSig2 nonces and partial signatures (proprietary key-values with BITGO identifier)
244+
for (source_input, dest_input) in source.inputs.iter().zip(dest.inputs.iter_mut()) {
245+
// Only process if the input is a MuSig2 input
246+
if !p2tr_musig2_input::Musig2Input::is_musig2_input(source_input) {
247+
continue;
248+
}
249+
250+
// Parse nonces from source input using native Musig2 functions
251+
let nonces = p2tr_musig2_input::parse_musig2_nonces(source_input)
252+
.map_err(|e| format!("Failed to parse MuSig2 nonces from source: {}", e))?;
253+
254+
// Copy each nonce to the destination input
255+
for nonce in nonces {
256+
let (key, value) = nonce.to_key_value().to_key_value();
257+
dest_input.proprietary.insert(key, value);
258+
}
259+
260+
// Also copy partial signatures if present
261+
// Partial sigs are stored as tap_script_sigs in the PSBT input
262+
for (control_block, leaf_script) in &source_input.tap_script_sigs {
263+
dest_input
264+
.tap_script_sigs
265+
.insert(*control_block, *leaf_script);
266+
}
267+
}
268+
269+
Ok(())
270+
}
271+
207272
/// Serialize the PSBT to bytes, using network-specific logic
208273
pub fn serialize(&self) -> Result<Vec<u8>, SerializeError> {
209274
match self {

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

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -504,6 +504,26 @@ impl BitGoPsbt {
504504
.map_err(|e| WasmUtxoError::new(&format!("Failed to sign input: {}", e)))
505505
}
506506

507+
/// Combine/merge data from another PSBT into this one
508+
///
509+
/// This method copies MuSig2 nonces and signatures (proprietary key-value pairs) from the
510+
/// source PSBT to this PSBT. This is useful for merging PSBTs during the nonce exchange
511+
/// and signature collection phases.
512+
///
513+
/// # Arguments
514+
/// * `source_psbt` - The source PSBT containing data to merge
515+
///
516+
/// # Returns
517+
/// Ok(()) if data was successfully merged
518+
///
519+
/// # Errors
520+
/// Returns error if networks don't match
521+
pub fn combine_musig2_nonces(&mut self, source_psbt: &BitGoPsbt) -> Result<(), WasmUtxoError> {
522+
self.psbt
523+
.combine_musig2_nonces(&source_psbt.psbt)
524+
.map_err(|e| WasmUtxoError::new(&format!("Failed to combine PSBTs: {}", e)))
525+
}
526+
507527
/// Finalize all inputs in the PSBT
508528
///
509529
/// This method attempts to finalize all inputs in the PSBT, computing the final

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

Lines changed: 64 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,37 +1,89 @@
11
import assert from "assert";
2-
import { BitGoPsbt } from "../../js/fixedScriptWallet/index.js";
32
import { BIP32 } from "../../js/bip32.js";
4-
import {
5-
loadPsbtFixture,
6-
getBitGoPsbt,
7-
type Fixture,
8-
} from "./fixtureUtil.js";
3+
import { loadPsbtFixture, getBitGoPsbt, type Fixture } from "./fixtureUtil.js";
94

105
describe("MuSig2 nonce management", function () {
116
describe("Bitcoin mainnet", function () {
127
const networkName = "bitcoin";
138
let fixture: Fixture;
14-
let unsignedBitgoPsbt: BitGoPsbt;
159
let userKey: BIP32;
10+
let backupKey: BIP32;
11+
let bitgoKey: BIP32;
1612

1713
before(function () {
1814
fixture = loadPsbtFixture(networkName, "unsigned");
19-
unsignedBitgoPsbt = getBitGoPsbt(fixture, networkName);
2015
userKey = BIP32.fromBase58(fixture.walletKeys[0]);
16+
backupKey = BIP32.fromBase58(fixture.walletKeys[1]);
17+
bitgoKey = BIP32.fromBase58(fixture.walletKeys[2]);
2118
});
2219

2320
it("should generate nonces for MuSig2 inputs with auto-generated session ID", function () {
21+
const unsignedBitgoPsbt = getBitGoPsbt(fixture, networkName);
2422
// Generate nonces with auto-generated session ID (no second parameter)
2523
assert.doesNotThrow(() => {
2624
unsignedBitgoPsbt.generateMusig2Nonces(userKey);
2725
});
28-
2926
// Verify nonces were stored by serializing and deserializing
30-
const serialized = unsignedBitgoPsbt.serialize();
31-
assert.ok(serialized.length > getBitGoPsbt(fixture, networkName).serialize().length);
27+
const serializedWithUserNonces = unsignedBitgoPsbt.serialize();
28+
assert.ok(
29+
serializedWithUserNonces.length > getBitGoPsbt(fixture, networkName).serialize().length,
30+
);
31+
32+
assert.doesNotThrow(() => {
33+
unsignedBitgoPsbt.generateMusig2Nonces(bitgoKey);
34+
});
35+
36+
const serializedWithBitgoNonces = unsignedBitgoPsbt.serialize();
37+
assert.ok(serializedWithBitgoNonces.length > serializedWithUserNonces.length);
38+
39+
assert.throws(() => {
40+
unsignedBitgoPsbt.generateMusig2Nonces(backupKey);
41+
}, "Should throw error when generating nonces for backup key");
42+
});
43+
44+
it("implements combineMusig2Nonces", function () {
45+
const unsignedBitgoPsbtWithUserNonces = getBitGoPsbt(fixture, networkName);
46+
unsignedBitgoPsbtWithUserNonces.generateMusig2Nonces(userKey);
47+
48+
const unsignedBitgoPsbtWithBitgoNonces = getBitGoPsbt(fixture, networkName);
49+
unsignedBitgoPsbtWithBitgoNonces.generateMusig2Nonces(bitgoKey);
50+
51+
const unsignedBitgoPsbtWithBothNonces = getBitGoPsbt(fixture, networkName);
52+
unsignedBitgoPsbtWithBothNonces.combineMusig2Nonces(unsignedBitgoPsbtWithUserNonces);
53+
unsignedBitgoPsbtWithBothNonces.combineMusig2Nonces(unsignedBitgoPsbtWithBitgoNonces);
54+
55+
{
56+
const psbt = getBitGoPsbt(fixture, networkName);
57+
psbt.combineMusig2Nonces(unsignedBitgoPsbtWithUserNonces);
58+
assert.strictEqual(
59+
psbt.serialize().length,
60+
unsignedBitgoPsbtWithUserNonces.serialize().length,
61+
);
62+
}
63+
64+
{
65+
const psbt = getBitGoPsbt(fixture, networkName);
66+
psbt.combineMusig2Nonces(unsignedBitgoPsbtWithBitgoNonces);
67+
assert.strictEqual(
68+
psbt.serialize().length,
69+
unsignedBitgoPsbtWithBitgoNonces.serialize().length,
70+
);
71+
}
72+
73+
{
74+
const psbt = getBitGoPsbt(fixture, networkName);
75+
psbt.combineMusig2Nonces(unsignedBitgoPsbtWithUserNonces);
76+
psbt.combineMusig2Nonces(unsignedBitgoPsbtWithBitgoNonces);
77+
assert.strictEqual(
78+
psbt.serialize().length,
79+
unsignedBitgoPsbtWithBothNonces.serialize().length,
80+
);
81+
}
3282
});
3383

3484
it("should reject invalid session ID length", function () {
85+
const unsignedBitgoPsbt = getBitGoPsbt(fixture, networkName);
86+
3587
// Invalid session ID (wrong length)
3688
const invalidSessionId = new Uint8Array(16); // Should be 32 bytes
3789

@@ -41,6 +93,7 @@ describe("MuSig2 nonce management", function () {
4193
});
4294

4395
it("should reject custom session ID on mainnet (security)", function () {
96+
const unsignedBitgoPsbt = getBitGoPsbt(fixture, "bitcoin");
4497
// Custom session ID should be rejected on mainnet for security
4598
const customSessionId = new Uint8Array(32).fill(1);
4699

0 commit comments

Comments
 (0)