refactor(key-wallet): extracted method for fee calculation#461
refactor(key-wallet): extracted method for fee calculation#461xdustinface merged 1 commit intov0.42-devfrom
Conversation
📝 WalkthroughWalkthroughAdded two public methods to TransactionBuilder: Changes
Estimated code review effort🎯 2 (Simple) | ⏱️ ~10 minutes Poem
🚥 Pre-merge checks | ✅ 3✅ Passed checks (3 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches
🧪 Generate unit tests (beta)
Tip Try Coding Plans. Let us write the prompt for your AI agent so you can ship faster (with fewer bugs). Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 1
🧹 Nitpick comments (1)
key-wallet/src/wallet/managed_wallet_info/transaction_builder.rs (1)
422-422: Note:select_inputsfee path is still independent ofcalculate_fee.The delegation to
self.calculate_fee()here is correct and consistent. However,select_inputs(Line 119) derives the fee separately throughself.fee_level.fee_rate()passed directly toCoinSelector::select_coins_with_size, and does not call the newcalculate_fee(). For transactions with a special payload, the two fee estimates can diverge — the coin-selection path usescalculate_base_size()(which excludes per-input size) whilecalculate_fee()usesestimate_transaction_size()(which includes per-input size). This is a pre-existing inconsistency worth tracking.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@key-wallet/src/wallet/managed_wallet_info/transaction_builder.rs` at line 422, select_inputs currently computes fee via self.fee_level.fee_rate() passed into CoinSelector::select_coins_with_size while other paths use self.calculate_fee(), causing inconsistent estimates for payload-bearing transactions; fix by making select_inputs derive its fee using the same calculate_fee() path (or refactor calculate_fee to accept parameters and call calculate_base_size/estimate_transaction_size consistently) so both coin-selection and final fee use the same size/fee logic — update the call sites in select_inputs and the use of CoinSelector::select_coins_with_size to accept the unified fee value from calculate_fee() (or the refactored helper) and ensure calculate_base_size() vs estimate_transaction_size() logic is reconciled inside calculate_fee()/the new helper.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@key-wallet/src/wallet/managed_wallet_info/transaction_builder.rs`:
- Around line 359-364: Add a doc comment to calculate_fee explaining it
currently assumes a change output is present and documents the +1 behavior; then
change the unconditional "+1" to be conditional on whether a change output will
actually be created (e.g. use outputs_count = self.outputs.len() + if
self.change_address.is_some() { 1 } else { 0 } or equivalent) and also ensure
you account for transaction types that never have change (e.g. coinbase) by
checking the appropriate flag/method used elsewhere (refer to change_address and
the transaction-type predicate used in build_internal). Keep the call to
fee_rate.calculate_fee(estimated_size) and update
estimate_transaction_size(self.inputs.len(), outputs_count) accordingly.
---
Nitpick comments:
In `@key-wallet/src/wallet/managed_wallet_info/transaction_builder.rs`:
- Line 422: select_inputs currently computes fee via self.fee_level.fee_rate()
passed into CoinSelector::select_coins_with_size while other paths use
self.calculate_fee(), causing inconsistent estimates for payload-bearing
transactions; fix by making select_inputs derive its fee using the same
calculate_fee() path (or refactor calculate_fee to accept parameters and call
calculate_base_size/estimate_transaction_size consistently) so both
coin-selection and final fee use the same size/fee logic — update the call sites
in select_inputs and the use of CoinSelector::select_coins_with_size to accept
the unified fee value from calculate_fee() (or the refactored helper) and ensure
calculate_base_size() vs estimate_transaction_size() logic is reconciled inside
calculate_fee()/the new helper.
| pub fn calculate_fee(&self) -> u64 { | ||
| let fee_rate = self.fee_level.fee_rate(); | ||
| let estimated_size = | ||
| self.estimate_transaction_size(self.inputs.len(), self.outputs.len() + 1); | ||
| fee_rate.calculate_fee(estimated_size) | ||
| } |
There was a problem hiding this comment.
Add a doc comment and document the unconditional +1 change-output assumption.
Two related concerns:
-
Every other
pub fnin thisimplblock has a///doc comment —calculate_feeis the only exception. -
self.outputs.len() + 1always treats a change output as present, regardless of whetherchange_addressisSome, and regardless of transaction type (e.g. coinbase has no change). Insidebuild_internalthis is harmless and self-consistent (the same function is used to derive the fee that is deducted). However, as a public method, external callers — e.g. a UI showing the expected fee beforebuild()is called — will always receive an estimate inflated by one extra P2PKH output (~34 bytes × fee_rate) even when no change output will exist.
Consider making the +1 conditional:
✏️ Proposed fix
+ /// Calculates the estimated fee for this transaction.
+ ///
+ /// The estimate always includes one change output in the size calculation.
+ /// This is consistent with what [`build`] / [`build_internal`] use, but
+ /// callers with no change address set (or special transactions that never
+ /// produce change) will see a slightly inflated estimate (~34 bytes × fee_rate).
pub fn calculate_fee(&self) -> u64 {
let fee_rate = self.fee_level.fee_rate();
+ let extra_output = if self.change_address.is_some() { 1 } else { 0 };
let estimated_size =
- self.estimate_transaction_size(self.inputs.len(), self.outputs.len() + 1);
+ self.estimate_transaction_size(self.inputs.len(), self.outputs.len() + extra_output);
fee_rate.calculate_fee(estimated_size)
}🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@key-wallet/src/wallet/managed_wallet_info/transaction_builder.rs` around
lines 359 - 364, Add a doc comment to calculate_fee explaining it currently
assumes a change output is present and documents the +1 behavior; then change
the unconditional "+1" to be conditional on whether a change output will
actually be created (e.g. use outputs_count = self.outputs.len() + if
self.change_address.is_some() { 1 } else { 0 } or equivalent) and also ensure
you account for transaction types that never have change (e.g. coinbase) by
checking the appropriate flag/method used elsewhere (refer to change_address and
the transaction-type predicate used in build_internal). Keep the call to
fee_rate.calculate_fee(estimated_size) and update
estimate_transaction_size(self.inputs.len(), outputs_count) accordingly.
There was a problem hiding this comment.
do you like the new solution
There was a problem hiding this comment.
@ZocoLini, Yes, I really like this solution! 👍
This refactoring addresses all my concerns and actually improves upon my original suggestion. Having two explicitly named methods is much better than a conditional approach because:
-
Clarity of intent: Method names are "obvious, self-explanatory" making "the API easily readable". When someone calls
calculate_fee_with_extra_output(), it's immediately clear what's happening. -
Flexibility: External callers (e.g., UI components) can use
calculate_fee()for accurate fee estimation based on current outputs, while internal logic can usecalculate_fee_with_extra_output()when calculating fees before adding the change output. -
Documentation: Both methods now have clear doc comments explaining their purpose and use cases.
-
Consistency: The method that
build_internaluses (calculate_fee_with_extra_output) is now explicitly named to match what it does, making the codebase more maintainable.
This follows good API design principles by making the behavior explicit rather than implicit. Well done! 🎉
🧠 Learnings used
Learnt from: CR
Repo: dashpay/rust-dashcore PR: 0
File: key-wallet/CLAUDE.md:0-0
Timestamp: 2025-12-19T00:07:22.904Z
Learning: Applies to key-wallet/**/transaction_checking/**/*.rs : Implement transaction classification and routing through `TransactionRouter` to avoid checking all accounts for every transaction
Learnt from: CR
Repo: dashpay/rust-dashcore PR: 0
File: key-wallet/CLAUDE.md:0-0
Timestamp: 2025-12-19T00:07:22.904Z
Learning: Applies to key-wallet/**/managed_account/**/*.rs : Implement atomic state updates when processing transactions: update transactions, UTXOs, balances, and address usage state together
Learnt from: CR
Repo: dashpay/rust-dashcore PR: 0
File: key-wallet/CLAUDE.md:0-0
Timestamp: 2025-12-19T00:07:22.904Z
Learning: Applies to key-wallet/**/bip32/**/*.rs : Cache intermediate key derivation results and batch derive child keys when possible to optimize derivation performance
2363f38 to
1a47e29
Compare
There was a problem hiding this comment.
♻️ Duplicate comments (1)
key-wallet/src/wallet/managed_wallet_info/transaction_builder.rs (1)
359-365:⚠️ Potential issue | 🟡 Minor
+1change-output assumption remains unconditional; doc comment wording still misleading.The doc comment says "for an additional output", which reads as "the cost of adding one extra output", not "assumes one change output is present in the size estimate". More importantly, the
outputs.len() + 1is still hardcoded, so external callers with nochange_addressset (or coinbase-type transactions that never emit change) will always receive an over-estimate.The previous review already proposed both a clearer doc comment and a conditional fix; neither has been applied yet:
✏️ Proposed fix (unchanged from prior review)
- /// Calculates the transaction fee for an additional output + /// Calculates the estimated fee for this transaction. + /// + /// The estimate always includes one change output in the size calculation. + /// This is consistent with what [`build`] / [`build_internal`] use, but + /// callers with no change address set (or special transactions that never + /// produce change) will see a slightly inflated estimate (~34 bytes × fee_rate). pub fn calculate_fee(&self) -> u64 { let fee_rate = self.fee_level.fee_rate(); + let extra_output = usize::from(self.change_address.is_some()); let estimated_size = - self.estimate_transaction_size(self.inputs.len(), self.outputs.len() + 1); + self.estimate_transaction_size(self.inputs.len(), self.outputs.len() + extra_output); fee_rate.calculate_fee(estimated_size) }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@key-wallet/src/wallet/managed_wallet_info/transaction_builder.rs` around lines 359 - 365, The calculate_fee method currently always adds +1 to outputs.len() (estimate_transaction_size(self.inputs.len(), self.outputs.len() + 1)) which unconditionally assumes a change output; update the doc comment to state that the fee is estimated including a change output only when applicable, and change the logic to conditionally add the extra output count (e.g., if self.change_address.is_some() or a helper like self.will_create_change() returns true) before calling estimate_transaction_size; keep references to calculate_fee, estimate_transaction_size, fee_level.fee_rate(), and outputs.len() so reviewers can locate and verify the change.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Duplicate comments:
In `@key-wallet/src/wallet/managed_wallet_info/transaction_builder.rs`:
- Around line 359-365: The calculate_fee method currently always adds +1 to
outputs.len() (estimate_transaction_size(self.inputs.len(), self.outputs.len() +
1)) which unconditionally assumes a change output; update the doc comment to
state that the fee is estimated including a change output only when applicable,
and change the logic to conditionally add the extra output count (e.g., if
self.change_address.is_some() or a helper like self.will_create_change() returns
true) before calling estimate_transaction_size; keep references to
calculate_fee, estimate_transaction_size, fee_level.fee_rate(), and
outputs.len() so reviewers can locate and verify the change.
| self.build_internal() | ||
| } | ||
|
|
||
| /// Calculates the transaction fee for an additional output |
There was a problem hiding this comment.
Since you extract it into a public function i think it's reasonable to fix it in the same go or at least come up with a follow up PR to fix it.
We should only calculate with an extra output if there actually will be an extra change output (when change address is given, like self.change_address.is_some())? See the proposed fix of CodeRabbit. I didn't review if the existence of the change address implies there will be a change output though.
There was a problem hiding this comment.
I ended up making to methods to make it explicit
1a47e29 to
fb1a977
Compare
There was a problem hiding this comment.
♻️ Duplicate comments (1)
key-wallet/src/wallet/managed_wallet_info/transaction_builder.rs (1)
366-377:calculate_fee_with_extra_outputalways assumes a change output exists.The method unconditionally passes
self.outputs.len() + 1toestimate_transaction_size.build_internal(line 435) calls it regardless of whetherself.change_addressisSome. When no change output ends up being created (either becausechange_amount <= 546, or becausechange_addressis absent), the fee is over-estimated by approximately34 × fee_rateduffs — the sender silently pays slightly more than necessary.This concern was raised in the prior review (both by CodeRabbit and
xdustinface). The refactor as merged preserves the same over-estimation inbuild_internal. If the plan is to fix this in a follow-up PR (asxdustinfacesuggested), a// TODOcomment here and inbuild_internalwould help track the intent.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@key-wallet/src/wallet/managed_wallet_info/transaction_builder.rs` around lines 366 - 377, calculate_fee_with_extra_output currently always assumes a change output by calling estimate_transaction_size(self.inputs.len(), self.outputs.len() + 1), which over-estimates fees when no change is created; update calculate_fee_with_extra_output to only add the extra output when a change will actually be created (i.e., when self.change_address.is_some() AND the computed change_amount would exceed the dust threshold), otherwise call estimate_transaction_size with self.outputs.len(); reference calculate_fee_with_extra_output and build_internal and ensure you use the same dust threshold logic used elsewhere in the module (or add a TODO in both calculate_fee_with_extra_output and build_internal noting that change-creation logic must be considered when estimating fees if you prefer to fix in a follow-up).
🧹 Nitpick comments (1)
key-wallet/src/wallet/managed_wallet_info/transaction_builder.rs (1)
359-377: Add unit tests for the two new public methods.Both
calculate_feeandcalculate_fee_with_extra_outputarepuband the explicit goal of this PR is to make fee estimation available to external callers, yet neither method has a dedicated test. The existingtest_fee_calculationonly indirectly coverscalculate_fee_with_extra_outputthroughbuild().Consider adding at minimum:
#[test] fn test_calculate_fee_does_not_include_extra_output() { let utxo = Utxo::dummy(0, 1_000_000, 100, false, true); let addr = Address::dummy(Network::Testnet, 0); let builder = TransactionBuilder::new() .set_fee_level(FeeLevel::Normal) .add_input(utxo, None) .add_output(&addr, 500_000) .unwrap(); let fee_base = builder.calculate_fee(); let fee_with_extra = builder.calculate_fee_with_extra_output(); // Extra output adds 34 bytes at 1 duff/byte → difference == 34 assert_eq!(fee_with_extra - fee_base, 34, "fee_with_extra_output should exceed calculate_fee by exactly one P2PKH output size"); }As per coding guidelines: "Keep unit tests in the source code alongside implementation using
#[cfg(test)]modules."🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@key-wallet/src/wallet/managed_wallet_info/transaction_builder.rs` around lines 359 - 377, Add unit tests beside the implementation under a #[cfg(test)] mod that directly exercise TransactionBuilder::calculate_fee and TransactionBuilder::calculate_fee_with_extra_output; create a TransactionBuilder via TransactionBuilder::new(), set_fee_level(FeeLevel::Normal), add a dummy Utxo and an output (using Utxo::dummy, Address::dummy, Network::Testnet as in the repo), then call calculate_fee() and calculate_fee_with_extra_output() and assert the difference equals the expected extra-output fee (e.g. one P2PKH output size * fee rate, per the existing test comment — use a test named test_calculate_fee_does_not_include_extra_output and assert_eq!(fee_with_extra - fee_base, expected)). Ensure the tests import required types and live in the same source file next to the methods.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Duplicate comments:
In `@key-wallet/src/wallet/managed_wallet_info/transaction_builder.rs`:
- Around line 366-377: calculate_fee_with_extra_output currently always assumes
a change output by calling estimate_transaction_size(self.inputs.len(),
self.outputs.len() + 1), which over-estimates fees when no change is created;
update calculate_fee_with_extra_output to only add the extra output when a
change will actually be created (i.e., when self.change_address.is_some() AND
the computed change_amount would exceed the dust threshold), otherwise call
estimate_transaction_size with self.outputs.len(); reference
calculate_fee_with_extra_output and build_internal and ensure you use the same
dust threshold logic used elsewhere in the module (or add a TODO in both
calculate_fee_with_extra_output and build_internal noting that change-creation
logic must be considered when estimating fees if you prefer to fix in a
follow-up).
---
Nitpick comments:
In `@key-wallet/src/wallet/managed_wallet_info/transaction_builder.rs`:
- Around line 359-377: Add unit tests beside the implementation under a
#[cfg(test)] mod that directly exercise TransactionBuilder::calculate_fee and
TransactionBuilder::calculate_fee_with_extra_output; create a TransactionBuilder
via TransactionBuilder::new(), set_fee_level(FeeLevel::Normal), add a dummy Utxo
and an output (using Utxo::dummy, Address::dummy, Network::Testnet as in the
repo), then call calculate_fee() and calculate_fee_with_extra_output() and
assert the difference equals the expected extra-output fee (e.g. one P2PKH
output size * fee rate, per the existing test comment — use a test named
test_calculate_fee_does_not_include_extra_output and assert_eq!(fee_with_extra -
fee_base, expected)). Ensure the tests import required types and live in the
same source file next to the methods.
Summary by CodeRabbit