What changed, and why it matters
This commit fixes several user-interface edge cases in the Cake Wallet app. The changes mostly prevent visual glitches, incorrect number formatting, and accidental behavior in swapping and sending crypto. One change stops hardware wallets from appearing in a Layer-2 wallet selector, and another adds a safety fallback if an amount cannot be parsed. There is no clear evidence of an exploitable security vulnerability being patched.
Treat as a routine bug-fix/UI-hardening commit. Reviewers may want to verify that the `withDecimals` extension handles empty/invalid strings safely and that the amount-parsing fallback does not mask deeper input-validation issues. No urgent security response is indicated.
Security signals we found
UI state synchronization fix (Observer wrapper)
Input parsing fallback added to prevent unhandled exceptions
Hardware wallet exclusion from L2 wallet selector
Race-condition guard on deposit amount update
Fiat amount formatting normalization
Evidence from the diff
The patch addresses UI/UX edge cases across 8 files: (1) wraps the main navbar in a MobX Observer so it reacts to dynamic page-disabling state; (2) ensures fiat amounts on the send confirmation sheet are always shown with 2 decimals; (3) suppresses the swap-limit popup when the provider’s max limit is 0 (unloaded state); (4) prevents the deposit amount from being overwritten with the localized string ‘ALL’ due to a race condition in swap_page; (5) filters out hardware wallets from the L2 wallet selector; (6) falls back to a default card design if designs haven’t loaded; (7) adds a withDecimals string extension; and (8) catches exceptions in AmountParsingProxy.getDisplayCryptoAmount and returns the raw input as a fallback. The changes are defensive and reduce the chance of crashes or misleading UI states, but they do not appear to fix a known exploitable flaw.
Changed components
cw_core/lib/crypto_amount_format.dartlib/core/amount_parsing_proxy.dartlib/new-ui/pages/account_customizer.dartlib/new-ui/pages/swap_page.dartlib/new-ui/widgets/send_page/l2_action_wallet_selector.dartlib/new-ui/widgets/send_page/send_confirm_sheet.dartlib/new-ui/widgets/swap_page/swap_limit_popup.dartlib/src/screens/dashboard/widgets/new_main_navbar_widget.dartInspect captured patch +155 / −113
diff --git a/cw_core/lib/crypto_amount_format.dart b/cw_core/lib/crypto_amount_format.dart
index 1fb0d5e..d6ddf11 100644
--- a/cw_core/lib/crypto_amount_format.dart
+++ b/cw_core/lib/crypto_amount_format.dart
@@ -17,6 +17,26 @@ extension MaxDecimals on String {
return parts.join(".");
}
+ String withDecimals(int decimals) {
+ var parts = split(".");
+
+ if (parts.length > 2) {
+ parts = [parts.first, parts.sublist(1).join("")];
+ }
+
+ if (parts.length == 1) {
+ parts.add("");
+ }
+
+ if (parts[1].length > decimals) {
+ parts[1] = parts[1].substring(0, decimals);
+ } else {
+ parts[1] = parts[1].padRight(decimals, '0');
+ }
+
+ return parts.join(".");
+ }
+
/// Format a stringified number to a localized representation
/// 1.000.000,00 in de_DE
diff --git a/lib/core/amount_parsing_proxy.dart b/lib/core/amount_parsing_proxy.dart
index 58fb105..4b94112 100644
--- a/lib/core/amount_parsing_proxy.dart
+++ b/lib/core/amount_parsing_proxy.dart
@@ -2,6 +2,7 @@ import 'package:cake_wallet/entities/bitcoin_amount_display_mode.dart';
import 'package:cake_wallet/src/screens/wallet_connect/utils/string_parsing.dart';
import 'package:cw_core/crypto_amount_format.dart';
import 'package:cw_core/crypto_currency.dart';
+import 'package:cw_core/utils/print_verbose.dart';
class AmountParsingProxy {
final BitcoinAmountDisplayMode displayMode;
@@ -19,11 +20,19 @@ class AmountParsingProxy {
/// [getCryptoOutputAmount] turns the input [amount] into the preferred representation of [cryptoCurrency]
String getDisplayCryptoAmount(String amount, CryptoCurrency cryptoCurrency) {
- if (useSatoshi(cryptoCurrency) && amount.isNotEmpty) {
- return cryptoCurrency.parseAmount(amount.withMaxDecimals(cryptoCurrency.decimals)).toString();
+
+ try {
+ if (useSatoshi(cryptoCurrency) && amount.isNotEmpty) {
+ return cryptoCurrency.parseAmount(amount.withMaxDecimals(cryptoCurrency.decimals)).toString();
+ }
+
+ return amount.withMaxDecimals(cryptoCurrency.decimals);
+
+ } catch(_) {
+ printV("failed to parse amount $amount for currency ${cryptoCurrency.title}, falling back to showing unparsed");
+ return amount;
}
- return amount.withMaxDecimals(cryptoCurrency.decimals);
}
/// [getCryptoStringRepresentation] turns the input [amount] into the preferred representation of [cryptoCurrency]
diff --git a/lib/new-ui/pages/account_customizer.dart b/lib/new-ui/pages/account_customizer.dart
index 20f3b7b..fd93e45 100644
--- a/lib/new-ui/pages/account_customizer.dart
+++ b/lib/new-ui/pages/account_customizer.dart
@@ -17,6 +17,7 @@ import 'package:cake_wallet/view_model/monero_account_list/account_list_item.dar
import 'package:cake_wallet/view_model/monero_account_list/monero_account_edit_or_create_view_model.dart';
import 'package:cake_wallet/view_model/monero_account_list/monero_account_list_view_model.dart';
import 'package:cw_core/balance_card_style_settings.dart';
+import 'package:cw_core/card_design.dart';
import 'package:cw_core/generate_name.dart';
import 'package:cw_core/sync_status.dart';
import 'package:cw_core/utils/print_verbose.dart';
@@ -381,7 +382,9 @@ class _AccountCustomizerState extends State<AccountCustomizer> {
selected: true,
designSwitchDuration: Duration(milliseconds: 200),
width: cardWidth,
- design: widget.dashboardViewModel.cardDesigns[i],
+ design: i >= widget.dashboardViewModel.cardDesigns.length
+ ? CardDesign.genericDefault
+ : widget.dashboardViewModel.cardDesigns[i],
),
order: i,
accountListItem: accounts[i]));
diff --git a/lib/new-ui/pages/swap_page.dart b/lib/new-ui/pages/swap_page.dart
index 35ad8b3..3899a2a 100644
--- a/lib/new-ui/pages/swap_page.dart
+++ b/lib/new-ui/pages/swap_page.dart
@@ -292,7 +292,10 @@ class _NewSwapPageState extends State<NewSwapPage> {
_depositAmountDebounce.run(() {
widget.exchangeViewModel.calculateBestRate();
- widget.exchangeViewModel.changeDepositAmount(amount: depositAmountController.text);
+ if (depositAmountController.text != widget.exchangeViewModel.depositAmount &&
+ depositAmountController.text != S.of(context).all) {
+ widget.exchangeViewModel.changeDepositAmount(amount: depositAmountController.text);
+ }
widget.exchangeViewModel.isReceiveAmountEntered = false;
widget.exchangeViewModel.isFixedRateMode = false;
if (!receiveKey.currentState!.amountFocusNode.hasFocus) {
diff --git a/lib/new-ui/widgets/send_page/l2_action_wallet_selector.dart b/lib/new-ui/widgets/send_page/l2_action_wallet_selector.dart
index 40686a0..2ee57ef 100644
--- a/lib/new-ui/widgets/send_page/l2_action_wallet_selector.dart
+++ b/lib/new-ui/widgets/send_page/l2_action_wallet_selector.dart
@@ -57,7 +57,7 @@ class _L2ActionWalletSelectorState extends State<L2ActionWalletSelector> {
if (widget.showOtherWallets) {
() async {
items.addAll((await WalletInfo.getAll())
- .where((item) => item.type == widget.sendViewModel.walletType));
+ .where((item) => item.type == widget.sendViewModel.walletType && item.hardwareWalletType == null));
items.sort((a, b) {
if (a.name == widget.sendViewModel.wallet.name)
return -1;
diff --git a/lib/new-ui/widgets/send_page/send_confirm_sheet.dart b/lib/new-ui/widgets/send_page/send_confirm_sheet.dart
index 8516605..ac0f532 100644
--- a/lib/new-ui/widgets/send_page/send_confirm_sheet.dart
+++ b/lib/new-ui/widgets/send_page/send_confirm_sheet.dart
@@ -154,8 +154,10 @@ class SendTransactionDetails extends StatelessWidget {
String sumStr<T>(List<T> list, double Function(T) picker) =>
sumBy(list, picker).toString();
- String sumWithUnit<T>(List<T> list, double Function(T) picker, String unit) =>
- "${sumStr(list, picker)} $unit";
+ String sumWithUnit<T>(List<T> list, double Function(T) picker, String unit, {int? decimals}) {
+ final str = sumStr(list, picker);
+ return "${decimals == null ? str : str.withDecimals(decimals)} $unit";
+ }
Widget _buildMainContent(BuildContext context) {
@@ -198,6 +200,7 @@ class SendTransactionDetails extends StatelessWidget {
sendViewModel.outputs,
(o) => double.tryParse(o.fiatAmount.replaceAll(",", "")) ?? 0,
sendViewModel.fiatCurrency.title,
+ decimals:2
)
: sendViewModel.pendingTransactionFiatAmountFormatted;
@@ -206,6 +209,7 @@ class SendTransactionDetails extends StatelessWidget {
sendViewModel.outputs,
(o) => double.tryParse(o.estimatedFeeFiatAmount.replaceAll(",", "")) ?? 0,
sendViewModel.fiatCurrency.title,
+ decimals:2
)
: sendViewModel.pendingTransactionFeeFiatAmountFormatted;
diff --git a/lib/new-ui/widgets/swap_page/swap_limit_popup.dart b/lib/new-ui/widgets/swap_page/swap_limit_popup.dart
index a6520dd..3dea8a5 100644
--- a/lib/new-ui/widgets/swap_page/swap_limit_popup.dart
+++ b/lib/new-ui/widgets/swap_page/swap_limit_popup.dart
@@ -23,7 +23,7 @@ class SwapLimitPopup extends StatelessWidget {
final double? amount = double.tryParse(exchangeViewModel.depositAmountCanonical);
final max = exchangeViewModel.limits.max ?? double.infinity;
final min = exchangeViewModel.limits.min ?? 0;
- final tooLarge = amount != null && amount > max;
+ final tooLarge = amount != null && max != 0 && amount > max;
final tooSmall = amount != null && min != 0 && amount < min;
final show = amount != null && (tooLarge || tooSmall);
diff --git a/lib/src/screens/dashboard/widgets/new_main_navbar_widget.dart b/lib/src/screens/dashboard/widgets/new_main_navbar_widget.dart
index 86ecf3a..f16cb2f 100644
--- a/lib/src/screens/dashboard/widgets/new_main_navbar_widget.dart
+++ b/lib/src/screens/dashboard/widgets/new_main_navbar_widget.dart
@@ -2,6 +2,7 @@ import 'dart:io';
import 'dart:ui';
import 'package:cake_wallet/src/widgets/cake_image_widget.dart';
import 'package:flutter/material.dart';
+import 'package:flutter_mobx/flutter_mobx.dart';
import 'package:flutter_svg/flutter_svg.dart';
import 'package:cake_wallet/entities/new_main_actions.dart';
import 'package:cake_wallet/view_model/dashboard/dashboard_view_model.dart';
@@ -123,124 +124,126 @@ class _NEWNewMainNavBarState extends State<NewMainNavBar> {
final activeColor = theme.colorScheme.onSurface;
final inactiveColor = theme.colorScheme.primary;
- final visibleActions = NewMainActions.all
- .where(
- (action) => action.canShow?.call(widget.dashboardViewModel) ?? true)
- .toList();
-
- final pillWidth = _estimatePillWidthForAction(
- context, visibleActions[widget.selectedIndex],
- color: activeColor);
-
- final barWidth = calcBarWidth(pillWidth, visibleActions.length);
-
- final currentAction = visibleActions[widget.selectedIndex];
-
- return Align(
- alignment: Alignment.bottomCenter,
- child: SafeArea(
- bottom: !(Platform.isIOS),
- top: false,
- child: Padding(
- // tux PLEASE consult me (malik) before removing this padding.
- padding: EdgeInsets.only(bottom: NewMainNavBar.barBottomPadding),
- child: AnimatedContainer(
- duration: barResizeDuration,
- curve: Curves.easeOutCubic,
- width: barWidth,
- child: ClipRSuperellipse(
- borderRadius: BorderRadius.circular(barBorderRadius),
- child: BackdropFilter(
- filter: ImageFilter.blur(sigmaX: 3, sigmaY: 3),
- child: Container(
- height: NewMainNavBar.barHeight,
- decoration: ShapeDecoration(
- color: backgroundColor,
- shape: RoundedSuperellipseBorder(borderRadius: BorderRadius.circular(barBorderRadius),
- side: const BorderSide(color: Color(0x14FFFFFF), width: 1),
+ return Observer(
+ builder: (_) {
+ final visibleActions = NewMainActions.all
+ .where((action) => action.canShow?.call(widget.dashboardViewModel) ?? true)
+ .toList();
+
+ final pillWidth = _estimatePillWidthForAction(context, visibleActions[widget.selectedIndex],
+ color: activeColor);
+
+ final barWidth = calcBarWidth(pillWidth, visibleActions.length);
+
+ final currentAction = visibleActions[widget.selectedIndex];
+
+ return Align(
+ alignment: Alignment.bottomCenter,
+ child: SafeArea(
+ bottom: !(Platform.isIOS),
+ top: false,
+ child: Padding(
+ // tux PLEASE consult me (malik) before removing this padding.
+ padding: EdgeInsets.only(bottom: NewMainNavBar.barBottomPadding),
+ child: AnimatedContainer(
+ duration: barResizeDuration,
+ curve: Curves.easeOutCubic,
+ width: barWidth,
+ child: ClipRSuperellipse(
+ borderRadius: BorderRadius.circular(barBorderRadius),
+ child: BackdropFilter(
+ filter: ImageFilter.blur(sigmaX: 3, sigmaY: 3),
+ child: Container(
+ height: NewMainNavBar.barHeight,
+ decoration: ShapeDecoration(
+ color: backgroundColor,
+ shape: RoundedSuperellipseBorder(borderRadius: BorderRadius.circular(barBorderRadius),
+ side: const BorderSide(color: Color(0x14FFFFFF), width: 1),
+ ),
),
- ),
- child: Padding(
- padding: const EdgeInsets.symmetric(horizontal: barHorizontalPadding),
- child: Stack(
- alignment: Alignment.center,
- children: [
- AnimatedPill(
- left: calcLeft(widget.selectedIndex, pillWidth),
- pillColor: pillColor,
- currentAction: currentAction,
- pillIconHeight: pillIconHeight,
- pillIconWidth: pillIconWidth,
- pillIconSpacing: pillIconSpacing,
- pillBorderRadius: pillBorderRadius,
- contentColor: activeColor,
- estimateWidthForAction: pillWidth,
- pillTextStyle: pillTextStyle,
- pillMoveDuration: pillMoveDuration,
- pillResizeDuration: pillResizeDuration,
- ),
- for (int i = 0; i < visibleActions.length; i++)
- AnimatedPositioned(
- duration: pillResizeDuration,
- width: iconBoxWidth,
- left: calcLeft(i, pillWidth)+((i == widget.selectedIndex) ? iconHorizontalPadding/100 : 0),
- curve: Curves.easeOutCubic,
- child: InkWell(
- splashFactory: NoSplash.splashFactory,
- splashColor: Colors.transparent,
- borderRadius: BorderRadius.circular(pillBorderRadius),
- onTap: () => _onItemTap(i),
- child: AnimatedContainer(
- duration: _firstFrame
- ? Duration.zero
- : inactiveIconMoveDuration,
- curve: Curves.easeOutCubic,
- width:
- i == widget.selectedIndex ? pillWidth : iconBoxWidth,
- alignment: Alignment.center,
- child: AnimatedAlign(
- duration: inactiveIconFadeDuration,
+ child: Padding(
+ padding: const EdgeInsets.symmetric(horizontal: barHorizontalPadding),
+ child: Stack(
+ alignment: Alignment.center,
+ children: [
+ AnimatedPill(
+ left: calcLeft(widget.selectedIndex, pillWidth),
+ pillColor: pillColor,
+ currentAction: currentAction,
+ pillIconHeight: pillIconHeight,
+ pillIconWidth: pillIconWidth,
+ pillIconSpacing: pillIconSpacing,
+ pillBorderRadius: pillBorderRadius,
+ contentColor: activeColor,
+ estimateWidthForAction: pillWidth,
+ pillTextStyle: pillTextStyle,
+ pillMoveDuration: pillMoveDuration,
+ pillResizeDuration: pillResizeDuration,
+ ),
+ for (int i = 0; i < visibleActions.length; i++)
+ AnimatedPositioned(
+ duration: pillResizeDuration,
+ width: iconBoxWidth,
+ left: calcLeft(i, pillWidth)+((i == widget.selectedIndex) ? iconHorizontalPadding/100 : 0),
+ curve: Curves.easeOutCubic,
+ child: InkWell(
+ splashFactory: NoSplash.splashFactory,
+ splashColor: Colors.transparent,
+ borderRadius: BorderRadius.circular(pillBorderRadius),
+ onTap: () => _onItemTap(i),
+ child: AnimatedContainer(
+ duration: _firstFrame
+ ? Duration.zero
+ : inactiveIconMoveDuration,
curve: Curves.easeOutCubic,
+ width:
+ i == widget.selectedIndex ? pillWidth : iconBoxWidth,
alignment: Alignment.center,
- child: AnimatedScale(
- duration: inactiveIconAppearDuration,
+ child: AnimatedAlign(
+ duration: inactiveIconFadeDuration,
curve: Curves.easeOutCubic,
- scale: (i == widget.selectedIndex) ? 0.857 : 1.0,
- child: TweenAnimationBuilder<Color?>(
- tween: ColorTween(
- begin: (i == widget.selectedIndex) ? inactiveColor : activeColor,
- end: (i==widget.selectedIndex) ? activeColor : inactiveColor,
- ),
- duration: iconColorChangeDuration,
- builder: (context, value, child) {
- return Container(
- height: NewMainNavBar.barHeight,
- child: CakeImageWidget(imageUrl:
- visibleActions[i].image,
- width: iconWidth,
- height: iconHeight,
- //fit: BoxFit.scaleDown,
- colorFilter: ColorFilter.mode(
- value ?? inactiveColor,
- BlendMode.srcIn,
+ alignment: Alignment.center,
+ child: AnimatedScale(
+ duration: inactiveIconAppearDuration,
+ curve: Curves.easeOutCubic,
+ scale: (i == widget.selectedIndex) ? 0.857 : 1.0,
+ child: TweenAnimationBuilder<Color?>(
+ tween: ColorTween(
+ begin: (i == widget.selectedIndex) ? inactiveColor : activeColor,
+ end: (i==widget.selectedIndex) ? activeColor : inactiveColor,
+ ),
+ duration: iconColorChangeDuration,
+ builder: (context, value, child) {
+ return Container(
+ height: NewMainNavBar.barHeight,
+ child: CakeImageWidget(imageUrl:
+ visibleActions[i].image,
+ width: iconWidth,
+ height: iconHeight,
+ //fit: BoxFit.scaleDown,
+ colorFilter: ColorFilter.mode(
+ value ?? inactiveColor,
+ BlendMode.srcIn,
+ ),
),
- ),
- );
- }
+ );
+ }
+ ),
),
),
),
),
),
- ),
- ],
- ),
- )),
+ ],
+ ),
+ )),
+ ),
),
),
),
),
- ),
+ );
+ },
);
}
}
Why this scored 24/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.