Skip to content

Fee rate check hardening - #1845

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

Fee rate check hardening#1845
benalleng wants to merge 6 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 34396838644

Coverage increased (+0.2%) to 86.812%

Details

  • Coverage increased (+0.2%) from the base build.
  • Patch coverage: 3 uncovered changes across 2 files (244 of 247 lines covered, 98.79%).
  • 1 coverage regression across 1 file.

Uncovered Changes

File Changed Covered %
payjoin/src/core/receive/common/mod.rs 150 148 98.67%
payjoin/src/core/send/error.rs 1 0 0.0%
Total (5 files) 247 244 98.79%

Coverage Regressions

1 previously-covered line in 1 file lost coverage.

File Lines Losing Coverage Coverage
payjoin/src/core/send/mod.rs 1 98.65%

Coverage Stats

Coverage Status
Relevant Lines: 16765
Covered Lines: 14554
Line Coverage: 86.81%
Coverage Strength: 337.69 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 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.

Comment thread payjoin/src/core/receive/common/mod.rs
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.

@benalleng
benalleng force-pushed the fee-rate-overflow branch 2 times, most recently from 35f6215 to 221b9df Compare September 4, 2026 16:20

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

Comment thread payjoin/src/core/receive/common/mod.rs
Comment thread payjoin/src/core/receive/common/mod.rs Outdated

@DanGould DanGould left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Some questions regarding rationale of the changes as-is

Comment thread payjoin/src/core/receive/common/mod.rs Outdated
Some(fee_output) if !owned_vouts.contains(&additional_fee_output_index) => {
let max_contribution = fee_output
.value
.checked_sub(fee_output.script_pubkey.minimal_non_dust())

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

introducing the dust check introduces a lot of complexity beyond just preventing an underflow. It also This function uses the default value of 0.00003 BTC/kB (3 sat/vByte) also.

On the sender side, there is a defined min_fee_rate that could be provided to minimal_non_dust_custom. On the receiver side, a minimum feerate is passed in two places, once at check_broadcast_suitability and more consequentially at apply_fee_range. It seems that either of these values may be passed to a minimal_non_dust_custom function that more accurately calculates dust, if necessary.

Or wait, is it just the max_contribution effective fee rate supplied to custom, because the max fee subtracts the most sats, I think it's actually THAT supplied to _custom.

But still, taking on this responsibility of preventing dust calculations at least deserves a rationale. Is it essential?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Eager to get this one through. Pretty sure maxadditionalfeecontribution is the one that has potential to make a dust and therefore should be provided to minimal_non_dust_custom

@benalleng benalleng Sep 9, 2026

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.

Since it is looking for a feerate not an amount what exact benefit are you looking for creating some precise calculation between minimal_non_dust() and minimal_non_dust_custom(1 sat/vbyte) by using maxadditionalfeecontribution?

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.

Should we even be going outside of the realm of what bitcoin will generally relay through the network or should we be using something like max(min_fee_rate, 3 sat/vbyte) so the dust will always be safely relay-able and satisfy the user's expected dust min at the time of the pj.

@benalleng benalleng Sep 9, 2026

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 went with minimal_non_dust_custom(max(min_fee_rate, FeeRate::Dust)) where FeeRate::Dust is just the dust relay min of 3 sat / vbyte so the user's preference is accounted for before relay minimum.

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.

On the sender side, there is a defined min_fee_rate that could be provided to minimal_non_dust_custom. On the receiver side, a minimum feerate is passed in two places, once at check_broadcast_suitability and more consequentially at apply_fee_range. It seems that either of these values may be passed to a minimal_non_dust_custom function that more accurately calculates dust, if necessary.

I don't think I'm understanding why the min fee rate should be considered when ensuring that an output is over relay-able dust limit? Wouldn't that strictly be determined by node implementation? For example, with the updated implementation here if the min fee rate is over 3 sat/vByte then we are requiring the output value to be unnecessarily high.

But still, taking on this responsibility of preventing dust calculations at least deserves a rationale. Is it essential?

Thinking on this more I tend to think this is an important point to consider. Do we need to add complexity to account for dust limits right now? I think harding the fee rate check is the more urgent item and accounting for dust limits is a completely separate potential optimization that we would want to think through carefully. There do seem to already be tools for implementors to check the minimal output value required on their own and using the 3 sat/vByte limit also makes an assumption about node dust limit configuration. So maybe for now we should just focus on the fee rate check hardening?

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.

Ok, will do I will create a follow-up issue for us to address this fully.

Comment thread payjoin/src/core/receive/common/mod.rs
@benalleng
benalleng force-pushed the fee-rate-overflow branch 3 times, most recently from 997f620 to 0c38ce1 Compare September 9, 2026 19:38
This ensures that the sender output is capable of paying for its fee
contribution otherwise it is ignored.
By calculating the sender maxadditionalfeecontribution is within the
range that the sender inputs can afford to pay this should avoid most
cases where a receiver ignores this contribution from the sender in the
happy path.
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.

5 participants