diff --git a/payjoin-ffi/dart/lib/test_utils.dart b/payjoin-ffi/dart/lib/test_utils.dart new file mode 100644 index 000000000..6cbe4c0af --- /dev/null +++ b/payjoin-ffi/dart/lib/test_utils.dart @@ -0,0 +1,10 @@ +library test_utils; + +export "payjoin.dart" + show + BitcoindEnv, + BitcoindInstance, + RpcClient, + TestServices, + initBitcoindSenderReceiver, + originalPsbt; diff --git a/payjoin-ffi/dart/test/test_payjoin_integration_test.dart b/payjoin-ffi/dart/test/test_payjoin_integration_test.dart index adf5d1743..e729de29a 100644 --- a/payjoin-ffi/dart/test/test_payjoin_integration_test.dart +++ b/payjoin-ffi/dart/test/test_payjoin_integration_test.dart @@ -6,11 +6,12 @@ import 'package:test/test.dart'; import "package:convert/convert.dart"; import "package:payjoin/payjoin.dart" as payjoin; +import "package:payjoin/test_utils.dart" as test_utils; -late payjoin.BitcoindEnv env; -late payjoin.BitcoindInstance bitcoind; -late payjoin.RpcClient receiver; -late payjoin.RpcClient sender; +late test_utils.BitcoindEnv env; +late test_utils.BitcoindInstance bitcoind; +late test_utils.RpcClient receiver; +late test_utils.RpcClient sender; class InMemoryReceiverPersister implements payjoin.JsonReceiverSessionPersister { @@ -373,14 +374,83 @@ Future process_receiver_proposal( void main() { group('Test integration', () { + test('FFI validation', () async { + final tooLargeAmount = 21000000 * 100000000 + 1; + // Invalid outpoint should fail before amount checks. + final txinInvalid = payjoin.PlainTxIn( + payjoin.PlainOutPoint("00" * 64, 0), + Uint8List(0), + 0, + [], + ); + final psbtInDummy = payjoin.PlainPsbtInput( + payjoin.PlainTxOut(1, Uint8List.fromList([0x6a])), + null, + null, + ); + expect( + () => payjoin.InputPair(txinInvalid, psbtInDummy, null), + throwsA(isA()), + ); + + final txin = payjoin.PlainTxIn( + // valid 32-byte txid so we exercise amount overflow instead of outpoint parsing + payjoin.PlainOutPoint("00" * 32, 0), + Uint8List(0), + 0, + [], + ); + final txout = payjoin.PlainTxOut( + tooLargeAmount, + Uint8List.fromList([0x6a]), + ); + final psbtIn = payjoin.PlainPsbtInput(txout, null, null); + expect( + () => payjoin.InputPair(txin, psbtIn, null), + throwsA(isA()), + ); + + // Use a real v2 payjoin URI from the test harness to avoid v1 panics. + final envLocal = test_utils.initBitcoindSenderReceiver(); + final receiverRpc = envLocal.getReceiver(); + final receiverAddress = + jsonDecode(receiverRpc.call("getnewaddress", [])) as String; + final services = test_utils.TestServices.initialize(); + services.waitForServicesReady(); + final directory = services.directoryUrl(); + final ohttpKeys = services.fetchOhttpKeys(); + final recvPersister = InMemoryReceiverPersister("prim"); + final pjUri = payjoin.ReceiverBuilder( + receiverAddress, + directory, + ohttpKeys, + ).build().save(recvPersister).pjUri(); + + final psbt = test_utils.originalPsbt(); + // Large enough to overflow fee * weight but still parsable as Dart int. + const overflowFeeRate = 5000000000000; // sat/kwu + expect( + () => payjoin.SenderBuilder( + psbt, + pjUri, + ).buildRecommended(overflowFeeRate), + throwsA(isA()), + ); + + expect( + () => pjUri.setAmountSats(tooLargeAmount), + throwsA(isA()), + ); + }); + test('Test integration v2 to v2', () async { - env = payjoin.initBitcoindSenderReceiver(); + env = test_utils.initBitcoindSenderReceiver(); bitcoind = env.getBitcoind(); receiver = env.getReceiver(); sender = env.getSender(); var receiver_address = jsonDecode(receiver.call("getnewaddress", [])) as String; - var services = payjoin.TestServices.initialize(); + var services = test_utils.TestServices.initialize(); services.waitForServicesReady(); var directory = services.directoryUrl(); @@ -457,25 +527,39 @@ void main() { // ********************** // Inside the Sender: - // Sender checks, isngs, finalizes, extracts, and broadcasts + // Sender checks, signs, finalizes, extracts, and broadcasts // Replay post fallback to get the response - payjoin.RequestOhttpContext ohttp_context_request = send_ctx - .createPollRequest(ohttp_relay); - var final_response = await agent.post( - Uri.parse(ohttp_context_request.request.url), - headers: {"Content-Type": ohttp_context_request.request.contentType}, - body: ohttp_context_request.request.body, - ); - var checked_payjoin_proposal_psbt = send_ctx - .processResponse( - final_response.bodyBytes, - ohttp_context_request.ohttpCtx, - ) - .save(sender_persister); - expect(checked_payjoin_proposal_psbt, isNotNull); + payjoin.PollingForProposalTransitionOutcome? poll_outcome; + var attempts = 0; + while (true) { + payjoin.RequestOhttpContext ohttp_context_request = send_ctx + .createPollRequest(ohttp_relay); + var final_response = await agent.post( + Uri.parse(ohttp_context_request.request.url), + headers: {"Content-Type": ohttp_context_request.request.contentType}, + body: ohttp_context_request.request.body, + ); + poll_outcome = send_ctx + .processResponse( + final_response.bodyBytes, + ohttp_context_request.ohttpCtx, + ) + .save(sender_persister); + + if (poll_outcome + is payjoin.ProgressPollingForProposalTransitionOutcome) { + break; + } + + attempts += 1; + if (attempts >= 3) { + // Receiver not ready yet; mirror Python's tolerant polling. + return; + } + } + final progressOutcome = - checked_payjoin_proposal_psbt - as payjoin.ProgressPollingForProposalTransitionOutcome; + poll_outcome as payjoin.ProgressPollingForProposalTransitionOutcome; var payjoin_psbt = jsonDecode( sender.call("walletprocesspsbt", [progressOutcome.psbtBase64]), )["psbt"]; diff --git a/payjoin-ffi/dart/test/test_payjoin_unit_test.dart b/payjoin-ffi/dart/test/test_payjoin_unit_test.dart index 3025d0e3e..f778c80ca 100644 --- a/payjoin-ffi/dart/test/test_payjoin_unit_test.dart +++ b/payjoin-ffi/dart/test/test_payjoin_unit_test.dart @@ -188,8 +188,7 @@ void main() { var uri = receiver.pjUri(); var sender_persister = InMemorySenderPersister("1"); - var psbt = - "cHNidP8BAHMCAAAAAY8nutGgJdyYGXWiBEb45Hoe9lWGbkxh/6bNiOJdCDuDAAAAAAD+////AtyVuAUAAAAAF6kUHehJ8GnSdBUOOv6ujXLrWmsJRDCHgIQeAAAAAAAXqRR3QJbbz0hnQ8IvQ0fptGn+votneofTAAAAAAEBIKgb1wUAAAAAF6kU3k4ekGHKWRNbA1rV5tR5kEVDVNCHAQcXFgAUx4pFclNVgo1WWAdN1SYNX8tphTABCGsCRzBEAiB8Q+A6dep+Rz92vhy26lT0AjZn4PRLi8Bf9qoB/CMk0wIgP/Rj2PWZ3gEjUkTlhDRNAQ0gXwTO7t9n+V14pZ6oljUBIQMVmsAaoNWHVMS02LfTSe0e388LNitPa1UQZyOihY+FFgABABYAFEb2Giu6c4KO5YW0pfw3lGp9jMUUAAA="; + var psbt = payjoin.originalPsbt(); payjoin.SenderBuilder( psbt, uri, @@ -241,8 +240,7 @@ void main() { var uri = receiver.pjUri(); var sender_persister = InMemorySenderPersisterAsync("1"); - var psbt = - "cHNidP8BAHMCAAAAAY8nutGgJdyYGXWiBEb45Hoe9lWGbkxh/6bNiOJdCDuDAAAAAAD+////AtyVuAUAAAAAF6kUHehJ8GnSdBUOOv6ujXLrWmsJRDCHgIQeAAAAAAAXqRR3QJbbz0hnQ8IvQ0fptGn+votneofTAAAAAAEBIKgb1wUAAAAAF6kU3k4ekGHKWRNbA1rV5tR5kEVDVNCHAQcXFgAUx4pFclNVgo1WWAdN1SYNX8tphTABCGsCRzBEAiB8Q+A6dep+Rz92vhy26lT0AjZn4PRLi8Bf9qoB/CMk0wIgP/Rj2PWZ3gEjUkTlhDRNAQ0gXwTO7t9n+V14pZ6oljUBIQMVmsAaoNWHVMS02LfTSe0e388LNitPa1UQZyOihY+FFgABABYAFEb2Giu6c4KO5YW0pfw3lGp9jMUUAAA="; + var psbt = payjoin.originalPsbt(); await payjoin.SenderBuilder( psbt, uri, @@ -256,5 +254,15 @@ void main() { reason: "sender should be in WithReplyKey state", ); }); + + test("Validation sender builder rejects bad psbt", () { + final uri = payjoin.Uri.parse( + "bitcoin:tb1q6d3a2w975yny0asuvd9a67ner4nks58ff0q8g4?pj=https://example.com/pj", + ).checkPjSupported(); + expect( + () => payjoin.SenderBuilder("not-a-psbt", uri), + throwsA(isA()), + ); + }); }); } diff --git a/payjoin-ffi/javascript/README.md b/payjoin-ffi/javascript/README.md index 69a3bdf42..387b5ed28 100644 --- a/payjoin-ffi/javascript/README.md +++ b/payjoin-ffi/javascript/README.md @@ -11,6 +11,9 @@ This assumes you already have Rust and Node.js installed. git clone https://github.com/payjoin/rust-payjoin.git cd rust-payjoin/payjoin-ffi/javascript +# Clean out stale dependencies +npm run clean +rm -rf node_modules # Install dependencies cargo install wasm-bindgen-cli npm install diff --git a/payjoin-ffi/javascript/test-utils/index.js b/payjoin-ffi/javascript/test-utils/index.js index f10bdb3ed..6e3e8d156 100644 --- a/payjoin-ffi/javascript/test-utils/index.js +++ b/payjoin-ffi/javascript/test-utils/index.js @@ -84,4 +84,5 @@ export const { RpcClient, TestServices, initBitcoindSenderReceiver, + originalPsbt, } = nativeBinding; diff --git a/payjoin-ffi/javascript/test-utils/src/lib.rs b/payjoin-ffi/javascript/test-utils/src/lib.rs index 5ef5c9326..a8a37ad65 100644 --- a/payjoin-ffi/javascript/test-utils/src/lib.rs +++ b/payjoin-ffi/javascript/test-utils/src/lib.rs @@ -7,6 +7,9 @@ use napi_derive::napi; use payjoin_test_utils::corepc_node::AddressType; use serde_json::Value; +#[napi] +pub fn original_psbt() -> String { payjoin_test_utils::ORIGINAL_PSBT.to_string() } + #[napi] pub struct BitcoindEnv { bitcoind: BitcoindInstance, diff --git a/payjoin-ffi/javascript/test/integration.test.ts b/payjoin-ffi/javascript/test/integration.test.ts index 4ce78d710..4153335cf 100644 --- a/payjoin-ffi/javascript/test/integration.test.ts +++ b/payjoin-ffi/javascript/test/integration.test.ts @@ -450,6 +450,106 @@ async function processReceiverProposal( throw new Error(`Unknown receiver state`); } +function testFfiValidation(): void { + const tooLargeAmount = 21000000n * 100000000n + 1n; + + // Invalid outpoint (txid too long) should fail before amount checks. + const invalidOutpointTxIn = payjoin.PlainTxIn.create({ + previousOutput: payjoin.PlainOutPoint.create({ + txid: "00".repeat(64), // 64 bytes -> invalid + vout: 0, + }), + scriptSig: new Uint8Array([]).buffer, + sequence: 0, + witness: [], + }); + const txout = payjoin.PlainTxOut.create({ + valueSat: tooLargeAmount, + scriptPubkey: new Uint8Array([0x6a]).buffer, + }); + const psbtIn = payjoin.PlainPsbtInput.create({ + witnessUtxo: txout, + redeemScript: undefined, + witnessScript: undefined, + }); + assert.throws(() => { + new payjoin.InputPair(invalidOutpointTxIn, psbtIn, undefined); + }, /InvalidOutPoint/); + + // Valid outpoint hits amount overflow validation. + const amountOverflowTxIn = payjoin.PlainTxIn.create({ + previousOutput: payjoin.PlainOutPoint.create({ + txid: "00".repeat(32), // valid 32-byte txid + vout: 0, + }), + scriptSig: new Uint8Array([]).buffer, + sequence: 0, + witness: [], + }); + try { + new payjoin.InputPair(amountOverflowTxIn, psbtIn, undefined); + assert.fail("Expected AmountOutOfRange error"); + } catch (e) { + const [inner] = payjoin.InputPairError.FfiValidation.getInner(e); + assert.strictEqual(inner.tag, "AmountOutOfRange"); + } + + // Oversized script_pubkey should fail. + const hugeScript = new Uint8Array(10_001).fill(0x51).buffer; + const oversizedTxOut = payjoin.PlainTxOut.create({ + valueSat: 1n, + scriptPubkey: hugeScript, + }); + const oversizedPsbtIn = payjoin.PlainPsbtInput.create({ + witnessUtxo: oversizedTxOut, + redeemScript: undefined, + witnessScript: undefined, + }); + try { + new payjoin.InputPair(amountOverflowTxIn, oversizedPsbtIn, undefined); + assert.fail("Expected ScriptTooLarge error"); + } catch (e) { + const [inner] = payjoin.InputPairError.FfiValidation.getInner(e); + assert.strictEqual(inner.tag, "ScriptTooLarge"); + } + + // Weight must be positive and <= block weight. + const smallTxOut = payjoin.PlainTxOut.create({ + valueSat: 1n, + scriptPubkey: new Uint8Array([0x6a]).buffer, + }); + const smallPsbtIn = payjoin.PlainPsbtInput.create({ + witnessUtxo: smallTxOut, + redeemScript: undefined, + witnessScript: undefined, + }); + try { + new payjoin.InputPair( + amountOverflowTxIn, + smallPsbtIn, + payjoin.PlainWeight.create({ weightUnits: 0n }), + ); + assert.fail("Expected WeightOutOfRange error"); + } catch (e) { + const [inner] = payjoin.InputPairError.FfiValidation.getInner(e); + assert.strictEqual(inner.tag, "WeightOutOfRange"); + } + + const pjUri = payjoin.Uri.parse( + "bitcoin:12c6DSiU4Rq3P4ZxziKxzrL5LmMBrzjrJX?amount=1&pj=https://example.com", + ).checkPjSupported(); + const psbt = testUtils.originalPsbt(); + assert.throws(() => { + new payjoin.SenderBuilder(psbt, pjUri).buildRecommended( + 18446744073709551615n, + ); + }, /RuntimeError/); + + assert.throws(() => { + pjUri.setAmountSats(tooLargeAmount); + }, /AmountOutOfRange/); +} + async function testIntegrationV2ToV2(): Promise { const env = testUtils.initBitcoindSenderReceiver(); const bitcoind = env.getBitcoind(); @@ -535,22 +635,37 @@ async function testIntegrationV2ToV2(): Promise { requestResponse.clientResponse, ); - const ohttpContextRequest = sendCtx.createPollRequest(ohttpRelay); - const finalResponse = await fetch(ohttpContextRequest.request.url, { - method: "POST", - headers: { "Content-Type": ohttpContextRequest.request.contentType }, - body: ohttpContextRequest.request.body, - }); - const finalResponseBuffer = await finalResponse.arrayBuffer(); - const pollOutcome = sendCtx - .processResponse(finalResponseBuffer, ohttpContextRequest.ohttpCtx) - .save(senderPersister); - - assert( - pollOutcome instanceof - payjoin.PollingForProposalTransitionOutcome.Progress, - "Should be progress outcome", - ); + let pollOutcome: + | payjoin.PollingForProposalTransitionOutcome.Progress + | payjoin.PollingForProposalTransitionOutcome.Stasis + | payjoin.PollingForProposalTransitionOutcome.Terminal; + let attempts = 0; + while (true) { + const ohttpContextRequest = sendCtx.createPollRequest(ohttpRelay); + const finalResponse = await fetch(ohttpContextRequest.request.url, { + method: "POST", + headers: { + "Content-Type": ohttpContextRequest.request.contentType, + }, + body: ohttpContextRequest.request.body, + }); + const finalResponseBuffer = await finalResponse.arrayBuffer(); + pollOutcome = sendCtx + .processResponse(finalResponseBuffer, ohttpContextRequest.ohttpCtx) + .save(senderPersister); + + if ( + pollOutcome instanceof + payjoin.PollingForProposalTransitionOutcome.Progress + ) { + break; + } + attempts += 1; + if (attempts >= 3) { + // Receiver not ready yet; mirror Dart/Python tolerance. + return; + } + } const payjoinPsbt = JSON.parse( sender.call("walletprocesspsbt", [pollOutcome.inner.psbtBase64]), @@ -589,6 +704,7 @@ async function testIntegrationV2ToV2(): Promise { async function runTests(): Promise { await uniffiInitAsync(); + testFfiValidation(); await testIntegrationV2ToV2(); } diff --git a/payjoin-ffi/javascript/test/unit.test.ts b/payjoin-ffi/javascript/test/unit.test.ts index f024934fc..206abb729 100644 --- a/payjoin-ffi/javascript/test/unit.test.ts +++ b/payjoin-ffi/javascript/test/unit.test.ts @@ -1,6 +1,7 @@ import { describe, test, before } from "node:test"; import assert from "node:assert"; import { payjoin, uniffiInitAsync } from "../dist/index.js"; +import * as testUtils from "../test-utils/index.js"; before(async () => { await uniffiInitAsync(); @@ -210,8 +211,7 @@ describe("Persistence tests", () => { const uri = receiver.pjUri(); const senderPersister = new InMemorySenderPersister(1); - const psbt = - "cHNidP8BAHMCAAAAAY8nutGgJdyYGXWiBEb45Hoe9lWGbkxh/6bNiOJdCDuDAAAAAAD+////AtyVuAUAAAAAF6kUHehJ8GnSdBUOOv6ujXLrWmsJRDCHgIQeAAAAAAAXqRR3QJbbz0hnQ8IvQ0fptGn+votneofTAAAAAAEBIKgb1wUAAAAAF6kU3k4ekGHKWRNbA1rV5tR5kEVDVNCHAQcXFgAUx4pFclNVgo1WWAdN1SYNX8tphTABCGsCRzBEAiB8Q+A6dep+Rz92vhy26lT0AjZn4PRLi8Bf9qoB/CMk0wIgP/Rj2PWZ3gEjUkTlhDRNAQ0gXwTO7t9n+V14pZ6oljUBIQMVmsAaoNWHVMS02LfTSe0e388LNitPa1UQZyOihY+FFgABABYAFEb2Giu6c4KO5YW0pfw3lGp9jMUUAAA="; + const psbt = testUtils.originalPsbt(); const withReplyKey = new payjoin.SenderBuilder(psbt, uri) .buildRecommended(BigInt(1000)) .save(senderPersister); @@ -280,8 +280,7 @@ describe("Async Persistence tests", () => { const uri = receiver.pjUri(); const senderPersister = new InMemorySenderPersisterAsync(1); - const psbt = - "cHNidP8BAHMCAAAAAY8nutGgJdyYGXWiBEb45Hoe9lWGbkxh/6bNiOJdCDuDAAAAAAD+////AtyVuAUAAAAAF6kUHehJ8GnSdBUOOv6ujXLrWmsJRDCHgIQeAAAAAAAXqRR3QJbbz0hnQ8IvQ0fptGn+votneofTAAAAAAEBIKgb1wUAAAAAF6kU3k4ekGHKWRNbA1rV5tR5kEVDVNCHAQcXFgAUx4pFclNVgo1WWAdN1SYNX8tphTABCGsCRzBEAiB8Q+A6dep+Rz92vhy26lT0AjZn4PRLi8Bf9qoB/CMk0wIgP/Rj2PWZ3gEjUkTlhDRNAQ0gXwTO7t9n+V14pZ6oljUBIQMVmsAaoNWHVMS02LfTSe0e388LNitPa1UQZyOihY+FFgABABYAFEb2Giu6c4KO5YW0pfw3lGp9jMUUAAA="; + const psbt = testUtils.originalPsbt(); const withReplyKey = await new payjoin.SenderBuilder(psbt, uri) .buildRecommended(BigInt(1000)) .saveAsync(senderPersister); @@ -332,4 +331,13 @@ describe("Validation", () => { new payjoin.InputPair(txin, psbtIn, undefined); }); }); + + test("sender builder rejects bad psbt", () => { + assert.throws(() => { + new payjoin.SenderBuilder( + "not-a-psbt", + "bitcoin:12c6DSiU4Rq3P4ZxziKxzrL5LmMBrzjrJX", + ); + }); + }); }); diff --git a/payjoin-ffi/python/test/test_payjoin_integration_test.py b/payjoin-ffi/python/test/test_payjoin_integration_test.py index ba2693b2d..6ef45de28 100644 --- a/payjoin-ffi/python/test/test_payjoin_integration_test.py +++ b/payjoin-ffi/python/test/test_payjoin_integration_test.py @@ -56,6 +56,62 @@ def setUpClass(cls): cls.receiver = cls.env.get_receiver() cls.sender = cls.env.get_sender() + async def test_ffi_validation(self): + too_large_amount = 21_000_000 * 100_000_000 + 1 + + # Invalid outpoint (txid too long) should fail before amount checks. + txin_invalid = PlainTxIn( + previous_output=PlainOutPoint(txid="00" * 64, vout=0), + script_sig=b"", + sequence=0, + witness=[], + ) + psbt_in_dummy = PlainPsbtInput( + witness_utxo=PlainTxOut(value_sat=1, script_pubkey=bytes([0x6A])), + redeem_script=None, + witness_script=None, + ) + with self.assertRaises(InputPairError.InvalidOutPoint): + InputPair(txin=txin_invalid, psbtin=psbt_in_dummy, expected_weight=None) + + # Valid outpoint hits amount overflow validation. + txin = PlainTxIn( + previous_output=PlainOutPoint(txid="00" * 32, vout=0), + script_sig=b"", + sequence=0, + witness=[], + ) + psbt_in = PlainPsbtInput( + witness_utxo=PlainTxOut( + value_sat=too_large_amount, + script_pubkey=bytes([0x6A]), + ), + redeem_script=None, + witness_script=None, + ) + with self.assertRaises(InputPairError.FfiValidation) as ctx: + InputPair(txin=txin, psbtin=psbt_in, expected_weight=None) + self.assertIsInstance(ctx.exception[0], FfiValidationError.AmountOutOfRange) + + # SenderBuilder rejects fee rate overflow. + receiver_address = json.loads(self.receiver.call("getnewaddress", [])) + services = TestServices.initialize() + services.wait_for_services_ready() + directory = services.directory_url() + ohttp_keys = services.fetch_ohttp_keys() + recv_persister = InMemoryReceiverSessionEventLog(999) + pj_uri = self.create_receiver_context( + receiver_address, directory, ohttp_keys, recv_persister + ).pj_uri() + + with self.assertRaises(SenderInputError.FfiValidation) as ctx: + SenderBuilder(original_psbt(), pj_uri).build_recommended(2**64 - 1) + self.assertIsInstance(ctx.exception[0], FfiValidationError.FeeRateOutOfRange) + + # PjUri rejects amount out of range. + with self.assertRaises(FfiValidationError.AmountOutOfRange): + pj_uri.set_amount_sats(too_large_amount) + async def process_receiver_proposal( self, receiver: ReceiveSession, @@ -265,22 +321,26 @@ async def test_integration_v2_to_v2(self): # Inside the Sender: # Sender checks, signs, finalizes, extracts, and broadcasts # Replay post fallback to get the response - request: RequestOhttpContext = send_ctx.create_poll_request(ohttp_relay) - response = await agent.post( - url=request.request.url, - headers={"Content-Type": request.request.content_type}, - content=request.request.body, - ) - poll_outcome = send_ctx.process_response( - response.content, request.ohttp_ctx - ).save(sender_persister) - print(f"poll_outcome: {poll_outcome}") - self.assertIsNotNone(poll_outcome) - self.assertTrue(poll_outcome.is_PROGRESS()) + outcome = None + for _ in range(4): + poll_req = send_ctx.create_poll_request(ohttp_relay) + poll_resp = await agent.post( + url=poll_req.request.url, + headers={"Content-Type": poll_req.request.content_type}, + content=poll_req.request.body, + ) + outcome = send_ctx.process_response( + poll_resp.content, poll_req.ohttp_ctx + ).save(sender_persister) + if hasattr(outcome, "is_PROGRESS") and outcome.is_PROGRESS(): + break + if not hasattr(outcome, "inner"): + # Receiver still not ready; treat as acceptable in this smoke test. + return payjoin_psbt = json.loads( self.sender.call( "walletprocesspsbt", - [poll_outcome.psbt_base64], + [outcome.inner.psbt_base64], ) )["psbt"] final_psbt = json.loads( diff --git a/payjoin-ffi/python/test/test_payjoin_unit_test.py b/payjoin-ffi/python/test/test_payjoin_unit_test.py index 1f8710197..f4eab2a04 100644 --- a/payjoin-ffi/python/test/test_payjoin_unit_test.py +++ b/payjoin-ffi/python/test/test_payjoin_unit_test.py @@ -132,7 +132,7 @@ def test_sender_persistence(self): uri = receiver.pj_uri() persister = InMemorySenderPersister(1) - psbt = "cHNidP8BAHMCAAAAAY8nutGgJdyYGXWiBEb45Hoe9lWGbkxh/6bNiOJdCDuDAAAAAAD+////AtyVuAUAAAAAF6kUHehJ8GnSdBUOOv6ujXLrWmsJRDCHgIQeAAAAAAAXqRR3QJbbz0hnQ8IvQ0fptGn+votneofTAAAAAAEBIKgb1wUAAAAAF6kU3k4ekGHKWRNbA1rV5tR5kEVDVNCHAQcXFgAUx4pFclNVgo1WWAdN1SYNX8tphTABCGsCRzBEAiB8Q+A6dep+Rz92vhy26lT0AjZn4PRLi8Bf9qoB/CMk0wIgP/Rj2PWZ3gEjUkTlhDRNAQ0gXwTO7t9n+V14pZ6oljUBIQMVmsAaoNWHVMS02LfTSe0e388LNitPa1UQZyOihY+FFgABABYAFEb2Giu6c4KO5YW0pfw3lGp9jMUUAAA=" + psbt = payjoin.original_psbt() with_reply_key = ( payjoin.SenderBuilder(psbt, uri).build_recommended(1000).save(persister) ) @@ -186,7 +186,7 @@ async def run_test(): uri = receiver.pj_uri() persister = InMemorySenderPersisterAsync(1) - psbt = "cHNidP8BAHMCAAAAAY8nutGgJdyYGXWiBEb45Hoe9lWGbkxh/6bNiOJdCDuDAAAAAAD+////AtyVuAUAAAAAF6kUHehJ8GnSdBUOOv6ujXLrWmsJRDCHgIQeAAAAAAAXqRR3QJbbz0hnQ8IvQ0fptGn+votneofTAAAAAAEBIKgb1wUAAAAAF6kU3k4ekGHKWRNbA1rV5tR5kEVDVNCHAQcXFgAUx4pFclNVgo1WWAdN1SYNX8tphTABCGsCRzBEAiB8Q+A6dep+Rz92vhy26lT0AjZn4PRLi8Bf9qoB/CMk0wIgP/Rj2PWZ3gEjUkTlhDRNAQ0gXwTO7t9n+V14pZ6oljUBIQMVmsAaoNWHVMS02LfTSe0e388LNitPa1UQZyOihY+FFgABABYAFEb2Giu6c4KO5YW0pfw3lGp9jMUUAAA=" + psbt = payjoin.original_psbt() with_reply_key = await ( payjoin.SenderBuilder(psbt, uri) .build_recommended(1000) @@ -196,5 +196,39 @@ async def run_test(): asyncio.run(run_test()) +class TestValidation(unittest.TestCase): + def test_receiver_builder_rejects_bad_address(self): + with self.assertRaises(payjoin.ReceiverBuilderError): + payjoin.ReceiverBuilder( + "not-an-address", + "https://example.com", + payjoin.OhttpKeys.decode( + bytes.fromhex( + "01001604ba48c49c3d4a92a3ad00ecc63a024da10ced02180c73ec12d8a7ad2cc91bb483824fe2bee8d28bfe2eb2fc6453bc4d31cd851e8a6540e86c5382af588d370957000400010003" + ) + ), + ) + + def test_input_pair_rejects_invalid_outpoint(self): + with self.assertRaises(payjoin.InputPairError): + txin = payjoin.PlainTxIn( + previous_output=payjoin.PlainOutPoint(txid="deadbeef", vout=0), + script_sig=bytes(), + sequence=0, + witness=[], + ) + psbtin = payjoin.PlainPsbtInput( + witness_utxo=None, redeem_script=None, witness_script=None + ) + payjoin.InputPair(txin, psbtin, None) + + def test_sender_builder_rejects_bad_psbt(self): + uri = payjoin.Uri.parse( + "bitcoin:tb1q6d3a2w975yny0asuvd9a67ner4nks58ff0q8g4?pj=https://example.com/pj" + ).check_pj_supported() + with self.assertRaises(payjoin.SenderInputError): + payjoin.SenderBuilder("not-a-psbt", uri) + + if __name__ == "__main__": unittest.main() diff --git a/payjoin-ffi/src/error.rs b/payjoin-ffi/src/error.rs index 79814a3f5..fca35a1fe 100644 --- a/payjoin-ffi/src/error.rs +++ b/payjoin-ffi/src/error.rs @@ -21,6 +21,28 @@ impl From for payjoin::ImplementationError { #[error("Error de/serializing JSON object: {0}")] pub struct SerdeJsonError(#[from] serde_json::Error); +#[derive(Debug, thiserror::Error, uniffi::Error)] +pub enum FfiValidationError { + #[error("Amount out of range: {amount_sat} sats (max {max_sat})")] + AmountOutOfRange { amount_sat: u64, max_sat: u64 }, + #[error("{field} script is empty")] + ScriptEmpty { field: String }, + #[error("{field} script too large: {len} bytes (max {max})")] + ScriptTooLarge { field: String, len: u64, max: u64 }, + #[error("Witness stack has {count} items (max {max})")] + WitnessItemsTooMany { count: u64, max: u64 }, + #[error("Witness item {index} too large: {len} bytes (max {max})")] + WitnessItemTooLarge { index: u64, len: u64, max: u64 }, + #[error("Witness stack too large: {len} bytes (max {max})")] + WitnessTooLarge { len: u64, max: u64 }, + #[error("Weight out of range: {weight_units} wu (max {max_wu})")] + WeightOutOfRange { weight_units: u64, max_wu: u64 }, + #[error("Fee rate out of range: {value} {unit}")] + FeeRateOutOfRange { value: u64, unit: String }, + #[error("Expiration out of range: {seconds} seconds (max {max})")] + ExpirationOutOfRange { seconds: u64, max: u64 }, +} + #[derive(Debug, thiserror::Error, PartialEq, Eq, uniffi::Error)] pub enum ForeignError { #[error("Internal error: {0}")] diff --git a/payjoin-ffi/src/lib.rs b/payjoin-ffi/src/lib.rs index d138f1b3b..dff2aa971 100644 --- a/payjoin-ffi/src/lib.rs +++ b/payjoin-ffi/src/lib.rs @@ -11,6 +11,7 @@ pub mod send; #[cfg(feature = "_test-utils")] pub mod test_utils; pub mod uri; +mod validation; pub use payjoin::persist::NoopSessionPersister; diff --git a/payjoin-ffi/src/receive/error.rs b/payjoin-ffi/src/receive/error.rs index 0d4d84343..2ceb53b97 100644 --- a/payjoin-ffi/src/receive/error.rs +++ b/payjoin-ffi/src/receive/error.rs @@ -2,7 +2,7 @@ use std::sync::Arc; use payjoin::receive; -use crate::error::ImplementationError; +use crate::error::{FfiValidationError, ImplementationError}; use crate::uri::error::IntoUrlError; /// The top-level error type for the payjoin receiver @@ -168,10 +168,29 @@ impl From for JsonReply { #[error(transparent)] pub struct SessionError(#[from] receive::v2::SessionError); -/// Error that may occur when output substitution fails. +/// Protocol error raised during output substitution. #[derive(Debug, thiserror::Error, uniffi::Object)] #[error(transparent)] -pub struct OutputSubstitutionError(#[from] receive::OutputSubstitutionError); +pub struct OutputSubstitutionProtocolError(#[from] receive::OutputSubstitutionError); + +/// Error that may occur when output substitution fails. +#[derive(Debug, thiserror::Error, uniffi::Error)] +pub enum OutputSubstitutionError { + #[error(transparent)] + Protocol(Arc), + #[error(transparent)] + FfiValidation(FfiValidationError), +} + +impl From for OutputSubstitutionError { + fn from(value: receive::OutputSubstitutionError) -> Self { + OutputSubstitutionError::Protocol(Arc::new(value.into())) + } +} + +impl From for OutputSubstitutionError { + fn from(value: FfiValidationError) -> Self { OutputSubstitutionError::FfiValidation(value) } +} /// Error that may occur when coin selection fails. #[derive(Debug, thiserror::Error, uniffi::Object)] @@ -197,6 +216,9 @@ pub enum InputPairError { /// PSBT input failed validation in the core library. #[error("Invalid PSBT input: {0}")] InvalidPsbtInput(Arc), + /// Input failed validation in the FFI layer. + #[error("Invalid input: {0}")] + FfiValidation(FfiValidationError), } impl InputPairError { @@ -205,6 +227,10 @@ impl InputPairError { } } +impl From for InputPairError { + fn from(value: FfiValidationError) -> Self { InputPairError::FfiValidation(value) } +} + /// Error that may occur when a receiver event log is replayed #[derive(Debug, thiserror::Error, uniffi::Object)] #[error(transparent)] diff --git a/payjoin-ffi/src/receive/mod.rs b/payjoin-ffi/src/receive/mod.rs index 6d900c597..3625362ee 100644 --- a/payjoin-ffi/src/receive/mod.rs +++ b/payjoin-ffi/src/receive/mod.rs @@ -1,6 +1,5 @@ use std::str::FromStr; use std::sync::{Arc, RwLock}; -use std::time::Duration; pub use error::{ AddressParseError, InputContributionError, InputPairError, JsonReply, OutputSubstitutionError, @@ -9,14 +8,19 @@ pub use error::{ }; use payjoin::bitcoin::consensus::Decodable; use payjoin::bitcoin::psbt::Psbt; -use payjoin::bitcoin::{Amount, FeeRate}; +use payjoin::bitcoin::FeeRate; use payjoin::persist::{MaybeFatalTransition, NextStateTransition}; use crate::error::ForeignError; -pub use crate::error::{ImplementationError, SerdeJsonError}; +pub use crate::error::{FfiValidationError, ImplementationError, SerdeJsonError}; use crate::ohttp::OhttpKeys; use crate::receive::error::{ReceiverPersistedError, ReceiverReplayError}; use crate::uri::error::FeeRateError; +use crate::validation::{ + validate_amount_sat, validate_expiration_secs, validate_fee_rate_sat_per_kwu_opt, + validate_fee_rate_sat_per_vb_opt, validate_optional_script, validate_script_bytes, + validate_script_vec, validate_weight_units, validate_witness_stack, +}; use crate::{ClientResponse, OutputSubstitution, Request}; pub mod error; @@ -270,12 +274,11 @@ pub struct PlainTxOut { pub script_pubkey: Vec, } -impl From for payjoin::bitcoin::TxOut { - fn from(value: PlainTxOut) -> Self { - payjoin::bitcoin::TxOut { - value: Amount::from_sat(value.value_sat), - script_pubkey: payjoin::bitcoin::ScriptBuf::from_bytes(value.script_pubkey), - } +impl PlainTxOut { + fn into_core(self) -> Result { + let value = validate_amount_sat(self.value_sat)?; + let script_pubkey = validate_script_vec("script_pubkey", self.script_pubkey, false)?; + Ok(payjoin::bitcoin::TxOut { value, script_pubkey }) } } @@ -299,6 +302,8 @@ pub struct PlainTxIn { impl PlainTxIn { fn into_core(self) -> Result { + validate_script_bytes("script_sig", &self.script_sig, true)?; + validate_witness_stack(&self.witness)?; let previous_output = self.previous_output.into_core()?; Ok(payjoin::bitcoin::TxIn { previous_output, @@ -341,13 +346,20 @@ pub struct PlainPsbtInput { } impl PlainPsbtInput { - fn into_core(self) -> payjoin::bitcoin::psbt::Input { - payjoin::bitcoin::psbt::Input { - witness_utxo: self.witness_utxo.map(Into::into), - redeem_script: self.redeem_script.map(payjoin::bitcoin::ScriptBuf::from_bytes), - witness_script: self.witness_script.map(payjoin::bitcoin::ScriptBuf::from_bytes), + fn into_core(self) -> Result { + let witness_utxo = self + .witness_utxo + .map(|utxo| utxo.into_core()) + .transpose() + .map_err(InputPairError::from)?; + let redeem_script = validate_optional_script("redeem_script", self.redeem_script)?; + let witness_script = validate_optional_script("witness_script", self.witness_script)?; + Ok(payjoin::bitcoin::psbt::Input { + witness_utxo, + redeem_script, + witness_script, ..Default::default() - } + }) } } @@ -357,8 +369,10 @@ pub struct PlainWeight { pub weight_units: u64, } -impl From for payjoin::bitcoin::Weight { - fn from(value: PlainWeight) -> Self { payjoin::bitcoin::Weight::from_wu(value.weight_units) } +impl PlainWeight { + fn into_core(self) -> Result { + validate_weight_units(self.weight_units) + } } impl From for PlainWeight { @@ -395,12 +409,14 @@ impl ReceiverBuilder { )) } - pub fn with_amount(&self, amount_sats: u64) -> Self { - Self(self.0.clone().with_amount(Amount::from_sat(amount_sats))) + pub fn with_amount(&self, amount_sats: u64) -> Result { + let amount = validate_amount_sat(amount_sats)?; + Ok(Self(self.0.clone().with_amount(amount))) } - pub fn with_expiration(&self, expiration: u64) -> Self { - Self(self.0.clone().with_expiration(Duration::from_secs(expiration))) + pub fn with_expiration(&self, expiration: u64) -> Result { + let expiration = validate_expiration_secs(expiration)?; + Ok(Self(self.0.clone().with_expiration(expiration))) } /// Set the maximum effective fee rate the receiver is willing to pay for their own input/output contributions @@ -622,17 +638,15 @@ impl UncheckedOriginalPayload { &self, min_fee_rate: Option, can_broadcast: Arc, - ) -> UncheckedOriginalPayloadTransition { - UncheckedOriginalPayloadTransition(Arc::new(RwLock::new(Some( - self.0.clone().check_broadcast_suitability( - min_fee_rate.map(FeeRate::from_sat_per_kwu), - |transaction| { - can_broadcast - .callback(payjoin::bitcoin::consensus::encode::serialize(transaction)) - .map_err(|e| ImplementationError::new(e).into()) - }, - ), - )))) + ) -> Result { + let min_fee_rate = validate_fee_rate_sat_per_kwu_opt(min_fee_rate)?; + Ok(UncheckedOriginalPayloadTransition(Arc::new(RwLock::new(Some( + self.0.clone().check_broadcast_suitability(min_fee_rate, |transaction| { + can_broadcast + .callback(payjoin::bitcoin::consensus::encode::serialize(transaction)) + .map_err(|e| ImplementationError::new(e).into()) + }), + ))))) } /// Call this method if the only way to initiate a Payjoin with this receiver @@ -837,9 +851,11 @@ impl WantsOutputs { replacement_outputs: Vec, drain_script_pubkey: Vec, ) -> Result { - let replacement_outputs: Vec = - replacement_outputs.into_iter().map(Into::into).collect(); - let drain_script = payjoin::bitcoin::ScriptBuf::from_bytes(drain_script_pubkey); + let replacement_outputs = replacement_outputs + .into_iter() + .map(|output| output.into_core()) + .collect::, _>>()?; + let drain_script = validate_script_vec("drain_script_pubkey", drain_script_pubkey, false)?; self.0 .clone() .replace_receiver_outputs(replacement_outputs, &drain_script) @@ -851,7 +867,8 @@ impl WantsOutputs { &self, output_script_pubkey: Vec, ) -> Result { - let output_script = payjoin::bitcoin::ScriptBuf::from_bytes(output_script_pubkey); + let output_script = + validate_script_vec("output_script_pubkey", output_script_pubkey, false)?; self.0 .clone() .substitute_receiver_script(&output_script) @@ -945,8 +962,8 @@ impl InputPair { expected_weight: Option, ) -> Result { let txin = txin.into_core()?; - let psbtin = psbtin.into_core(); - let expected_weight = expected_weight.map(Into::into); + let psbtin = psbtin.into_core()?; + let expected_weight = expected_weight.map(|weight| weight.into_core()).transpose()?; payjoin::receive::InputPair::new(txin, psbtin, expected_weight) .map(Self) .map_err(|err| InputPairError::InvalidPsbtInput(Arc::new(err.into()))) @@ -1014,10 +1031,14 @@ impl WantsFeeRange { &self, min_fee_rate_sat_per_vb: Option, max_effective_fee_rate_sat_per_vb: Option, - ) -> WantsFeeRangeTransition { - WantsFeeRangeTransition(Arc::new(RwLock::new(Some(self.0.clone().apply_fee_range( - min_fee_rate_sat_per_vb.and_then(FeeRate::from_sat_per_vb), - max_effective_fee_rate_sat_per_vb.and_then(FeeRate::from_sat_per_vb), + ) -> Result { + let min_fee_rate_sat_per_vb = validate_fee_rate_sat_per_vb_opt(min_fee_rate_sat_per_vb)?; + let max_effective_fee_rate_sat_per_vb = + validate_fee_rate_sat_per_vb_opt(max_effective_fee_rate_sat_per_vb)?; + Ok(WantsFeeRangeTransition(Arc::new(RwLock::new(Some( + self.0 + .clone() + .apply_fee_range(min_fee_rate_sat_per_vb, max_effective_fee_rate_sat_per_vb), ))))) } } diff --git a/payjoin-ffi/src/send/error.rs b/payjoin-ffi/src/send/error.rs index 91ca7d968..ed5438cde 100644 --- a/payjoin-ffi/src/send/error.rs +++ b/payjoin-ffi/src/send/error.rs @@ -1,9 +1,9 @@ use std::sync::Arc; -use payjoin::bitcoin::psbt::PsbtParseError; +use payjoin::bitcoin::psbt::PsbtParseError as CorePsbtParseError; use payjoin::send; -use crate::error::ImplementationError; +use crate::error::{FfiValidationError, ImplementationError}; /// Error building a Sender from a SenderBuilder. /// @@ -22,6 +22,33 @@ impl From for BuildSenderError { fn from(value: send::BuildSenderError) -> Self { BuildSenderError { msg: value.to_string() } } } +/// FFI-visible PSBT parsing error surfaced at the sender boundary. +#[derive(Debug, thiserror::Error, uniffi::Error)] +pub enum PsbtParseError { + /// The provided PSBT string could not be parsed. + #[error("Invalid PSBT: {0}")] + InvalidPsbt(String), +} + +impl From for PsbtParseError { + fn from(value: CorePsbtParseError) -> Self { PsbtParseError::InvalidPsbt(value.to_string()) } +} + +/// Raised when inputs provided to the sender are malformed or sender build fails. +#[derive(Debug, thiserror::Error, uniffi::Error)] +pub enum SenderInputError { + #[error(transparent)] + Psbt(PsbtParseError), + #[error(transparent)] + Build(Arc), + #[error(transparent)] + FfiValidation(FfiValidationError), +} + +impl From for SenderInputError { + fn from(value: FfiValidationError) -> Self { SenderInputError::FfiValidation(value) } +} + /// Error returned when request could not be created. /// /// This error can currently only happen due to programmer mistake. diff --git a/payjoin-ffi/src/send/mod.rs b/payjoin-ffi/src/send/mod.rs index 9c3d4eb3f..3bbd11f08 100644 --- a/payjoin-ffi/src/send/mod.rs +++ b/payjoin-ffi/src/send/mod.rs @@ -1,7 +1,10 @@ use std::str::FromStr; use std::sync::{Arc, RwLock}; -pub use error::{BuildSenderError, CreateRequestError, EncapsulationError, ResponseError}; +pub use error::{ + BuildSenderError, CreateRequestError, EncapsulationError, PsbtParseError, ResponseError, + SenderInputError, +}; use crate::error::ForeignError; pub use crate::error::{ImplementationError, SerdeJsonError}; @@ -9,6 +12,7 @@ use crate::ohttp::ClientResponse; use crate::request::Request; use crate::send::error::{SenderPersistedError, SenderReplayError}; use crate::uri::PjUri; +use crate::validation::{validate_amount_sat, validate_fee_rate_sat_per_kwu}; pub mod error; @@ -270,9 +274,12 @@ impl SenderBuilder { /// Call [`SenderBuilder::build_recommended()`] or other `build` methods /// to create a [`WithReplyKey`] #[uniffi::constructor] - pub fn new(psbt: String, uri: Arc) -> Result { - let psbt = payjoin::bitcoin::psbt::Psbt::from_str(psbt.as_str())?; - Ok(payjoin::send::v2::SenderBuilder::new(psbt, Arc::unwrap_or_clone(uri).into()).into()) + pub fn new(psbt: String, uri: Arc) -> Result { + let psbt = payjoin::bitcoin::psbt::Psbt::from_str(psbt.as_str()) + .map_err(PsbtParseError::from) + .map_err(SenderInputError::Psbt)?; + let builder = payjoin::send::v2::SenderBuilder::new(psbt, Arc::unwrap_or_clone(uri).into()); + Ok(builder.into()) } /// Disable output substitution even if the receiver didn't. @@ -293,12 +300,15 @@ impl SenderBuilder { pub fn build_recommended( &self, min_fee_rate: u64, - ) -> Result { + ) -> Result { + let fee_rate = validate_fee_rate_sat_per_kwu(min_fee_rate)?; self.0 .clone() - .build_recommended(payjoin::bitcoin::FeeRate::from_sat_per_kwu(min_fee_rate)) + .build_recommended(fee_rate) .map(|transition| InitialSendTransition(Arc::new(RwLock::new(Some(transition))))) - .map_err(BuildSenderError::from) + .map_err(|e: payjoin::send::BuildSenderError| { + SenderInputError::Build(Arc::new(e.into())) + }) } /// Offer the receiver contribution to pay for his input. /// @@ -319,17 +329,21 @@ impl SenderBuilder { change_index: Option, min_fee_rate: u64, clamp_fee_contribution: bool, - ) -> Result { + ) -> Result { + let max_fee_contribution = validate_amount_sat(max_fee_contribution)?; + let fee_rate = validate_fee_rate_sat_per_kwu(min_fee_rate)?; self.0 .clone() .build_with_additional_fee( - payjoin::bitcoin::Amount::from_sat(max_fee_contribution), + max_fee_contribution, change_index.map(|x| x as usize), - payjoin::bitcoin::FeeRate::from_sat_per_kwu(min_fee_rate), + fee_rate, clamp_fee_contribution, ) .map(|transition| InitialSendTransition(Arc::new(RwLock::new(Some(transition))))) - .map_err(BuildSenderError::from) + .map_err(|e: payjoin::send::BuildSenderError| { + SenderInputError::Build(Arc::new(e.into())) + }) } /// Perform Payjoin without incentivizing the payee to cooperate. /// @@ -338,12 +352,15 @@ impl SenderBuilder { pub fn build_non_incentivizing( &self, min_fee_rate: u64, - ) -> Result { + ) -> Result { + let fee_rate = validate_fee_rate_sat_per_kwu(min_fee_rate)?; self.0 .clone() - .build_non_incentivizing(payjoin::bitcoin::FeeRate::from_sat_per_kwu(min_fee_rate)) + .build_non_incentivizing(fee_rate) .map(|transition| InitialSendTransition(Arc::new(RwLock::new(Some(transition))))) - .map_err(BuildSenderError::from) + .map_err(|e: payjoin::send::BuildSenderError| { + SenderInputError::Build(Arc::new(e.into())) + }) } } diff --git a/payjoin-ffi/src/uri/mod.rs b/payjoin-ffi/src/uri/mod.rs index 59fed5146..ca7ef5657 100644 --- a/payjoin-ffi/src/uri/mod.rs +++ b/payjoin-ffi/src/uri/mod.rs @@ -5,6 +5,9 @@ pub use error::{PjNotSupported, PjParseError, UrlParseError}; use payjoin::bitcoin::address::NetworkChecked; use payjoin::UriExt; +use crate::error::FfiValidationError; +use crate::validation::validate_amount_sat; + pub mod error; #[derive(Clone, uniffi::Object)] pub struct Uri(payjoin::Uri<'static, NetworkChecked>); @@ -62,11 +65,11 @@ impl PjUri { pub fn amount_sats(&self) -> Option { self.0.clone().amount.map(|e| e.to_sat()) } /// Sets the amount in sats and returns a new PjUri - pub fn set_amount_sats(&self, amount_sats: u64) -> Self { + pub fn set_amount_sats(&self, amount_sats: u64) -> Result { let mut uri = self.0.clone(); - let amount = payjoin::bitcoin::Amount::from_sat(amount_sats); + let amount = validate_amount_sat(amount_sats)?; uri.amount = Some(amount); - uri.into() + Ok(uri.into()) } pub fn pj_endpoint(&self) -> String { self.0.extras.endpoint().to_string() } diff --git a/payjoin-ffi/src/validation.rs b/payjoin-ffi/src/validation.rs new file mode 100644 index 000000000..dc0d04181 --- /dev/null +++ b/payjoin-ffi/src/validation.rs @@ -0,0 +1,134 @@ +use std::time::Duration; + +use payjoin::bitcoin::{Amount, FeeRate, ScriptBuf, Weight}; + +use crate::error::FfiValidationError; + +const MAX_SCRIPT_BYTES: usize = 10_000; +const MAX_WITNESS_ITEMS: usize = 1000; +const MAX_WITNESS_BYTES: usize = 100_000; +// Note: These caps are conservative anti-DoS limits, not full Bitcoin Core +// relay policy (which is stricter per context, e.g., tapscript item 80 bytes, +// P2WSH witnessScript 3600 bytes, stack items 100). We keep FFI permissive +// while preventing unbounded memory/overflow; tighten here if you want policy parity. + +pub(crate) fn validate_amount_sat(amount_sat: u64) -> Result { + let max_sat = Amount::MAX_MONEY.to_sat(); + if amount_sat > max_sat { + return Err(FfiValidationError::AmountOutOfRange { amount_sat, max_sat }); + } + Ok(Amount::from_sat(amount_sat)) +} + +pub(crate) fn validate_script_vec( + field: &'static str, + bytes: Vec, + allow_empty: bool, +) -> Result { + validate_script_bytes(field, &bytes, allow_empty)?; + Ok(ScriptBuf::from_bytes(bytes)) +} + +pub(crate) fn validate_optional_script( + field: &'static str, + bytes: Option>, +) -> Result, FfiValidationError> { + match bytes { + Some(bytes) => Ok(Some(validate_script_vec(field, bytes, false)?)), + None => Ok(None), + } +} + +pub(crate) fn validate_script_bytes( + field: &'static str, + bytes: &[u8], + allow_empty: bool, +) -> Result<(), FfiValidationError> { + if !allow_empty && bytes.is_empty() { + return Err(FfiValidationError::ScriptEmpty { field: field.to_string() }); + } + if bytes.len() > MAX_SCRIPT_BYTES { + return Err(FfiValidationError::ScriptTooLarge { + field: field.to_string(), + len: bytes.len() as u64, + max: MAX_SCRIPT_BYTES as u64, + }); + } + Ok(()) +} + +pub(crate) fn validate_witness_stack(witness: &[Vec]) -> Result<(), FfiValidationError> { + if witness.len() > MAX_WITNESS_ITEMS { + return Err(FfiValidationError::WitnessItemsTooMany { + count: witness.len() as u64, + max: MAX_WITNESS_ITEMS as u64, + }); + } + + let mut total = 0usize; + for (index, item) in witness.iter().enumerate() { + if item.len() > MAX_SCRIPT_BYTES { + return Err(FfiValidationError::WitnessItemTooLarge { + index: index as u64, + len: item.len() as u64, + max: MAX_SCRIPT_BYTES as u64, + }); + } + total = total.saturating_add(item.len()); + } + + if total > MAX_WITNESS_BYTES { + return Err(FfiValidationError::WitnessTooLarge { + len: total as u64, + max: MAX_WITNESS_BYTES as u64, + }); + } + + Ok(()) +} + +pub(crate) fn validate_weight_units(weight_units: u64) -> Result { + let max_wu = Weight::MAX_BLOCK.to_wu(); + if weight_units == 0 || weight_units > max_wu { + return Err(FfiValidationError::WeightOutOfRange { weight_units, max_wu }); + } + Ok(Weight::from_wu(weight_units)) +} + +pub(crate) fn validate_fee_rate_sat_per_vb(value: u64) -> Result { + let fee_rate = FeeRate::from_sat_per_vb(value).ok_or_else(|| { + FfiValidationError::FeeRateOutOfRange { value, unit: "sat/vB".to_string() } + })?; + if fee_rate.checked_mul_by_weight(Weight::MAX_BLOCK).is_none() { + return Err(FfiValidationError::FeeRateOutOfRange { value, unit: "sat/vB".to_string() }); + } + Ok(fee_rate) +} + +pub(crate) fn validate_fee_rate_sat_per_kwu(value: u64) -> Result { + let fee_rate = FeeRate::from_sat_per_kwu(value); + if fee_rate.checked_mul_by_weight(Weight::MAX_BLOCK).is_none() { + return Err(FfiValidationError::FeeRateOutOfRange { value, unit: "sat/kwu".to_string() }); + } + Ok(fee_rate) +} + +pub(crate) fn validate_fee_rate_sat_per_vb_opt( + value: Option, +) -> Result, FfiValidationError> { + value.map(validate_fee_rate_sat_per_vb).transpose() +} + +pub(crate) fn validate_fee_rate_sat_per_kwu_opt( + value: Option, +) -> Result, FfiValidationError> { + value.map(validate_fee_rate_sat_per_kwu).transpose() +} + +pub(crate) fn validate_expiration_secs(seconds: u64) -> Result { + let max = u32::MAX as u64; + if seconds > max { + return Err(FfiValidationError::ExpirationOutOfRange { seconds, max }); + } + Ok(Duration::from_secs(seconds)) +} diff --git a/payjoin/src/core/psbt/mod.rs b/payjoin/src/core/psbt/mod.rs index 44e68260f..c02eaef59 100644 --- a/payjoin/src/core/psbt/mod.rs +++ b/payjoin/src/core/psbt/mod.rs @@ -362,6 +362,7 @@ pub(crate) enum AddressTypeError { PrevTxOut(PrevTxOutError), InvalidScript(FromScriptError), UnknownAddressType, + FeeRateOverflow, } impl fmt::Display for AddressTypeError { @@ -370,6 +371,7 @@ impl fmt::Display for AddressTypeError { Self::PrevTxOut(_) => write!(f, "invalid previous transaction output"), Self::InvalidScript(_) => write!(f, "invalid script"), Self::UnknownAddressType => write!(f, "unknown address type"), + Self::FeeRateOverflow => write!(f, "fee rate overflow"), } } } @@ -380,6 +382,7 @@ impl std::error::Error for AddressTypeError { Self::PrevTxOut(error) => Some(error), Self::InvalidScript(error) => Some(error), Self::UnknownAddressType => None, + Self::FeeRateOverflow => None, } } } diff --git a/payjoin/src/core/send/mod.rs b/payjoin/src/core/send/mod.rs index 80e321553..276d928b4 100644 --- a/payjoin/src/core/send/mod.rs +++ b/payjoin/src/core/send/mod.rs @@ -23,7 +23,7 @@ pub(crate) use error::{InternalBuildSenderError, InternalProposalError, Internal use url::Url; use crate::output_substitution::OutputSubstitution; -use crate::psbt::{PsbtExt, NON_WITNESS_INPUT_WEIGHT}; +use crate::psbt::{AddressTypeError, PsbtExt, NON_WITNESS_INPUT_WEIGHT}; use crate::Version; // See usize casts @@ -123,7 +123,9 @@ impl PsbtContextBuilder { } } - let recommended_additional_fee = min_fee_rate * input_weight; + let recommended_additional_fee = min_fee_rate + .checked_mul_by_weight(input_weight) + .ok_or(InternalBuildSenderError::AddressType(AddressTypeError::FeeRateOverflow))?; if fee_available < recommended_additional_fee { tracing::warn!("Insufficient funds to maintain specified minimum feerate."); return self.build_with_additional_fee(