What changed, and why it matters
This commit fixes how custom transaction fees are calculated for MWEB (a privacy feature in Litecoin) in Stack Wallet. Previously, when a user set a custom fee in 'satoshis per virtual byte' (sats/vByte), the app sometimes used the wrong fee rate or mixed up per-byte and per-kilobyte units. The fix makes the code consistently convert sats/vByte to a per-kilobyte rate before sending it to the MWEB library. A new test confirms that a custom sats/vByte setting now overrides the default per-kilobyte rate. This is a correctness bug that could cause users to overpay or underpay fees, and underpayment could potentially delay or stall transactions.
Review the full MWEB fee path to ensure no other locations mix sats/vByte and sats/kB units, and confirm the new ceiling-division formula matches the wallet's documented fee behavior. Consider adding tests for edge cases such as very low custom fees and large MWEB peg-in transactions.
Security signals we found
Fee-rate unit confusion between sats/vByte and sats/kB
Potential transaction fee underpayment leading to stuck transactions
Potential fee overpayment causing user financial loss
Floating-point arithmetic replaced with integer/BigInt math for fee calculation
MWEB privacy transaction fee path affected
Evidence from the diff
The patch changes mweb_interface.dart in two places (processMwebTransaction and the dry-run fee estimation path). It introduces a consistent conversion: if txData.satsPerVByte is set, multiply it by 1000 to get feeRatePerKB; otherwise fall back to txData.feeRateAmount. It also replaces a floating-point fee-rate calculation and an integer feeRate * 41 adjustment with a BigInt ceiling-division formula (feeRatePerKB * 41 + 999) ~/ 1000. A unit test verifies that a custom 2 sats/vByte rate produces a fee of 382 (2 × 191 vsize) rather than using the 50000 per-kB default.
Changed components
lib/wallets/wallet/wallet_mixin_interfaces/mweb_interface.dartMWEB transaction fee calculationElectrum fee planner testsInspect captured patch +29 / −5
diff --git a/lib/wallets/wallet/wallet_mixin_interfaces/mweb_interface.dart b/lib/wallets/wallet/wallet_mixin_interfaces/mweb_interface.dart
index bfeb24e..5f9fe71 100644
--- a/lib/wallets/wallet/wallet_mixin_interfaces/mweb_interface.dart
+++ b/lib/wallets/wallet/wallet_mixin_interfaces/mweb_interface.dart
@@ -442,12 +442,17 @@ mixin MwebInterface<T extends ElectrumXCurrencyInterface>
Future<TxData> processMwebTransaction(TxData txData) async {
final client = await _client;
+ final vBytesPerKilobyte = BigInt.from(1000);
+ final customSatsPerVByte = txData.satsPerVByte;
+ final feeRatePerKB = customSatsPerVByte != null
+ ? BigInt.from(customSatsPerVByte) * vBytesPerKilobyte
+ : txData.feeRateAmount!;
final response = await client.create(
CreateRequest(
rawTx: txData.raw!.toUint8ListFromHex,
scanSecret: await _scanSecret,
spendSecret: await _spendSecret,
- feeRatePerKb: Int64(txData.feeRateAmount!.toInt()),
+ feeRatePerKb: Int64(feeRatePerKB.toInt()),
dryRun: false,
),
);
@@ -968,8 +973,11 @@ mixin MwebInterface<T extends ElectrumXCurrencyInterface>
final preOutputSum = outputs.fold(BigInt.zero, (p, e) => p + e.amount.raw);
final fee = sumOfUtxosValue - preOutputSum;
- final feeRate =
- txData.satsPerVByte ?? (txData.feeRateAmount!.toInt() / 1000).ceil();
+ final vBytesPerKilobyte = BigInt.from(1000);
+ final customSatsPerVByte = txData.satsPerVByte;
+ final feeRatePerKB = customSatsPerVByte != null
+ ? BigInt.from(customSatsPerVByte) * vBytesPerKilobyte
+ : txData.feeRateAmount!;
final client = await _client;
@@ -978,7 +986,7 @@ mixin MwebInterface<T extends ElectrumXCurrencyInterface>
rawTx: txData.raw!.toUint8ListFromHex,
scanSecret: await _scanSecret,
spendSecret: await _spendSecret,
- feeRatePerKb: Int64(feeRate * 1000),
+ feeRatePerKb: Int64(feeRatePerKB.toInt()),
dryRun: true,
),
);
@@ -1010,7 +1018,9 @@ mixin MwebInterface<T extends ElectrumXCurrencyInterface>
BigInt feeIncrease = posOutputSum - expectedPegin;
if (expectedPegin > BigInt.zero) {
- feeIncrease += BigInt.from(feeRate * 41);
+ feeIncrease +=
+ (feeRatePerKB * BigInt.from(41) + vBytesPerKilobyte - BigInt.one) ~/
+ vBytesPerKilobyte;
}
return Amount(
diff --git a/test/wallets/electrum_fee_planner_test.dart b/test/wallets/electrum_fee_planner_test.dart
index 5a541f5..fc47ed6 100644
--- a/test/wallets/electrum_fee_planner_test.dart
+++ b/test/wallets/electrum_fee_planner_test.dart
@@ -68,6 +68,20 @@ void main() {
expect(plan.result.fee, BigInt.from(192));
});
+ test('custom sats/vByte overrides the per-kilobyte rate', () async {
+ final plan = await _plan(
+ mode: ElectrumFeeMode.sweep,
+ inputTotal: 10000,
+ recipientAmount: 10000,
+ dustLimit: 546,
+ vSizes: [191],
+ satsPerVByte: 2,
+ feeRatePerKB: 50000,
+ );
+
+ expect(plan.result.fee, BigInt.from(382));
+ });
+
test('does not let a minimum fee underpay the measured vsize', () async {
final plan = await _plan(
mode: ElectrumFeeMode.sweep,
Why this scored 46/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.