Skip to content
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

Merged
merged 3 commits into from
Dec 13, 2023

Conversation

jbesraa
Copy link
Contributor

@jbesraa jbesraa commented Nov 10, 2023

resolves #105

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`.

/// 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;
Copy link
Contributor Author

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:

  1. self.psbt.fee() (this thing returns Bitcoin::Amount but I guess we can convert it to bitcoin::FeeRate
  2. self.params.addition_fee_contr - this need to be validated(other index input should be set) first which we do in a different check

Copy link
Contributor

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.

Copy link
Contributor Author

@jbesraa jbesraa Nov 23, 2023

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

Copy link
Contributor Author

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.

payjoin/src/receive/mod.rs Outdated Show resolved Hide resolved
@jbesraa jbesraa force-pushed the feat/reciever-min-feerate branch 4 times, most recently from f7e97b7 to 91792f1 Compare November 28, 2023 15:34
@jbesraa jbesraa added the receive receiving payjoin label Nov 28, 2023
payjoin/src/receive/error.rs Outdated Show resolved Hide resolved
Comment on lines 844 to 867
fn proposal_from_test_vector(
min_feerate: Option<u64>,
) -> Result<UncheckedProposal, RequestError> {
Copy link
Contributor

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 Show resolved Hide resolved
payjoin/src/receive/error.rs Outdated Show resolved Hide resolved
payjoin/src/receive/error.rs Outdated Show resolved Hide resolved
payjoin/src/receive/mod.rs Show resolved Hide resolved
Comment on lines 368 to 387
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(),
));
}
}
Copy link
Contributor

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.

Comment on lines 146 to 163
// 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| {
Copy link
Contributor

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.

@jbesraa jbesraa force-pushed the feat/reciever-min-feerate branch 6 times, most recently from 9b06a53 to 1a26d3d Compare December 8, 2023 12:49
@@ -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",
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

not required

@@ -890,4 +911,10 @@ mod test {

assert!(payjoin.is_ok(), "Payjoin should be a valid PSBT");
}

#[test]
fn psbt_fee_rate() {
Copy link
Contributor Author

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`]
@jbesraa jbesraa force-pushed the feat/reciever-min-feerate branch from 1a26d3d to 7b3678f Compare December 12, 2023 17:39
@jbesraa
Copy link
Contributor Author

jbesraa commented Dec 12, 2023

@DanGould
Rebased from master branch which required some minor changes to v2 stuff, and removed not required changes in the scope of this pull request.
We talked about test vectors for different PSBTs, but I was not able to find a quick solution to generate PSBTs with some configuration regrading fee or weights, might be something we want to develop internally.
I think this pr can go on and we can look into more robust testing in the (near) future.

Copy link
Contributor

@DanGould DanGould left a 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

@@ -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() }
Copy link
Contributor

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

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> {
Copy link
Contributor

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

Comment on lines 383 to 384
return Err(Error::BadRequest(
InternalRequestError::PsbtBelowFeeRate(original_psbt_fee_rate, min_fee_rate)
Copy link
Contributor

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);
Copy link
Contributor

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.

Copy link
Contributor Author

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.

/// call assume_interactive_receive to proceed with validation.
pub struct UncheckedProposal {
inner: super::UncheckedProposal,
pub inner: super::UncheckedProposal,
Copy link
Contributor

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.

Comment on lines 367 to 371
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)?;
Copy link
Contributor

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.

Comment on lines +160 to +169
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
),
),
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Very nice! 🤌

Comment on lines 91 to 92
/// First argument is the calculated fee rate of the original PSBT.
/// [`UncheckedProposal::psbt_fee_rate`]
Copy link
Contributor

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.

@DanGould DanGould added the api label Dec 13, 2023
    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`.
@jbesraa jbesraa force-pushed the feat/reciever-min-feerate branch from 7b3678f to 3036b2f Compare December 13, 2023 04:51
@jbesraa
Copy link
Contributor Author

jbesraa commented Dec 13, 2023

Thanks for the input @DanGould
I have addressed all the above

Copy link
Contributor

@DanGould DanGould left a 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

@DanGould DanGould merged commit 624ab7f into payjoin:master Dec 13, 2023
5 checks passed
@DanGould
Copy link
Contributor

Ci failed to catch the need to change here:

let proposal = proposal.check_can_broadcast(|tx| {

We need to make sure payjoin-cli builds in v2

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
api receive receiving payjoin
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Specify receiver minfeerate
2 participants