Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
934fce8
fix(fees): reallow sub-1 sat/vByte transactions
ethicnology May 26, 2026
e5da628
refactor(fees): default custom is relative
ethicnology May 27, 2026
9c86222
fix: numpad overflow in send
i5hi May 27, 2026
77ad6a0
fix(send): show realistic bitcoin fee from PSBT, not prediction
ethicnology May 28, 2026
670f95e
fix(send): trim IEEE 754 noise from custom-fee preview
ethicnology May 28, 2026
d2d7e9f
feat(send): highlight custom-fee tile while editing
ethicnology May 28, 2026
0ee4adb
refactor(fees): share custom-fee tile between send and swap
ethicnology May 28, 2026
dbe5f3a
refactor(fees): fold RBF custom-fee into the shared widget
ethicnology May 28, 2026
486f0a9
fix(fees): align displayed fees with reality across all surfaces
ethicnology May 28, 2026
50385e6
feat(fees): typing is the selection; dismissal is the apply
ethicnology May 28, 2026
47ddf76
refactor(fees): extract preview use cases and cache value object
ethicnology May 28, 2026
8a039f5
refactor(fees): unify preview pipeline, cache, and modal across send …
ethicnology May 28, 2026
02f9b74
fix(fees): three divergence-class bugs caught in PR review
ethicnology May 28, 2026
bf3fbe5
refactor(wallet): move bitcoin send + absolute-fee use cases to core
ethicnology May 28, 2026
2f44b48
chore(l10n): translate sendEstimatedDeliveryHours into all locales
ethicnology May 28, 2026
68fe701
Merge remote-tracking branch 'origin/develop' into 2133-allow-creatin…
ethicnology Jun 11, 2026
1e9304d
fix(fees): close two stale-PSBT-cache holes found in audit
ethicnology Jun 11, 2026
25f7582
fix(fees): bump preview epoch on custom-fee arm too
ethicnology Jun 11, 2026
7e26a1e
feat(fees): use mempool precise endpoint for sub-1 sat/vByte presets
ethicnology Jun 15, 2026
0d24196
Merge remote-tracking branch 'origin/develop' into 2133-allow-creatin…
ethicnology Jun 15, 2026
337b47b
fix(fees): validate against live minimumFee + resolve audit findings
ethicnology Jun 15, 2026
5afbea8
fix(send): preserve fee selection across a fee refresh
ethicnology Jun 15, 2026
fa5c425
fix(replace_by_fee): reject sub-minimum bumps under congestion
ethicnology Jun 15, 2026
2413a67
fix(send): re-assert relay floor on built tx; cap rate field at 2 dec…
ethicnology Jun 23, 2026
aedd33d
fix(swap): restore send symmetry — relay-floor re-assert and utxo cac…
ethicnology Jun 23, 2026
128102d
fix(fees): gate RBF below-floor broadcast; tolerate non-JSON precise …
ethicnology Jun 23, 2026
00d7016
Merge branch 'develop' into 2133-allow-creating-transactions-with-les…
ethicnology Jun 24, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 5 additions & 6 deletions integration_test/coins_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import 'package:bb_mobile/core/wallet/data/repositories/wallet_repository.dart';
import 'package:bb_mobile/core/wallet/domain/entities/wallet.dart';
import 'package:bb_mobile/core/wallet/data/repositories/wallet_address_repository.dart';
import 'package:bb_mobile/core/wallet/domain/repositories/wallet_utxo_repository.dart';
import 'package:bb_mobile/features/send/domain/usecases/prepare_bitcoin_send_usecase.dart';
import 'package:bb_mobile/core/wallet/domain/usecases/prepare_bitcoin_send_usecase.dart';
import 'package:bb_mobile/features/settings/domain/usecases/set_environment_usecase.dart';
import 'package:bb_mobile/locator.dart';
import 'package:bb_mobile/main.dart';
Expand Down Expand Up @@ -86,10 +86,9 @@ Future<void> main({bool isInitialized = false}) async {
final dbFile = File('${dir.path}/restart.sqlite');

final before = SqliteDatabase(NativeDatabase(dbFile));
await FrozenWalletUtxoDatasource(db: before).freezeOutpoints(
walletId: walletId,
outpoints: [outpoint],
);
await FrozenWalletUtxoDatasource(
db: before,
).freezeOutpoints(walletId: walletId, outpoints: [outpoint]);
await before.close();

final after = SqliteDatabase(NativeDatabase(dbFile));
Expand Down Expand Up @@ -229,7 +228,7 @@ Future<void> main({bool isInitialized = false}) async {
walletId: wallet.id,
address: receive.address,
drain: true,
networkFee: const NetworkFee.relative(2),
networkFee: NetworkFee.relativeFromSatPerVbyte(2),
),
throwsA(isA<NoSpendableUtxoException>()),
);
Expand Down
104 changes: 71 additions & 33 deletions lib/core/fees/data/fees_datasource.dart
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
import 'package:bb_mobile/core/fees/domain/fees_entity.dart';
import 'dart:convert';

import 'package:bb_mobile/core/errors/bull_exception.dart';
import 'package:bb_mobile/core/fees/data/models/mempool_fees_model.dart';
import 'package:bb_mobile/core/mempool/application/usecases/get_active_mempool_server_usecase.dart';
import 'package:bb_mobile/core/mempool/domain/repositories/mempool_settings_repository.dart';
import 'package:bb_mobile/core/mempool/domain/value_objects/mempool_server_network.dart';
Expand All @@ -9,67 +12,102 @@ class FeesDatasource {
final GetActiveMempoolServerUsecase _getActiveMempoolServerUsecase;
final MempoolSettingsRepository _mempoolSettingsRepository;

/// Builds the HTTP client for a resolved base URL. Injected so tests can
/// supply a mock; defaults to a real Dio. The base URL is only known at
/// call time (custom server vs BB, mainnet vs testnet), so this is a
/// builder rather than a pre-built client.
final Dio Function(String baseUrl) _dioBuilder;

FeesDatasource({
required this._getActiveMempoolServerUsecase,
required this._mempoolSettingsRepository,
});
Dio Function(String baseUrl)? dioBuilder,
}) : _dioBuilder = dioBuilder ?? _defaultDioBuilder;

Future<FeeOptions> getBitcoinNetworkFeeOptions({
static Dio _defaultDioBuilder(String baseUrl) =>
Dio(BaseOptions(baseUrl: baseUrl));

/// Fetches precise (sub-1 sat/vByte) fee rates from the mempool API.
///
/// Tries `/api/v1/fees/precise` first. If it fails for any reason — a
/// server too old to expose it (404), a transient error, or a malformed
/// body — falls back to the rounded `/api/v1/fees/recommended` so a
/// custom/self-hosted mempool keeps working. Both endpoints return the
/// same JSON shape, so the same model parses either. Throws only when
/// neither endpoint yields a usable response.
Future<MempoolFeesModel> fetchBitcoinNetworkFees({
required bool isTestnet,
}) async {
// Get network settings
final network = MempoolServerNetwork.fromEnvironment(
isTestnet: isTestnet,
isLiquid: false,
);
final settings = await _mempoolSettingsRepository.fetchByNetwork(network);

// Determine which mempool server to use
// Determine which mempool server to use.
String baseUrl;
if (settings.useForFeeEstimation) {
// Use custom or default mempool server from settings
// Use custom or default mempool server from settings.
final server = await _getActiveMempoolServerUsecase.execute(
isTestnet: isTestnet,
isLiquid: false,
);
baseUrl = server.fullUrl;
} else {
// Fall back to BB's mempool
// Fall back to BB's mempool.
baseUrl = isTestnet
? 'https://${ApiServiceConstants.testnetMempoolUrlPath}'
: 'https://${ApiServiceConstants.bbMempoolUrlPath}';
}

final http = Dio(BaseOptions(baseUrl: baseUrl));
const path = '/api/v1/fees/recommended';
final http = _dioBuilder(baseUrl);

final resp = await http.get(path);
if (resp.statusCode == null || resp.statusCode != 200) {
throw 'Error fetching fees from Mempool API (status: ${resp.statusCode})';
final fees =
await _getFees(http, ApiServiceConstants.mempoolPreciseFeesPath) ??
await _getFees(http, ApiServiceConstants.mempoolRecommendedFeesPath);
if (fees == null) {
throw MempoolFeesException(
'No mempool fee endpoint available at $baseUrl',
);
}
final data = resp.data as Map<String, dynamic>;
final fastestFee = data['fastestFee'] as int;
final economyFee = data['economyFee'] as int;
final minimumFee = data['minimumFee'] as int;

final feeOptions = FeeOptions(
fastest: NetworkFee.relative(fastestFee.toDouble()),
economic: NetworkFee.relative(economyFee.toDouble()),
slow: NetworkFee.relative(minimumFee.toDouble()),
);

return feeOptions;
return fees;
}

Future<FeeOptions> getLiquidNetworkFeeOptions({
required bool isTestnet,
}) async {
const feeOptions = FeeOptions(
fastest: NetworkFee.relative(0.1),
economic: NetworkFee.relative(0.1),
slow: NetworkFee.relative(0.1),
);

return feeOptions;
/// GETs a fee endpoint and parses it. Returns the model on a 200 with a
/// well-formed body, or `null` on any failure — non-200, network/Dio
/// error, non-object body, or a 200 whose body is missing or has a
/// non-numeric fee field — so the caller can fall back to the next path.
/// Parsing happens here (not at the call site) so a malformed-but-200
/// precise response falls back to recommended instead of throwing.
Future<MempoolFeesModel?> _getFees(Dio http, String path) async {
try {
final resp = await http.get<dynamic>(path);
if (resp.statusCode != 200) return null;
var data = resp.data;
// Dio only auto-decodes when the server sends a JSON content-type. A
// working-but-misconfigured self-hosted mempool returning the body as
// text/plain would otherwise silently drop precise → recommended,
// losing the sub-1 sat/vByte rates this whole path exists for. Decode
// a string body before the Map check; a non-JSON string throws and is
// caught below (→ fallback).
if (data is String && data.isNotEmpty) {
data = jsonDecode(data);
}
if (data is Map<String, dynamic>) {
return MempoolFeesModel.fromJson(data);
}
return null;
} on DioException {
return null;
} catch (_) {
// A 200 with a malformed/partial body — `fromJson` throws on a missing
// or non-numeric fee field. Fall back rather than failing the fetch.
return null;
}
}
}

class MempoolFeesException extends BullException {
MempoolFeesException(super.message);
}
27 changes: 0 additions & 27 deletions lib/core/fees/data/fees_repository.dart

This file was deleted.

32 changes: 32 additions & 0 deletions lib/core/fees/data/fees_repository_impl.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import 'package:bb_mobile/core/fees/data/fees_datasource.dart';
import 'package:bb_mobile/core/fees/data/mappers/mempool_fees_mapper.dart';
import 'package:bb_mobile/core/fees/domain/fees_entity.dart';
import 'package:bb_mobile/core/fees/domain/repositories/fees_repository.dart';
import 'package:bb_mobile/core/wallet/domain/entities/wallet.dart';

class FeesRepositoryImpl implements FeesRepository {
final FeesDatasource _feesDatasource;

const FeesRepositoryImpl({required this._feesDatasource});

@override
Future<FeeOptions> getNetworkFees({required Network network}) async {
if (network.isBitcoin) {
final fees = await _feesDatasource.fetchBitcoinNetworkFees(
isTestnet: network.isTestnet,
);
return MempoolFeesMapper.toFeeOptions(fees);
}

// Liquid blocks are typically empty, so the network's minrelayfee
// (0.1 sat/vByte = 25 sat/kwu) is the only relevant fee tier today.
// The three presets are kept identical for UI parity with Bitcoin.
const minRelay = RelativeFee(NetworkFeeRelayPolicy.minRelaySatPerKwu);
return const FeeOptions(
fastest: minRelay,
economic: minRelay,
slow: minRelay,
minRelay: minRelay,
);
}
}
43 changes: 43 additions & 0 deletions lib/core/fees/data/mappers/mempool_fees_mapper.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import 'dart:math';

import 'package:bb_mobile/core/fees/data/models/mempool_fees_model.dart';
import 'package:bb_mobile/core/fees/domain/fees_entity.dart';

/// Maps a mempool fees response into the app's three preset tiers.
///
/// Tier policy:
/// - **Fastest** ← `fastestFee` (next-block target).
/// - **Economic** ← `hourFee` (~1-hour target).
/// - **Slow** ← `economyFee`.
///
/// The relay floor is `max(minimumFee, 0.1 sat/vByte)`: the live mempool
/// `minimumFee` (the rate below which that node won't relay — typically 0.1
/// at quiet blocks, higher under congestion), clamped up to the static 0.1
/// safety floor. Every tier is floored at it, and it is exposed as
/// [FeeOptions.minRelay] so the validation gates reject anything below the
/// network's current minimum rather than a hardcoded constant. Because
/// mempool returns the fields in non-increasing order (`fastestFee ≥ hourFee
/// ≥ economyFee ≥ minimumFee`) and `max` is monotonic, flooring all three
/// preserves the Fastest ≥ Economic ≥ Slow ordering.
///
/// `halfHourFee` is intentionally unused — the app exposes exactly three
/// tiers. `minimumFee` feeds only the relay floor (above), never a tier, so
/// Slow stays a real economy rate instead of collapsing onto the floor.
class MempoolFeesMapper {
const MempoolFeesMapper._();

static FeeOptions toFeeOptions(MempoolFeesModel model) {
final floorSatPerVbyte = max(
model.minimumFee,
NetworkFeeRelayPolicy.minRelaySatPerVbyte,
);
RelativeFee tier(double satPerVbyte) =>
NetworkFee.relativeFromSatPerVbyte(max(satPerVbyte, floorSatPerVbyte));
return FeeOptions(
fastest: tier(model.fastestFee),
economic: tier(model.hourFee),
slow: tier(model.economyFee),
minRelay: NetworkFee.relativeFromSatPerVbyte(floorSatPerVbyte),
);
}
}
36 changes: 36 additions & 0 deletions lib/core/fees/data/models/mempool_fees_model.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
/// Wire model for the mempool fee endpoints (`/api/v1/fees/precise` and the
/// `/api/v1/fees/recommended` fallback). Pure serialization — no business
/// rules, no tier policy; that lives in the mapper/repository.
///
/// Every field is a `double`. The precise endpoint returns sub-1 sat/vByte
/// rates (e.g. `0.92`) but encodes whole values as JSON integers (e.g. `1`),
/// so a single payload can mix `int` and `double`. Reading each field as
/// `num` then `.toDouble()` accepts both; a bare `as int`/`as double` cast
/// throws on the other JSON number type ("type 'double' is not a subtype of
/// type 'int'").
class MempoolFeesModel {
final double fastestFee;
final double halfHourFee;
final double hourFee;
final double economyFee;
final double minimumFee;

const MempoolFeesModel({
required this.fastestFee,
required this.halfHourFee,
required this.hourFee,
required this.economyFee,
required this.minimumFee,
});

factory MempoolFeesModel.fromJson(Map<String, dynamic> json) {
double parse(String key) => (json[key] as num).toDouble();
return MempoolFeesModel(
fastestFee: parse('fastestFee'),
halfHourFee: parse('halfHourFee'),
hourFee: parse('hourFee'),
economyFee: parse('economyFee'),
minimumFee: parse('minimumFee'),
);
}
}
69 changes: 69 additions & 0 deletions lib/core/fees/domain/fee_preview_cache.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import 'package:bb_mobile/core/fees/domain/fees_entity.dart';
import 'package:freezed_annotation/freezed_annotation.dart';

part 'fee_preview_cache.freezed.dart';

/// One slot of the four-tile fee-preview cache. Holds the real fee read
/// from a built unsigned PSBT (`psbt.fee()`), plus the PSBT bytes and
/// txSize so the commit path can rebroadcast the exact tx the user saw
/// — defeating BDK's randomized coin selection.
///
/// An empty slot (`feeSat == null && unsignedPsbt == null && txSize == null`)
/// means the modal has not built this preset for the current input shape
/// yet; the UI shimmers.
@freezed
abstract class BitcoinFeePreviewSlot with _$BitcoinFeePreviewSlot {
const factory BitcoinFeePreviewSlot({
int? feeSat,
String? unsignedPsbt,
int? txSize,
}) = _BitcoinFeePreviewSlot;
const BitcoinFeePreviewSlot._();

/// Whether this slot can be reused at commit time. Both PSBT and txSize
/// must be present — `feeSat` alone is a display value, not enough to
/// short-circuit the build.
bool get isCacheReady => unsignedPsbt != null && txSize != null;
}

/// The full four-slot fee-preview cache, plus the two loading flags the
/// UI shimmers off. Replaces 11 nullable fields on `SendState` /
/// `TransferState` with one composite value object so the cache lifecycle
/// is reasonable about and the matrix of partial states stops bleeding
/// through state.copyWith calls.
///
/// Indexed by [FeeSelection]; both states expose `state.feePreviewCache`
/// to selectors and read slots via [slotFor].
@freezed
abstract class BitcoinFeePreviewCache with _$BitcoinFeePreviewCache {
const factory BitcoinFeePreviewCache({
@Default(BitcoinFeePreviewSlot()) BitcoinFeePreviewSlot fastest,
@Default(BitcoinFeePreviewSlot()) BitcoinFeePreviewSlot economic,
@Default(BitcoinFeePreviewSlot()) BitcoinFeePreviewSlot slow,
@Default(BitcoinFeePreviewSlot()) BitcoinFeePreviewSlot custom,
@Default(false) bool presetsLoading,
@Default(false) bool customLoading,
}) = _BitcoinFeePreviewCache;
const BitcoinFeePreviewCache._();

/// Empty cache — no slot has been built. Use as the default on the
/// owning state and as the value to `copyWith` into on invalidation.
static const empty = BitcoinFeePreviewCache();

BitcoinFeePreviewSlot slotFor(FeeSelection selection) => switch (selection) {
FeeSelection.fastest => fastest,
FeeSelection.economic => economic,
FeeSelection.slow => slow,
FeeSelection.custom => custom,
};

BitcoinFeePreviewCache withSlot(
FeeSelection selection,
BitcoinFeePreviewSlot slot,
) => switch (selection) {
FeeSelection.fastest => copyWith(fastest: slot),
FeeSelection.economic => copyWith(economic: slot),
FeeSelection.slow => copyWith(slow: slot),
FeeSelection.custom => copyWith(custom: slot),
};
}
Loading