Skip to content

Commit 6cf29e4

Browse files
committed
fix(fees): reallow sub-1 sat/vByte transactions
Store relative fees in BDK's native sat/kwu unit so fractional rates survive the SDK boundary without precision loss. BDK's fromSatPerVb takes an int sat/vByte; the call site was .round()-ing the user's double, turning 0.5 into 1 — the root of #2133. LWK and BDK's BumpFeeTxBuilder are rate-only, so absolute Liquid fees are now converted back to a rate via a placeholder PSET (new CalculateLiquidPsetSizeUsecase). The custom-fee UI warns between 0.1 and 1 sat/vByte and blocks below 0.1.
1 parent ef810e5 commit 6cf29e4

25 files changed

Lines changed: 547 additions & 100 deletions

File tree

integration_test/payjoin_test.dart

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -165,7 +165,8 @@ Future<void> main({bool isInitialized = false}) async {
165165
walletId: senderWallet.id,
166166
address: address.address,
167167
amountSat: 10000,
168-
networkFee: const NetworkFee.relative(networkFeesSatPerVb),
168+
// 1000 sat/vByte * 250 = 250000 sat/kwu.
169+
networkFee: const RelativeFee(250000),
169170
ignoreUnspendableInputs: false,
170171
);
171172

lib/core/fees/data/fees_datasource.dart

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -54,9 +54,9 @@ class FeesDatasource {
5454
final minimumFee = data['minimumFee'] as int;
5555

5656
final feeOptions = FeeOptions(
57-
fastest: NetworkFee.relative(fastestFee.toDouble()),
58-
economic: NetworkFee.relative(economyFee.toDouble()),
59-
slow: NetworkFee.relative(minimumFee.toDouble()),
57+
fastest: NetworkFee.relativeFromSatPerVbyte(fastestFee.toDouble()),
58+
economic: NetworkFee.relativeFromSatPerVbyte(economyFee.toDouble()),
59+
slow: NetworkFee.relativeFromSatPerVbyte(minimumFee.toDouble()),
6060
);
6161

6262
return feeOptions;
@@ -65,10 +65,13 @@ class FeesDatasource {
6565
Future<FeeOptions> getLiquidNetworkFeeOptions({
6666
required bool isTestnet,
6767
}) async {
68+
// Liquid blocks are typically empty, so the network's minrelayfee
69+
// (0.1 sat/vByte = 25 sat/kwu) is the only relevant fee tier today.
70+
// The three presets are kept for UI parity with the Bitcoin path.
6871
const feeOptions = FeeOptions(
69-
fastest: NetworkFee.relative(0.1),
70-
economic: NetworkFee.relative(0.1),
71-
slow: NetworkFee.relative(0.1),
72+
fastest: RelativeFee(25),
73+
economic: RelativeFee(25),
74+
slow: RelativeFee(25),
7275
);
7376

7477
return feeOptions;

lib/core/fees/domain/fees_entity.dart

Lines changed: 97 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -3,29 +3,97 @@ import 'package:freezed_annotation/freezed_annotation.dart';
33

44
part 'fees_entity.freezed.dart';
55

6+
/// A fee paid for a Bitcoin or Liquid transaction.
7+
///
8+
/// Two variants exist because the user can input either:
9+
/// - an absolute amount of satoshis they want to pay (regardless of tx size),
10+
/// - or a fee rate in sat/vByte (which the wallet multiplies by the actual
11+
/// tx vsize when building the transaction).
12+
///
13+
/// At the SDK boundary:
14+
/// - BDK's `TxBuilder` accepts both via `feeAbsolute()` and `feeRate()`.
15+
/// - BDK's `BumpFeeTxBuilder` (RBF) accepts ONLY a fee rate.
16+
/// - LWK's `buildLbtcTx` accepts ONLY a fee rate.
17+
///
18+
/// For the rate variant we store the value in BDK's native unit — sat per
19+
/// kilo-weight-unit (kwu) — which lets us hit the SDK with zero conversion
20+
/// and supports fee rates well below 1 sat/vByte without precision loss.
21+
/// 1 vByte = 4 weight units, so 1 sat/vByte = 250 sat/kwu.
622
@freezed
723
sealed class NetworkFee with _$NetworkFee {
824
const NetworkFee._();
925

10-
const factory NetworkFee.absolute(int value) = AbsoluteFee;
11-
const factory NetworkFee.relative(double value) = RelativeFee;
26+
/// An absolute fee in satoshis.
27+
const factory NetworkFee.absolute(int sats) = AbsoluteFee;
28+
29+
/// A relative fee stored in BDK's native unit (sat per kilo-weight-unit).
30+
///
31+
/// Prefer [NetworkFee.relativeFromSatPerVbyte] for code that reasons in
32+
/// the user-facing sat/vByte unit. This constructor exists to keep
33+
/// well-known defaults `const`-able (e.g. `0.1 sat/vByte == 25 sat/kwu`).
34+
const factory NetworkFee.relativeSatPerKwu(int satPerKwu) = RelativeFee;
35+
36+
/// Build a [RelativeFee] from a sat/vByte value as typed by the user
37+
/// or returned by the mempool API.
38+
///
39+
/// Returns [RelativeFee] (not [NetworkFee]) so callers don't need to cast
40+
/// — sealed-union factories would static-type to the parent.
41+
///
42+
/// The conversion rounds to the nearest sat/kwu: precision loss is bounded
43+
/// by 0.5 / 250 ≈ 0.002 sat/vByte, well below any meaningful UX threshold.
44+
static RelativeFee relativeFromSatPerVbyte(double satPerVbyte) =>
45+
RelativeFee((satPerVbyte * 250).round());
46+
47+
/// Build a [RelativeFee] from an absolute fee target plus an estimated
48+
/// tx vsize.
49+
///
50+
/// Used when the user enters an absolute amount on a network whose SDK
51+
/// only accepts a rate (Liquid send, RBF bump). The dance costs at most
52+
/// ±1 sat in the final fee (BDK/LWK may rebuild with a slightly different
53+
/// vsize than the estimate) — that's the structural cost of the SDK's
54+
/// rate-only contract, not something we can eliminate.
55+
static RelativeFee relativeFromAbsoluteAndVsize({
56+
required int absoluteSats,
57+
required int vsize,
58+
}) {
59+
assert(vsize > 0, 'vsize must be positive');
60+
// sat/kwu = (absoluteSats / vsize) * 250
61+
// = (absoluteSats * 250) / vsize, rounded half-up via integer math.
62+
return RelativeFee((absoluteSats * 250 + vsize ~/ 2) ~/ vsize);
63+
}
1264

1365
bool get isAbsolute => this is AbsoluteFee;
1466
bool get isRelative => this is RelativeFee;
1567

16-
@override
68+
/// Numeric value kept for display-layer compatibility.
69+
///
70+
/// - [AbsoluteFee] returns its amount in sats (`int`).
71+
/// - [RelativeFee] returns its rate in sat/vByte (`double`).
1772
num get value => switch (this) {
18-
AbsoluteFee(:final value) => value,
19-
RelativeFee(:final value) => value,
73+
AbsoluteFee(:final sats) => sats,
74+
RelativeFee(:final satPerKwu) => satPerKwu / 250.0,
2075
};
2176

22-
// add to absolute fee
23-
NetworkFee toAbsolute(int size) {
24-
return switch (this) {
25-
AbsoluteFee(:final value) => NetworkFee.absolute(value),
26-
RelativeFee(:final value) => NetworkFee.absolute((value * size).round()),
27-
};
28-
}
77+
/// Convert this fee to its absolute form given a tx vsize (in vbytes).
78+
///
79+
/// Identity for [AbsoluteFee]; computes `(satPerKwu * vsize) / 250`
80+
/// rounded half-up for [RelativeFee].
81+
NetworkFee toAbsolute(int vsize) => switch (this) {
82+
AbsoluteFee() => this,
83+
RelativeFee(:final satPerKwu) =>
84+
NetworkFee.absolute((satPerKwu * vsize + 125) ~/ 250),
85+
};
86+
}
87+
88+
/// UI-facing accessors on [RelativeFee] — these are display conversions only,
89+
/// never round-tripped back into storage.
90+
extension RelativeFeeDisplay on RelativeFee {
91+
/// Display in sat/vByte (the unit users type).
92+
double get satPerVbyte => satPerKwu / 250.0;
93+
94+
/// Display in sat per 1000 vBytes — LWK's preferred unit.
95+
/// 1 kvB = 4 kwu, so 1 sat/kwu = 4 sat/kvByte.
96+
double get satPerKvbyte => satPerKwu * 4.0;
2997
}
3098

3199
@freezed
@@ -37,43 +105,25 @@ abstract class FeeOptions with _$FeeOptions {
37105
}) = _FeeOptions;
38106
const FeeOptions._();
39107

40-
FeeOptions toAbsolute(int size) {
41-
return FeeOptions(
42-
fastest: switch (fastest) {
43-
AbsoluteFee(:final value) => NetworkFee.absolute(value),
44-
RelativeFee(:final value) => NetworkFee.absolute(
45-
(value * size).round(),
46-
),
47-
},
48-
economic: switch (economic) {
49-
AbsoluteFee(:final value) => NetworkFee.absolute(value),
50-
RelativeFee(:final value) => NetworkFee.absolute(
51-
(value * size).round(),
52-
),
53-
},
54-
slow: switch (slow) {
55-
AbsoluteFee(:final value) => NetworkFee.absolute(value),
56-
RelativeFee(:final value) => NetworkFee.absolute(
57-
(value * size).round(),
58-
),
59-
},
60-
);
61-
}
108+
FeeOptions toAbsolute(int vsize) => FeeOptions(
109+
fastest: fastest.toAbsolute(vsize),
110+
economic: economic.toAbsolute(vsize),
111+
slow: slow.toAbsolute(vsize),
112+
);
62113

63-
FeeOptions toRelative(int size) {
114+
FeeOptions toRelative(int vsize) {
115+
NetworkFee asRelative(NetworkFee fee) => switch (fee) {
116+
AbsoluteFee(:final sats) =>
117+
NetworkFee.relativeFromAbsoluteAndVsize(
118+
absoluteSats: sats,
119+
vsize: vsize,
120+
),
121+
RelativeFee() => fee,
122+
};
64123
return FeeOptions(
65-
fastest: switch (fastest) {
66-
AbsoluteFee(:final value) => NetworkFee.relative(value / size),
67-
RelativeFee(:final value) => NetworkFee.relative(value),
68-
},
69-
economic: switch (economic) {
70-
AbsoluteFee(:final value) => NetworkFee.relative(value / size),
71-
RelativeFee(:final value) => NetworkFee.relative(value),
72-
},
73-
slow: switch (slow) {
74-
AbsoluteFee(:final value) => NetworkFee.relative(value / size),
75-
RelativeFee(:final value) => NetworkFee.relative(value),
76-
},
124+
fastest: asRelative(fastest),
125+
economic: asRelative(economic),
126+
slow: asRelative(slow),
77127
);
78128
}
79129
}

lib/core/swaps/domain/usecases/auto_swap_execution_usecase.dart

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -149,7 +149,8 @@ class AutoSwapExecutionUsecase {
149149
walletId: defaultLiquidWallet.id,
150150
address: swap.paymentAddress,
151151
amountSat: swap.paymentAmount,
152-
networkFee: const NetworkFee.relative(0.1),
152+
// 0.1 sat/vByte = 25 sat/kwu — Liquid's network minrelayfee default.
153+
feeRate: const RelativeFee(25),
153154
);
154155

155156
debugPrint('Getting absolute fees from PSET...');

lib/core/wallet/data/datasources/bdk_wallet_datasource.dart

Lines changed: 17 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -203,14 +203,18 @@ class BdkWalletDatasource {
203203
// so we set the sequence to 0xFFFFFFFE if replaceByFee is explicitly set to false to disable RBF.
204204
if (!replaceByFee) txBuilder.setExactSequence(nsequence: 0xFFFFFFFE);
205205

206-
if (networkFee.isAbsolute) {
207-
txBuilder = txBuilder.feeAbsolute(
208-
feeAmount: bdk.Amount.fromSat(satoshi: networkFee.value.toInt()),
209-
);
210-
} else {
211-
txBuilder = txBuilder.feeRate(
212-
feeRate: bdk.FeeRate.fromSatPerVb(satVb: networkFee.value.round()),
213-
);
206+
switch (networkFee) {
207+
case AbsoluteFee(:final sats):
208+
txBuilder = txBuilder.feeAbsolute(
209+
feeAmount: bdk.Amount.fromSat(satoshi: sats),
210+
);
211+
case RelativeFee(:final satPerKwu):
212+
// sat/kwu is BDK's native u64 unit, so this is a zero-rounding
213+
// pass-through. Using fromSatPerVb would force an int sat/vByte
214+
// and silently drop fractional rates (the bug fixed in #2133).
215+
txBuilder = txBuilder.feeRate(
216+
feeRate: bdk.FeeRate.fromSatPerKwu(satKwu: satPerKwu),
217+
);
214218
}
215219

216220
// Make sure utxos that are unspendable are not used
@@ -629,13 +633,16 @@ class BdkWalletDatasource {
629633

630634
Future<String> createUnsignedReplaceByFeePsbt({
631635
required String txid,
632-
required double feeRate,
636+
required RelativeFee feeRate,
633637
required WalletModel wallet,
634638
}) async {
635639
final bdkWallet = await BdkFacade.createWallet(wallet);
640+
// BumpFeeTxBuilder is rate-only by BDK design (BIP-125 requires a
641+
// higher rate, not absolute, to replace). We hit it with sat/kwu so
642+
// sub-1 sat/vByte bumps survive without precision loss.
636643
final tx = bdk.BumpFeeTxBuilder(
637644
txid: bdk.Txid.fromString(hex: txid),
638-
feeRate: bdk.FeeRate.fromSatPerVb(satVb: feeRate.round()),
645+
feeRate: bdk.FeeRate.fromSatPerKwu(satKwu: feeRate.satPerKwu),
639646
);
640647
final psbt = tx.finish(wallet: bdkWallet);
641648
return psbt.serialize();

lib/core/wallet/data/datasources/lwk_wallet_datasource.dart

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -378,21 +378,20 @@ class LwkWalletDatasource {
378378

379379
Future<String> buildPset({
380380
required String address,
381-
required NetworkFee networkFee,
381+
required RelativeFee feeRate,
382382
int? amountSat,
383383
bool drain = false,
384384
required WalletModel wallet,
385385
}) async {
386386
try {
387387
final lwkWallet = await LwkFacade.createPublicWallet(wallet);
388-
if (networkFee.isAbsolute) {
389-
throw Exception('Absolute fee is not supported for liquid yet!');
390-
}
391-
log.info(networkFee.value.toDouble().toString());
388+
// LWK accepts sat/kvByte as a double. Our RelativeFee stores sat/kwu,
389+
// and 1 sat/kwu = 4 sat/kvByte, so the conversion is exact integer
390+
// arithmetic promoted to double — no precision loss at the SDK boundary.
392391
final pset = await lwkWallet.buildLbtcTx(
393392
sats: BigInt.from(amountSat ?? 0),
394393
outAddress: address,
395-
feeRate: networkFee.value.toDouble() * 1000,
394+
feeRate: feeRate.satPerKvbyte,
396395
drain: drain,
397396
);
398397
final decoded = await lwkWallet.decodeTx(pset: pset);

lib/core/wallet/data/repositories/bitcoin_wallet_repository.dart

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -211,7 +211,7 @@ class BitcoinWalletRepository {
211211
Future<String> bumpFee({
212212
required String walletId,
213213
required String txid,
214-
required double newFeeRate,
214+
required RelativeFee newFeeRate,
215215
}) async {
216216
final wallet = await getPrivateWallet(walletId: walletId);
217217
final psbt = await _bdkWallet.createUnsignedReplaceByFeePsbt(

lib/core/wallet/data/repositories/liquid_wallet_repository.dart

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ class LiquidWalletRepository {
2323
required String walletId,
2424
required String address,
2525
int? amountSat,
26-
required NetworkFee networkFee,
26+
required RelativeFee feeRate,
2727
bool? drain,
2828
}) async {
2929
final metadata = await _walletMetadataDatasource.fetch(walletId);
@@ -45,7 +45,7 @@ class LiquidWalletRepository {
4545
wallet: wallet,
4646
address: address,
4747
amountSat: amountSat,
48-
networkFee: networkFee,
48+
feeRate: feeRate,
4949
drain: drain ?? false,
5050
);
5151

lib/features/pay/presentation/pay_bloc.dart

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -231,7 +231,8 @@ class PayBloc extends Bloc<PayEvent, PayState> {
231231
walletId: event.wallet.id,
232232
address: dummyAddressForFeeCalculation.address,
233233
amountSat: requiredAmountSat,
234-
networkFee: const NetworkFee.relative(0.1),
234+
// 0.1 sat/vByte = 25 sat/kwu — Liquid's network minrelayfee default.
235+
feeRate: const RelativeFee(25),
235236
);
236237
absoluteFees = await _calculateLiquidAbsoluteFeesUsecase.execute(
237238
pset: pset,
@@ -427,7 +428,8 @@ class PayBloc extends Bloc<PayEvent, PayState> {
427428
walletId: wallet.id,
428429
address: payPaymentState.payOrder.liquidAddress!,
429430
amountSat: payinAmountSat,
430-
networkFee: const NetworkFee.relative(0.1),
431+
// 0.1 sat/vByte = 25 sat/kwu — Liquid's network minrelayfee default.
432+
feeRate: const RelativeFee(25),
431433
);
432434
final signedPset = await _signLiquidTxUsecase.execute(
433435
pset: pset,
@@ -653,7 +655,8 @@ class PayBloc extends Bloc<PayEvent, PayState> {
653655
walletId: wallet.id,
654656
address: dummyAddressForFeeCalculation.address,
655657
amountSat: payinAmountSat,
656-
networkFee: const NetworkFee.relative(0.1),
658+
// 0.1 sat/vByte = 25 sat/kwu — Liquid's network minrelayfee default.
659+
feeRate: const RelativeFee(25),
657660
);
658661
final absoluteFees = await _calculateLiquidAbsoluteFeesUsecase.execute(
659662
pset: pset,

lib/features/replace_by_fee/domain/bump_fee_usecase.dart

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import 'package:bb_mobile/core/fees/domain/fees_entity.dart';
12
import 'package:bb_mobile/core/utils/logger.dart';
23
import 'package:bb_mobile/core/wallet/data/repositories/bitcoin_wallet_repository.dart';
34

@@ -10,7 +11,7 @@ class BumpFeeUsecase {
1011
Future<String> execute({
1112
required String walletId,
1213
required String txid,
13-
required double newFeeRate,
14+
required RelativeFee newFeeRate,
1415
}) async {
1516
try {
1617
final psbt = await _bitcoinWalletRepository.bumpFee(

0 commit comments

Comments
 (0)