Skip to content

Commit 76eb6e5

Browse files
committed
FFI sender validation and fee overflow guard
1 parent d3393d2 commit 76eb6e5

11 files changed

Lines changed: 338 additions & 66 deletions

File tree

payjoin-ffi/javascript/README.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,9 @@ This assumes you already have Rust and Node.js installed.
1111
git clone https://github.com/payjoin/rust-payjoin.git
1212
cd rust-payjoin/payjoin-ffi/javascript
1313

14+
# Clean out stale dependencies
15+
npm run clean
16+
rm -rf node_modules
1417
# Install dependencies
1518
cargo install wasm-bindgen-cli
1619
npm install

payjoin-ffi/src/error.rs

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,28 @@ impl From<ImplementationError> for payjoin::ImplementationError {
2121
#[error("Error de/serializing JSON object: {0}")]
2222
pub struct SerdeJsonError(#[from] serde_json::Error);
2323

24+
#[derive(Debug, thiserror::Error, uniffi::Error)]
25+
pub enum PrimitiveError {
26+
#[error("Amount out of range: {amount_sat} sats (max {max_sat})")]
27+
AmountOutOfRange { amount_sat: u64, max_sat: u64 },
28+
#[error("{field} script is empty")]
29+
ScriptEmpty { field: String },
30+
#[error("{field} script too large: {len} bytes (max {max})")]
31+
ScriptTooLarge { field: String, len: u64, max: u64 },
32+
#[error("Witness stack has {count} items (max {max})")]
33+
WitnessItemsTooMany { count: u64, max: u64 },
34+
#[error("Witness item {index} too large: {len} bytes (max {max})")]
35+
WitnessItemTooLarge { index: u64, len: u64, max: u64 },
36+
#[error("Witness stack too large: {len} bytes (max {max})")]
37+
WitnessTooLarge { len: u64, max: u64 },
38+
#[error("Weight out of range: {weight_units} wu (max {max_wu})")]
39+
WeightOutOfRange { weight_units: u64, max_wu: u64 },
40+
#[error("Fee rate out of range: {value} {unit}")]
41+
FeeRateOutOfRange { value: u64, unit: String },
42+
#[error("Expiration out of range: {seconds} seconds (max {max})")]
43+
ExpirationOutOfRange { seconds: u64, max: u64 },
44+
}
45+
2446
#[derive(Debug, thiserror::Error, PartialEq, Eq, uniffi::Error)]
2547
pub enum ForeignError {
2648
#[error("Internal error: {0}")]

payjoin-ffi/src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ pub mod send;
1111
#[cfg(feature = "_test-utils")]
1212
pub mod test_utils;
1313
pub mod uri;
14+
mod validation;
1415

1516
pub use payjoin::persist::NoopSessionPersister;
1617

payjoin-ffi/src/receive/error.rs

Lines changed: 43 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ use std::sync::Arc;
22

33
use payjoin::receive;
44

5-
use crate::error::ImplementationError;
5+
use crate::error::{ImplementationError, PrimitiveError};
66
use crate::uri::error::IntoUrlError;
77

88
/// The top-level error type for the payjoin receiver
@@ -168,10 +168,29 @@ impl From<ProtocolError> for JsonReply {
168168
#[error(transparent)]
169169
pub struct SessionError(#[from] receive::v2::SessionError);
170170

171-
/// Error that may occur when output substitution fails.
171+
/// Protocol error raised during output substitution.
172172
#[derive(Debug, thiserror::Error, uniffi::Object)]
173173
#[error(transparent)]
174-
pub struct OutputSubstitutionError(#[from] receive::OutputSubstitutionError);
174+
pub struct OutputSubstitutionProtocolError(#[from] receive::OutputSubstitutionError);
175+
176+
/// Error that may occur when output substitution fails.
177+
#[derive(Debug, thiserror::Error, uniffi::Error)]
178+
pub enum OutputSubstitutionError {
179+
#[error(transparent)]
180+
Protocol(Arc<OutputSubstitutionProtocolError>),
181+
#[error(transparent)]
182+
Primitive(PrimitiveError),
183+
}
184+
185+
impl From<receive::OutputSubstitutionError> for OutputSubstitutionError {
186+
fn from(value: receive::OutputSubstitutionError) -> Self {
187+
OutputSubstitutionError::Protocol(Arc::new(value.into()))
188+
}
189+
}
190+
191+
impl From<PrimitiveError> for OutputSubstitutionError {
192+
fn from(value: PrimitiveError) -> Self { OutputSubstitutionError::Primitive(value) }
193+
}
175194

176195
/// Error that may occur when coin selection fails.
177196
#[derive(Debug, thiserror::Error, uniffi::Object)]
@@ -194,9 +213,18 @@ pub enum InputPairError {
194213
/// Provided outpoint could not be parsed.
195214
#[error("Invalid outpoint (txid={txid}, vout={vout})")]
196215
InvalidOutPoint { txid: String, vout: u32 },
216+
/// Amount exceeds allowed maximum.
217+
#[error("Amount out of range: {amount_sat} sats (max {max_sat})")]
218+
AmountOutOfRange { amount_sat: u64, max_sat: u64 },
219+
/// Weight must be positive and no more than a block.
220+
#[error("Weight out of range: {weight_units} wu (max {max_wu})")]
221+
WeightOutOfRange { weight_units: u64, max_wu: u64 },
197222
/// PSBT input failed validation in the core library.
198223
#[error("Invalid PSBT input: {0}")]
199224
InvalidPsbtInput(Arc<PsbtInputError>),
225+
/// Primitive input failed validation in the FFI layer.
226+
#[error("Invalid primitive input: {0}")]
227+
InvalidPrimitive(PrimitiveError),
200228
}
201229

202230
impl InputPairError {
@@ -205,6 +233,18 @@ impl InputPairError {
205233
}
206234
}
207235

236+
impl From<PrimitiveError> for InputPairError {
237+
fn from(value: PrimitiveError) -> Self {
238+
match value {
239+
PrimitiveError::AmountOutOfRange { amount_sat, max_sat } =>
240+
InputPairError::AmountOutOfRange { amount_sat, max_sat },
241+
PrimitiveError::WeightOutOfRange { weight_units, max_wu } =>
242+
InputPairError::WeightOutOfRange { weight_units, max_wu },
243+
other => InputPairError::InvalidPrimitive(other),
244+
}
245+
}
246+
}
247+
208248
/// Error that may occur when a receiver event log is replayed
209249
#[derive(Debug, thiserror::Error, uniffi::Object)]
210250
#[error(transparent)]

payjoin-ffi/src/receive/mod.rs

Lines changed: 63 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
use std::str::FromStr;
22
use std::sync::{Arc, RwLock};
3-
use std::time::Duration;
43

54
pub use error::{
65
AddressParseError, InputContributionError, InputPairError, JsonReply, OutputSubstitutionError,
@@ -9,14 +8,19 @@ pub use error::{
98
};
109
use payjoin::bitcoin::consensus::Decodable;
1110
use payjoin::bitcoin::psbt::Psbt;
12-
use payjoin::bitcoin::{Amount, FeeRate};
11+
use payjoin::bitcoin::FeeRate;
1312
use payjoin::persist::{MaybeFatalTransition, NextStateTransition};
1413

1514
use crate::error::ForeignError;
16-
pub use crate::error::{ImplementationError, SerdeJsonError};
15+
pub use crate::error::{ImplementationError, PrimitiveError, SerdeJsonError};
1716
use crate::ohttp::OhttpKeys;
1817
use crate::receive::error::{ReceiverPersistedError, ReceiverReplayError};
1918
use crate::uri::error::FeeRateError;
19+
use crate::validation::{
20+
validate_amount_sat, validate_expiration_secs, validate_fee_rate_sat_per_kwu_opt,
21+
validate_fee_rate_sat_per_vb_opt, validate_optional_script, validate_script_bytes,
22+
validate_script_vec, validate_weight_units, validate_witness_stack,
23+
};
2024
use crate::{ClientResponse, OutputSubstitution, Request};
2125

2226
pub mod error;
@@ -270,12 +274,11 @@ pub struct PlainTxOut {
270274
pub script_pubkey: Vec<u8>,
271275
}
272276

273-
impl From<PlainTxOut> for payjoin::bitcoin::TxOut {
274-
fn from(value: PlainTxOut) -> Self {
275-
payjoin::bitcoin::TxOut {
276-
value: Amount::from_sat(value.value_sat),
277-
script_pubkey: payjoin::bitcoin::ScriptBuf::from_bytes(value.script_pubkey),
278-
}
277+
impl PlainTxOut {
278+
fn into_core(self) -> Result<payjoin::bitcoin::TxOut, PrimitiveError> {
279+
let value = validate_amount_sat(self.value_sat)?;
280+
let script_pubkey = validate_script_vec("script_pubkey", self.script_pubkey, false)?;
281+
Ok(payjoin::bitcoin::TxOut { value, script_pubkey })
279282
}
280283
}
281284

@@ -299,6 +302,8 @@ pub struct PlainTxIn {
299302

300303
impl PlainTxIn {
301304
fn into_core(self) -> Result<payjoin::bitcoin::TxIn, InputPairError> {
305+
validate_script_bytes("script_sig", &self.script_sig, true)?;
306+
validate_witness_stack(&self.witness)?;
302307
let previous_output = self.previous_output.into_core()?;
303308
Ok(payjoin::bitcoin::TxIn {
304309
previous_output,
@@ -341,13 +346,20 @@ pub struct PlainPsbtInput {
341346
}
342347

343348
impl PlainPsbtInput {
344-
fn into_core(self) -> payjoin::bitcoin::psbt::Input {
345-
payjoin::bitcoin::psbt::Input {
346-
witness_utxo: self.witness_utxo.map(Into::into),
347-
redeem_script: self.redeem_script.map(payjoin::bitcoin::ScriptBuf::from_bytes),
348-
witness_script: self.witness_script.map(payjoin::bitcoin::ScriptBuf::from_bytes),
349+
fn into_core(self) -> Result<payjoin::bitcoin::psbt::Input, InputPairError> {
350+
let witness_utxo = self
351+
.witness_utxo
352+
.map(|utxo| utxo.into_core())
353+
.transpose()
354+
.map_err(InputPairError::from)?;
355+
let redeem_script = validate_optional_script("redeem_script", self.redeem_script)?;
356+
let witness_script = validate_optional_script("witness_script", self.witness_script)?;
357+
Ok(payjoin::bitcoin::psbt::Input {
358+
witness_utxo,
359+
redeem_script,
360+
witness_script,
349361
..Default::default()
350-
}
362+
})
351363
}
352364
}
353365

@@ -357,8 +369,10 @@ pub struct PlainWeight {
357369
pub weight_units: u64,
358370
}
359371

360-
impl From<PlainWeight> for payjoin::bitcoin::Weight {
361-
fn from(value: PlainWeight) -> Self { payjoin::bitcoin::Weight::from_wu(value.weight_units) }
372+
impl PlainWeight {
373+
fn into_core(self) -> Result<payjoin::bitcoin::Weight, PrimitiveError> {
374+
validate_weight_units(self.weight_units)
375+
}
362376
}
363377

364378
impl From<payjoin::bitcoin::Weight> for PlainWeight {
@@ -395,12 +409,14 @@ impl ReceiverBuilder {
395409
))
396410
}
397411

398-
pub fn with_amount(&self, amount_sats: u64) -> Self {
399-
Self(self.0.clone().with_amount(Amount::from_sat(amount_sats)))
412+
pub fn with_amount(&self, amount_sats: u64) -> Result<Self, PrimitiveError> {
413+
let amount = validate_amount_sat(amount_sats)?;
414+
Ok(Self(self.0.clone().with_amount(amount)))
400415
}
401416

402-
pub fn with_expiration(&self, expiration: u64) -> Self {
403-
Self(self.0.clone().with_expiration(Duration::from_secs(expiration)))
417+
pub fn with_expiration(&self, expiration: u64) -> Result<Self, PrimitiveError> {
418+
let expiration = validate_expiration_secs(expiration)?;
419+
Ok(Self(self.0.clone().with_expiration(expiration)))
404420
}
405421

406422
/// Set the maximum effective fee rate the receiver is willing to pay for their own input/output contributions
@@ -622,17 +638,15 @@ impl UncheckedOriginalPayload {
622638
&self,
623639
min_fee_rate: Option<u64>,
624640
can_broadcast: Arc<dyn CanBroadcast>,
625-
) -> UncheckedOriginalPayloadTransition {
626-
UncheckedOriginalPayloadTransition(Arc::new(RwLock::new(Some(
627-
self.0.clone().check_broadcast_suitability(
628-
min_fee_rate.map(FeeRate::from_sat_per_kwu),
629-
|transaction| {
630-
can_broadcast
631-
.callback(payjoin::bitcoin::consensus::encode::serialize(transaction))
632-
.map_err(|e| ImplementationError::new(e).into())
633-
},
634-
),
635-
))))
641+
) -> Result<UncheckedOriginalPayloadTransition, PrimitiveError> {
642+
let min_fee_rate = validate_fee_rate_sat_per_kwu_opt(min_fee_rate)?;
643+
Ok(UncheckedOriginalPayloadTransition(Arc::new(RwLock::new(Some(
644+
self.0.clone().check_broadcast_suitability(min_fee_rate, |transaction| {
645+
can_broadcast
646+
.callback(payjoin::bitcoin::consensus::encode::serialize(transaction))
647+
.map_err(|e| ImplementationError::new(e).into())
648+
}),
649+
)))))
636650
}
637651

638652
/// Call this method if the only way to initiate a Payjoin with this receiver
@@ -837,9 +851,11 @@ impl WantsOutputs {
837851
replacement_outputs: Vec<PlainTxOut>,
838852
drain_script_pubkey: Vec<u8>,
839853
) -> Result<WantsOutputs, OutputSubstitutionError> {
840-
let replacement_outputs: Vec<payjoin::bitcoin::TxOut> =
841-
replacement_outputs.into_iter().map(Into::into).collect();
842-
let drain_script = payjoin::bitcoin::ScriptBuf::from_bytes(drain_script_pubkey);
854+
let replacement_outputs = replacement_outputs
855+
.into_iter()
856+
.map(|output| output.into_core())
857+
.collect::<Result<Vec<_>, _>>()?;
858+
let drain_script = validate_script_vec("drain_script_pubkey", drain_script_pubkey, false)?;
843859
self.0
844860
.clone()
845861
.replace_receiver_outputs(replacement_outputs, &drain_script)
@@ -851,7 +867,8 @@ impl WantsOutputs {
851867
&self,
852868
output_script_pubkey: Vec<u8>,
853869
) -> Result<WantsOutputs, OutputSubstitutionError> {
854-
let output_script = payjoin::bitcoin::ScriptBuf::from_bytes(output_script_pubkey);
870+
let output_script =
871+
validate_script_vec("output_script_pubkey", output_script_pubkey, false)?;
855872
self.0
856873
.clone()
857874
.substitute_receiver_script(&output_script)
@@ -945,8 +962,8 @@ impl InputPair {
945962
expected_weight: Option<PlainWeight>,
946963
) -> Result<Self, InputPairError> {
947964
let txin = txin.into_core()?;
948-
let psbtin = psbtin.into_core();
949-
let expected_weight = expected_weight.map(Into::into);
965+
let psbtin = psbtin.into_core()?;
966+
let expected_weight = expected_weight.map(|weight| weight.into_core()).transpose()?;
950967
payjoin::receive::InputPair::new(txin, psbtin, expected_weight)
951968
.map(Self)
952969
.map_err(|err| InputPairError::InvalidPsbtInput(Arc::new(err.into())))
@@ -1014,10 +1031,14 @@ impl WantsFeeRange {
10141031
&self,
10151032
min_fee_rate_sat_per_vb: Option<u64>,
10161033
max_effective_fee_rate_sat_per_vb: Option<u64>,
1017-
) -> WantsFeeRangeTransition {
1018-
WantsFeeRangeTransition(Arc::new(RwLock::new(Some(self.0.clone().apply_fee_range(
1019-
min_fee_rate_sat_per_vb.and_then(FeeRate::from_sat_per_vb),
1020-
max_effective_fee_rate_sat_per_vb.and_then(FeeRate::from_sat_per_vb),
1034+
) -> Result<WantsFeeRangeTransition, PrimitiveError> {
1035+
let min_fee_rate_sat_per_vb = validate_fee_rate_sat_per_vb_opt(min_fee_rate_sat_per_vb)?;
1036+
let max_effective_fee_rate_sat_per_vb =
1037+
validate_fee_rate_sat_per_vb_opt(max_effective_fee_rate_sat_per_vb)?;
1038+
Ok(WantsFeeRangeTransition(Arc::new(RwLock::new(Some(
1039+
self.0
1040+
.clone()
1041+
.apply_fee_range(min_fee_rate_sat_per_vb, max_effective_fee_rate_sat_per_vb),
10211042
)))))
10221043
}
10231044
}

payjoin-ffi/src/send/error.rs

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
use std::sync::Arc;
22

3-
use payjoin::bitcoin::psbt::PsbtParseError;
3+
use payjoin::bitcoin::psbt::PsbtParseError as CorePsbtParseError;
44
use payjoin::send;
55

6-
use crate::error::ImplementationError;
6+
use crate::error::{ImplementationError, PrimitiveError};
77

88
/// Error building a Sender from a SenderBuilder.
99
///
@@ -22,6 +22,33 @@ impl From<send::BuildSenderError> for BuildSenderError {
2222
fn from(value: send::BuildSenderError) -> Self { BuildSenderError { msg: value.to_string() } }
2323
}
2424

25+
/// FFI-visible PSBT parsing error surfaced at the sender boundary.
26+
#[derive(Debug, thiserror::Error, uniffi::Error)]
27+
pub enum PsbtParseError {
28+
/// The provided PSBT string could not be parsed.
29+
#[error("Invalid PSBT: {0}")]
30+
InvalidPsbt(String),
31+
}
32+
33+
impl From<CorePsbtParseError> for PsbtParseError {
34+
fn from(value: CorePsbtParseError) -> Self { PsbtParseError::InvalidPsbt(value.to_string()) }
35+
}
36+
37+
/// Raised when inputs provided to the sender are malformed or sender build fails.
38+
#[derive(Debug, thiserror::Error, uniffi::Error)]
39+
pub enum SenderInputError {
40+
#[error(transparent)]
41+
Psbt(PsbtParseError),
42+
#[error(transparent)]
43+
Build(Arc<BuildSenderError>),
44+
#[error(transparent)]
45+
Primitive(PrimitiveError),
46+
}
47+
48+
impl From<PrimitiveError> for SenderInputError {
49+
fn from(value: PrimitiveError) -> Self { SenderInputError::Primitive(value) }
50+
}
51+
2552
/// Error returned when request could not be created.
2653
///
2754
/// This error can currently only happen due to programmer mistake.

0 commit comments

Comments
 (0)