Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 31 additions & 3 deletions packages/wasm-utxo/js/fixedScriptWallet/BitGoPsbt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ export type ParsedOutput = {
script: Uint8Array;
value: bigint;
scriptId: ScriptId | null;
paygo: boolean;
};

export type ParsedTransaction = {
Expand Down Expand Up @@ -74,15 +75,22 @@ export class BitGoPsbt {
* Parse transaction with wallet keys to identify wallet inputs/outputs
* @param walletKeys - The wallet keys to use for identification
* @param replayProtection - Scripts that are allowed as inputs without wallet validation
* @param payGoPubkeys - Optional public keys for PayGo attestation verification
* @returns Parsed transaction information
*/
parseTransactionWithWalletKeys(
walletKeys: WalletKeysArg,
replayProtection: ReplayProtectionArg,
payGoPubkeys?: ECPairArg[],
): ParsedTransaction {
const keys = RootWalletKeys.from(walletKeys);
const rp = ReplayProtection.from(replayProtection, this.wasm.network());
return this.wasm.parse_transaction_with_wallet_keys(keys.wasm, rp.wasm) as ParsedTransaction;
const pubkeys = payGoPubkeys?.map((arg) => ECPair.from(arg).wasm);
return this.wasm.parse_transaction_with_wallet_keys(
keys.wasm,
rp.wasm,
pubkeys,
) as ParsedTransaction;
}

/**
Expand All @@ -93,12 +101,32 @@ export class BitGoPsbt {
* wallet than the inputs.
*
* @param walletKeys - The wallet keys to use for identification
* @param payGoPubkeys - Optional public keys for PayGo attestation verification
* @returns Array of parsed outputs
* @note This method does NOT validate wallet inputs. It only parses outputs.
*/
parseOutputsWithWalletKeys(walletKeys: WalletKeysArg): ParsedOutput[] {
parseOutputsWithWalletKeys(
walletKeys: WalletKeysArg,
payGoPubkeys?: ECPairArg[],
): ParsedOutput[] {
const keys = RootWalletKeys.from(walletKeys);
return this.wasm.parse_outputs_with_wallet_keys(keys.wasm) as ParsedOutput[];
const pubkeys = payGoPubkeys?.map((arg) => ECPair.from(arg).wasm);
return this.wasm.parse_outputs_with_wallet_keys(keys.wasm, pubkeys) as ParsedOutput[];
}

/**
* Add a PayGo attestation to a PSBT output
*
* This adds a cryptographic proof that the output address was authorized by a signing authority.
* The attestation is stored in PSBT proprietary key-values and can be verified later.
*
* @param outputIndex - The index of the output to add the attestation to
* @param entropy - 64 bytes of entropy (must be exactly 64 bytes)
* @param signature - ECDSA signature bytes (typically 65 bytes in recoverable format)
* @throws Error if output index is out of bounds or entropy is not 64 bytes
*/
addPayGoAttestation(outputIndex: number, entropy: Uint8Array, signature: Uint8Array): void {
this.wasm.add_paygo_attestation(outputIndex, entropy, signature);
}

/**
Expand Down
228 changes: 220 additions & 8 deletions packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -389,6 +389,37 @@ impl BitGoPsbt {
self.psbt().unsigned_tx.compute_txid()
}

/// Add a PayGo attestation to a PSBT output
///
/// # Arguments
/// * `output_index` - The index of the output to add the attestation to
/// * `entropy` - 64 bytes of entropy
/// * `signature` - ECDSA signature bytes
///
/// # Returns
/// * `Ok(())` if the attestation was successfully added
/// * `Err(String)` if the output index is out of bounds or entropy is invalid
pub fn add_paygo_attestation(
&mut self,
output_index: usize,
entropy: Vec<u8>,
signature: Vec<u8>,
) -> Result<(), String> {
let psbt = self.psbt_mut();

// Check output index bounds
if output_index >= psbt.outputs.len() {
return Err(format!(
"Output index {} out of bounds (total outputs: {})",
output_index,
psbt.outputs.len()
));
}

// Add the attestation
crate::paygo::add_paygo_attestation(&mut psbt.outputs[output_index], entropy, signature)
}

/// Helper function to create a MuSig2 context for an input
///
/// This validates that:
Expand Down Expand Up @@ -713,6 +744,7 @@ impl BitGoPsbt {
///
/// # Arguments
/// - `wallet_keys`: The wallet's root keys for deriving scripts
/// - `paygo_pubkeys`: Public keys for PayGo attestation verification
///
/// # Returns
/// - `Ok(Vec<ParsedOutput>)` with parsed outputs
Expand All @@ -724,6 +756,7 @@ impl BitGoPsbt {
fn parse_outputs(
&self,
wallet_keys: &crate::fixed_script_wallet::RootWalletKeys,
paygo_pubkeys: &[secp256k1::PublicKey],
) -> Result<Vec<ParsedOutput>, ParseTransactionError> {
let psbt = self.psbt();
let network = self.network();
Expand All @@ -734,12 +767,11 @@ impl BitGoPsbt {
.zip(psbt.outputs.iter())
.enumerate()
.map(|(output_index, (tx_output, psbt_output))| {
ParsedOutput::parse(psbt_output, tx_output, wallet_keys, network).map_err(|error| {
ParseTransactionError::Output {
ParsedOutput::parse(psbt_output, tx_output, wallet_keys, network, paygo_pubkeys)
.map_err(|error| ParseTransactionError::Output {
index: output_index,
error,
}
})
})
})
.collect()
}
Expand Down Expand Up @@ -1090,6 +1122,7 @@ impl BitGoPsbt {
///
/// # Arguments
/// - `wallet_keys`: A wallet's root keys for deriving scripts (can be different wallet than the inputs)
/// - `paygo_pubkeys`: Public keys for PayGo attestation verification (empty slice to skip verification)
///
/// # Returns
/// - `Ok(Vec<ParsedOutput>)` with parsed outputs
Expand All @@ -1101,15 +1134,17 @@ impl BitGoPsbt {
pub fn parse_outputs_with_wallet_keys(
&self,
wallet_keys: &crate::fixed_script_wallet::RootWalletKeys,
paygo_pubkeys: &[secp256k1::PublicKey],
) -> Result<Vec<ParsedOutput>, ParseTransactionError> {
self.parse_outputs(wallet_keys)
self.parse_outputs(wallet_keys, paygo_pubkeys)
}

/// Parse transaction with wallet keys to identify wallet inputs/outputs and calculate metrics
///
/// # Arguments
/// - `wallet_keys`: The wallet's root keys for deriving scripts
/// - `replay_protection`: Scripts that are allowed as inputs without wallet validation
/// - `paygo_pubkeys`: Public keys for PayGo attestation verification (empty slice to skip verification)
///
/// # Returns
/// - `Ok(ParsedTransaction)` with parsed inputs, outputs, spend amount, fee, and size
Expand All @@ -1118,12 +1153,13 @@ impl BitGoPsbt {
&self,
wallet_keys: &crate::fixed_script_wallet::RootWalletKeys,
replay_protection: &crate::fixed_script_wallet::ReplayProtection,
paygo_pubkeys: &[secp256k1::PublicKey],
) -> Result<ParsedTransaction, ParseTransactionError> {
let psbt = self.psbt();

// Parse inputs and outputs
let parsed_inputs = self.parse_inputs(wallet_keys, replay_protection)?;
let parsed_outputs = self.parse_outputs(wallet_keys)?;
let parsed_outputs = self.parse_outputs(wallet_keys, paygo_pubkeys)?;

// Calculate totals
let total_input_value = Self::sum_input_values(&parsed_inputs)?;
Expand Down Expand Up @@ -1788,6 +1824,182 @@ mod tests {
);
}, ignore: [BitcoinGold, BitcoinCash, Ecash, Zcash]);

#[test]
fn test_add_paygo_attestation() {
use crate::test_utils::fixtures;

// Load a test fixture
let fixture = fixtures::load_psbt_fixture_with_network(
Network::Bitcoin,
fixtures::SignatureState::Unsigned,
)
.unwrap();
let mut bitgo_psbt = fixture
.to_bitgo_psbt(Network::Bitcoin)
.expect("Failed to convert to BitGo PSBT");

// Add an output to the PSBT for testing
let psbt = bitgo_psbt.psbt_mut();
let output_index = psbt.outputs.len();
psbt.outputs
.push(miniscript::bitcoin::psbt::Output::default());
psbt.unsigned_tx.output.push(miniscript::bitcoin::TxOut {
value: miniscript::bitcoin::Amount::from_sat(10000),
script_pubkey: miniscript::bitcoin::ScriptBuf::from_hex(
"76a91479b000887626b294a914501a4cd226b58b23598388ac",
)
.unwrap(),
});

// Test fixtures
let entropy = vec![0u8; 64];
let signature = hex::decode(
"1fd62abac20bb963f5150aa4b3f4753c5f2f53ced5183ab7761d0c95c2820f6b\
b722b6d0d9adbab782d2d0d66402794b6bd6449dc26f634035ee388a2b5e7b53f6",
)
.unwrap();

// Add PayGo attestation
let result =
bitgo_psbt.add_paygo_attestation(output_index, entropy.clone(), signature.clone());
assert!(result.is_ok(), "Should add attestation successfully");

// Extract and verify
let address = "1CdWUVacSQQJ617HuNWByGiisEGXGNx2c";
let psbt = bitgo_psbt.psbt();

// Verify it was added (with address, no verification)
let has_attestation = crate::paygo::has_paygo_attestation_verify(
&psbt.outputs[output_index],
Some(address),
&[],
);
assert!(has_attestation.is_ok());
assert!(
!has_attestation.unwrap(),
"Should be false when no pubkeys provided"
);

let attestation =
crate::paygo::extract_paygo_attestation(&psbt.outputs[output_index], address).unwrap();
assert_eq!(attestation.entropy, entropy);
assert_eq!(attestation.signature, signature);
assert_eq!(attestation.address, address);
}

#[test]
fn test_add_paygo_attestation_invalid_index() {
use crate::test_utils::fixtures;

let fixture = fixtures::load_psbt_fixture_with_network(
Network::Bitcoin,
fixtures::SignatureState::Unsigned,
)
.unwrap();
let mut bitgo_psbt = fixture
.to_bitgo_psbt(Network::Bitcoin)
.expect("Failed to convert to BitGo PSBT");

let entropy = vec![0u8; 64];
let signature = vec![1u8; 65];

// Try to add to invalid index
let result = bitgo_psbt.add_paygo_attestation(999, entropy, signature);
assert!(result.is_err());
assert!(result.unwrap_err().contains("out of bounds"));
}

#[test]
fn test_add_paygo_attestation_invalid_entropy() {
use crate::test_utils::fixtures;

let fixture = fixtures::load_psbt_fixture_with_network(
Network::Bitcoin,
fixtures::SignatureState::Unsigned,
)
.unwrap();
let mut bitgo_psbt = fixture
.to_bitgo_psbt(Network::Bitcoin)
.expect("Failed to convert to BitGo PSBT");

// Add an output
let psbt = bitgo_psbt.psbt_mut();
psbt.outputs
.push(miniscript::bitcoin::psbt::Output::default());

let entropy = vec![0u8; 32]; // Wrong length
let signature = vec![1u8; 65];

// Try to add with invalid entropy
let result = bitgo_psbt.add_paygo_attestation(0, entropy, signature);
assert!(result.is_err());
assert!(result.unwrap_err().contains("Invalid entropy length"));
}

#[test]
fn test_paygo_parse_outputs_integration() {
use crate::test_utils::fixtures;

// Load fixture
let fixture = fixtures::load_psbt_fixture_with_network(
Network::Bitcoin,
fixtures::SignatureState::Unsigned,
)
.unwrap();
let mut bitgo_psbt = fixture
.to_bitgo_psbt(Network::Bitcoin)
.expect("Failed to convert to BitGo PSBT");

// Add an output with a known address
let psbt = bitgo_psbt.psbt_mut();
let output_index = psbt.outputs.len();
psbt.outputs
.push(miniscript::bitcoin::psbt::Output::default());
psbt.unsigned_tx.output.push(miniscript::bitcoin::TxOut {
value: miniscript::bitcoin::Amount::from_sat(10000),
script_pubkey: miniscript::bitcoin::ScriptBuf::from_hex(
"76a91479b000887626b294a914501a4cd226b58b23598388ac",
)
.unwrap(), // Address: 1CdWUVacSQQJ617HuNWByGiisEGXGNx2c
});

// Add PayGo attestation
let entropy = vec![0u8; 64];
let signature = hex::decode(
"1fd62abac20bb963f5150aa4b3f4753c5f2f53ced5183ab7761d0c95c2820f6b\
b722b6d0d9adbab782d2d0d66402794b6bd6449dc26f634035ee388a2b5e7b53f6",
)
.unwrap();
bitgo_psbt
.add_paygo_attestation(output_index, entropy, signature)
.unwrap();

// Parse outputs without PayGo pubkeys - should detect but not verify
let wallet_keys = fixture.get_wallet_xprvs().unwrap().to_root_wallet_keys();
let parsed_outputs = bitgo_psbt
.parse_outputs_with_wallet_keys(&wallet_keys, &[])
.unwrap();

// The PayGo output should have paygo: false (not verified)
assert!(!parsed_outputs[output_index].paygo);

// Parse outputs WITH PayGo pubkey - should verify
let pubkey_bytes =
hex::decode("02456f4f788b6af55eb9c54d88692cadef4babdbc34cde75218cc1d6b6de3dea2d")
.unwrap();
let pubkey = secp256k1::PublicKey::from_slice(&pubkey_bytes).unwrap();

// Note: Signature verification with bitcoinjs-message format is not fully working yet
// So parsing with pubkey will fail validation
let parsed_result = bitgo_psbt.parse_outputs_with_wallet_keys(&wallet_keys, &[pubkey]);

// We expect this to fail validation for now
assert!(
parsed_result.is_err(),
"Expected verification to fail with current signature format"
);
}

crate::test_psbt_fixtures!(test_parse_transaction_with_wallet_keys, network, format, {
// Load fixture and get PSBT
let fixture = fixtures::load_psbt_fixture_with_format(
Expand All @@ -1813,9 +2025,9 @@ mod tests {
.expect("Failed to parse replay protection output script"),
]);

// Parse the transaction
// Parse the transaction (no PayGo verification in tests)
let parsed = bitgo_psbt
.parse_transaction_with_wallet_keys(&wallet_keys, &replay_protection)
.parse_transaction_with_wallet_keys(&wallet_keys, &replay_protection, &[])
.expect("Failed to parse transaction");

// Basic validations
Expand Down
Loading