Skip to content

Fix/wallet fee calculation #1916

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

Open
wants to merge 3 commits into
base: master
Choose a base branch
from
Open

Fix/wallet fee calculation #1916

wants to merge 3 commits into from

Conversation

OBorce
Copy link
Contributor

@OBorce OBorce commented Apr 29, 2025

  • The initial bug was caused by the calculation of the TX size with 0 inputs, and the bug got triggered when a TX was constructed with 100+ inputs which caused the compact length to have 1 more byte then before.
    This was fixed by passing the number of inputs to the tx_size_with_outputs.
  • Currently as before the fee of a TX can be up to 16 bytes more per change output because of the unknown amount of change that will be returned, and the calculation always uses Amount::MAX.
  • The fees test has been rewritten to have both a TX with many inputs up to 1k and a sweep TX which uses a separate implementation which doesn't have a change output and always pays correctly the exact fee.
  • In the new test a rounding error was discovered, and fixed which happens in case of individual input fee calculation vs total fee calculation using entire tx size.
  • So now we still calculate the fee per input for the selection algorithm to know which input has how much effective value, but at the end after the inputs have been selected we recalculate the fee and add the difference back as change. (it should always be up to number of inputs in atoms, which is not a lot but still was crushing the test)
  • Also added the missing limit for tx size/weight, which now should prevent creating a TX with size over 1MB retrieved from chain_config.

Closes #1208

@OBorce OBorce force-pushed the fix/wallet-fee-calculation branch from 05abe2e to 6b1fe65 Compare May 1, 2025 09:52
OBorce added 3 commits May 6, 2025 12:29
- account for compact input length encoding
- fix rounding error in case of individual input fee calculation vs
  total fee calculation using entire tx size
- add missing limit for tx size/weight
@OBorce OBorce force-pushed the fix/wallet-fee-calculation branch from 6b1fe65 to 5a4271e Compare May 6, 2025 10:30
@@ -1447,7 +1462,12 @@ fn wallet_transaction_with_fees(#[case] seed: Seed) {
.unwrap();

let tx_size = serialization::Encode::encoded_size(&transaction);
let fee = feerate.compute_fee(tx_size).unwrap();

// 16 bytes (u128) is the max tx size diffference in the estimation,
Copy link
Contributor

Choose a reason for hiding this comment

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

typo - diffference

let fee = feerate.compute_fee(tx_size).unwrap();

// 16 bytes (u128) is the max tx size diffference in the estimation,
// because of the compact encoding for the change amount which is unknown beforhand
Copy link
Contributor

Choose a reason for hiding this comment

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

beforhand -> beforehand

total_fees_not_paid =
(total_fees_not_paid + cost_of_change).ok_or(WalletError::OutputAmountOverflow)?;

// There can be an small rounding error of computing the fee for each individual input
Copy link
Contributor

Choose a reason for hiding this comment

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

a small

Comment on lines 204 to +209
let tx = SignedTransaction::new(
Transaction::new(1, vec![], outputs.into()).expect("should not fail"),
Transaction::new(1, vec![], vec![]).expect("should not fail"),
vec![],
)
.expect("should not fail");
serialization::Encode::encoded_size(&tx)
let size = serialization::Encode::encoded_size(&tx);
Copy link
Contributor

Choose a reason for hiding this comment

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

Put empty tx size calculation to a constant? (in a lazy_static block)
(Can be local to this function)

let output_compact_size_diff =
serialization::Encode::encoded_size(&CompactSize {
value: num_outputs as u64,
}) - serialization::Encode::encoded_size(&CompactSize { value: 0 });
Copy link
Contributor

Choose a reason for hiding this comment

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

Plz calculate the fixed part (serialization::Encode::encoded_size(&CompactSize { value: 0 })) only once.

Also:

  1. The codec used u32 for the length of the vector/slice instead of u64 - https://github.com/paritytech/parity-scale-codec/blob/0a0295a465ac40fd2d38359220fdcc3f3de94cae/src/codec.rs#L1090 (not that it makes much of a difference though).
  2. It may make sense to get rid of the CompactSize and use Compact::<u32>::compact_len here instead (like Compact::<u32>::compact_len(&(num_outputs as u32)) - Compact::<u32>::compact_len(&0)).
    (probably first checking that num_outputs actually fits into u32)

Comment on lines 384 to 391
let selection_result = select_coins(
utxos,
(amount_to_be_paid_in_currency_with_fees - preselected_amount).unwrap_or(Amount::ZERO),
PayFee::PayFeeWithThisCurrency,
cost_of_change,
selection_algo,
self.chain_config.max_tx_size_for_mempool(),
)?;
Copy link
Contributor

Choose a reason for hiding this comment

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

I wonder, why do we need to call select_coins in the case when amount_to_be_paid_in_currency_with_fees == preselected_amount?

Also, select_coins has this code:

if selection_target == Amount::ZERO && pay_fees == PayFee::DoNotPayFeeWithThisCurrency {
    return Ok(SelectionResult::new(selection_target));
}

Why doesn't it ignore zero amount in the PayFeeWithThisCurrency case too?

It looks like we select coins for the change even when we don't need any change.

pay_fee_with_currency,
(amount_to_be_paid_in_currency_with_fees + selection_result.get_total_fees())
.ok_or(WalletError::OutputAmountOverflow)?,
);
selected_inputs.insert(pay_fee_with_currency, selection_result);

// Check outputs against inputs and create change
self.check_outputs_and_add_change(
Copy link
Contributor

Choose a reason for hiding this comment

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

I would say that check_outputs_and_add_change no longer checks outputs, so it's better to rename it.
(Or maybe remove it completely and put its body directly into select_inputs_for_send_request. Since it's not really reusable IMO).

(inputs.amount, inputs.fee, inputs.total_input_size)
});

let (size_of_change, cost_of_change) = output_change_size_and_fees(
Copy link
Contributor

Choose a reason for hiding this comment

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

There is a tight coupling between output_change_size_and_fees and check_outputs_and_add_change - the former accepts Option<&Address<Destination>> and assumes PublicKeyHash if it's None. And the latter then calls key_chain.next_unused_address if change_addresses doesn't contain the corresponding currency, and next_unused_address is assumed to always return PublicKeyHash I guess, which is not a great assumption either.

I wonder if we could refactor it in this PR. E.g. modify change_addresses in advance, calling key_chain.next_unused_address if necessary, so that the map already contains all possible currencies.
(If not, then it's better to at least add some comments to output_change_size_and_fees and check_outputs_and_add_change, mentioning this interdependency).

Comment on lines +2543 to +2558
// increase fee and total_input_size, this is same for all inputs
match preselected_inputs.entry(Currency::Coin) {
Entry::Vacant(entry) => {
entry.insert(PreselectedInputAmounts {
amount: Amount::ZERO,
fee,
burn: Amount::ZERO,
total_input_size,
});
}
Entry::Occupied(mut entry) => {
let existing = entry.get_mut();
existing.fee = (existing.fee + fee).ok_or(WalletError::OutputAmountOverflow)?;
existing.total_input_size += total_input_size;
}
};
Copy link
Contributor

Choose a reason for hiding this comment

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

This looks hacky.

  1. select_inputs_for_send_request operates on pay_fee_with_currency, so I guess you should pass it here instead of assuming that it's always Coin.
    (or if you think it's redundant, then maybe select_inputs_for_send_request should no longer pretend that it can handle other currencies to pay fees with).
  2. The code in select_inputs_for_send_request, where it iterates over "non-pay_fee_with_currency" currencies and updates the total tx size and fee, doesn't make sense anymore, because those values will always be zeroes now.

So,
a) I wonder what was wrong with the previous approach. Why can't we return the sizes/fees on per-currency basis as before, and make the caller combine the result (like it still does)?
b) If the old approach is not good anymore, then maybe this function's return type should be changed to something else that better reflects the data that is being collected?

// 1. give X amount of coins to the wallet
// 2. Try to create a transaction where the feerate is so high that the UTXO will need to pay more
// fee than it has so it will return an NoUTXOs error
// 3. Successfuly create a TX with many outputs and make sure the fee paid is within the range of
Copy link
Contributor

Choose a reason for hiding this comment

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

Successfully

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.

Limit Wallet transaction size
2 participants