What changed, and why it matters
This commit removes a local QR-restore parser and switches the wallet to use a shared parser from another package. It also fixes two unrelated bugs in the send screen: one that could permanently block fee estimates after a single failure, and another that displayed tiny 'Max' balances in scientific notation, breaking sending. There is no obvious security vulnerability in the diff itself; the main concern is whether the new shared parser is as safe as the old one, which cannot be judged from this commit alone.
Review the implementation of parseRestoreQr in the wallet_domain package to confirm it preserves the same URI scheme allow-list, query decoding fallback, and seed/height validation as the removed local code. Also verify that the new package is pinned to a trusted version and that no additional schemes or relaxed parsing were introduced.
Security signals we found
Parser logic moved to an external package (wallet_domain) without showing the new implementation
Old parser used Uri.decodeQueryComponent with a fallback on failure; new parser behavior is not visible in this commit
No input validation or sanitization changes are visible in the consuming code
Two functional bug fixes in fee estimation and amount formatting
Evidence from the diff
The diff deletes lib/util/restore_qr.dart and replaces its parseRestoreQr/buildRestoreQr logic with an import from wallet_domain. In lib/screens/send.dart it resets _lastFeeFetchKey on fee-estimate errors to prevent a stale cache from suppressing future fetches, and introduces _plainAmount() to format sub-micro XMR balances as plain decimals instead of exponential notation, fixing BigInt.parse failures when sending the full balance.
Changed components
lib/screens/restore_wallet.dartlib/screens/send.dartlib/util/restore_qr.dartwallet_domain package (external)Inspect captured patch +26 / −62
diff --git a/lib/screens/restore_wallet.dart b/lib/screens/restore_wallet.dart
index 5989ec9..b82b989 100644
--- a/lib/screens/restore_wallet.dart
+++ b/lib/screens/restore_wallet.dart
@@ -11,11 +11,11 @@ import 'package:polyseed/polyseed.dart';
import 'package:skylight_wallet/l10n/app_localizations.dart';
import 'package:skylight_wallet/models/fiat_rate_model.dart';
import 'package:skylight_wallet/util/get_height_by_date.dart';
-import 'package:skylight_wallet/util/restore_qr.dart';
import 'package:skylight_wallet/util/secure_screen.dart';
import 'package:skylight_wallet/util/logging.dart';
import 'package:skylight_wallet/wallet_core_glue.dart';
import 'package:skylight_wallet/widgets/ui/ui.dart';
+import 'package:wallet_domain/wallet_domain.dart' show parseRestoreQr;
class RestoreWalletScreen extends StatefulWidget {
const RestoreWalletScreen({super.key});
diff --git a/lib/screens/send.dart b/lib/screens/send.dart
index 0d667cc..8395c48 100644
--- a/lib/screens/send.dart
+++ b/lib/screens/send.dart
@@ -361,6 +361,12 @@ class _SendScreenState extends State<SendScreen> {
});
}
} catch (error) {
+ // Let the same address+amount be asked again. The key is claimed before
+ // the work starts, so without this a single failure pins the fee at '—'
+ // for that pair forever: every later revalidation matches the key and
+ // returns early, and only editing the address or amount can clear it.
+ _lastFeeFetchKey = '';
+
// Only update state if this is still the latest request
if (currentRequest == _feeCalculationCounter && mounted) {
setState(() {
@@ -544,7 +550,7 @@ class _SendScreenState extends State<SendScreen> {
void _setBalanceAsSendAmount() {
final wallet = appWalletOf(context);
- _amountController.text = (wallet.unlockedBalance ?? 0).toString();
+ _amountController.text = _plainAmount(wallet.unlockedBalance ?? 0);
setState(() {
_isSweepAll = true;
@@ -691,6 +697,24 @@ class _SendScreenState extends State<SendScreen> {
/// Monero is decimal-12; cap the displayed amount for legibility.
String _amountText(double amount) => amount.toStringAsFixed(5);
+/// Monero's decimal places. Matches the adapter's own constant; kept local so
+/// this file does not reach into the adapter's privates.
+const _xmrDecimals = 12;
+
+/// [amount] as a plain decimal, never exponential.
+///
+/// `double.toString()` switches to exponential notation below 1e-6 -- a
+/// 500000-piconero balance renders as "5e-7". `decimalToBaseUnits` splits on
+/// '.' and hands the rest to `BigInt.parse`, which throws on an exponent, so
+/// the fee estimate fails and the field cannot be sent. Only the Max button
+/// fills this field from a raw double, which is why it only broke there.
+String _plainAmount(double amount) {
+ final fixed = amount.toStringAsFixed(_xmrDecimals);
+ if (!fixed.contains('.')) return fixed;
+ final trimmed = fixed.replaceFirst(RegExp(r'0+$'), '');
+ return trimmed.endsWith('.') ? trimmed.substring(0, trimmed.length - 1) : trimmed;
+}
+
/// `abcd…wxyz`: keeps [head] leading and [tail] trailing chars of a long
/// address, eliding the middle. Returns the string unchanged when short.
String _shortenMiddle(String value, {required int head, required int tail}) {
diff --git a/lib/util/restore_qr.dart b/lib/util/restore_qr.dart
deleted file mode 100644
index 0b53ed8..0000000
--- a/lib/util/restore_qr.dart
+++ /dev/null
@@ -1,60 +0,0 @@
-/// Parses restore QR payloads. Accepts the URI format
-/// (`monero:`, `monero-wallet:` or `monero_wallet:` with `seed`/`height` query
-/// params) as well as a bare seed phrase.
-library;
-
-const _moneroRestoreSchemes = {'monero', 'monero-wallet', 'monero_wallet'};
-
-class ParsedRestoreQr {
- final String seed;
- final int? restoreHeight;
-
- const ParsedRestoreQr({required this.seed, this.restoreHeight});
-}
-
-ParsedRestoreQr? parseRestoreQr(String raw) {
- final code = raw.trim();
- if (code.isEmpty) return null;
-
- final colon = code.indexOf(':');
- if (colon > 0) {
- final scheme = code.substring(0, colon).toLowerCase();
- if (_moneroRestoreSchemes.contains(scheme)) {
- final query = code.substring(colon + 1).replaceAll('?', '&');
- final params = _parseQuery(query);
- final seed = (params['seed'] ?? '').trim();
- if (seed.isEmpty) return null;
- final heightStr = params['height'] ?? params['restoreHeight'];
- final height = heightStr != null ? int.tryParse(heightStr.trim()) : null;
- return ParsedRestoreQr(seed: seed, restoreHeight: height);
- }
- }
-
- // Not a recognized URI — treat the whole payload as a seed phrase.
- return ParsedRestoreQr(seed: code);
-}
-
-/// Builds a restore payload (`monero_wallet:?seed=...&height=...`)
-/// for encoding into a QR code. The seed is URL-encoded so spaces survive.
-String buildRestoreQr({required String seed, int? height}) {
- final buffer = StringBuffer('monero_wallet:?seed=${Uri.encodeQueryComponent(seed)}');
- if (height != null) buffer.write('&height=$height');
- return buffer.toString();
-}
-
-Map<String, String> _parseQuery(String query) {
- final result = <String, String>{};
- for (final pair in query.split('&')) {
- if (pair.isEmpty) continue;
- final eq = pair.indexOf('=');
- if (eq < 0) continue;
- final key = pair.substring(0, eq);
- final value = pair.substring(eq + 1);
- try {
- result[key] = Uri.decodeQueryComponent(value);
- } catch (_) {
- result[key] = value;
- }
- }
- return result;
-}
Why this scored 25/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.