diff --git a/payjoin-ffi/Cargo.toml b/payjoin-ffi/Cargo.toml index 8ac6c0bb2..5ddb184ad 100644 --- a/payjoin-ffi/Cargo.toml +++ b/payjoin-ffi/Cargo.toml @@ -31,7 +31,10 @@ serde = { version = "1.0.219", features = ["derive"] } serde_json = "1.0.142" thiserror = "2.0.14" tokio = { version = "1.47.1", features = ["full"], optional = true } -uniffi = { version = "0.30.0", features = ["cli"] } +uniffi = { version = "0.30.0", features = [ + "cli", + "wasm-unstable-single-threaded", +] } uniffi-dart = { git = "https://github.com/Uniffi-Dart/uniffi-dart.git", rev = "5bdcc79", optional = true } url = "2.5.4" diff --git a/payjoin-ffi/dart/test/test_payjoin_integration_test.dart b/payjoin-ffi/dart/test/test_payjoin_integration_test.dart index adf5d1743..eba66b1cf 100644 --- a/payjoin-ffi/dart/test/test_payjoin_integration_test.dart +++ b/payjoin-ffi/dart/test/test_payjoin_integration_test.dart @@ -373,6 +373,41 @@ Future process_receiver_proposal( void main() { group('Test integration', () { + test('Invalid primitives', () async { + final tooLargeAmount = 21000000 * 100000000 + 1; + final txin = payjoin.PlainTxIn( + payjoin.PlainOutPoint("00" * 64, 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()), + ); + + final pjUri = payjoin.Uri.parse( + "bitcoin:12c6DSiU4Rq3P4ZxziKxzrL5LmMBrzjrJX?amount=1&pj=https://example.com", + ).checkPjSupported(); + final psbt = + "cHNidP8BAHMCAAAAAY8nutGgJdyYGXWiBEb45Hoe9lWGbkxh/6bNiOJdCDuDAAAAAAD+////AtyVuAUAAAAAF6kUHehJ8GnSdBUOOv6ujXLrWmsJRDCHgIQeAAAAAAAXqRR3QJbbz0hnQ8IvQ0fptGn+votneofTAAAAAAEBIKgb1wUAAAAAF6kU3k4ekGHKWRNbA1rV5tR5kEVDVNCHAQcXFgAUx4pFclNVgo1WWAdN1SYNX8tphTABCGsCRzBEAiB8Q+A6dep+Rz92vhy26lT0AjZn4PRLi8Bf9qoB/CMk0wIgP/Rj2PWZ3gEjUkTlhDRNAQ0gXwTO7t9n+V14pZ6oljUBIQMVmsAaoNWHVMS02LfTSe0e388LNitPa1UQZyOihY+FFgABABYAFEb2Giu6c4KO5YW0pfw3lGp9jMUUAAA="; + final maxU64 = int.parse("18446744073709551615"); + expect( + () => payjoin.SenderBuilder(psbt, pjUri).buildRecommended(maxU64), + throwsA(isA()), + ); + + expect( + () => pjUri.setAmountSats(tooLargeAmount), + throwsA(isA()), + ); + }); + test('Test integration v2 to v2', () async { env = payjoin.initBitcoindSenderReceiver(); bitcoind = env.getBitcoind(); diff --git a/payjoin-ffi/dart/test/test_payjoin_unit_test.dart b/payjoin-ffi/dart/test/test_payjoin_unit_test.dart index 945330f7b..4e4147b40 100644 --- a/payjoin-ffi/dart/test/test_payjoin_unit_test.dart +++ b/payjoin-ffi/dart/test/test_payjoin_unit_test.dart @@ -152,5 +152,15 @@ void main() { reason: "persistence should return a reply key", ); }); + + 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/test/integration.test.ts b/payjoin-ffi/javascript/test/integration.test.ts index 4ce78d710..ec9b2850c 100644 --- a/payjoin-ffi/javascript/test/integration.test.ts +++ b/payjoin-ffi/javascript/test/integration.test.ts @@ -450,6 +450,46 @@ async function processReceiverProposal( throw new Error(`Unknown receiver state`); } +function testInvalidPrimitives(): void { + const tooLargeAmount = 21000000n * 100000000n + 1n; + const txin = payjoin.PlainTxIn.create({ + previousOutput: payjoin.PlainOutPoint.create({ + txid: "00".repeat(64), + 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(txin, psbtIn, undefined); + }, /Amount out of range/); + + const pjUri = payjoin.Uri.parse( + "bitcoin:12c6DSiU4Rq3P4ZxziKxzrL5LmMBrzjrJX?amount=1&pj=https://example.com", + ).checkPjSupported(); + const psbt = + "cHNidP8BAHMCAAAAAY8nutGgJdyYGXWiBEb45Hoe9lWGbkxh/6bNiOJdCDuDAAAAAAD+////AtyVuAUAAAAAF6kUHehJ8GnSdBUOOv6ujXLrWmsJRDCHgIQeAAAAAAAXqRR3QJbbz0hnQ8IvQ0fptGn+votneofTAAAAAAEBIKgb1wUAAAAAF6kU3k4ekGHKWRNbA1rV5tR5kEVDVNCHAQcXFgAUx4pFclNVgo1WWAdN1SYNX8tphTABCGsCRzBEAiB8Q+A6dep+Rz92vhy26lT0AjZn4PRLi8Bf9qoB/CMk0wIgP/Rj2PWZ3gEjUkTlhDRNAQ0gXwTO7t9n+V14pZ6oljUBIQMVmsAaoNWHVMS02LfTSe0e388LNitPa1UQZyOihY+FFgABABYAFEb2Giu6c4KO5YW0pfw3lGp9jMUUAAA="; + assert.throws(() => { + new payjoin.SenderBuilder(psbt, pjUri).buildRecommended( + 18446744073709551615n, + ); + }, /Fee rate out of range/); + + assert.throws(() => { + pjUri.setAmountSats(tooLargeAmount); + }, /Amount out of range/); +} + async function testIntegrationV2ToV2(): Promise { const env = testUtils.initBitcoindSenderReceiver(); const bitcoind = env.getBitcoind(); @@ -589,6 +629,7 @@ async function testIntegrationV2ToV2(): Promise { async function runTests(): Promise { await uniffiInitAsync(); + testInvalidPrimitives(); await testIntegrationV2ToV2(); } diff --git a/payjoin-ffi/javascript/test/unit.test.ts b/payjoin-ffi/javascript/test/unit.test.ts index d1f50ae69..8e99e3e0e 100644 --- a/payjoin-ffi/javascript/test/unit.test.ts +++ b/payjoin-ffi/javascript/test/unit.test.ts @@ -195,6 +195,12 @@ describe("Validation", () => { }); }); + test("sender builder rejects bad psbt", () => { + assert.throws(() => { + new payjoin.SenderBuilder("not-a-psbt", payjoin.exampleUrl()); + }); + }); + test("input pair rejects invalid outpoint", () => { assert.throws(() => { const txin = payjoin.PlainTxIn.create({ @@ -214,4 +220,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 ccb30e6d5..ad290b424 100644 --- a/payjoin-ffi/python/test/test_payjoin_integration_test.py +++ b/payjoin-ffi/python/test/test_payjoin_integration_test.py @@ -3,16 +3,11 @@ import httpx import json +from decimal import Decimal from payjoin import * from typing import Optional import unittest -try: - import payjoin.bitcoin as bitcoinffi -except ImportError: - bitcoinffi = None - raise unittest.SkipTest("bitcoin_ffi helpers are not available in this binding") - # The below sys path setting is required to use the 'payjoin' module in the 'src' directory # This script is in the 'tests' directory and the 'payjoin' module is in the 'src' directory sys.path.insert( @@ -62,6 +57,37 @@ def setUpClass(cls): cls.receiver = cls.env.get_receiver() cls.sender = cls.env.get_sender() + async def test_invalid_primitives(self): + too_large_amount = 21_000_000 * 100_000_000 + 1 + txin = PlainTxIn( + previous_output=PlainOutPoint(txid="00" * 64, 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) as ctx: + InputPair(txin=txin, psbtin=psbt_in, expected_weight=None) + self.assertIn("Amount out of range", str(ctx.exception)) + + pj_uri = Uri.parse( + "bitcoin:12c6DSiU4Rq3P4ZxziKxzrL5LmMBrzjrJX?amount=1&pj=https://example.com" + ).check_pj_supported() + with self.assertRaises(SenderInputError) as ctx: + SenderBuilder(original_psbt(), pj_uri).build_recommended(2**64 - 1) + self.assertIn("Fee rate out of range", str(ctx.exception)) + + with self.assertRaises(PrimitiveError) as ctx: + pj_uri.set_amount_sats(too_large_amount) + self.assertIn("Amount out of range", str(ctx.exception)) + async def process_receiver_proposal( self, receiver: ReceiveSession, @@ -101,7 +127,7 @@ async def process_receiver_proposal( def create_receiver_context( self, - receiver_address: bitcoinffi.Address, + receiver_address: str, directory: str, ohttp_keys: OhttpKeys, recv_persister: InMemoryReceiverSessionEventLog, @@ -207,10 +233,7 @@ async def process_provisional_proposal( async def test_integration_v2_to_v2(self): try: - receiver_address = bitcoinffi.Address( - json.loads(self.receiver.call("getnewaddress", [])), - bitcoinffi.Network.REGTEST, - ) + receiver_address = json.loads(self.receiver.call("getnewaddress", [])) init_tracing() services = TestServices.initialize() @@ -295,19 +318,26 @@ async def test_integration_v2_to_v2(self): [checked_payjoin_proposal_psbt.serialize_base64()], ) )["psbt"] - final_psbt = json.loads( - self.sender.call("finalizepsbt", [payjoin_psbt, json.dumps(False)]) - )["psbt"] - payjoin_tx = bitcoinffi.Psbt.deserialize_base64(final_psbt).extract_tx() - self.sender.call( - "sendrawtransaction", [json.dumps(payjoin_tx.serialize().hex())] + finalized = json.loads( + self.sender.call("finalizepsbt", [payjoin_psbt, json.dumps(True)]) + ) + payjoin_tx_hex = finalized["hex"] + txid = json.loads( + self.sender.call("sendrawtransaction", [json.dumps(payjoin_tx_hex)]) + ) + decoded_tx = json.loads( + self.sender.call("decoderawtransaction", [json.dumps(payjoin_tx_hex)]) ) # Check resulting transaction and balances - network_fees = bitcoinffi.Psbt.deserialize_base64(final_psbt).fee().to_btc() + mempool_entry = json.loads( + self.sender.call("getmempoolentry", [json.dumps(txid)]) + ) + fees = mempool_entry.get("fees", {}) + network_fees = fees.get("base", mempool_entry.get("fee", 0)) # Sender sent the entire value of their utxo to receiver (minus fees) - self.assertEqual(len(payjoin_tx.input()), 2) - self.assertEqual(len(payjoin_tx.output()), 1) + self.assertEqual(len(decoded_tx["vin"]), 2) + self.assertEqual(len(decoded_tx["vout"]), 1) self.assertEqual( float( json.loads(self.receiver.call("getbalances", []))["mine"][ @@ -323,7 +353,7 @@ async def test_integration_v2_to_v2(self): raise -def build_sweep_psbt(sender: RpcClient, pj_uri: PjUri) -> bitcoinffi.Psbt: +def build_sweep_psbt(sender: RpcClient, pj_uri: PjUri) -> str: outputs = {} outputs[pj_uri.address()] = 50 psbt = json.loads( @@ -355,12 +385,6 @@ def get_inputs(rpc_connection: RpcClient) -> list[InputPair]: utxos = json.loads(rpc_connection.call("listunspent", [])) inputs = [] for utxo in utxos[:1]: - txin = bitcoinffi.TxIn( - previous_output=bitcoinffi.OutPoint(txid=utxo["txid"], vout=utxo["vout"]), - script_sig=bitcoinffi.Script(bytes()), - sequence=0, - witness=[], - ) raw_tx = json.loads( rpc_connection.call( "gettransaction", @@ -368,11 +392,21 @@ def get_inputs(rpc_connection: RpcClient) -> list[InputPair]: ) ) prev_out = raw_tx["decoded"]["vout"][utxo["vout"]] - prev_spk = bitcoinffi.Script(bytes.fromhex(prev_out["scriptPubKey"]["hex"])) - prev_amount = bitcoinffi.Amount.from_btc(prev_out["value"]) - tx_out = bitcoinffi.TxOut(value=prev_amount, script_pubkey=prev_spk) - psbt_in = PsbtInput( - witness_utxo=tx_out, redeem_script=None, witness_script=None + value_sat = int(Decimal(str(prev_out["value"])) * Decimal("100000000")) + txin = PlainTxIn( + previous_output=PlainOutPoint(txid=utxo["txid"], vout=utxo["vout"]), + script_sig=b"", + sequence=0, + witness=[], + ) + tx_out = PlainTxOut( + value_sat=value_sat, + script_pubkey=bytes.fromhex(prev_out["scriptPubKey"]["hex"]), + ) + psbt_in = PlainPsbtInput( + witness_utxo=tx_out, + redeem_script=None, + witness_script=None, ) inputs.append(InputPair(txin=txin, psbtin=psbt_in, expected_weight=None)) @@ -402,12 +436,36 @@ def __init__(self, connection: RpcClient): def callback(self, script): try: - address = bitcoinffi.Address.from_script( - bitcoinffi.Script(script), bitcoinffi.Network.REGTEST + script_hex = bytes(script).hex() + decoded_script = json.loads( + self.connection.call("decodescript", [json.dumps(script_hex)]) ) - return json.loads(self.connection.call("getaddressinfo", [str(address)]))[ - "ismine" - ] + + candidates = [] + address = decoded_script.get("address") + if isinstance(address, str): + candidates.append(address) + addresses = decoded_script.get("addresses") + if isinstance(addresses, list): + candidates.extend([addr for addr in addresses if isinstance(addr, str)]) + segwit = decoded_script.get("segwit") + if isinstance(segwit, dict): + segwit_address = segwit.get("address") + if isinstance(segwit_address, str): + candidates.append(segwit_address) + segwit_addresses = segwit.get("addresses") + if isinstance(segwit_addresses, list): + candidates.extend( + [addr for addr in segwit_addresses if isinstance(addr, str)] + ) + + for addr in candidates: + info = json.loads( + self.connection.call("getaddressinfo", [json.dumps(addr)]) + ) + if info.get("ismine") is True: + return True + return False except Exception as e: print(f"An error occurred: {e}") return None diff --git a/payjoin-ffi/python/test/test_payjoin_unit_test.py b/payjoin-ffi/python/test/test_payjoin_unit_test.py index f08803a84..ae19ec25e 100644 --- a/payjoin-ffi/python/test/test_payjoin_unit_test.py +++ b/payjoin-ffi/python/test/test_payjoin_unit_test.py @@ -106,5 +106,39 @@ def test_sender_persistence(self): ) +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..6cd5ff2b2 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 PrimitiveError { + #[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..ec2e46529 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::{ImplementationError, PrimitiveError}; 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)] + Primitive(PrimitiveError), +} + +impl From for OutputSubstitutionError { + fn from(value: receive::OutputSubstitutionError) -> Self { + OutputSubstitutionError::Protocol(Arc::new(value.into())) + } +} + +impl From for OutputSubstitutionError { + fn from(value: PrimitiveError) -> Self { OutputSubstitutionError::Primitive(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), + /// Primitive input failed validation in the FFI layer. + #[error("Invalid primitive input: {0}")] + InvalidPrimitive(PrimitiveError), } impl InputPairError { @@ -205,6 +227,10 @@ impl InputPairError { } } +impl From for InputPairError { + fn from(value: PrimitiveError) -> Self { InputPairError::InvalidPrimitive(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 c3c091a9b..85bd21da3 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::{ImplementationError, PrimitiveError, 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; @@ -229,12 +233,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 }) } } @@ -258,6 +261,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, @@ -300,13 +305,16 @@ 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()?; + 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() - } + }) } } @@ -316,8 +324,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 { @@ -354,12 +364,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 @@ -567,17 +579,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 @@ -782,9 +792,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) @@ -796,7 +808,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) @@ -890,8 +903,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()))) @@ -959,10 +972,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..89fba2299 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::{ImplementationError, PrimitiveError}; /// 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)] + Primitive(PrimitiveError), +} + +impl From for SenderInputError { + fn from(value: PrimitiveError) -> Self { SenderInputError::Primitive(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 612c916a8..091043824 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; @@ -229,9 +233,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. @@ -252,12 +259,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. /// @@ -278,17 +288,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. /// @@ -297,12 +311,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..35a5a335d 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::PrimitiveError; +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..2226604a7 --- /dev/null +++ b/payjoin-ffi/src/validation.rs @@ -0,0 +1,136 @@ +use std::time::Duration; + +use payjoin::bitcoin::{Amount, FeeRate, ScriptBuf, Weight}; + +use crate::error::PrimitiveError; + +const MAX_SCRIPT_BYTES: usize = 10_000; +const MAX_WITNESS_ITEMS: usize = 1000; +const MAX_WITNESS_BYTES: usize = 100_000; + +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(PrimitiveError::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, PrimitiveError> { + 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<(), PrimitiveError> { + if !allow_empty && bytes.is_empty() { + return Err(PrimitiveError::ScriptEmpty { field: field.to_string() }); + } + if bytes.len() > MAX_SCRIPT_BYTES { + return Err(PrimitiveError::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<(), PrimitiveError> { + if witness.len() > MAX_WITNESS_ITEMS { + return Err(PrimitiveError::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(PrimitiveError::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(PrimitiveError::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 > max_wu { + return Err(PrimitiveError::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(|| { + PrimitiveError::FeeRateOutOfRange { value, unit: "sat/vB".to_string() } + })?; + if fee_rate.checked_mul_by_weight(Weight::MAX_BLOCK).is_none() { + return Err(PrimitiveError::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(PrimitiveError::FeeRateOutOfRange { + value, + unit: "sat/kwu".to_string(), + }); + } + Ok(fee_rate) +} + +pub(crate) fn validate_fee_rate_sat_per_vb_opt( + value: Option, +) -> Result, PrimitiveError> { + value.map(validate_fee_rate_sat_per_vb).transpose() +} + +pub(crate) fn validate_fee_rate_sat_per_kwu_opt( + value: Option, +) -> Result, PrimitiveError> { + 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(PrimitiveError::ExpirationOutOfRange { seconds, max }); + } + Ok(Duration::from_secs(seconds)) +}