-
Notifications
You must be signed in to change notification settings - Fork 44
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Add a check for receiver to validate minimum required fees #113
Conversation
payjoin/src/receive/mod.rs
Outdated
/// Check that the Original PSBT meet the minimum feerate receiver requires. | ||
pub fn check_if_meet_min_feerate(self, receiver_min_feerate: FeeRate) -> Result<(), Error> { | ||
let sender_fee_rate = self.params.min_feerate; | ||
// let sender_addtional_fee_contribution = self.params.additional_fee_contribution; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
what else should we take into account in this check?
we have another couple of params that can be interesting:
self.psbt.fee()
(this thing returnsBitcoin::Amount
but I guess we can convert it tobitcoin::FeeRate
self.params.addition_fee_contr
- this need to be validated(other index input should be set) first which we do in a different check
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
params.min_feerate
!= original psbt feerate. That parameter can be set arbitrarily. What we want to check is the effective FeeRate of self.psbt
to make sure this is a reliable fallback if the sender disappears. Otherwise, receiver would be subject to probing attacks where the original psbt fee rate is too low for the receiver to actually ever get it included in a block in the fallback case. As you mention, additional_fee_contribution
is handled later on and can be ignored in this check.
Perhaps instead of check_if_meet_min_feerate
this functionality could be combined with check_can_broadcast
. Since that is already a testmempoolaccept
check to prevent probing, which is the basis for this issue.
Something like check_broadcast_suitability(self, min_feerate: Option<bitcoin::FeeRate>, can_broadcast: impl Fn(&bitcoin::Transaction) -> Result<bool, Error>
could replace check_can_broadcast.
min_feerate could be enforced on `assume_interactive_receiver(self, min_feerate: Optionbitcoin::FeeRate)`` using a shared helper function, too
As a bonus this uses our typestate machine so the check can't be so easily overlooked.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
thanks for the explanation
sorry for the delay, working on it
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
thanks for the patience
I pushed a new version and split the commits.
f7e97b7
to
91792f1
Compare
payjoin/src/receive/mod.rs
Outdated
fn proposal_from_test_vector( | ||
min_feerate: Option<u64>, | ||
) -> Result<UncheckedProposal, RequestError> { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Why is this fn prematurely paramaterized if the param is not being used?
payjoin/src/receive/mod.rs
Outdated
if let (Some(receiver_min_feerate), Ok(psbt_feerate)) = (min_feerate, self.psbt.fee()) { | ||
if receiver_min_feerate.to_sat_per_kwu() > psbt_feerate.to_sat() { | ||
return Err(Error::BadRequest( | ||
InternalRequestError::FeeTooLow(psbt_feerate, receiver_min_feerate).into(), | ||
)); | ||
} | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I do not believe this is an apples-to-apples comparison. It compaes paramaterized FeeRate expressed per kwu with the absolute fee of the original_psbt.
e.g. the param could be 10sat/b which and the absolute psbt.fee()
would be e.g. 14,067. Comparing 10sat/b * 250 sat per kwu = 2500sat [per kwu] to 14,067sat makes no sense. That would be comparing two totally different things.
I think it makes more sense to compare FeeRates.
First, calculate the psbt_feerate (the current so-called psbt_feerate is not a feerate at all, there is no denominator) by dividing the psbt.fee()
with the extracted transaction weight. e.g.
let original_transaction = self.psbt.clone().extract_tx();
if let (Some(min_feerate), Ok(original_psbt_fee)) = (min_feerate, self.psbt.fee()) {
let original_psbt_feerate = original_psbt_fee / original_transaction.weight();
if original_psbt_feerate < min_feerate {
return Err(Error::BadRequest(
InternalRequestError::FeeTooLow(original_psbt_feerate, min_feerate).into(),
));
}
}
Even my suggested code is also incomplete. It disregards the condition where self.psbt.fee().is_err()
. That condition MUST also be handled with an error. I'll leave that exercise to you.
payjoin/tests/integration.rs
Outdated
// Here we set receiver min feerate to be higher than the proposal's psbt feerate | ||
// This should fail the check | ||
let receiver_min_feerate = bitcoin::FeeRate::from_sat_per_kwu(283); | ||
assert!(proposal | ||
.clone() | ||
.check_broadcast_suitability(Some(receiver_min_feerate), |tx| { | ||
Ok(receiver | ||
.test_mempool_accept(&[bitcoin::consensus::encode::serialize_hex(&tx)]) | ||
.unwrap() | ||
.first() | ||
.unwrap() | ||
.allowed) | ||
}) | ||
.is_err()); | ||
// Here we set receiver min feerate to be lower than the proposal's psbt feerate | ||
let receiver_min_feerate = bitcoin::FeeRate::from_sat_per_kwu(281); | ||
let proposal = proposal | ||
.check_can_broadcast(|tx| { | ||
.check_broadcast_suitability(Some(receiver_min_feerate), |tx| { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If we want to show test succeeds / fails, we should get the extracted txs feerate in the test and make sure the params are relevant. Because current check has nothing to do with the actual original_psbt feerate! only absolute fee.
9b06a53
to
1a26d3d
Compare
payjoin/src/receive/mod.rs
Outdated
@@ -852,7 +873,7 @@ mod test { | |||
let headers = MockHeaders::new(body.len() as u64); | |||
UncheckedProposal::from_request( | |||
body, | |||
"?maxadditionalfeecontribution=182?additionalfeeoutputindex=0", | |||
"maxadditionalfeecontribution=182&additionalfeeoutputindex=0&minfeerate=0", |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
not required
payjoin/src/receive/mod.rs
Outdated
@@ -890,4 +911,10 @@ mod test { | |||
|
|||
assert!(payjoin.is_ok(), "Payjoin should be a valid PSBT"); | |||
} | |||
|
|||
#[test] | |||
fn psbt_fee_rate() { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
add another test vectors
The previous `query` used `?` to separate between pairs while it should be `&` as per the `url_fromencoded` crate.
[`payjoin::receive::optional_parameters::Params`] [`payjoin::receive::UncheckedProposal`]
1a26d3d
to
7b3678f
Compare
@DanGould |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Getting much closer 🔥 I think this gets the job done and only request a few cleanups
payjoin/src/receive/mod.rs
Outdated
@@ -345,26 +346,46 @@ impl UncheckedProposal { | |||
} | |||
|
|||
/// The Sender's Original PSBT | |||
pub fn extract_tx_to_schedule_broadcast(&self) -> bitcoin::Transaction { | |||
self.psbt.clone().extract_tx() | |||
pub fn psbt_transaction(&self) -> bitcoin::Transaction { self.psbt.clone().extract_tx() } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This name change seems unrelated to the receiver check this PR is about and I'd prefer it wasn't made. extract_tx (for the specific api reason) tries to convey why the api is available, and extract lets you know it's calculating rather than returning something from memory.
Updating this would also merit a change to the documentation at the front matter of this file
payjoin/src/receive/mod.rs
Outdated
pub fn psbt_transaction(&self) -> bitcoin::Transaction { self.psbt.clone().extract_tx() } | ||
|
||
/// The Sender's Original PSBT fee rate. | ||
pub fn psbt_fee_rate(&self) -> Result<FeeRate, Error> { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Not sure this function should be public, especially since it returns BadRequest, which doesn't make much sense as a return type for a fee_rate here
payjoin/src/receive/mod.rs
Outdated
return Err(Error::BadRequest( | ||
InternalRequestError::PsbtBelowFeeRate(original_psbt_fee_rate, min_fee_rate) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I suggest implicit conversion to Error by using .into()
on its own. See return Err(InternalRequestError::InvalidContentType(content_type.to_owned()).into());
@@ -873,6 +894,7 @@ mod test { | |||
use bitcoin::{Address, Network}; | |||
|
|||
let proposal = proposal_from_test_vector().unwrap(); | |||
assert_eq!(proposal.psbt_fee_rate().unwrap().to_sat_per_vb_floor(), 2); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I understand why you might make psbt_fee_rate public for this function, but it's so brief and nonessential to the payjoin api I'd rather see it repeated in the test than exposed publically.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
the pub was a leftover from when i was testing in integration, removed that.
here in the unit test it can access all the functions/structs in the same module so its fine.
payjoin/src/receive/v2.rs
Outdated
/// call assume_interactive_receive to proceed with validation. | ||
pub struct UncheckedProposal { | ||
inner: super::UncheckedProposal, | ||
pub inner: super::UncheckedProposal, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Why make this public? Ideally these typestate machines are kept opaque so that the only modifications happen through the machine.
payjoin/src/receive/v2.rs
Outdated
pub fn check_broadcast_suitability( | ||
self, | ||
can_broadcast: impl Fn(&bitcoin::Transaction) -> Result<bool, Error>, | ||
) -> Result<MaybeInputsOwned, Error> { | ||
let inner = self.inner.check_can_broadcast(can_broadcast)?; | ||
let inner = self.inner.check_broadcast_suitability(None, can_broadcast)?; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This v2 UncheckedProposal should have the same interface for self.inner.check_boradcast_suitablity
and accept min_feerate
. HardcodingNone
should raise a yellow flag.
InternalRequestError::PsbtBelowFeeRate( | ||
original_psbt_fee_rate, | ||
receiver_min_fee_rate, | ||
) => write_error( | ||
f, | ||
"original-psbt-rejected", | ||
&format!( | ||
"Original PSBT fee rate too low: {} < {}.", | ||
original_psbt_fee_rate, receiver_min_fee_rate | ||
), | ||
), |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Very nice! 🤌
payjoin/src/receive/error.rs
Outdated
/// First argument is the calculated fee rate of the original PSBT. | ||
/// [`UncheckedProposal::psbt_fee_rate`] |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
remember to remove this comment if choosing to remove psbt_fee_rate from the public interface.
Payjoin receiver can set minimum `fee_rate` in order to reject payjoin requests that contain a psbt which doesnt meet the minimum `fee_rate` threshold. We add this functionality to `check_broadcast_suitability` function which previously was called `check_can_broadcast`.
7b3678f
to
3036b2f
Compare
Thanks for the input @DanGould |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Powerful speed on the turnaround here.
The only discrepancy I see is that between receive::UncheckedProposal
and receive::v2::UncheckedProposal
implementing Clone
. But that's a problem because the underlying ohttp::KeyConfig
is not Clone
so it would be a pain to make happen.
I might have also dropped psbt_fee_rate
entirely and tested check_broadcast_suitability
in the unchecked_proposal_unlocks_after_checks
receiver checks instead of leaving assume
and calling assert_eq!(proposal.psbt_fee_rate().unwrap().to_sat_per_vb_floor(), 2);
But fortunately I see this works anyhow. It may be done in a follow up.
e.g.
.check_broadcast_suitability(Some(FeeRate::from_sat_per_vb(3).unwrap()), |_| Ok(true))
// is Err
.check_broadcast_suitability(Some(FeeRate::from_sat_per_vb(2).unwrap()), |_| Ok(true))
// is Ok
Ci failed to catch the need to change here: rust-payjoin/payjoin-cli/src/app.rs Line 542 in 624ab7f
We need to make sure payjoin-cli builds in v2 |
resolves #105