Skip to content

Commit df73e9b

Browse files
committed
fix(payjoin): format BIP21 amount as plain decimal
double.toString() emits scientific notation below 1e-6 (e.g. amount=1e-8 for 1 sat), which is not a valid BIP21 amount. Add formatBtcAmount (8 decimals, trailing zeros trimmed) and apply it to the payjoin, bitcoin and liquid receive paths. Add tests covering sub-100-sat amounts.
1 parent 7940b49 commit df73e9b

2 files changed

Lines changed: 57 additions & 3 deletions

File tree

lib/features/receive/presentation/bloc/receive_state.dart

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -111,7 +111,7 @@ abstract class ReceiveState with _$ReceiveState {
111111
scheme: 'bitcoin',
112112
path: bitcoinAddress!.address,
113113
queryParameters: {
114-
if (confirmedAmountBtc > 0) 'amount': confirmedAmountBtc.toString(),
114+
if (confirmedAmountBtc > 0) 'amount': formatBtcAmount(confirmedAmountBtc),
115115
if (note.isNotEmpty) 'message': note,
116116
},
117117
);
@@ -128,7 +128,7 @@ abstract class ReceiveState with _$ReceiveState {
128128
scheme: 'liquidnetwork',
129129
path: liquidAddress!.address,
130130
queryParameters: {
131-
if (confirmedAmountBtc > 0) 'amount': confirmedAmountBtc.toString(),
131+
if (confirmedAmountBtc > 0) 'amount': formatBtcAmount(confirmedAmountBtc),
132132
if (note.isNotEmpty) 'message': note,
133133
'assetid':
134134
wallet != null && wallet!.network == Network.liquidMainnet
@@ -163,13 +163,26 @@ abstract class ReceiveState with _$ReceiveState {
163163
final base = pjUri.substring(0, q); // bitcoin:<address>
164164
final pdkQuery = pjUri.substring(q + 1); // [pjos=0&]pj=...
165165
final extras = <String>[
166-
if (amountBtc > 0) 'amount=$amountBtc',
166+
if (amountBtc > 0) 'amount=${formatBtcAmount(amountBtc)}',
167167
if (note.isNotEmpty) 'message=${Uri.encodeQueryComponent(note)}',
168168
if (!pdkQuery.contains('pjos=')) 'pjos=0',
169169
];
170170
return extras.isEmpty ? pjUri : '$base?${extras.join('&')}&$pdkQuery';
171171
}
172172

173+
/// Formats a BTC amount as a plain decimal for a BIP21 `amount` parameter.
174+
///
175+
/// `double.toString()` switches to scientific notation below 1e-6 (e.g.
176+
/// `1e-8` for 1 sat), which is not a valid BIP21 amount. Format with 8
177+
/// decimals (sat precision) and trim trailing zeros.
178+
static String formatBtcAmount(double amountBtc) {
179+
var s = amountBtc.toStringAsFixed(8);
180+
if (s.contains('.')) {
181+
s = s.replaceAll(RegExp(r'0+$'), '').replaceAll(RegExp(r'\.$'), '');
182+
}
183+
return s;
184+
}
185+
173186
String get addressOrInvoiceOnly {
174187
switch (type) {
175188
case ReceiveType.bitcoin:

test/features/receive/receive_payjoin_uri_test.dart

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,47 @@ void main() {
7575
});
7676
});
7777

78+
group('formatBtcAmount', () {
79+
test('never uses scientific notation for small amounts', () {
80+
// 1 sat and 10 sats would render as 1e-8 / 1e-7 via double.toString().
81+
expect(ReceiveState.formatBtcAmount(0.00000001), equals('0.00000001'));
82+
expect(ReceiveState.formatBtcAmount(0.0000001), equals('0.0000001'));
83+
expect(ReceiveState.formatBtcAmount(0.000001), equals('0.000001'));
84+
});
85+
86+
test('trims trailing zeros', () {
87+
expect(ReceiveState.formatBtcAmount(0.001), equals('0.001'));
88+
expect(ReceiveState.formatBtcAmount(1.0), equals('1'));
89+
expect(ReceiveState.formatBtcAmount(0.00666666), equals('0.00666666'));
90+
});
91+
});
92+
93+
group('regression: the previous dart:core Uri reconstruction', () {
94+
// Reproduces the old implementation to document precisely why it was
95+
// replaced: round-tripping the PDK pjUri through dart:core Uri
96+
// percent-encoded the :// and dropped pjos.
97+
test('over-encoded :// and dropped pjos', () {
98+
final pjUri = Uri.parse(pdkPjUri);
99+
var bip21Uri = Uri(
100+
scheme: 'bitcoin',
101+
path: 'tb1q6q6de88mj8qkg0q5lupmpfexwnqjsr4d2gvx2p',
102+
queryParameters: {'amount': '0.001'},
103+
);
104+
bip21Uri = bip21Uri.replace(
105+
queryParameters: {
106+
...bip21Uri.queryParameters,
107+
if (pjUri.queryParameters['pjos'] != null)
108+
'pjos': pjUri.queryParameters['pjos']!,
109+
'pj': pjUri.queryParameters['pj']!,
110+
},
111+
);
112+
final old = bip21Uri.toString();
113+
expect(old.contains('%3A%2F%2F'), isTrue, reason: ':// over-encoded');
114+
expect(old.contains('HTTPS://'), isFalse);
115+
expect(old.contains('pjos'), isFalse, reason: 'pjos dropped');
116+
});
117+
});
118+
78119
group('generated URI is consumable by the bip21_uri decoder', () {
79120
test('the pj endpoint survives a decode round-trip', () {
80121
final generated = ReceiveState.buildPayjoinPaymentRequest(

0 commit comments

Comments
 (0)