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' ;
25import 'package:bb_mobile/core/mempool/application/usecases/get_active_mempool_server_usecase.dart' ;
36import 'package:bb_mobile/core/mempool/domain/repositories/mempool_settings_repository.dart' ;
47import '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+ }
0 commit comments