From 6aa80c2e5094300ce69949935145d57131b70262 Mon Sep 17 00:00:00 2001 From: chavic Date: Fri, 9 Oct 2026 12:39:48 +0200 Subject: [PATCH 1/6] Expose P2WPKH and P2TR InputPair constructors Bindings could only build an InputPair through the generic `new`, which needs a caller-supplied weight for inputs whose weight cannot be predicted. An unsigned P2TR input has no witness, so `new` fails for every Taproot coin unless the caller passes a weight, and binding users such as the BTCPay plugin pass none. Wrap the core `new_p2wpkh`, `new_p2tr_keyspend` and `new_p2tr_scriptpath_spend` constructors so bindings can contribute these inputs without building the TxIn and PSBT input by hand. The key path constructor supplies the expected weight itself. --- payjoin-ffi/src/receive/mod.rs | 150 +++++++++++++++++++++++++++++++++ 1 file changed, 150 insertions(+) diff --git a/payjoin-ffi/src/receive/mod.rs b/payjoin-ffi/src/receive/mod.rs index 92fea3d9c..8150a91ab 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,46 @@ impl InputPair { .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 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 +2109,104 @@ 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 { .. })) + )); + } + + /// 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"); + } +} From 2e5eb3ef2898bad9017466ac05371a0c3ca478f0 Mon Sep 17 00:00:00 2001 From: chavic Date: Fri, 9 Oct 2026 14:36:00 +0200 Subject: [PATCH 2/6] Use typed InputPair constructors in binding tests The binding integration tests built each receiver input by hand from a TxIn and PSBT input and passed them to the generic constructor. They are also the example code binding users copy, and hand building is how Taproot receivers end up without an input weight. Build the receiver's P2WPKH inputs with `new_p2wpkh` instead. --- payjoin-ffi/csharp/IntegrationTests.cs | 10 +--------- .../test/test_payjoin_integration_test.dart | 18 +++--------------- .../javascript/test/integration.test.ts | 18 ++++-------------- .../org/payjoindevkit/IntegrationTests.kt | 13 +------------ .../test/test_payjoin_integration_test.py | 14 +++----------- 5 files changed, 12 insertions(+), 61 deletions(-) diff --git a/payjoin-ffi/csharp/IntegrationTests.cs b/payjoin-ffi/csharp/IntegrationTests.cs index afa13a144..88a10c9ec 100644 --- a/payjoin-ffi/csharp/IntegrationTests.cs +++ b/payjoin-ffi/csharp/IntegrationTests.cs @@ -251,16 +251,8 @@ 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)); + inputs.Add(InputPair.NewP2wpkh(txout, new OutPoint(txid, vout))); } return inputs.ToArray(); diff --git a/payjoin-ffi/dart/test/test_payjoin_integration_test.dart b/payjoin-ffi/dart/test/test_payjoin_integration_test.dart index 632005062..c6bc3d50c 100644 --- a/payjoin-ffi/dart/test/test_payjoin_integration_test.dart +++ b/payjoin-ffi/dart/test/test_payjoin_integration_test.dart @@ -209,24 +209,12 @@ 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); + inputs.add(payjoin.InputPair.newP2wpkh(txout: txout, outpoint: outpoint)); } return inputs; diff --git a/payjoin-ffi/javascript/test/integration.test.ts b/payjoin-ffi/javascript/test/integration.test.ts index 98d8ca60e..8c2a4d586 100644 --- a/payjoin-ffi/javascript/test/integration.test.ts +++ b/payjoin-ffi/javascript/test/integration.test.ts @@ -226,26 +226,16 @@ 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)); + inputs.push(payjoin.InputPair.newP2wpkh(txOut, outpoint)); } return inputs; } 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..6f3daa05f 100644 --- a/payjoin-ffi/kotlin/src/test/kotlin/org/payjoindevkit/IntegrationTests.kt +++ b/payjoin-ffi/kotlin/src/test/kotlin/org/payjoindevkit/IntegrationTests.kt @@ -477,18 +477,7 @@ 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) + InputPair.newP2wpkh(TxOut(amountSat, scriptPubkey), 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..0a35991a8 100644 --- a/payjoin-ffi/python/test/test_payjoin_integration_test.py +++ b/payjoin-ffi/python/test/test_payjoin_integration_test.py @@ -526,17 +526,9 @@ 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) + inputs.append(InputPair.new_p2wpkh(txout=txout, outpoint=outpoint)) return inputs From 114efe37e28b2f0f760b87f826fada1977353cf1 Mon Sep 17 00:00:00 2001 From: chavic Date: Fri, 9 Oct 2026 14:38:25 +0200 Subject: [PATCH 3/6] Add Taproot bitcoind test util for bindings The bindings' bitcoind test util always funds both wallets with P2WPKH coins, so binding tests cannot exercise a receiver contributing a Taproot input. Add a variant that funds both wallets with P2TR coins, mirroring the core `v2_to_v2_taproot` integration test setup. JavaScript has its own napi test utils crate, so the variant is added there too. --- payjoin-ffi/dart/lib/test_utils.dart | 1 + payjoin-ffi/javascript/test-utils/index.js | 1 + payjoin-ffi/javascript/test-utils/src/lib.rs | 17 +++++++++++++++++ payjoin-ffi/src/test_utils.rs | 17 +++++++++++++++++ 4 files changed, 36 insertions(+) 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/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/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() } From d9da1d9d0384c696f4d278ab913a0d46bfb1b4e1 Mon Sep 17 00:00:00 2001 From: chavic Date: Fri, 9 Oct 2026 14:54:18 +0200 Subject: [PATCH 4/6] Test Taproot input contribution in bindings A receiver holding Taproot coins could not contribute an input through the bindings: an unsigned P2TR input has no witness to predict its weight from, so the generic constructor rejected it without a caller supplied weight. Run the v2 to v2 integration flow with Taproot wallets in every binding, building P2TR receiver inputs with `new_p2tr_keyspend`, so the payjoin is signed and accepted by bitcoind end to end. --- payjoin-ffi/csharp/IntegrationTests.cs | 21 +++++++++++---- .../test/test_payjoin_integration_test.dart | 26 ++++++++++++++++--- .../javascript/test/integration.test.ts | 20 ++++++++++++-- .../org/payjoindevkit/IntegrationTests.kt | 14 ++++++++-- .../test/test_payjoin_integration_test.py | 12 ++++++++- 5 files changed, 80 insertions(+), 13 deletions(-) diff --git a/payjoin-ffi/csharp/IntegrationTests.cs b/payjoin-ffi/csharp/IntegrationTests.cs index 88a10c9ec..2d5d8cb68 100644 --- a/payjoin-ffi/csharp/IntegrationTests.cs +++ b/payjoin-ffi/csharp/IntegrationTests.cs @@ -252,7 +252,15 @@ private static InputPair[] GetInputs(RpcClient rpc) var valueSat = (ulong)Math.Round(amountBtc * 100_000_000.0); var txout = new TxOut(valueSat, Convert.FromHexString(scriptPubKeyHex)); - inputs.Add(InputPair.NewP2wpkh(txout, new OutPoint(txid, vout))); + // 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(); @@ -679,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/test/test_payjoin_integration_test.dart b/payjoin-ffi/dart/test/test_payjoin_integration_test.dart index c6bc3d50c..43a91ec82 100644 --- a/payjoin-ffi/dart/test/test_payjoin_integration_test.dart +++ b/payjoin-ffi/dart/test/test_payjoin_integration_test.dart @@ -214,7 +214,14 @@ List get_inputs(payjoin.RpcClient rpc_connection) { scriptPubkey: scriptPubKey, ); final outpoint = payjoin.OutPoint(txid: txid, vout: vout); - inputs.add(payjoin.InputPair.newP2wpkh(txout: txout, outpoint: outpoint)); + // 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; @@ -484,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(); @@ -765,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/integration.test.ts b/payjoin-ffi/javascript/test/integration.test.ts index 8c2a4d586..1b091da5d 100644 --- a/payjoin-ffi/javascript/test/integration.test.ts +++ b/payjoin-ffi/javascript/test/integration.test.ts @@ -235,7 +235,12 @@ function getInputs( txid: utxo.txid, vout: utxo.vout, }); - inputs.push(payjoin.InputPair.newP2wpkh(txOut, outpoint)); + // 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; } @@ -607,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(); @@ -763,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 6f3daa05f..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,7 +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() - InputPair.newP2wpkh(TxOut(amountSat, scriptPubkey), OutPoint(txid, vout)) + 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 0a35991a8..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 = {} @@ -528,7 +534,11 @@ def get_inputs(rpc_connection: RpcClient) -> list[InputPair]: txout = TxOut(value_sat=amount_sat, script_pubkey=script_pubkey) outpoint = OutPoint(txid=txid, vout=vout) - inputs.append(InputPair.new_p2wpkh(txout=txout, outpoint=outpoint)) + # 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 From 795b0b383222ac0488760ded14a703306f348020 Mon Sep 17 00:00:00 2001 From: chavic Date: Fri, 9 Oct 2026 14:55:59 +0200 Subject: [PATCH 5/6] Expose InputPair::new_p2pkh in FFI The FFI PSBT input record has no `non_witness_utxo` field, so bindings had no way to contribute a legacy P2PKH input. Wrap the core `new_p2pkh` constructor, taking the funding transaction as consensus encoded bytes like the rest of the FFI. Decoding rejects trailing bytes, and a new `InputPairError::InvalidTransaction` variant reports bytes that do not decode. Output amounts above MAX_MONEY are rejected at the FFI boundary, as the other constructors do. Bindings that match exhaustively on `InputPairError` need to handle the new variant. --- payjoin-ffi/src/receive/error.rs | 3 + payjoin-ffi/src/receive/mod.rs | 99 ++++++++++++++++++++++++++++++++ 2 files changed, 102 insertions(+) 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 8150a91ab..2ec49f2c0 100644 --- a/payjoin-ffi/src/receive/mod.rs +++ b/payjoin-ffi/src/receive/mod.rs @@ -1404,6 +1404,27 @@ 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 { @@ -2209,4 +2230,82 @@ mod tests { 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(_)) + )); + } } From a4e89cc0a7563390f1d691e12ca80298fe975724 Mon Sep 17 00:00:00 2001 From: chavic Date: Fri, 9 Oct 2026 14:57:25 +0200 Subject: [PATCH 6/6] Expose InputPair::new_p2wsh in FFI Receivers spending P2WSH outputs, such as multisig wallets, had to build the TxIn and PSBT input by hand and pass a weight to the generic constructor. Wrap the core `new_p2wsh` constructor. A witness script spend has no predictable weight, so the caller still supplies it. --- payjoin-ffi/src/receive/mod.rs | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/payjoin-ffi/src/receive/mod.rs b/payjoin-ffi/src/receive/mod.rs index 2ec49f2c0..b13dacb0d 100644 --- a/payjoin-ffi/src/receive/mod.rs +++ b/payjoin-ffi/src/receive/mod.rs @@ -1435,6 +1435,24 @@ impl InputPair { .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] @@ -2207,6 +2225,20 @@ mod tests { )); } + #[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.