From 2e8829ef83d89f1e883c5ed3159d3f5e4c47857b Mon Sep 17 00:00:00 2001 From: 2ro <17595647+2ro@users.noreply.github.com> Date: Sat, 13 Jun 2026 02:51:29 -0400 Subject: [PATCH] Build 42: block over-balance sends + roomier search icon MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Guard against paying more than the spendable balance at every entry: the Pay tab, the send-flow amount step, and the Review screen all render the amount in red with "You don't have enough grin" and refuse to proceed, so a payment that would only fail later at the node is stopped up front. On Android the phone gives a short error buzz on the blocked tap — new VIBRATE bridge (PlatformCallbacks::vibrate_error -> MainActivity.vibrateError); no-op on desktop. Grim silently clamped the amount and showed nothing. Also enlarge and vertically center the magnifier in the "Send to" search field (an 18px muted label became a 22px centered glyph). --- android/app/src/main/AndroidManifest.xml | 1 + .../java/mw/gri/android/MainActivity.java | 15 ++++ src/gui/platform/android/mod.rs | 4 + src/gui/platform/mod.rs | 4 + src/gui/views/goblin/mod.rs | 42 +++++++-- src/gui/views/goblin/send.rs | 86 ++++++++++++++++--- 6 files changed, 133 insertions(+), 19 deletions(-) diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index dfbef0b0..1321f03a 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -8,6 +8,7 @@ + diff --git a/android/app/src/main/java/mw/gri/android/MainActivity.java b/android/app/src/main/java/mw/gri/android/MainActivity.java index 59ab13f2..d23ee7fa 100644 --- a/android/app/src/main/java/mw/gri/android/MainActivity.java +++ b/android/app/src/main/java/mw/gri/android/MainActivity.java @@ -510,6 +510,21 @@ public class MainActivity extends GameActivity { startActivity(Intent.createChooser(intent, "Share data")); } + // Called from native code to play a short "error" haptic (rejected payment). + public void vibrateError() { + Vibrator vibrator = (Vibrator) getSystemService(Context.VIBRATOR_SERVICE); + if (vibrator == null || !vibrator.hasVibrator()) { + return; + } + // Two short pulses read as "no" / rejected, distinct from a tap. + long[] pattern = new long[]{0, 40, 70, 40}; + if (Build.VERSION.SDK_INT >= 26) { + vibrator.vibrate(VibrationEffect.createWaveform(pattern, -1)); + } else { + vibrator.vibrate(pattern, -1); + } + } + // Called from native code to set status-bar icon color to contrast the // in-app theme. white = light icons for a dark background. The app draws // edge-to-edge, so the OS status-bar background is the app's own content; diff --git a/src/gui/platform/android/mod.rs b/src/gui/platform/android/mod.rs index 77346240..b3141397 100644 --- a/src/gui/platform/android/mod.rs +++ b/src/gui/platform/android/mod.rs @@ -207,6 +207,10 @@ impl PlatformCallbacks for Android { &[JValue::Bool(white as u8)], ); } + + fn vibrate_error(&self) { + let _ = self.call_java_method("vibrateError", "()V", &[]); + } } lazy_static! { diff --git a/src/gui/platform/mod.rs b/src/gui/platform/mod.rs index 3bc207be..633ab9fd 100644 --- a/src/gui/platform/mod.rs +++ b/src/gui/platform/mod.rs @@ -47,4 +47,8 @@ pub trait PlatformCallbacks { /// Set the status-bar icon color to contrast the current theme. `white` = /// light icons (for a dark background). No-op off Android. fn set_status_bar_white_icons(&self, _white: bool) {} + + /// Play a short "error" haptic (e.g. a rejected over-balance payment). + /// No-op off Android. + fn vibrate_error(&self) {} } diff --git a/src/gui/views/goblin/mod.rs b/src/gui/views/goblin/mod.rs index 025e7c0b..a1b548d6 100644 --- a/src/gui/views/goblin/mod.rs +++ b/src/gui/views/goblin/mod.rs @@ -323,7 +323,7 @@ impl GoblinWalletView { .show_inside(ui, |ui| { w::centered_column(ui, Content::SIDE_PANEL_WIDTH * 1.2, |ui| match self.tab { Tab::Home => self.home_ui(ui, wallet, cb, wide_desktop), - Tab::Pay => self.pay_ui(ui, wallet), + Tab::Pay => self.pay_ui(ui, wallet, cb), Tab::Activity => self.activity_ui(ui, wallet, cb), Tab::Receive => self.receive_ui(ui, wallet, cb), Tab::Me => self.me_ui(ui, wallet, cb), @@ -806,7 +806,7 @@ impl GoblinWalletView { } /// Pay tab: amount-first combined pay/request surface. - fn pay_ui(&mut self, ui: &mut egui::Ui, _wallet: &Wallet) { + fn pay_ui(&mut self, ui: &mut egui::Ui, wallet: &Wallet, cb: &dyn PlatformCallbacks) { let t = theme::tokens(); ui.add_space(8.0); ui.horizontal(|ui| { @@ -848,8 +848,22 @@ impl GoblinWalletView { self.pay_amount.clone() }; let tall = ui.available_height() > 560.0; + // Block paying more than the spendable balance: red amount + a message + // + an error buzz on tap (Request is unguarded — you can request more + // than you hold). + let spendable = wallet + .get_data() + .map(|d| d.info.amount_currently_spendable) + .unwrap_or(0); + let over = grin_core::core::amount_from_hr_string(&self.pay_amount) + .map(|a| a > spendable) + .unwrap_or(false); ui.add_space(if tall { 56.0 } else { 24.0 }); - w::amount_text_centered(ui, &display, 76.0); + if over { + w::amount_text_centered_ink(ui, &display, 76.0, t.neg, t.neg); + } else { + w::amount_text_centered(ui, &display, 76.0); + } if let Ok(grin) = display.parse::() { if let Some(preview) = pairing_preview(grin) { ui.add_space(6.0); @@ -862,6 +876,16 @@ impl GoblinWalletView { }); } } + if over { + ui.add_space(6.0); + ui.vertical_centered(|ui| { + ui.label( + RichText::new("You don't have enough grin") + .font(FontId::new(14.0, fonts::regular())) + .color(t.neg), + ); + }); + } ui.add_space(if tall { 32.0 } else { 16.0 }); // Numpad at narrow (mobile-shell) widths, typed input on the wide @@ -912,10 +936,14 @@ impl GoblinWalletView { )), |ui| { if w::big_action(ui, "Pay", false).clicked() && valid { - let mut f = SendFlow::default(); - f.prefill_amount(self.pay_amount.clone()); - self.pay_amount.clear(); - self.send = Some(f); + if over { + cb.vibrate_error(); + } else { + let mut f = SendFlow::default(); + f.prefill_amount(self.pay_amount.clone()); + self.pay_amount.clear(); + self.send = Some(f); + } } }, ); diff --git a/src/gui/views/goblin/send.rs b/src/gui/views/goblin/send.rs index 20038b26..c6a4025f 100644 --- a/src/gui/views/goblin/send.rs +++ b/src/gui/views/goblin/send.rs @@ -278,10 +278,14 @@ impl SendFlow { } .show(ui, |ui| { ui.horizontal(|ui| { - ui.label( - RichText::new(MAGNIFYING_GLASS) - .font(FontId::new(18.0, fonts::regular())) - .color(t.surface_text_mute), + let (icon_rect, _) = + ui.allocate_exact_size(egui::Vec2::new(24.0, 42.0), egui::Sense::hover()); + ui.painter().text( + icon_rect.center(), + egui::Align2::CENTER_CENTER, + MAGNIFYING_GLASS, + FontId::new(22.0, fonts::regular()), + t.surface_text_dim, ); ui.add_space(8.0); let mut te = TextEdit::new(egui::Id::from("send_search")) @@ -716,6 +720,16 @@ impl SendFlow { } let recipient = self.recipient.clone().unwrap(); + // Block sends over the spendable balance: red amount + a message + an + // error buzz on tap, rather than letting it fail later at the node. + let spendable = wallet + .get_data() + .map(|d| d.info.amount_currently_spendable) + .unwrap_or(0); + let over = amount_from_hr_string(&self.amount) + .map(|a| a > spendable) + .unwrap_or(false); + // Recipient chip, centered per the design. let name_label = format!("To {}", recipient.name); let name_galley = ui.painter().layout_no_wrap( @@ -743,7 +757,11 @@ impl SendFlow { } else { self.amount.clone() }; - w::amount_text_centered(ui, &display, 64.0); + if over { + w::amount_text_centered_ink(ui, &display, 64.0, t.neg, t.neg); + } else { + w::amount_text_centered(ui, &display, 64.0); + } if let Ok(grin) = display.parse::() { if let Some(preview) = super::pairing_preview(grin) { ui.add_space(6.0); @@ -756,6 +774,16 @@ impl SendFlow { }); } } + if over { + ui.add_space(6.0); + ui.vertical_centered(|ui| { + ui.label( + RichText::new("You don't have enough grin") + .font(FontId::new(14.0, fonts::regular())) + .color(t.neg), + ); + }); + } ui.add_space(16.0); // Quick chips. @@ -815,7 +843,11 @@ impl SendFlow { .unwrap_or(false); ui.add_enabled_ui(valid, |ui| { if w::big_action(ui, "Review", false).clicked() { - self.stage = Stage::Review; + if over { + cb.vibrate_error(); + } else { + self.stage = Stage::Review; + } } }); false @@ -836,6 +868,18 @@ impl SendFlow { let amount = self.amount.clone(); let hero_tex = tex_for(avatars, ui.ctx(), wallet, &recipient.name); + // Over-balance guard: every path that reaches Review (including the + // Pay-tab / scan prefill that jumps straight here, skipping the amount + // step) must not offer a completable send for more than the spendable + // balance. Re-checked each frame so a mid-flow balance drop disables it. + let spendable = wallet + .get_data() + .map(|d| d.info.amount_currently_spendable) + .unwrap_or(0); + let over = amount_from_hr_string(&amount) + .map(|a| a > spendable) + .unwrap_or(false); + w::card(ui, |ui| { ui.set_min_width(ui.available_width()); ui.add_space(8.0); @@ -871,16 +915,34 @@ impl SendFlow { w::info_row(ui, "Delivery", "Encrypted nostr DM over Tor"); ui.add_space(16.0); - if self.hold.ui(ui, "Hold to send") { - self.dispatch(wallet); - self.stage = Stage::Sending; + if over { + ui.vertical_centered(|ui| { + ui.label( + RichText::new("You don't have enough grin") + .font(FontId::new(14.0, fonts::regular())) + .color(t.neg), + ); + }); + ui.add_space(8.0); } + // Greyed out while over balance; the `&& !over` also refuses the send in + // case the hold widget ignores the disabled state. + ui.add_enabled_ui(!over, |ui| { + if self.hold.ui(ui, "Hold to send") && !over { + self.dispatch(wallet); + self.stage = Stage::Sending; + } + }); ui.add_space(6.0); ui.vertical_centered(|ui| { ui.label( - RichText::new("Press and hold to confirm") - .font(FontId::new(12.0, fonts::regular())) - .color(t.text_mute), + RichText::new(if over { + "Go back and lower the amount" + } else { + "Press and hold to confirm" + }) + .font(FontId::new(12.0, fonts::regular())) + .color(t.text_mute), ); }); false