Skip to content

Commit 47ddf76

Browse files
committed
refactor(fees): extract preview use cases and cache value object
Unifies the fee-preview state machine that was duplicated between SendCubit and TransferBloc — same parallel build with rate-dedupe, same custom-rate debounce, same 11 nullable cache fields, identical 0.1-floor check. New primitives in lib/core/fees/domain/: - BitcoinFeePreviewSlot + BitcoinFeePreviewCache value objects collapse the 11 nullable fields per state into one composite keyed by FeeSelection. - NetworkFeeRelayPolicy with a single minRelay constant. - BitcoinFeePresetPolicy owns the Slow-pin (0.1 sat/vB) decision instead of FeesDatasource. New use cases in lib/features/send/domain/usecases/ (swap imports them via the same pre-existing pattern it uses for PrepareBitcoinSendUsecase): - PreviewBitcoinFeeUsecase builds one PSBT and reports the real psbt.fee() + cached bytes. - PreviewBitcoinFeePresetsUsecase builds fastest/economic/slow in parallel and dedupes by rate — same-rate presets share one PSBT so a quiet mempool can't make Slow look more expensive than Economic. Behaviour fixes folded in: - Use case logs build failures instead of silently swallowing them. - The custom-fee tile prefills the input with the previously committed value on reopen. - L10n key sendEstimatedDeliveryFewHours renamed to sendEstimatedDeliveryHours; English value is now "hours". Other locales keep their pre-rename strings (translation follow-up). 3 new test files cover the slot/cache invariants, the minRelay policy boundary, and the preset-policy slow-pin. Widget tests gain 3 prefill cases. 192 tests pass; analyze --fatal-warnings --fatal-infos clean. Deferred: - Single-modal widget unification across send/swap — needs careful bloc-provider rewiring with a manual smoke test. - Moving PrepareBitcoinSendUsecase + CalculateBitcoinAbsoluteFees- Usecase to lib/core/wallet/ to fix the pre-existing swap → send feature-isolation violation. - Translating "hours" into the 25 non-English locales.
1 parent 50385e6 commit 47ddf76

45 files changed

Lines changed: 1765 additions & 712 deletions

Some content is hidden

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

lib/core/fees/data/fees_datasource.dart

Lines changed: 4 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -51,15 +51,12 @@ class FeesDatasource {
5151
final data = resp.data as Map<String, dynamic>;
5252
final fastestFee = data['fastestFee'] as int;
5353
final economyFee = data['economyFee'] as int;
54-
final minimumFee = data['minimumFee'] as int;
5554

56-
final feeOptions = FeeOptions(
57-
fastest: NetworkFee.relativeFromSatPerVbyte(fastestFee.toDouble()),
58-
economic: NetworkFee.relativeFromSatPerVbyte(economyFee.toDouble()),
59-
slow: NetworkFee.relativeFromSatPerVbyte(minimumFee.toDouble()),
55+
// Policy lives in domain — datasource is pure HTTP + delegate.
56+
return BitcoinFeePresetPolicy.fromMempool(
57+
fastestSatPerVbyte: fastestFee.toDouble(),
58+
economicSatPerVbyte: economyFee.toDouble(),
6059
);
61-
62-
return feeOptions;
6360
}
6461

6562
Future<FeeOptions> getLiquidNetworkFeeOptions({
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+
}

lib/core/fees/domain/fees_entity.dart

Lines changed: 43 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
import 'package:bb_mobile/core/utils/amount_conversions.dart';
21
import 'package:freezed_annotation/freezed_annotation.dart';
32

43
part 'fees_entity.freezed.dart';
@@ -96,6 +95,49 @@ extension RelativeFeeDisplay on RelativeFee {
9695
double get satPerKvbyte => satPerKwu * 4.0;
9796
}
9897

98+
/// Minimum-relay policy for transaction fees — single source of truth used
99+
/// by the cubit/bloc commit gates, the custom-fee widget's "below floor"
100+
/// banner, and the slow-preset pin in [BitcoinFeePresetPolicy]. The value
101+
/// matches both Bitcoin Core's lowest sensible relay policy and Liquid's
102+
/// network minrelayfee, so the same constant is correct on both chains.
103+
extension NetworkFeeRelayPolicy on NetworkFee {
104+
/// 0.1 sat/vByte = 25 sat/kwu, exact.
105+
static const double minRelaySatPerVbyte = 0.1;
106+
static const int minRelaySatPerKwu = 25;
107+
108+
/// True when this fee is at or above the network minrelayfee. Absolute
109+
/// fees need a [txSize] to express as a rate; [txSize] ≤ 0 → false (the
110+
/// caller is in a transient pre-build state and shouldn't be allowed to
111+
/// commit yet).
112+
bool aboveMinRelay({int? txSize}) => switch (this) {
113+
RelativeFee(:final satPerKwu) => satPerKwu >= minRelaySatPerKwu,
114+
AbsoluteFee(:final sats) =>
115+
txSize != null && txSize > 0 && (sats / txSize) >= minRelaySatPerVbyte,
116+
};
117+
}
118+
119+
/// Maps a mempool API response into the three preset tiers. Slow is pinned
120+
/// to the network minrelayfee instead of mempool's `minimumFee` — the whole
121+
/// point of #2133 was to offer a real sub-1 sat/vByte slot for users
122+
/// willing to wait, which `minimumFee` (typically 1 at quiet blocks) defeats.
123+
class BitcoinFeePresetPolicy {
124+
const BitcoinFeePresetPolicy._();
125+
126+
/// Constructs the preset triple from the two mempool fields we still
127+
/// trust. `fastestFee` and `economyFee` come straight from the API;
128+
/// `slow` is pinned to [NetworkFeeRelayPolicy.minRelaySatPerVbyte].
129+
static FeeOptions fromMempool({
130+
required double fastestSatPerVbyte,
131+
required double economicSatPerVbyte,
132+
}) => FeeOptions(
133+
fastest: NetworkFee.relativeFromSatPerVbyte(fastestSatPerVbyte),
134+
economic: NetworkFee.relativeFromSatPerVbyte(economicSatPerVbyte),
135+
slow: NetworkFee.relativeFromSatPerVbyte(
136+
NetworkFeeRelayPolicy.minRelaySatPerVbyte,
137+
),
138+
);
139+
}
140+
99141
@freezed
100142
abstract class FeeOptions with _$FeeOptions {
101143
const factory FeeOptions({
@@ -128,52 +170,6 @@ abstract class FeeOptions with _$FeeOptions {
128170
}
129171
}
130172

131-
extension FeeOptionsDisplay on FeeOptions {
132-
List<(String, String, String)> display(
133-
int txSize,
134-
double exchangeRate,
135-
String currencySymbol,
136-
) {
137-
// Predictions only — preset tiles never have a real PSBT to read from
138-
// (no commit happened yet). Use integer math via NetworkFee.toAbsolute
139-
// so the line doesn't render IEEE-noisy doubles like 208.0 or
140-
// 14.100000000000001. BDK may still pay 1-3 sats more at sub-1
141-
// sat/vByte rates due to ceil + dust absorption — that's documented
142-
// BDK behaviour we can't predict without building a real tx.
143-
final fastestAbsSats = fastest.toAbsolute(txSize).value.toInt();
144-
final economicAbsSats = economic.toAbsolute(txSize).value.toInt();
145-
final slowAbsSats = slow.toAbsolute(txSize).value.toInt();
146-
final fastestFiatEq = ConvertAmount.satsToFiat(
147-
fastestAbsSats,
148-
exchangeRate,
149-
);
150-
final economicFiatEq = ConvertAmount.satsToFiat(
151-
economicAbsSats,
152-
exchangeRate,
153-
);
154-
final slowFiatEq = ConvertAmount.satsToFiat(slowAbsSats, exchangeRate);
155-
// `~` not `=` between rate and sat-count: the sat-count is a
156-
// prediction (rate × vsize, integer-rounded). BDK pays 1-3 sats more
157-
// at sub-1 sat/vByte due to ceil + sub-dust change absorption.
158-
return [
159-
(
160-
'Fastest',
161-
'Estimated delivery ~ 10 minutes',
162-
'${fastest.value} sats/byte ~ $fastestAbsSats sats (~ $fastestFiatEq) $currencySymbol',
163-
),
164-
(
165-
'Economic',
166-
'Estimated delivery ~ 30 minutes',
167-
'${economic.value} sats/byte ~ $economicAbsSats sats (~ $economicFiatEq) $currencySymbol',
168-
),
169-
(
170-
'Slow',
171-
'Estimated delivery ~ few hours',
172-
'${slow.value} sats/byte ~ $slowAbsSats sats (~ $slowFiatEq) $currencySymbol',
173-
),
174-
];
175-
}
176-
}
177173

178174
enum FeeSelection { fastest, economic, slow, custom }
179175

lib/core/widgets/dropdown/selectable_list.dart

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import 'package:bb_mobile/core/themes/app_theme.dart';
2+
import 'package:bb_mobile/core/widgets/loading/loading_line_content.dart';
23
import 'package:bb_mobile/core/widgets/text/text.dart';
34
import 'package:flutter/material.dart';
45
import 'package:gap/gap.dart';
@@ -10,12 +11,19 @@ class SelectableListItem {
1011
final String subtitle2;
1112
final String value;
1213

14+
/// When true, [subtitle2] is hidden and replaced with a shimmer line —
15+
/// used by fee preset tiles while the caller builds an unsigned PSBT
16+
/// to read the real fee. Prevents the row from displaying its own
17+
/// `rate × vsize` math while a real value is on the way.
18+
final bool isSubtitle2Loading;
19+
1320
const SelectableListItem({
1421
this.iconPath,
1522
required this.title,
1623
required this.subtitle1,
1724
required this.subtitle2,
1825
required this.value,
26+
this.isSubtitle2Loading = false,
1927
});
2028
}
2129

@@ -88,7 +96,17 @@ class _SelectableRow extends StatelessWidget {
8896
const Gap(4),
8997
BBText(item.subtitle1, style: context.font.labelMedium),
9098
const Gap(2),
91-
BBText(item.subtitle2, style: context.font.labelMedium),
99+
if (item.isSubtitle2Loading)
100+
LoadingLineContent(
101+
width: 160,
102+
height: 12,
103+
padding: EdgeInsets.zero,
104+
)
105+
else
106+
BBText(
107+
item.subtitle2,
108+
style: context.font.labelMedium,
109+
),
92110
],
93111
),
94112
),

0 commit comments

Comments
 (0)