Skip to content

Commit df8d870

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 df8d870

File tree

4 files changed

+197
-20
lines changed

4 files changed

+197
-20
lines changed

packages/wasm-utxo/js/fixedScriptWallet.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,4 +106,19 @@ 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 a wallet
112+
* with the given wallet keys.
113+
*
114+
* This is useful in cases where we want to identify outputs that belong to a different
115+
* wallet than the inputs.
116+
*
117+
* @param walletKeys - The wallet keys to use for identification
118+
* @returns Array of parsed outputs
119+
* @note This method does NOT validate wallet inputs. It only parses outputs.
120+
*/
121+
parseOutputsWithWalletKeys(walletKeys: WalletKeys): ParsedOutput[] {
122+
return this.wasm.parseOutputsWithWalletKeys(walletKeys);
123+
}
109124
}

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

Lines changed: 72 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -463,6 +463,67 @@ 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 a particular wallet.
504+
///
505+
/// This is useful in cases where we want to identify outputs that belong to a different
506+
/// wallet than the inputs.
507+
///
508+
/// If you only want to identify change outputs, use `parse_transaction_with_wallet_keys` instead.
509+
///
510+
/// # Arguments
511+
/// - `wallet_keys`: A wallet's root keys for deriving scripts (can be different wallet than the inputs)
512+
///
513+
/// # Returns
514+
/// - `Ok(Vec<ParsedOutput>)` with parsed outputs
515+
/// - `Err(ParseTransactionError)` if output parsing fails
516+
///
517+
/// # Note
518+
/// This method does NOT validate wallet inputs. It only parses outputs to identify
519+
/// which ones belong to the provided wallet keys.
520+
pub fn parse_outputs_with_wallet_keys(
521+
&self,
522+
wallet_keys: &crate::fixed_script_wallet::RootWalletKeys,
523+
) -> Result<Vec<ParsedOutput>, ParseTransactionError> {
524+
self.parse_outputs(wallet_keys)
525+
}
526+
466527
/// Parse transaction with wallet keys to identify wallet inputs/outputs and calculate metrics
467528
///
468529
/// # Arguments
@@ -512,27 +573,26 @@ impl BitGoPsbt {
512573
parsed_inputs.push(parsed_input);
513574
}
514575

515-
// Parse outputs
516-
let mut parsed_outputs = Vec::new();
576+
// Parse outputs using the reusable method
577+
let parsed_outputs = self.parse_outputs(wallet_keys)?;
578+
579+
// Calculate totals and spend amount
517580
let mut total_output_value = 0u64;
518581
let mut spend_amount = 0u64;
519582

520-
for (output_index, tx_output) in psbt.unsigned_tx.output.iter().enumerate() {
521-
let psbt_output = &psbt.outputs[output_index];
522-
583+
for (output_index, (tx_output, parsed_output)) in psbt
584+
.unsigned_tx
585+
.output
586+
.iter()
587+
.zip(parsed_outputs.iter())
588+
.enumerate()
589+
{
523590
total_output_value = total_output_value
524591
.checked_add(tx_output.value.to_sat())
525592
.ok_or(ParseTransactionError::OutputValueOverflow {
526593
index: output_index,
527594
})?;
528595

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-
536596
// If this is an external output, add to spend amount
537597
if parsed_output.is_external() {
538598
spend_amount = spend_amount.checked_add(tx_output.value.to_sat()).ok_or(
@@ -541,8 +601,6 @@ impl BitGoPsbt {
541601
},
542602
)?;
543603
}
544-
545-
parsed_outputs.push(parsed_output);
546604
}
547605

548606
// 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 a 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)