Skip to content

Commit e984fc6

Browse files
OttoAllmendingerllm-git
andcommitted
feat(wasm-utxo): add method to parse outputs with wallet keys
Adds a new method to BitGoPsbt that allows parsing transaction outputs with wallet keys without requiring valid wallet inputs. This enables identification of which outputs belong to a given wallet, even if the inputs don't belong to the same wallet. The implementation reuses existing parsing logic and adds proper tests that verify outputs can be correctly identified when using different wallet keys. Issue: BTC-2652 Co-authored-by: llm-git <[email protected]>
1 parent db14c7f commit e984fc6

File tree

4 files changed

+187
-20
lines changed

4 files changed

+187
-20
lines changed

packages/wasm-utxo/js/fixedScriptWallet.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,4 +106,14 @@ export class BitGoPsbt {
106106
): ParsedTransaction {
107107
return this.wasm.parseTransactionWithWalletKeys(walletKeys, replayProtection);
108108
}
109+
110+
/**
111+
* Parse outputs with wallet keys to identify which outputs belong to the wallet
112+
* @param walletKeys - The wallet keys to use for identification
113+
* @returns Array of parsed outputs
114+
* @note This method does NOT validate wallet inputs. It only parses outputs.
115+
*/
116+
parseOutputsWithWalletKeys(walletKeys: WalletKeys): ParsedOutput[] {
117+
return this.wasm.parseOutputsWithWalletKeys(walletKeys);
118+
}
109119
}

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

Lines changed: 67 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -463,6 +463,62 @@ impl BitGoPsbt {
463463
}
464464
}
465465

466+
/// Parse outputs with wallet keys to identify which outputs belong to the wallet
467+
///
468+
/// # Arguments
469+
/// - `wallet_keys`: The wallet's root keys for deriving scripts
470+
///
471+
/// # Returns
472+
/// - `Ok(Vec<ParsedOutput>)` with parsed outputs
473+
/// - `Err(ParseTransactionError)` if output parsing fails
474+
///
475+
/// # Note
476+
/// This method does NOT validate wallet inputs. It only parses outputs to identify
477+
/// which ones belong to the provided wallet keys.
478+
fn parse_outputs(
479+
&self,
480+
wallet_keys: &crate::fixed_script_wallet::RootWalletKeys,
481+
) -> Result<Vec<ParsedOutput>, ParseTransactionError> {
482+
let psbt = self.psbt();
483+
let network = self.network();
484+
485+
let mut parsed_outputs = Vec::new();
486+
487+
for (output_index, tx_output) in psbt.unsigned_tx.output.iter().enumerate() {
488+
let psbt_output = &psbt.outputs[output_index];
489+
490+
// Parse the output
491+
let parsed_output = ParsedOutput::parse(psbt_output, tx_output, wallet_keys, network)
492+
.map_err(|error| ParseTransactionError::Output {
493+
index: output_index,
494+
error,
495+
})?;
496+
497+
parsed_outputs.push(parsed_output);
498+
}
499+
500+
Ok(parsed_outputs)
501+
}
502+
503+
/// Parse outputs with wallet keys to identify which outputs belong to the wallet
504+
///
505+
/// # Arguments
506+
/// - `wallet_keys`: The wallet's root keys for deriving scripts
507+
///
508+
/// # Returns
509+
/// - `Ok(Vec<ParsedOutput>)` with parsed outputs
510+
/// - `Err(ParseTransactionError)` if output parsing fails
511+
///
512+
/// # Note
513+
/// This method does NOT validate wallet inputs. It only parses outputs to identify
514+
/// which ones belong to the provided wallet keys.
515+
pub fn parse_outputs_with_wallet_keys(
516+
&self,
517+
wallet_keys: &crate::fixed_script_wallet::RootWalletKeys,
518+
) -> Result<Vec<ParsedOutput>, ParseTransactionError> {
519+
self.parse_outputs(wallet_keys)
520+
}
521+
466522
/// Parse transaction with wallet keys to identify wallet inputs/outputs and calculate metrics
467523
///
468524
/// # Arguments
@@ -512,27 +568,26 @@ impl BitGoPsbt {
512568
parsed_inputs.push(parsed_input);
513569
}
514570

515-
// Parse outputs
516-
let mut parsed_outputs = Vec::new();
571+
// Parse outputs using the reusable method
572+
let parsed_outputs = self.parse_outputs(wallet_keys)?;
573+
574+
// Calculate totals and spend amount
517575
let mut total_output_value = 0u64;
518576
let mut spend_amount = 0u64;
519577

520-
for (output_index, tx_output) in psbt.unsigned_tx.output.iter().enumerate() {
521-
let psbt_output = &psbt.outputs[output_index];
522-
578+
for (output_index, (tx_output, parsed_output)) in psbt
579+
.unsigned_tx
580+
.output
581+
.iter()
582+
.zip(parsed_outputs.iter())
583+
.enumerate()
584+
{
523585
total_output_value = total_output_value
524586
.checked_add(tx_output.value.to_sat())
525587
.ok_or(ParseTransactionError::OutputValueOverflow {
526588
index: output_index,
527589
})?;
528590

529-
// Parse the output
530-
let parsed_output = ParsedOutput::parse(psbt_output, tx_output, wallet_keys, network)
531-
.map_err(|error| ParseTransactionError::Output {
532-
index: output_index,
533-
error,
534-
})?;
535-
536591
// If this is an external output, add to spend amount
537592
if parsed_output.is_external() {
538593
spend_amount = spend_amount.checked_add(tx_output.value.to_sat()).ok_or(
@@ -541,8 +596,6 @@ impl BitGoPsbt {
541596
},
542597
)?;
543598
}
544-
545-
parsed_outputs.push(parsed_output);
546599
}
547600

548601
// Calculate miner fee

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

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -171,4 +171,25 @@ impl BitGoPsbt {
171171
// Convert to JsValue directly using TryIntoJsValue
172172
parsed_tx.try_to_js_value()
173173
}
174+
175+
/// Parse outputs with wallet keys to identify which outputs belong to the wallet
176+
///
177+
/// Note: This method does NOT validate wallet inputs. It only parses outputs.
178+
#[wasm_bindgen(js_name = parseOutputsWithWalletKeys)]
179+
pub fn parse_outputs_with_wallet_keys(
180+
&self,
181+
wallet_keys: JsValue,
182+
) -> Result<JsValue, WasmUtxoError> {
183+
// Convert wallet keys from JsValue
184+
let wallet_keys = root_wallet_keys_from_jsvalue(&wallet_keys)?;
185+
186+
// Call the Rust implementation
187+
let parsed_outputs = self
188+
.psbt
189+
.parse_outputs_with_wallet_keys(&wallet_keys)
190+
.map_err(|e| WasmUtxoError::new(&format!("Failed to parse outputs: {}", e)))?;
191+
192+
// Convert Vec<ParsedOutput> to JsValue
193+
parsed_outputs.try_to_js_value()
194+
}
174195
}

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

Lines changed: 89 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,15 @@ import * as fs from "node:fs";
33
import * as path from "node:path";
44
import * as utxolib from "@bitgo/utxo-lib";
55
import { fixedScriptWallet } from "../../js";
6+
import { BitGoPsbt } from "../../js/fixedScriptWallet";
67

78
type Triple<T> = [T, T, T];
89

10+
function getOtherWalletKeys(): utxolib.bitgo.RootWalletKeys {
11+
const otherWalletKeys = utxolib.testutil.getKeyTriple("too many secrets");
12+
return new utxolib.bitgo.RootWalletKeys(otherWalletKeys);
13+
}
14+
915
/**
1016
* Load a PSBT fixture from JSON file and return the PSBT bytes
1117
*/
@@ -72,13 +78,17 @@ describe("parseTransactionWithWalletKeys", function () {
7278
const networkName = utxolib.getNetworkName(network);
7379

7480
describe(`network: ${networkName}`, function () {
75-
it("should parse transaction and identify internal/external outputs", function () {
76-
// Load PSBT from fixture
77-
const psbtBytes = loadPsbtFixture(networkName);
78-
const rootWalletKeys = loadWalletKeysFromFixture(networkName);
81+
let psbtBytes: Buffer;
82+
let bitgoPsbt: BitGoPsbt;
83+
let rootWalletKeys: utxolib.bitgo.RootWalletKeys;
84+
85+
before(function () {
86+
psbtBytes = loadPsbtFixture(networkName);
87+
bitgoPsbt = fixedScriptWallet.BitGoPsbt.fromBytes(psbtBytes, networkName);
88+
rootWalletKeys = loadWalletKeysFromFixture(networkName);
89+
});
7990

80-
// Parse with WASM
81-
const bitgoPsbt = fixedScriptWallet.BitGoPsbt.fromBytes(psbtBytes, networkName);
91+
it("should parse transaction and identify internal/external outputs", function () {
8292
const parsed = bitgoPsbt.parseTransactionWithWalletKeys(rootWalletKeys, {
8393
outputScripts: [replayProtectionScript],
8494
});
@@ -144,6 +154,79 @@ describe("parseTransactionWithWalletKeys", function () {
144154
assert.ok(typeof parsed.virtualSize === "number", "Virtual size should be a number");
145155
assert.ok(parsed.virtualSize > 0, "Virtual size should be > 0");
146156
});
157+
158+
it("should fail to parse with other wallet keys", function () {
159+
assert.throws(
160+
() => {
161+
bitgoPsbt.parseTransactionWithWalletKeys(getOtherWalletKeys(), {
162+
outputScripts: [replayProtectionScript],
163+
});
164+
},
165+
(error: Error) => {
166+
return error.message.includes(
167+
"Failed to parse transaction: Input 0: wallet validation failed",
168+
);
169+
},
170+
);
171+
});
172+
173+
it("should recognize output for other wallet keys", function () {
174+
const parsedOutputs = bitgoPsbt.parseOutputsWithWalletKeys(getOtherWalletKeys());
175+
176+
// Should return an array of parsed outputs
177+
assert.ok(Array.isArray(parsedOutputs), "Should return an array");
178+
assert.ok(parsedOutputs.length > 0, "Should have at least one output");
179+
180+
// Verify all outputs have proper structure
181+
parsedOutputs.forEach((output, i) => {
182+
assert.ok(output.script instanceof Uint8Array, `Output ${i} script should be Uint8Array`);
183+
assert.ok(typeof output.value === "bigint", `Output ${i} value should be bigint`);
184+
assert.ok(output.value > 0n, `Output ${i} value should be > 0`);
185+
// Address can be null for non-standard scripts
186+
assert.ok(
187+
typeof output.address === "string" || output.address === null,
188+
`Output ${i} address should be string or null`,
189+
);
190+
// scriptId can be null for external outputs
191+
assert.ok(
192+
output.scriptId === null ||
193+
(typeof output.scriptId === "object" &&
194+
typeof output.scriptId.chain === "number" &&
195+
typeof output.scriptId.index === "number"),
196+
`Output ${i} scriptId should be null or an object with chain and index`,
197+
);
198+
});
199+
200+
// Compare with the original wallet keys to verify we get different results
201+
const originalParsedOutputs = bitgoPsbt.parseOutputsWithWalletKeys(rootWalletKeys);
202+
203+
// Should have the same number of outputs
204+
assert.strictEqual(
205+
parsedOutputs.length,
206+
originalParsedOutputs.length,
207+
"Should parse the same number of outputs",
208+
);
209+
210+
// Find outputs that belong to the other wallet keys (scriptId !== null)
211+
const otherWalletOutputs = parsedOutputs.filter((o) => o.scriptId !== null);
212+
213+
// Should have exactly one output for the other wallet keys
214+
assert.strictEqual(
215+
otherWalletOutputs.length,
216+
1,
217+
"Should have exactly one output belonging to the other wallet keys",
218+
);
219+
220+
// Verify that this output is marked as external (scriptId === null) under regular wallet keys
221+
const otherWalletOutputIndex = parsedOutputs.findIndex((o) => o.scriptId !== null);
222+
const sameOutputWithRegularKeys = originalParsedOutputs[otherWalletOutputIndex];
223+
224+
assert.strictEqual(
225+
sameOutputWithRegularKeys.scriptId,
226+
null,
227+
"The output belonging to other wallet keys should be marked as external (scriptId === null) when parsed with regular wallet keys",
228+
);
229+
});
147230
});
148231
});
149232

0 commit comments

Comments
 (0)