diff --git a/payjoin-ffi/csharp/IntegrationTests.cs b/payjoin-ffi/csharp/IntegrationTests.cs index afa13a144..2d5d8cb68 100644 --- a/payjoin-ffi/csharp/IntegrationTests.cs +++ b/payjoin-ffi/csharp/IntegrationTests.cs @@ -251,16 +251,16 @@ private static InputPair[] GetInputs(RpcClient rpc) var amountBtc = utxo.GetProperty("amount").GetDouble(); var valueSat = (ulong)Math.Round(amountBtc * 100_000_000.0); - var txin = new TxIn( - new OutPoint(txid, vout), - Array.Empty(), - 0, - Array.Empty()); - var txout = new TxOut(valueSat, Convert.FromHexString(scriptPubKeyHex)); - var psbtIn = new PsbtInput(txout, null, null); - - inputs.Add(new InputPair(txin, psbtIn, null)); + // OP_1 OP_PUSHBYTES_32 is a P2TR output + if (scriptPubKeyHex.StartsWith("5120")) + { + inputs.Add(InputPair.NewP2trKeyspend(txout, new OutPoint(txid, vout))); + } + else + { + inputs.Add(InputPair.NewP2wpkh(txout, new OutPoint(txid, vout))); + } } return inputs.ToArray(); @@ -687,13 +687,16 @@ public void TestFfiValidation() /// restart because every state transition is saved to a persister first. /// [Theory] - [InlineData(TransitionMode.Callback)] - [InlineData(TransitionMode.Nonblocking)] - public async Task TestIntegrationV2ToV2(TransitionMode mode) + [InlineData(TransitionMode.Callback, false)] + [InlineData(TransitionMode.Nonblocking, false)] + [InlineData(TransitionMode.Callback, true)] + public async Task TestIntegrationV2ToV2(TransitionMode mode, bool taproot) { var cancellationToken = TestContext.Current.CancellationToken; - using var env = PayjoinMethods.InitBitcoindSenderReceiver(); + using var env = taproot + ? PayjoinMethods.InitBitcoindSenderReceiverTaproot() + : PayjoinMethods.InitBitcoindSenderReceiver(); using var bitcoind = env.GetBitcoind(); using var receiver = env.GetReceiver(); using var sender = env.GetSender(); diff --git a/payjoin-ffi/dart/lib/test_utils.dart b/payjoin-ffi/dart/lib/test_utils.dart index 6cbe4c0af..4e128063c 100644 --- a/payjoin-ffi/dart/lib/test_utils.dart +++ b/payjoin-ffi/dart/lib/test_utils.dart @@ -7,4 +7,5 @@ export "payjoin.dart" RpcClient, TestServices, initBitcoindSenderReceiver, + initBitcoindSenderReceiverTaproot, originalPsbt; diff --git a/payjoin-ffi/dart/test/test_payjoin_integration_test.dart b/payjoin-ffi/dart/test/test_payjoin_integration_test.dart index 632005062..43a91ec82 100644 --- a/payjoin-ffi/dart/test/test_payjoin_integration_test.dart +++ b/payjoin-ffi/dart/test/test_payjoin_integration_test.dart @@ -209,24 +209,19 @@ List get_inputs(payjoin.RpcClient rpc_connection) { final amountBtc = utxo["amount"] as num; final amountSat = (amountBtc * 100000000).round(); - final txin = payjoin.TxIn( - previousOutput: payjoin.OutPoint(txid: txid, vout: vout), - scriptSig: Uint8List(0), - sequence: 0, - witness: [], - ); - final witnessUtxo = payjoin.TxOut( + final txout = payjoin.TxOut( valueSat: amountSat, scriptPubkey: scriptPubKey, ); - final psbt_in = payjoin.PsbtInput( - witnessUtxo: witnessUtxo, - redeemScript: null, - witnessScript: null, - ); - inputs.add( - payjoin.InputPair(txin: txin, psbtin: psbt_in, expectedWeight: null), - ); + final outpoint = payjoin.OutPoint(txid: txid, vout: vout); + // OP_1 OP_PUSHBYTES_32 is a P2TR output + if ((utxo["scriptPubKey"] as String).startsWith("5120")) { + inputs.add( + payjoin.InputPair.newP2trKeyspend(txout: txout, outpoint: outpoint), + ); + } else { + inputs.add(payjoin.InputPair.newP2wpkh(txout: txout, outpoint: outpoint)); + } } return inputs; @@ -496,8 +491,12 @@ Future process_receiver_proposal( throw Exception("Unknown receiver state: $receiver"); } -Future run_integration_v2_to_v2(TransitionMode mode) async { - env = test_utils.initBitcoindSenderReceiver(); +Future run_integration_v2_to_v2( + TransitionMode mode, { + test_utils.BitcoindEnv Function() initEnv = + test_utils.initBitcoindSenderReceiver, +}) async { + env = initEnv(); bitcoind = env.getBitcoind(); receiver = env.getReceiver(); sender = env.getSender(); @@ -777,5 +776,14 @@ void main() { () async => run_integration_v2_to_v2(TransitionMode.nonblocking), timeout: const Timeout(Duration(minutes: 5)), ); + + test( + 'Test integration v2 to v2 (taproot)', + () async => run_integration_v2_to_v2( + TransitionMode.callback, + initEnv: test_utils.initBitcoindSenderReceiverTaproot, + ), + timeout: const Timeout(Duration(minutes: 5)), + ); }); } diff --git a/payjoin-ffi/javascript/test-utils/index.js b/payjoin-ffi/javascript/test-utils/index.js index 6e3e8d156..66256caaf 100644 --- a/payjoin-ffi/javascript/test-utils/index.js +++ b/payjoin-ffi/javascript/test-utils/index.js @@ -84,5 +84,6 @@ export const { RpcClient, TestServices, initBitcoindSenderReceiver, + initBitcoindSenderReceiverTaproot, originalPsbt, } = nativeBinding; diff --git a/payjoin-ffi/javascript/test-utils/src/lib.rs b/payjoin-ffi/javascript/test-utils/src/lib.rs index a8a37ad65..b760472bf 100644 --- a/payjoin-ffi/javascript/test-utils/src/lib.rs +++ b/payjoin-ffi/javascript/test-utils/src/lib.rs @@ -162,3 +162,20 @@ pub fn init_bitcoind_sender_receiver() -> Result { sender: RpcClient { inner: Arc::new(sender) }, }) } + +/// Like `init_bitcoind_sender_receiver`, but both wallets are funded with +/// Taproot (P2TR) coins. +#[napi] +pub fn init_bitcoind_sender_receiver_taproot() -> Result { + let (bitcoind, receiver, sender) = payjoin_test_utils::init_bitcoind_sender_receiver( + Some(AddressType::Bech32m), + Some(AddressType::Bech32m), + ) + .map_err(|e| Error::from_reason(format!("Failed to initialize bitcoind: {}", e)))?; + + Ok(BitcoindEnv { + bitcoind: BitcoindInstance { _inner: Arc::new(bitcoind) }, + receiver: RpcClient { inner: Arc::new(receiver) }, + sender: RpcClient { inner: Arc::new(sender) }, + }) +} diff --git a/payjoin-ffi/javascript/test/integration.test.ts b/payjoin-ffi/javascript/test/integration.test.ts index 98d8ca60e..1b091da5d 100644 --- a/payjoin-ffi/javascript/test/integration.test.ts +++ b/payjoin-ffi/javascript/test/integration.test.ts @@ -226,26 +226,21 @@ function getInputs( const utxos: Utxo[] = JSON.parse(rpcConnection.call("listunspent", [])); const inputs: PJ<"InputPair">[] = []; for (const utxo of utxos) { - const txin = payjoin.TxIn.create({ - previousOutput: payjoin.OutPoint.create({ - txid: utxo.txid, - vout: utxo.vout, - }), - scriptSig: new Uint8Array([]).buffer, - sequence: 0, - witness: [], - }); const txOut = payjoin.TxOut.create({ valueSat: BigInt(Math.round(utxo.amount * 100_000_000)), // @ts-ignore scriptPubkey: Buffer.from(utxo.scriptPubKey, "hex"), }); - const psbtIn = payjoin.PsbtInput.create({ - witnessUtxo: txOut, - redeemScript: undefined, - witnessScript: undefined, + const outpoint = payjoin.OutPoint.create({ + txid: utxo.txid, + vout: utxo.vout, }); - inputs.push(new payjoin.InputPair(txin, psbtIn, undefined)); + // OP_1 OP_PUSHBYTES_32 is a P2TR output + if (utxo.scriptPubKey.startsWith("5120")) { + inputs.push(payjoin.InputPair.newP2trKeyspend(txOut, outpoint)); + } else { + inputs.push(payjoin.InputPair.newP2wpkh(txOut, outpoint)); + } } return inputs; } @@ -617,8 +612,9 @@ function testFfiValidation(payjoin: PayjoinModule): void { async function testIntegrationV2ToV2( payjoin: PayjoinModule, mode: TransitionMode, + initEnv: () => testUtils.BitcoindEnv = testUtils.initBitcoindSenderReceiver, ): Promise { - const env = testUtils.initBitcoindSenderReceiver(); + const env = initEnv(); const receiver = env.getReceiver(); const sender = env.getSender(); @@ -773,11 +769,21 @@ async function runTests(): Promise { testFfiValidation(nodejsPayjoin); await testIntegrationV2ToV2(nodejsPayjoin, "callback"); await testIntegrationV2ToV2(nodejsPayjoin, "nonblocking"); + await testIntegrationV2ToV2( + nodejsPayjoin, + "callback", + testUtils.initBitcoindSenderReceiverTaproot, + ); await webUniffiInitAsync(); testFfiValidation(webPayjoin); await testIntegrationV2ToV2(webPayjoin, "callback"); await testIntegrationV2ToV2(webPayjoin, "nonblocking"); + await testIntegrationV2ToV2( + webPayjoin, + "callback", + testUtils.initBitcoindSenderReceiverTaproot, + ); } runTests().catch((error: unknown) => { diff --git a/payjoin-ffi/kotlin/src/test/kotlin/org/payjoindevkit/IntegrationTests.kt b/payjoin-ffi/kotlin/src/test/kotlin/org/payjoindevkit/IntegrationTests.kt index 16e855c6e..3816ff8c5 100644 --- a/payjoin-ffi/kotlin/src/test/kotlin/org/payjoindevkit/IntegrationTests.kt +++ b/payjoin-ffi/kotlin/src/test/kotlin/org/payjoindevkit/IntegrationTests.kt @@ -81,8 +81,12 @@ class IntegrationTests { @Test fun v2ToV2PayjoinNonblocking() = runScenario(mode = TransitionMode.Nonblocking) + @Test + fun v2ToV2TaprootPayjoin() = runScenario(initEnv = ::initBitcoindSenderReceiverTaproot) + private fun runScenario( mode: TransitionMode = TransitionMode.Callback, + initEnv: () -> BitcoindEnv = ::initBitcoindSenderReceiver, cancelReceiver: ((PayjoinProposal, InMemoryReceiverPersister) -> Unit)? = null, ) { initTracing() @@ -92,7 +96,7 @@ class IntegrationTests { val relay = services.ohttpRelayUrl() services.fetchOhttpKeys().use { ohttpKeys -> TestHttp(services).use { http -> - initBitcoindSenderReceiver().use { env -> + initEnv().use { env -> env.getSender().use { senderRpc -> env.getReceiver().use { receiverRpc -> runV2ToV2( @@ -477,18 +481,13 @@ private fun getInputs(rpcConnection: RpcClient): List { val vout = obj.getValue("vout").jsonPrimitive.double.toInt().toUInt() val scriptPubkey = HexFormat.of().parseHex(obj.getValue("scriptPubKey").jsonPrimitive.content) val amountSat = kotlin.math.round(obj.getValue("amount").jsonPrimitive.double * 100_000_000.0).toULong() - val txin = TxIn( - previousOutput = OutPoint(txid, vout), - scriptSig = ByteArray(0), - sequence = 0u, - witness = emptyList(), - ) - val psbtIn = PsbtInput( - witnessUtxo = TxOut(amountSat, scriptPubkey), - redeemScript = null, - witnessScript = null, - ) - InputPair(txin, psbtIn, null) + val txout = TxOut(amountSat, scriptPubkey) + // OP_1 OP_PUSHBYTES_32 is a P2TR output + if (obj.getValue("scriptPubKey").jsonPrimitive.content.startsWith("5120")) { + InputPair.newP2trKeyspend(txout, OutPoint(txid, vout)) + } else { + InputPair.newP2wpkh(txout, OutPoint(txid, vout)) + } } } diff --git a/payjoin-ffi/python/test/test_payjoin_integration_test.py b/payjoin-ffi/python/test/test_payjoin_integration_test.py index 11ef4e156..689e4dc12 100644 --- a/payjoin-ffi/python/test/test_payjoin_integration_test.py +++ b/payjoin-ffi/python/test/test_payjoin_integration_test.py @@ -488,6 +488,12 @@ async def test_integration_v2_to_v2_callback(self): async def test_integration_v2_to_v2_nonblocking(self): await self._run_integration_v2_to_v2("nonblocking") + async def test_integration_v2_to_v2_taproot(self): + self.env = init_bitcoind_sender_receiver_taproot() + self.receiver = self.env.get_receiver() + self.sender = self.env.get_sender() + await self._run_integration_v2_to_v2("callback") + def build_sweep_psbt(sender: RpcClient, pj_uri: PjUri) -> str: outputs = {} @@ -526,17 +532,13 @@ def get_inputs(rpc_connection: RpcClient) -> list[InputPair]: script_pubkey = bytes.fromhex(utxo["scriptPubKey"]) amount_sat = round(utxo["amount"] * 100_000_000) - txin = TxIn( - previous_output=OutPoint(txid=txid, vout=vout), - script_sig=bytes(), - sequence=0, - witness=[], - ) - witness_utxo = TxOut(value_sat=amount_sat, script_pubkey=script_pubkey) - psbt_in = PsbtInput( - witness_utxo=witness_utxo, redeem_script=None, witness_script=None - ) - inputs.append(InputPair(txin=txin, psbtin=psbt_in, expected_weight=None)) + txout = TxOut(value_sat=amount_sat, script_pubkey=script_pubkey) + outpoint = OutPoint(txid=txid, vout=vout) + # OP_1 OP_PUSHBYTES_32 is a P2TR output + if utxo["scriptPubKey"].startswith("5120"): + inputs.append(InputPair.new_p2tr_keyspend(txout=txout, outpoint=outpoint)) + else: + inputs.append(InputPair.new_p2wpkh(txout=txout, outpoint=outpoint)) return inputs diff --git a/payjoin-ffi/src/receive/error.rs b/payjoin-ffi/src/receive/error.rs index a04c2dfe9..8d100b51a 100644 --- a/payjoin-ffi/src/receive/error.rs +++ b/payjoin-ffi/src/receive/error.rs @@ -257,6 +257,9 @@ pub enum InputPairError { /// Input failed validation in the FFI layer. #[error("Invalid input: {0}")] FfiValidation(FfiValidationError), + /// Provided transaction could not be consensus decoded. + #[error("Invalid transaction: {0}")] + InvalidTransaction(String), } impl InputPairError { diff --git a/payjoin-ffi/src/receive/mod.rs b/payjoin-ffi/src/receive/mod.rs index 92fea3d9c..b13dacb0d 100644 --- a/payjoin-ffi/src/receive/mod.rs +++ b/payjoin-ffi/src/receive/mod.rs @@ -1381,6 +1381,15 @@ pub struct InputPair(payjoin::receive::InputPair); #[uniffi::export] impl InputPair { + /// Creates a new InputPair from a raw transaction input and PSBT input, validating + /// that both refer to the same UTXO. + /// + /// Prefer a typed constructor such as [`InputPair::new_p2wpkh`] or + /// [`InputPair::new_p2tr_keyspend`], which build both halves and set the expected + /// input weight for you. + /// + /// `expected_weight` is required when the weight cannot be predicted from the + /// input itself, such as an unsigned P2TR input, and must be `None` otherwise. #[uniffi::constructor] pub fn new( txin: TxIn, @@ -1395,6 +1404,85 @@ impl InputPair { .map_err(|err| InputPairError::InvalidPsbtInput(Arc::new(err.into()))) } + /// Constructs a new InputPair for spending a legacy P2PKH output. + /// + /// `non_witness_utxo` is the consensus encoded transaction that created the + /// output being spent. + #[uniffi::constructor] + pub fn new_p2pkh( + non_witness_utxo: Vec, + outpoint: OutPoint, + ) -> Result { + let non_witness_utxo: payjoin::bitcoin::Transaction = + payjoin::bitcoin::consensus::deserialize(&non_witness_utxo) + .map_err(|e| InputPairError::InvalidTransaction(e.to_string()))?; + for txout in &non_witness_utxo.output { + validate_amount_sat(txout.value.to_sat())?; + } + let outpoint = outpoint.into_core()?; + payjoin::receive::InputPair::new_p2pkh(non_witness_utxo, outpoint) + .map(Self) + .map_err(|err| InputPairError::InvalidPsbtInput(Arc::new(err.into()))) + } + + /// Constructs a new InputPair for spending a native SegWit P2WPKH output. + #[uniffi::constructor] + pub fn new_p2wpkh(txout: TxOut, outpoint: OutPoint) -> Result { + let txout = txout.into_core()?; + let outpoint = outpoint.into_core()?; + payjoin::receive::InputPair::new_p2wpkh(txout, outpoint) + .map(Self) + .map_err(|err| InputPairError::InvalidPsbtInput(Arc::new(err.into()))) + } + + /// Constructs a new InputPair for spending a native SegWit P2WSH output. + /// + /// `expected_weight` must accurately reflect the witness script spend, or fee + /// calculation will be wrong. + #[uniffi::constructor] + pub fn new_p2wsh( + txout: TxOut, + outpoint: OutPoint, + expected_weight: Weight, + ) -> Result { + let txout = txout.into_core()?; + let outpoint = outpoint.into_core()?; + let expected_weight = expected_weight.into_core()?; + payjoin::receive::InputPair::new_p2wsh(txout, outpoint, expected_weight) + .map(Self) + .map_err(|err| InputPairError::InvalidPsbtInput(Arc::new(err.into()))) + } + + /// Constructs a new InputPair for spending a P2TR output via the key path, + /// using the default taproot sighash (64-byte signature) and no annex. + #[uniffi::constructor] + pub fn new_p2tr_keyspend(txout: TxOut, outpoint: OutPoint) -> Result { + let txout = txout.into_core()?; + let outpoint = outpoint.into_core()?; + payjoin::receive::InputPair::new_p2tr_keyspend(txout, outpoint) + .map(Self) + .map_err(|err| InputPairError::InvalidPsbtInput(Arc::new(err.into()))) + } + + /// Constructs a new InputPair for spending a P2TR output via the script path. + /// + /// `expected_weight` must accurately reflect the script path spend, or fee + /// calculation will be wrong. Use [`InputPair::new_p2tr_keyspend`] for key path + /// spends. + #[uniffi::constructor] + pub fn new_p2tr_scriptpath_spend( + txout: TxOut, + outpoint: OutPoint, + expected_weight: Weight, + ) -> Result { + let txout = txout.into_core()?; + let outpoint = outpoint.into_core()?; + let expected_weight = expected_weight.into_core()?; + payjoin::receive::InputPair::new_p2tr_scriptpath_spend(txout, outpoint, expected_weight) + .map(Self) + .map_err(|err| InputPairError::InvalidPsbtInput(Arc::new(err.into()))) + } + /// Returns the outpoint spent by this input pair. pub fn outpoint(&self) -> OutPoint { self.0.outpoint().into() } } @@ -2060,3 +2148,196 @@ impl payjoin::persist::AsyncSessionPersister for AsyncCallbackPersisterAdapter { async move { persister.close().await } } } + +#[cfg(test)] +mod tests { + use super::*; + + const TXID: &str = "0000000000000000000000000000000000000000000000000000000000000001"; + + fn outpoint() -> OutPoint { OutPoint { txid: TXID.to_string(), vout: 0 } } + + fn p2wpkh_txout() -> TxOut { + let mut script_pubkey = vec![0x00, 0x14]; + script_pubkey.extend([0x01; 20]); + TxOut { value_sat: 100_000, script_pubkey } + } + + fn p2tr_txout() -> TxOut { + let mut script_pubkey = vec![0x51, 0x20]; + script_pubkey.extend([0x01; 32]); + TxOut { value_sat: 100_000, script_pubkey } + } + + #[test] + fn typed_constructors_accept_matching_script_types() { + InputPair::new_p2wpkh(p2wpkh_txout(), outpoint()).expect("valid p2wpkh input"); + InputPair::new_p2tr_keyspend(p2tr_txout(), outpoint()).expect("valid p2tr key path input"); + InputPair::new_p2tr_scriptpath_spend( + p2tr_txout(), + outpoint(), + Weight { weight_units: 200 }, + ) + .expect("valid p2tr script path input"); + } + + #[test] + fn typed_constructors_reject_mismatched_script_types() { + assert!(matches!( + InputPair::new_p2wpkh(p2tr_txout(), outpoint()), + Err(InputPairError::InvalidPsbtInput(_)) + )); + assert!(matches!( + InputPair::new_p2tr_keyspend(p2wpkh_txout(), outpoint()), + Err(InputPairError::InvalidPsbtInput(_)) + )); + assert!(matches!( + InputPair::new_p2tr_scriptpath_spend( + p2wpkh_txout(), + outpoint(), + Weight { weight_units: 200 } + ), + Err(InputPairError::InvalidPsbtInput(_)) + )); + } + + #[test] + fn typed_constructors_reject_invalid_ffi_values() { + let bad_outpoint = OutPoint { txid: "not-a-txid".to_string(), vout: 0 }; + assert!(matches!( + InputPair::new_p2wpkh(p2wpkh_txout(), bad_outpoint), + Err(InputPairError::InvalidOutPoint { .. }) + )); + + let too_much = TxOut { value_sat: u64::MAX, ..p2tr_txout() }; + assert!(matches!( + InputPair::new_p2tr_keyspend(too_much, outpoint()), + Err(InputPairError::FfiValidation(FfiValidationError::AmountOutOfRange { .. })) + )); + + assert!(matches!( + InputPair::new_p2tr_scriptpath_spend( + p2tr_txout(), + outpoint(), + Weight { weight_units: u64::MAX } + ), + Err(InputPairError::FfiValidation(FfiValidationError::WeightOutOfRange { .. })) + )); + } + + #[test] + fn p2wsh_constructor_validates_script_type() { + let mut script_pubkey = vec![0x00, 0x20]; + script_pubkey.extend([0x01; 32]); + let p2wsh_txout = TxOut { value_sat: 100_000, script_pubkey }; + InputPair::new_p2wsh(p2wsh_txout, outpoint(), Weight { weight_units: 300 }) + .expect("valid p2wsh input"); + + assert!(matches!( + InputPair::new_p2wsh(p2wpkh_txout(), outpoint(), Weight { weight_units: 300 }), + Err(InputPairError::InvalidPsbtInput(_)) + )); + } + + /// An unsigned P2TR input has no witness to predict its weight from, so the + /// generic constructor fails without a caller supplied weight while the key + /// path constructor supplies it. + #[test] + fn p2tr_keyspend_does_not_need_caller_weight() { + let txin = TxIn { + previous_output: outpoint(), + script_sig: vec![], + sequence: u32::MAX, + witness: vec![], + }; + let psbtin = PsbtInput { + witness_utxo: Some(p2tr_txout()), + redeem_script: None, + witness_script: None, + }; + assert!(matches!( + InputPair::new(txin, psbtin, None), + Err(InputPairError::InvalidPsbtInput(_)) + )); + + InputPair::new_p2tr_keyspend(p2tr_txout(), outpoint()).expect("valid p2tr key path input"); + } + + fn p2pkh_script_pubkey() -> Vec { + let mut script_pubkey = vec![0x76, 0xa9, 0x14]; + script_pubkey.extend([0x01; 20]); + script_pubkey.extend([0x88, 0xac]); + script_pubkey + } + + fn funding_tx(script_pubkey: Vec) -> payjoin::bitcoin::Transaction { + payjoin::bitcoin::Transaction { + version: payjoin::bitcoin::transaction::Version::TWO, + lock_time: payjoin::bitcoin::absolute::LockTime::ZERO, + input: vec![payjoin::bitcoin::TxIn::default()], + output: vec![payjoin::bitcoin::TxOut { + value: payjoin::bitcoin::Amount::from_sat(100_000), + script_pubkey: payjoin::bitcoin::ScriptBuf::from_bytes(script_pubkey), + }], + } + } + + #[test] + fn p2pkh_constructor_accepts_funding_transaction() { + let tx = funding_tx(p2pkh_script_pubkey()); + let outpoint = OutPoint { txid: tx.compute_txid().to_string(), vout: 0 }; + InputPair::new_p2pkh(payjoin::bitcoin::consensus::serialize(&tx), outpoint) + .expect("valid p2pkh input"); + } + + #[test] + fn p2pkh_constructor_rejects_invalid_funding_transaction() { + let tx = funding_tx(p2pkh_script_pubkey()); + let tx_bytes = payjoin::bitcoin::consensus::serialize(&tx); + let txid = tx.compute_txid().to_string(); + + // Output index past the end of the funding transaction + assert!(matches!( + InputPair::new_p2pkh(tx_bytes.clone(), OutPoint { txid, vout: 1 }), + Err(InputPairError::InvalidPsbtInput(_)) + )); + + // Funding transaction does not hash to the outpoint txid + assert!(matches!( + InputPair::new_p2pkh(tx_bytes.clone(), outpoint()), + Err(InputPairError::InvalidPsbtInput(_)) + )); + + // Funding output is not P2PKH + let p2wpkh_tx = funding_tx(p2wpkh_txout().script_pubkey); + assert!(matches!( + InputPair::new_p2pkh( + payjoin::bitcoin::consensus::serialize(&p2wpkh_tx), + OutPoint { txid: p2wpkh_tx.compute_txid().to_string(), vout: 0 } + ), + Err(InputPairError::InvalidPsbtInput(_)) + )); + + assert!(matches!( + InputPair::new_p2pkh(vec![0x00], outpoint()), + Err(InputPairError::InvalidTransaction(_)) + )); + + let mut too_much = funding_tx(p2pkh_script_pubkey()); + too_much.output[0].value = payjoin::bitcoin::Amount::from_sat(u64::MAX); + assert!(matches!( + InputPair::new_p2pkh( + payjoin::bitcoin::consensus::serialize(&too_much), + OutPoint { txid: too_much.compute_txid().to_string(), vout: 0 } + ), + Err(InputPairError::FfiValidation(FfiValidationError::AmountOutOfRange { .. })) + )); + + let mut trailing_bytes = tx_bytes; + trailing_bytes.push(0x00); + assert!(matches!( + InputPair::new_p2pkh(trailing_bytes, outpoint()), + Err(InputPairError::InvalidTransaction(_)) + )); + } +} diff --git a/payjoin-ffi/src/test_utils.rs b/payjoin-ffi/src/test_utils.rs index 04f751ce9..a577dc7ea 100644 --- a/payjoin-ffi/src/test_utils.rs +++ b/payjoin-ffi/src/test_utils.rs @@ -193,6 +193,23 @@ pub fn init_bitcoind_sender_receiver() -> Result, FfiError> { })) } +/// Like [`init_bitcoind_sender_receiver`], but both wallets are funded with +/// Taproot (P2TR) coins. +#[uniffi::export] +pub fn init_bitcoind_sender_receiver_taproot() -> Result, FfiError> { + let (bitcoind, receiver, sender) = payjoin_test_utils::init_bitcoind_sender_receiver( + Some(AddressType::Bech32m), + Some(AddressType::Bech32m), + ) + .map_err(|e| FfiError::InitError(e.to_string()))?; + + Ok(Arc::new(BitcoindEnv { + bitcoind: Arc::new(BitcoindInstance { _inner: bitcoind }), + receiver: Arc::new(RpcClient { inner: receiver }), + sender: Arc::new(RpcClient { inner: sender }), + })) +} + #[uniffi::export] pub fn example_url() -> String { EXAMPLE_URL.to_string() }