Skip to content

Commit 3cb336c

Browse files
authored
Merge pull request #2199 from SatoshiPortal/2133-allow-creating-transactions-with-less-than-1-satvbyte-fee-rate
fix(fees): sub-1 sat/vByte fees via mempool precise endpoint (#2133)
2 parents 9cac660 + 00d7016 commit 3cb336c

91 files changed

Lines changed: 5125 additions & 1441 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

integration_test/coins_test.dart

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import 'package:bb_mobile/core/wallet/data/repositories/wallet_repository.dart';
1111
import 'package:bb_mobile/core/wallet/domain/entities/wallet.dart';
1212
import 'package:bb_mobile/core/wallet/data/repositories/wallet_address_repository.dart';
1313
import 'package:bb_mobile/core/wallet/domain/repositories/wallet_utxo_repository.dart';
14-
import 'package:bb_mobile/features/send/domain/usecases/prepare_bitcoin_send_usecase.dart';
14+
import 'package:bb_mobile/core/wallet/domain/usecases/prepare_bitcoin_send_usecase.dart';
1515
import 'package:bb_mobile/features/settings/domain/usecases/set_environment_usecase.dart';
1616
import 'package:bb_mobile/locator.dart';
1717
import 'package:bb_mobile/main.dart';
@@ -86,10 +86,9 @@ Future<void> main({bool isInitialized = false}) async {
8686
final dbFile = File('${dir.path}/restart.sqlite');
8787

8888
final before = SqliteDatabase(NativeDatabase(dbFile));
89-
await FrozenWalletUtxoDatasource(db: before).freezeOutpoints(
90-
walletId: walletId,
91-
outpoints: [outpoint],
92-
);
89+
await FrozenWalletUtxoDatasource(
90+
db: before,
91+
).freezeOutpoints(walletId: walletId, outpoints: [outpoint]);
9392
await before.close();
9493

9594
final after = SqliteDatabase(NativeDatabase(dbFile));
@@ -229,7 +228,7 @@ Future<void> main({bool isInitialized = false}) async {
229228
walletId: wallet.id,
230229
address: receive.address,
231230
drain: true,
232-
networkFee: const NetworkFee.relative(2),
231+
networkFee: NetworkFee.relativeFromSatPerVbyte(2),
233232
),
234233
throwsA(isA<NoSpendableUtxoException>()),
235234
);
Lines changed: 71 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
1-
import 'package:bb_mobile/core/fees/domain/fees_entity.dart';
1+
import 'dart:convert';
2+
3+
import 'package:bb_mobile/core/errors/bull_exception.dart';
4+
import 'package:bb_mobile/core/fees/data/models/mempool_fees_model.dart';
25
import 'package:bb_mobile/core/mempool/application/usecases/get_active_mempool_server_usecase.dart';
36
import 'package:bb_mobile/core/mempool/domain/repositories/mempool_settings_repository.dart';
47
import 'package:bb_mobile/core/mempool/domain/value_objects/mempool_server_network.dart';
@@ -9,67 +12,102 @@ class FeesDatasource {
912
final GetActiveMempoolServerUsecase _getActiveMempoolServerUsecase;
1013
final MempoolSettingsRepository _mempoolSettingsRepository;
1114

15+
/// Builds the HTTP client for a resolved base URL. Injected so tests can
16+
/// supply a mock; defaults to a real Dio. The base URL is only known at
17+
/// call time (custom server vs BB, mainnet vs testnet), so this is a
18+
/// builder rather than a pre-built client.
19+
final Dio Function(String baseUrl) _dioBuilder;
20+
1221
FeesDatasource({
1322
required this._getActiveMempoolServerUsecase,
1423
required this._mempoolSettingsRepository,
15-
});
24+
Dio Function(String baseUrl)? dioBuilder,
25+
}) : _dioBuilder = dioBuilder ?? _defaultDioBuilder;
1626

17-
Future<FeeOptions> getBitcoinNetworkFeeOptions({
27+
static Dio _defaultDioBuilder(String baseUrl) =>
28+
Dio(BaseOptions(baseUrl: baseUrl));
29+
30+
/// Fetches precise (sub-1 sat/vByte) fee rates from the mempool API.
31+
///
32+
/// Tries `/api/v1/fees/precise` first. If it fails for any reason — a
33+
/// server too old to expose it (404), a transient error, or a malformed
34+
/// body — falls back to the rounded `/api/v1/fees/recommended` so a
35+
/// custom/self-hosted mempool keeps working. Both endpoints return the
36+
/// same JSON shape, so the same model parses either. Throws only when
37+
/// neither endpoint yields a usable response.
38+
Future<MempoolFeesModel> fetchBitcoinNetworkFees({
1839
required bool isTestnet,
1940
}) async {
20-
// Get network settings
2141
final network = MempoolServerNetwork.fromEnvironment(
2242
isTestnet: isTestnet,
2343
isLiquid: false,
2444
);
2545
final settings = await _mempoolSettingsRepository.fetchByNetwork(network);
2646

27-
// Determine which mempool server to use
47+
// Determine which mempool server to use.
2848
String baseUrl;
2949
if (settings.useForFeeEstimation) {
30-
// Use custom or default mempool server from settings
50+
// Use custom or default mempool server from settings.
3151
final server = await _getActiveMempoolServerUsecase.execute(
3252
isTestnet: isTestnet,
3353
isLiquid: false,
3454
);
3555
baseUrl = server.fullUrl;
3656
} else {
37-
// Fall back to BB's mempool
57+
// Fall back to BB's mempool.
3858
baseUrl = isTestnet
3959
? 'https://${ApiServiceConstants.testnetMempoolUrlPath}'
4060
: 'https://${ApiServiceConstants.bbMempoolUrlPath}';
4161
}
4262

43-
final http = Dio(BaseOptions(baseUrl: baseUrl));
44-
const path = '/api/v1/fees/recommended';
63+
final http = _dioBuilder(baseUrl);
4564

46-
final resp = await http.get(path);
47-
if (resp.statusCode == null || resp.statusCode != 200) {
48-
throw 'Error fetching fees from Mempool API (status: ${resp.statusCode})';
65+
final fees =
66+
await _getFees(http, ApiServiceConstants.mempoolPreciseFeesPath) ??
67+
await _getFees(http, ApiServiceConstants.mempoolRecommendedFeesPath);
68+
if (fees == null) {
69+
throw MempoolFeesException(
70+
'No mempool fee endpoint available at $baseUrl',
71+
);
4972
}
50-
final data = resp.data as Map<String, dynamic>;
51-
final fastestFee = data['fastestFee'] as int;
52-
final economyFee = data['economyFee'] as int;
53-
final minimumFee = data['minimumFee'] as int;
5473

55-
final feeOptions = FeeOptions(
56-
fastest: NetworkFee.relative(fastestFee.toDouble()),
57-
economic: NetworkFee.relative(economyFee.toDouble()),
58-
slow: NetworkFee.relative(minimumFee.toDouble()),
59-
);
60-
61-
return feeOptions;
74+
return fees;
6275
}
6376

64-
Future<FeeOptions> getLiquidNetworkFeeOptions({
65-
required bool isTestnet,
66-
}) async {
67-
const feeOptions = FeeOptions(
68-
fastest: NetworkFee.relative(0.1),
69-
economic: NetworkFee.relative(0.1),
70-
slow: NetworkFee.relative(0.1),
71-
);
72-
73-
return feeOptions;
77+
/// GETs a fee endpoint and parses it. Returns the model on a 200 with a
78+
/// well-formed body, or `null` on any failure — non-200, network/Dio
79+
/// error, non-object body, or a 200 whose body is missing or has a
80+
/// non-numeric fee field — so the caller can fall back to the next path.
81+
/// Parsing happens here (not at the call site) so a malformed-but-200
82+
/// precise response falls back to recommended instead of throwing.
83+
Future<MempoolFeesModel?> _getFees(Dio http, String path) async {
84+
try {
85+
final resp = await http.get<dynamic>(path);
86+
if (resp.statusCode != 200) return null;
87+
var data = resp.data;
88+
// Dio only auto-decodes when the server sends a JSON content-type. A
89+
// working-but-misconfigured self-hosted mempool returning the body as
90+
// text/plain would otherwise silently drop precise → recommended,
91+
// losing the sub-1 sat/vByte rates this whole path exists for. Decode
92+
// a string body before the Map check; a non-JSON string throws and is
93+
// caught below (→ fallback).
94+
if (data is String && data.isNotEmpty) {
95+
data = jsonDecode(data);
96+
}
97+
if (data is Map<String, dynamic>) {
98+
return MempoolFeesModel.fromJson(data);
99+
}
100+
return null;
101+
} on DioException {
102+
return null;
103+
} catch (_) {
104+
// A 200 with a malformed/partial body — `fromJson` throws on a missing
105+
// or non-numeric fee field. Fall back rather than failing the fetch.
106+
return null;
107+
}
74108
}
75109
}
110+
111+
class MempoolFeesException extends BullException {
112+
MempoolFeesException(super.message);
113+
}

lib/core/fees/data/fees_repository.dart

Lines changed: 0 additions & 27 deletions
This file was deleted.
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import 'package:bb_mobile/core/fees/data/fees_datasource.dart';
2+
import 'package:bb_mobile/core/fees/data/mappers/mempool_fees_mapper.dart';
3+
import 'package:bb_mobile/core/fees/domain/fees_entity.dart';
4+
import 'package:bb_mobile/core/fees/domain/repositories/fees_repository.dart';
5+
import 'package:bb_mobile/core/wallet/domain/entities/wallet.dart';
6+
7+
class FeesRepositoryImpl implements FeesRepository {
8+
final FeesDatasource _feesDatasource;
9+
10+
const FeesRepositoryImpl({required this._feesDatasource});
11+
12+
@override
13+
Future<FeeOptions> getNetworkFees({required Network network}) async {
14+
if (network.isBitcoin) {
15+
final fees = await _feesDatasource.fetchBitcoinNetworkFees(
16+
isTestnet: network.isTestnet,
17+
);
18+
return MempoolFeesMapper.toFeeOptions(fees);
19+
}
20+
21+
// Liquid blocks are typically empty, so the network's minrelayfee
22+
// (0.1 sat/vByte = 25 sat/kwu) is the only relevant fee tier today.
23+
// The three presets are kept identical for UI parity with Bitcoin.
24+
const minRelay = RelativeFee(NetworkFeeRelayPolicy.minRelaySatPerKwu);
25+
return const FeeOptions(
26+
fastest: minRelay,
27+
economic: minRelay,
28+
slow: minRelay,
29+
minRelay: minRelay,
30+
);
31+
}
32+
}
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import 'dart:math';
2+
3+
import 'package:bb_mobile/core/fees/data/models/mempool_fees_model.dart';
4+
import 'package:bb_mobile/core/fees/domain/fees_entity.dart';
5+
6+
/// Maps a mempool fees response into the app's three preset tiers.
7+
///
8+
/// Tier policy:
9+
/// - **Fastest** ← `fastestFee` (next-block target).
10+
/// - **Economic** ← `hourFee` (~1-hour target).
11+
/// - **Slow** ← `economyFee`.
12+
///
13+
/// The relay floor is `max(minimumFee, 0.1 sat/vByte)`: the live mempool
14+
/// `minimumFee` (the rate below which that node won't relay — typically 0.1
15+
/// at quiet blocks, higher under congestion), clamped up to the static 0.1
16+
/// safety floor. Every tier is floored at it, and it is exposed as
17+
/// [FeeOptions.minRelay] so the validation gates reject anything below the
18+
/// network's current minimum rather than a hardcoded constant. Because
19+
/// mempool returns the fields in non-increasing order (`fastestFee ≥ hourFee
20+
/// ≥ economyFee ≥ minimumFee`) and `max` is monotonic, flooring all three
21+
/// preserves the Fastest ≥ Economic ≥ Slow ordering.
22+
///
23+
/// `halfHourFee` is intentionally unused — the app exposes exactly three
24+
/// tiers. `minimumFee` feeds only the relay floor (above), never a tier, so
25+
/// Slow stays a real economy rate instead of collapsing onto the floor.
26+
class MempoolFeesMapper {
27+
const MempoolFeesMapper._();
28+
29+
static FeeOptions toFeeOptions(MempoolFeesModel model) {
30+
final floorSatPerVbyte = max(
31+
model.minimumFee,
32+
NetworkFeeRelayPolicy.minRelaySatPerVbyte,
33+
);
34+
RelativeFee tier(double satPerVbyte) =>
35+
NetworkFee.relativeFromSatPerVbyte(max(satPerVbyte, floorSatPerVbyte));
36+
return FeeOptions(
37+
fastest: tier(model.fastestFee),
38+
economic: tier(model.hourFee),
39+
slow: tier(model.economyFee),
40+
minRelay: NetworkFee.relativeFromSatPerVbyte(floorSatPerVbyte),
41+
);
42+
}
43+
}
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
/// Wire model for the mempool fee endpoints (`/api/v1/fees/precise` and the
2+
/// `/api/v1/fees/recommended` fallback). Pure serialization — no business
3+
/// rules, no tier policy; that lives in the mapper/repository.
4+
///
5+
/// Every field is a `double`. The precise endpoint returns sub-1 sat/vByte
6+
/// rates (e.g. `0.92`) but encodes whole values as JSON integers (e.g. `1`),
7+
/// so a single payload can mix `int` and `double`. Reading each field as
8+
/// `num` then `.toDouble()` accepts both; a bare `as int`/`as double` cast
9+
/// throws on the other JSON number type ("type 'double' is not a subtype of
10+
/// type 'int'").
11+
class MempoolFeesModel {
12+
final double fastestFee;
13+
final double halfHourFee;
14+
final double hourFee;
15+
final double economyFee;
16+
final double minimumFee;
17+
18+
const MempoolFeesModel({
19+
required this.fastestFee,
20+
required this.halfHourFee,
21+
required this.hourFee,
22+
required this.economyFee,
23+
required this.minimumFee,
24+
});
25+
26+
factory MempoolFeesModel.fromJson(Map<String, dynamic> json) {
27+
double parse(String key) => (json[key] as num).toDouble();
28+
return MempoolFeesModel(
29+
fastestFee: parse('fastestFee'),
30+
halfHourFee: parse('halfHourFee'),
31+
hourFee: parse('hourFee'),
32+
economyFee: parse('economyFee'),
33+
minimumFee: parse('minimumFee'),
34+
);
35+
}
36+
}
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import 'package:bb_mobile/core/fees/domain/fees_entity.dart';
2+
import 'package:freezed_annotation/freezed_annotation.dart';
3+
4+
part 'fee_preview_cache.freezed.dart';
5+
6+
/// One slot of the four-tile fee-preview cache. Holds the real fee read
7+
/// from a built unsigned PSBT (`psbt.fee()`), plus the PSBT bytes and
8+
/// txSize so the commit path can rebroadcast the exact tx the user saw
9+
/// — defeating BDK's randomized coin selection.
10+
///
11+
/// An empty slot (`feeSat == null && unsignedPsbt == null && txSize == null`)
12+
/// means the modal has not built this preset for the current input shape
13+
/// yet; the UI shimmers.
14+
@freezed
15+
abstract class BitcoinFeePreviewSlot with _$BitcoinFeePreviewSlot {
16+
const factory BitcoinFeePreviewSlot({
17+
int? feeSat,
18+
String? unsignedPsbt,
19+
int? txSize,
20+
}) = _BitcoinFeePreviewSlot;
21+
const BitcoinFeePreviewSlot._();
22+
23+
/// Whether this slot can be reused at commit time. Both PSBT and txSize
24+
/// must be present — `feeSat` alone is a display value, not enough to
25+
/// short-circuit the build.
26+
bool get isCacheReady => unsignedPsbt != null && txSize != null;
27+
}
28+
29+
/// The full four-slot fee-preview cache, plus the two loading flags the
30+
/// UI shimmers off. Replaces 11 nullable fields on `SendState` /
31+
/// `TransferState` with one composite value object so the cache lifecycle
32+
/// is reasonable about and the matrix of partial states stops bleeding
33+
/// through state.copyWith calls.
34+
///
35+
/// Indexed by [FeeSelection]; both states expose `state.feePreviewCache`
36+
/// to selectors and read slots via [slotFor].
37+
@freezed
38+
abstract class BitcoinFeePreviewCache with _$BitcoinFeePreviewCache {
39+
const factory BitcoinFeePreviewCache({
40+
@Default(BitcoinFeePreviewSlot()) BitcoinFeePreviewSlot fastest,
41+
@Default(BitcoinFeePreviewSlot()) BitcoinFeePreviewSlot economic,
42+
@Default(BitcoinFeePreviewSlot()) BitcoinFeePreviewSlot slow,
43+
@Default(BitcoinFeePreviewSlot()) BitcoinFeePreviewSlot custom,
44+
@Default(false) bool presetsLoading,
45+
@Default(false) bool customLoading,
46+
}) = _BitcoinFeePreviewCache;
47+
const BitcoinFeePreviewCache._();
48+
49+
/// Empty cache — no slot has been built. Use as the default on the
50+
/// owning state and as the value to `copyWith` into on invalidation.
51+
static const empty = BitcoinFeePreviewCache();
52+
53+
BitcoinFeePreviewSlot slotFor(FeeSelection selection) => switch (selection) {
54+
FeeSelection.fastest => fastest,
55+
FeeSelection.economic => economic,
56+
FeeSelection.slow => slow,
57+
FeeSelection.custom => custom,
58+
};
59+
60+
BitcoinFeePreviewCache withSlot(
61+
FeeSelection selection,
62+
BitcoinFeePreviewSlot slot,
63+
) => switch (selection) {
64+
FeeSelection.fastest => copyWith(fastest: slot),
65+
FeeSelection.economic => copyWith(economic: slot),
66+
FeeSelection.slow => copyWith(slow: slot),
67+
FeeSelection.custom => copyWith(custom: slot),
68+
};
69+
}

0 commit comments

Comments
 (0)