Skip to content

Fee rate check hardening - #1845

Open
benalleng wants to merge 5 commits into
payjoin:masterfrom
benalleng:fee-rate-overflow
Open

Fee rate check hardening#1845
benalleng wants to merge 5 commits into
payjoin:masterfrom
benalleng:fee-rate-overflow

Conversation

@benalleng

@benalleng benalleng commented Aug 26, 2026

Copy link
Copy Markdown
Collaborator

This hardens the math on several areas of fee calculation. Namely this does checks that may occur in the bitcoin network but ensures that we do the checks early in the state machine ensuring that we can error out and either try again or close the session rather than accept a tx that will not succeed broadcasting.

Coded up with GLM-5.3

Pull Request Checklist

Please confirm the following before requesting review:

@coveralls

coveralls commented Aug 26, 2026

Copy link
Copy Markdown
Collaborator

Coverage Report for CI Build 33894569727

Coverage increased (+0.1%) to 86.761%

Details

  • Coverage increased (+0.1%) from the base build.
  • Patch coverage: 2 uncovered changes across 2 files (171 of 173 lines covered, 98.84%).
  • No coverage regressions found.

Uncovered Changes

File Changed Covered %
payjoin/src/core/receive/common/mod.rs 110 109 99.09%
payjoin/src/core/send/error.rs 1 0 0.0%
Total (5 files) 173 171 98.84%

Coverage Regressions

No coverage regressions found.


Coverage Stats

Coverage Status
Relevant Lines: 16625
Covered Lines: 14424
Line Coverage: 86.76%
Coverage Strength: 340.39 hits per line

💛 - Coveralls

@benalleng
benalleng marked this pull request as ready for review August 26, 2026 18:15
@benalleng benalleng changed the title Fee rate hardening Fee rate check hardening Aug 26, 2026
Comment thread payjoin/src/core/receive/common/mod.rs Outdated
Comment on lines +486 to +488
// Determine the additional amount that the sender will pay in fees,
// clamped to the fee output's value so a malicious contribution
// cannot underflow the sender output

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

This seems like something that should have been validated earlier. We probably shouldn't even proceed with the payjoin if a sender is intentionally specifying a max fee contribution that they can't match

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

I guess my only question is how do we do this check earlier? Don't we need all the receiver contributed inputs to be able to calculate this?

@spacebear21 spacebear21 Sep 1, 2026

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

i think the receiver should ignore any maxadditionalfeecontribution outside of the valid range [0;value of change output (+ dust value????)], and the sender should have an additional check when making the original PSBT that ensures their change output can fully cover maxadditionalfeecontribution

Comment thread payjoin/src/core/receive/common/mod.rs
Comment thread payjoin/src/core/receive/mod.rs Outdated
Comment on lines +389 to +397
let tx = self.psbt.clone().extract_tx_fee_rate_limit().map_err(|e| match e {
bitcoin::psbt::ExtractTxError::AbsurdFeeRate { fee_rate, .. } =>
InternalPayloadError::OriginalPsbtAbsurdFee(
fee_rate,
bitcoin::Psbt::DEFAULT_MAX_FEE_RATE,
),
_ => unreachable!("Input UTXOs validated and fee computed successfully"),
})?;
Ok(original_psbt_fee / tx.weight())

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

What's the benefit of sanity checking the sender's fee rate here?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

I guess that is a pretty simple and cheap check and sort of addresses an adjecent version of your statement above We probably shouldn't even proceed with the payjoin if a sender is intentionally specifying a max fee contribution that they can't match keeping this at the OriginalPayload stage. In this case its not the max fee though but actually a selected fee by the sender

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

I am also not sure if we need to do this sanity check here, as an absurdly high fee shouldn't affect anyone but the sender? In general I think a check like this should fall to the wallets to catch. Also I think this check is redundant because extract_tx_fee_rate_limit() seems to return ExtractTxError::AbsurdFeeRate by calling psbt.fee() under the hood and mapping the original Error::FeeOverflow error, but we would have already encountered said error because we already call psbt.fee() above.

Image

If we do want to do this sanity check though I think it would be more fitting to put it into check_broadcast_suitability instead (since we are already checking for min fee rate there and intuitively seems like more appropriate place to check this). Or I guess we could also just map Error::FeeOverflow error to the OriginalPsbtAbsurdFee error when we call self.psbt.fee() above.

let change_output = &mut payjoin_psbt.unsigned_tx.output[self.proposal.change_vout];
change_output.value =
change_output.value.checked_sub(receiver_additional_fee).ok_or_else(|| {
InternalPayloadError::FeeTooHigh(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Maybe we should have a different error for this, since the issue isn't the fee being too high but the output being too low value? Don't feel strongly either way.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Hmm, I tend to want to keep it simple here but I can see where you are coming from.

What do you think the error should be and what should it display?

OutputValueTooSmall but still compare feeRates?

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Yeah basically. And then maybe include the highest feasible fee for the output in the error enum? Since thats the only useful thing you can provide to the user to recover from this error at this state in the state machine.

But again, don't feel strongly on this one.

Comment thread payjoin/src/core/receive/common/mod.rs
Comment thread payjoin/src/core/receive/mod.rs Outdated
Comment on lines +389 to +397
let tx = self.psbt.clone().extract_tx_fee_rate_limit().map_err(|e| match e {
bitcoin::psbt::ExtractTxError::AbsurdFeeRate { fee_rate, .. } =>
InternalPayloadError::OriginalPsbtAbsurdFee(
fee_rate,
bitcoin::Psbt::DEFAULT_MAX_FEE_RATE,
),
_ => unreachable!("Input UTXOs validated and fee computed successfully"),
})?;
Ok(original_psbt_fee / tx.weight())

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

I am also not sure if we need to do this sanity check here, as an absurdly high fee shouldn't affect anyone but the sender? In general I think a check like this should fall to the wallets to catch. Also I think this check is redundant because extract_tx_fee_rate_limit() seems to return ExtractTxError::AbsurdFeeRate by calling psbt.fee() under the hood and mapping the original Error::FeeOverflow error, but we would have already encountered said error because we already call psbt.fee() above.

Image

If we do want to do this sanity check though I think it would be more fitting to put it into check_broadcast_suitability instead (since we are already checking for min fee rate there and intuitively seems like more appropriate place to check this). Or I guess we could also just map Error::FeeOverflow error to the OriginalPsbtAbsurdFee error when we call self.psbt.fee() above.

@xstoicunicornx xstoicunicornx left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Just one small new comment about accounting for dust when determining whether receiver output is of sufficient value.

let change_output = &mut payjoin_psbt.unsigned_tx.output[self.proposal.change_vout];
change_output.value =
change_output.value.checked_sub(receiver_additional_fee).ok_or_else(|| {
InternalPayloadError::FeeTooHigh(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Yeah basically. And then maybe include the highest feasible fee for the output in the error enum? Since thats the only useful thing you can provide to the user to recover from this error at this state in the state machine.

But again, don't feel strongly on this one.

// sender minfeerate makes the fee exceed the change output's value.
let change_output = &mut payjoin_psbt.unsigned_tx.output[self.proposal.change_vout];
change_output.value =
change_output.value.checked_sub(receiver_additional_fee).ok_or_else(|| {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Suggested change
change_output.value.checked_sub(receiver_additional_fee).ok_or_else(|| {
change_output.value.checked_sub(receiver_additional_fee + change_output.script_pubkey.minimal_non_dust()).ok_or_else(|| {

Should we account for dust here?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants