Add subaddresses limit reached check
What changed, and why it matters
This commit adds a safety check to a cryptocurrency wallet so that, when the server can no longer create new subaddresses, the app warns the user and falls back to reusing an already-used subaddress instead of silently failing or leaking privacy. It is a defensive/privacy-hardening change, not an obvious vulnerability fix, but it addresses a real privacy edge case: address reuse weakens transaction privacy.
Review the fallback logic to ensure reused subaddresses are rotated or flagged appropriately; verify that the `/upsert_subaddrs` probe correctly distinguishes "limit reached" from other server errors; consider adding tests for the limit-reached branch and for SharedPreferences migration/corner cases.
Security signals we found
Adds explicit handling for server-side subaddress limit exhaustion
Warns user when a used subaddress must be reused, reducing privacy loss from silent address reuse
Persists subaddress-support state across app restarts
Refactors receive screen to avoid stale or race-prone async address loading
Probes `/upsert_subaddrs` with the actual next index rather than a hardcoded [0,1] pair
Evidence from the diff
The patch introduces unusedSubaddressIndex and unusedSubaddressIndexIsSupported state in WalletModel, persists them via SharedPreferences, and probes the server endpoint /upsert_subaddrs with the next candidate subaddress index. If the server rejects the next index, the app decrements to the previous (already used) subaddress and displays a new warning string. ReceiveScreen is refactored from async address loading to synchronous reads of the cached state, with a progress indicator shown while support status is unknown. The change also re-runs the unused-index probe when new transactions are detected.
Changed components
lib/models/wallet_model.dartlib/screens/receive.dartlib/services/shared_preferences_service.dartlib/l10n/app_en.arblib/l10n/app_pt.arblib/l10n/app_localizations.dartlib/l10n/app_localizations_en.dartlib/l10n/app_localizations_pt.dartInspect captured patch +138 / −53
diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb
index 27810b7..eab8391 100644
--- a/lib/l10n/app_en.arb
+++ b/lib/l10n/app_en.arb
@@ -65,6 +65,7 @@
"receiveShowSubaddressButton": "Show Subaddress",
"receiveShowPrimaryAddressButton": "Show Primary Address",
"receiveServerNoSubaddressesWarn": "Warning: This server does not support subaddresses. For better privacy, consider using a server that supports them. You are receiving to your primary address.",
+ "receiveMaxSubaddressesReachedWarn": "You have reached the maximum number of subaddresses supported by this server. This is a used subaddress.",
"sendTitle": "Send",
"sendSendButton": "Send",
"sendTransactionSuccessfullySent": "Transaction successfully sent!",
diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart
index 16295e6..ec10393 100644
--- a/lib/l10n/app_localizations.dart
+++ b/lib/l10n/app_localizations.dart
@@ -488,6 +488,12 @@ abstract class AppLocalizations {
/// **'Warning: This server does not support subaddresses. For better privacy, consider using a server that supports them. You are receiving to your primary address.'**
String get receiveServerNoSubaddressesWarn;
+ /// No description provided for @receiveMaxSubaddressesReachedWarn.
+ ///
+ /// In en, this message translates to:
+ /// **'You have reached the maximum number of subaddresses supported by this server. This is a used subaddress.'**
+ String get receiveMaxSubaddressesReachedWarn;
+
/// No description provided for @sendTitle.
///
/// In en, this message translates to:
diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart
index 34227ae..1a83180 100644
--- a/lib/l10n/app_localizations_en.dart
+++ b/lib/l10n/app_localizations_en.dart
@@ -213,6 +213,10 @@ class AppLocalizationsEn extends AppLocalizations {
String get receiveServerNoSubaddressesWarn =>
'Warning: This server does not support subaddresses. For better privacy, consider using a server that supports them. You are receiving to your primary address.';
+ @override
+ String get receiveMaxSubaddressesReachedWarn =>
+ 'You have reached the maximum number of subaddresses supported by this server. This is a used subaddress.';
+
@override
String get sendTitle => 'Send';
diff --git a/lib/l10n/app_localizations_pt.dart b/lib/l10n/app_localizations_pt.dart
index d7f95d7..9d67f5d 100644
--- a/lib/l10n/app_localizations_pt.dart
+++ b/lib/l10n/app_localizations_pt.dart
@@ -213,6 +213,10 @@ class AppLocalizationsPt extends AppLocalizations {
String get receiveServerNoSubaddressesWarn =>
'Aviso: Este servidor não suporta subendereços. Para melhor privacidade, considere usar um servidor que os suporte. Você está recebendo no seu endereço primário.';
+ @override
+ String get receiveMaxSubaddressesReachedWarn =>
+ 'Você atingiu o número máximo de subendereços suportados por este servidor. Este é um subendereço já usado.';
+
@override
String get sendTitle => 'Enviar';
diff --git a/lib/l10n/app_pt.arb b/lib/l10n/app_pt.arb
index cfae72b..c5ded69 100644
--- a/lib/l10n/app_pt.arb
+++ b/lib/l10n/app_pt.arb
@@ -65,6 +65,7 @@
"receiveShowSubaddressButton": "Mostrar Subendereço",
"receiveShowPrimaryAddressButton": "Mostrar Endereço Primário",
"receiveServerNoSubaddressesWarn": "Aviso: Este servidor não suporta subendereços. Para melhor privacidade, considere usar um servidor que os suporte. Você está recebendo no seu endereço primário.",
+ "receiveMaxSubaddressesReachedWarn": "Você atingiu o número máximo de subendereços suportados por este servidor. Este é um subendereço já usado.",
"sendTitle": "Enviar",
"sendSendButton": "Enviar",
"sendTransactionSuccessfullySent": "Transação enviada com sucesso!",
diff --git a/lib/models/wallet_model.dart b/lib/models/wallet_model.dart
index c1f2786..8ec8725 100644
--- a/lib/models/wallet_model.dart
+++ b/lib/models/wallet_model.dart
@@ -148,6 +148,8 @@ class WalletModel with ChangeNotifier {
double? _totalBalance;
List<TxDetails> _txHistory = [];
bool? _serverSupportsSubaddresses;
+ int? _unusedSubaddressIndex;
+ bool? _unusedSubaddressIndexIsSupported;
Wallet2Wallet? get w2Wallet => _w2Wallet;
bool get hasAttemptedConnection => _hasAttemptedConnection;
@@ -159,6 +161,9 @@ class WalletModel with ChangeNotifier {
List<TxDetails> get txHistory => _txHistory;
bool get usingTor => _connectionUseTor;
bool? get serverSupportsSubaddresses => _serverSupportsSubaddresses;
+ int? get unusedSubaddressIndex => _unusedSubaddressIndex;
+ bool? get unusedSubaddressIndexIsSupported =>
+ _unusedSubaddressIndexIsSupported;
WalletModel() {
_startTimers();
@@ -212,11 +217,13 @@ class WalletModel with ChangeNotifier {
return;
}
- loadPersistedSubaddressSupport();
+ await loadPersistedSubaddressSupport();
+ await loadPersistedUnusedSubaddressIndex();
await refresh();
await loadAllStats();
await connectToDaemon();
- await checkSubaddressSupport();
+ await loadSubaddressSupport();
+ await loadUnusedSubaddressIndex();
}
Future<void> loadAllStats() async {
@@ -261,6 +268,10 @@ class WalletModel with ChangeNotifier {
await persistTxHistoryCount();
}
}
+
+ if (txCount > _txHistory.length) {
+ await loadUnusedSubaddressIndex();
+ }
}
Future<void> persistCurrentConnection() async {
@@ -420,16 +431,84 @@ class WalletModel with ChangeNotifier {
);
}
- Future<void> checkSubaddressSupport() async {
- final protocol = _connectionUseSsl ? 'https' : 'http';
- final url = Uri.parse('$protocol://$_connectionAddress/upsert_subaddrs');
+ Future<void> loadSubaddressSupport() async {
+ try {
+ final isSupported = await isSubaddressSupported(1);
+ _serverSupportsSubaddresses = isSupported;
+
+ await SharedPreferencesService.set<bool>(
+ SharedPreferencesKeys.serverSupportsSubaddresses,
+ _serverSupportsSubaddresses!,
+ );
+ } catch (e) {
+ //
+ }
+ }
+
+ Future<void> loadUnusedSubaddressIndex() async {
+ final txHistory = await _getFullTxHistory();
+
+ Set<int> usedIndexes = {};
+
+ for (final tx in txHistory) {
+ if (tx.accountIndex == 0) {
+ for (final subaddrIndex in tx.subaddrIndexList) {
+ usedIndexes.add(subaddrIndex);
+ }
+ }
+ }
+
+ int nextSubaddrIndex = 1;
+
+ while (usedIndexes.contains(nextSubaddrIndex)) {
+ nextSubaddrIndex++;
+ }
+
+ if (_unusedSubaddressIndex != nextSubaddrIndex) {
+ try {
+ final isSupported = await isSubaddressSupported(nextSubaddrIndex);
+ _unusedSubaddressIndex = nextSubaddrIndex;
+ _unusedSubaddressIndexIsSupported = isSupported;
+
+ await SharedPreferencesService.set<int>(
+ SharedPreferencesKeys.unusedSubaddressIndex,
+ _unusedSubaddressIndex!,
+ );
+ await SharedPreferencesService.set<bool>(
+ SharedPreferencesKeys.unusedSubaddressIndexIsSupported,
+ _unusedSubaddressIndexIsSupported!,
+ );
+
+ notifyListeners();
+ } catch (e) {
+ //
+ }
+ }
+ }
+
+ Future<void> loadPersistedUnusedSubaddressIndex() async {
+ _unusedSubaddressIndex =
+ await SharedPreferencesService.get<int>(
+ SharedPreferencesKeys.unusedSubaddressIndex,
+ ) ??
+ 1;
+ _unusedSubaddressIndexIsSupported =
+ await SharedPreferencesService.get<bool>(
+ SharedPreferencesKeys.unusedSubaddressIndexIsSupported,
+ ) ??
+ false;
+ }
+
+ Future<bool> isSubaddressSupported(int subaddrIndex) async {
+ final proto = _connectionUseSsl ? 'https' : 'http';
+ final url = Uri.parse('$proto://$_connectionAddress/upsert_subaddrs');
final primaryAddress = getPrimaryAddress();
final viewKey = _w2Wallet!.secretViewKey();
final subaddrs = [
{
"key": 0,
"value": [
- [0, 1],
+ [0, subaddrIndex],
],
},
];
@@ -490,19 +569,14 @@ class WalletModel with ChangeNotifier {
}
}
- _serverSupportsSubaddresses = httpStatus == 200;
-
- await SharedPreferencesService.set<bool>(
- SharedPreferencesKeys.serverSupportsSubaddresses,
- _serverSupportsSubaddresses!,
- );
+ final result = httpStatus == 200;
log(
LogLevel.info,
- 'Subaddress support check result: $_serverSupportsSubaddresses (status: $httpStatus)',
+ 'Subaddress support check result for subaddress $subaddrIndex: $result (status: $httpStatus)',
);
- notifyListeners();
+ return result;
}
Future<void> refresh() async {
@@ -786,6 +860,12 @@ class WalletModel with ChangeNotifier {
SharedPreferencesKeys.serverSupportsSubaddresses,
);
await SharedPreferencesService.remove(SharedPreferencesKeys.contacts);
+ await SharedPreferencesService.remove(
+ SharedPreferencesKeys.unusedSubaddressIndex,
+ );
+ await SharedPreferencesService.remove(
+ SharedPreferencesKeys.unusedSubaddressIndexIsSupported,
+ );
}
Future<bool> hasExistingWallet() async {
@@ -904,37 +984,29 @@ class WalletModel with ChangeNotifier {
return address;
}
- Future<String> getUnusedSubaddress() async {
- final txHistory = await _getFullTxHistory();
-
- Set<int> usedIndexes = {};
-
- for (final tx in txHistory) {
- if (tx.accountIndex == 0) {
- for (final subaddrIndex in tx.subaddrIndexList) {
- usedIndexes.add(subaddrIndex);
- }
- }
+ String? getUnusedSubaddress() {
+ if (_unusedSubaddressIndex == null) {
+ return null;
}
- int nextSubaddrIndex = 1;
+ var subaddrIndex = _unusedSubaddressIndex!;
- while (usedIndexes.contains(nextSubaddrIndex)) {
- nextSubaddrIndex++;
+ if (_unusedSubaddressIndexIsSupported == false) {
+ subaddrIndex -= 1;
}
log(LogLevel.info, 'Calling Wallet_address with parameters:');
log(LogLevel.info, ' accountIndex: 0');
- log(LogLevel.info, ' addressIndex: $nextSubaddrIndex');
+ log(LogLevel.info, ' addressIndex: $subaddrIndex');
- final address = _w2Wallet!.address(
+ final subaddress = _w2Wallet!.address(
accountIndex: 0,
- addressIndex: nextSubaddrIndex,
+ addressIndex: subaddrIndex,
);
- log(LogLevel.info, 'Wallet_address result: $address');
+ log(LogLevel.info, 'Wallet_address result: $subaddress');
- return address;
+ return subaddress;
}
Future<MoneroPendingTransaction> createTx(
diff --git a/lib/screens/receive.dart b/lib/screens/receive.dart
index ff04e92..ca34dfc 100644
--- a/lib/screens/receive.dart
+++ b/lib/screens/receive.dart
@@ -15,8 +15,6 @@ class ReceiveScreen extends StatefulWidget {
}
class _ReceiveScreenState extends State<ReceiveScreen> {
- var _primaryAddress = '';
- var _subaddress = '';
var _showSubaddress = true;
var _previousBrightness = 0.0;
@@ -24,7 +22,6 @@ class _ReceiveScreenState extends State<ReceiveScreen> {
void initState() {
super.initState();
- _loadAddresses();
_setBrightnessToMax();
}
@@ -34,18 +31,6 @@ class _ReceiveScreenState extends State<ReceiveScreen> {
super.dispose();
}
- Future<void> _loadAddresses() async {
- final wallet = Provider.of<WalletModel>(context, listen: false);
-
- final primaryAddress = wallet.getPrimaryAddress();
- final subaddress = await wallet.getUnusedSubaddress();
-
- setState(() {
- _primaryAddress = primaryAddress;
- _subaddress = subaddress;
- });
- }
-
void _setShowSubaddress(bool value) {
setState(() {
_showSubaddress = value;
@@ -69,14 +54,16 @@ class _ReceiveScreenState extends State<ReceiveScreen> {
final brightness = Theme.of(context).brightness;
final isDarkTheme = brightness == Brightness.dark;
final wallet = Provider.of<WalletModel>(context);
- var address = '';
+ final primaryAddress = wallet.getPrimaryAddress();
+ final subaddress = wallet.getUnusedSubaddress();
+ String? address;
if (wallet.serverSupportsSubaddresses == false) {
- address = _primaryAddress;
+ address = primaryAddress;
}
if (wallet.serverSupportsSubaddresses == true) {
- address = _showSubaddress ? _subaddress : _primaryAddress;
+ address = _showSubaddress ? subaddress : primaryAddress;
}
return Scaffold(
@@ -84,7 +71,7 @@ class _ReceiveScreenState extends State<ReceiveScreen> {
body: Center(
child: Padding(
padding: EdgeInsets.symmetric(horizontal: 20),
- child: wallet.serverSupportsSubaddresses == null
+ child: wallet.serverSupportsSubaddresses == null || address == null
? CircularProgressIndicator()
: Column(
mainAxisAlignment: MainAxisAlignment.center,
@@ -113,6 +100,13 @@ class _ReceiveScreenState extends State<ReceiveScreen> {
textAlign: TextAlign.center,
style: TextStyle(color: Colors.red),
),
+ if (_showSubaddress &&
+ wallet.unusedSubaddressIndexIsSupported == false)
+ Text(
+ i18n.receiveMaxSubaddressesReachedWarn,
+ textAlign: TextAlign.center,
+ style: TextStyle(color: Colors.red),
+ ),
GestureDetector(
child: Text(
address,
@@ -120,7 +114,7 @@ class _ReceiveScreenState extends State<ReceiveScreen> {
style: TextStyle(fontFamily: 'monospace'),
),
onTap: () async {
- await Clipboard.setData(ClipboardData(text: address));
+ await Clipboard.setData(ClipboardData(text: address!));
},
),
Row(
diff --git a/lib/services/shared_preferences_service.dart b/lib/services/shared_preferences_service.dart
index 68f950e..1eb2d76 100644
--- a/lib/services/shared_preferences_service.dart
+++ b/lib/services/shared_preferences_service.dart
@@ -17,6 +17,9 @@ class SharedPreferencesKeys {
static const String txHistoryCount = 'txHistoryCount';
static const String pendingOutgoingTxs = 'pendingOutgoingTxs';
static const String contacts = 'contacts';
+ static const String unusedSubaddressIndex = 'unusedSubaddressIndex';
+ static const String unusedSubaddressIndexIsSupported =
+ 'unusedSubaddressIndexIsSupported';
}
class SharedPreferencesService {
Why this scored 36/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.