From bb3b8c4ecc386a7b103399c8eb3e171c9818b46c Mon Sep 17 00:00:00 2001 From: 2ro <17595647+2ro@users.noreply.github.com> Date: Sun, 14 Jun 2026 06:25:44 -0400 Subject: [PATCH] Build 67: Pay over-balance shake, manual slatepacks, green CI, slimmer builds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pay: stop reddening the amount while typing — requesting more than you hold is a valid request, so the digits stay black. Pressing Pay without enough funds now shakes and briefly flashes the amount red and buzzes the phone, then settles back. Bigger scan-to-pay puck. Settings: the username "@" moves inside the field as the "@yourname" placeholder (a leading "@" the user types is stripped). Mixnet routing is shortened to "All traffic" and flagged in the privacy color. The Build row links to GitHub releases. Network reads "MW + Nym mixnet + nostr". The Nym third-party row shows the linked SDK version instead of "socks5". Wallet: expose GRIM's native by-hand slatepack flow as an advanced "Slatepacks" page — paste to receive/pay/finalize, or create a payment slatepack to hand over. The fallback for when a payment can't ride a @username. CI: fix the red GitHub builds. nym-sdk is a path dep on ../nym, which the runners didn't have. A composite action materializes the pinned upstream nym commit plus Goblin's small Android webpki patch before each build; aws-lc-sys uses prebuilt NASM on native Windows. Builds: strip release binaries, dropping ~17 MB of debug symbols from the desktop build. --- .github/actions/fetch-nym/action.yml | 31 +++ .github/workflows/build.yml | 12 +- .github/workflows/release.yml | 5 + Cargo.toml | 6 + ci/nym-webpki-android.patch | 82 +++++++ src/gui/views/goblin/mod.rs | 335 +++++++++++++++++++++++---- src/gui/views/goblin/widgets.rs | 15 +- src/wallet/types.rs | 10 + src/wallet/wallet.rs | 60 ++++- 9 files changed, 503 insertions(+), 53 deletions(-) create mode 100644 .github/actions/fetch-nym/action.yml create mode 100644 ci/nym-webpki-android.patch diff --git a/.github/actions/fetch-nym/action.yml b/.github/actions/fetch-nym/action.yml new file mode 100644 index 0000000..1c9957d --- /dev/null +++ b/.github/actions/fetch-nym/action.yml @@ -0,0 +1,31 @@ +name: Fetch patched nym SDK +description: > + Materialize the nym workspace at ../nym (sibling of the goblin checkout) so the + `nym-sdk = { path = "../nym/sdk/rust/nym-sdk" }` dependency resolves. We pin the + exact upstream commit Goblin was built against and apply Goblin's small + Android webpki-roots patch on top — no separate nym fork to maintain. + +runs: + using: composite + steps: + - name: Clone + patch nym + shell: bash + run: | + set -euo pipefail + # Upstream nymtech/nym commit Goblin's Cargo.lock was generated against. + NYM_SHA=b6eb391e85be7eb8fca62def6d1ac32fd1108c30 + DEST="$(dirname "$GITHUB_WORKSPACE")/nym" + if [ -e "$DEST/sdk/rust/nym-sdk/Cargo.toml" ]; then + echo "nym already present at $DEST" + exit 0 + fi + rm -rf "$DEST" + mkdir -p "$DEST" + cd "$DEST" + git init -q + git remote add origin https://github.com/nymtech/nym.git + # Fetch just the pinned commit (GitHub allows reachable-SHA fetches). + git fetch -q --depth 1 origin "$NYM_SHA" + git checkout -q FETCH_HEAD + git apply --whitespace=nowarn "$GITHUB_WORKSPACE/ci/nym-webpki-android.patch" + echo "nym materialized at $DEST ($NYM_SHA + Goblin webpki patch)" diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 53a7c65..4602639 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -1,6 +1,12 @@ name: Build on: [push, pull_request] +# aws-lc-sys (pulled in by nym-sdk) builds AWS-LC, which needs NASM on native +# Windows. Use the prebuilt NASM objects the crate ships so the runner doesn't +# need NASM installed; harmless on Linux/macOS. +env: + AWS_LC_SYS_PREBUILT_NASM: 1 + jobs: linux: name: Linux Build @@ -9,6 +15,8 @@ jobs: - uses: actions/checkout@v6 with: submodules: recursive + # nym-sdk is a path dep on ../nym; materialize it before building. + - uses: ./.github/actions/fetch-nym - name: Release build run: cargo build --release @@ -19,9 +27,10 @@ jobs: - uses: actions/checkout@v6 with: submodules: recursive + - uses: ./.github/actions/fetch-nym - name: Release build run: cargo build --release - + macos: name: MacOS Build runs-on: macos-latest @@ -29,5 +38,6 @@ jobs: - uses: actions/checkout@v6 with: submodules: recursive + - uses: ./.github/actions/fetch-nym - name: Release build run: cargo build --release diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ab43629..3cb8a82 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -23,6 +23,8 @@ permissions: env: TAG: ${{ inputs.tag || github.event.release.tag_name }} + # aws-lc-sys (via nym-sdk) needs NASM on native Windows; use its prebuilt NASM. + AWS_LC_SYS_PREBUILT_NASM: 1 jobs: linux: @@ -35,6 +37,7 @@ jobs: with: ref: ${{ inputs.tag || github.event.release.tag_name }} submodules: recursive + - uses: ./.github/actions/fetch-nym - name: Build shell: bash run: GOBLIN_BUILD="${TAG#build}" cargo build --release @@ -59,6 +62,7 @@ jobs: with: ref: ${{ inputs.tag || github.event.release.tag_name }} submodules: recursive + - uses: ./.github/actions/fetch-nym - name: Build shell: bash run: GOBLIN_BUILD="${TAG#build}" cargo build --release @@ -82,6 +86,7 @@ jobs: with: ref: ${{ inputs.tag || github.event.release.tag_name }} submodules: recursive + - uses: ./.github/actions/fetch-nym - name: Build both architectures run: | export GOBLIN_BUILD="${TAG#build}" diff --git a/Cargo.toml b/Cargo.toml index 712af0f..83b53a3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,6 +17,12 @@ path = "src/main.rs" name="grim" crate-type = ["rlib"] +# Desktop/CI release binaries ship stripped of debug symbols — the nym + nostr + +# grin tree leaves a large symbol table that's dead weight for users (~16 MB on +# Linux). opt-level stays at the default 3 for wallet/runtime speed. +[profile.release] +strip = true + [profile.release-apk] inherits = "release" strip = true diff --git a/ci/nym-webpki-android.patch b/ci/nym-webpki-android.patch new file mode 100644 index 0000000..2cc742c --- /dev/null +++ b/ci/nym-webpki-android.patch @@ -0,0 +1,82 @@ +From f6ed17d949cc19fee0fb51db3cb65771fd510d5b Mon Sep 17 00:00:00 2001 +From: 2ro <17595647+2ro@users.noreply.github.com> +Date: Sat, 13 Jun 2026 19:57:24 -0400 +Subject: [PATCH] http-api-client: preconfigured webpki roots on Android +MIME-Version: 1.0 +Content-Type: text/plain; charset=UTF-8 +Content-Transfer-Encoding: 8bit + +The default rustls platform verifier needs the app JNI context, which a +standalone client process (Goblin's bundled SOCKS5 sidecar) lacks — it panics +on the first nym-api HTTPS call. Pin webpki_roots::TLS_SERVER_ROOTS on Android +per Nym's own troubleshooting docs. +--- + Cargo.lock | 1 + + common/http-api-client/Cargo.toml | 5 ++++- + common/http-api-client/src/registry.rs | 22 ++++++++++++++++++++++ + 3 files changed, 27 insertions(+), 1 deletion(-) + +diff --git a/Cargo.lock b/Cargo.lock +index ba1fb92..cdeddfc 100644 +--- a/Cargo.lock ++++ b/Cargo.lock +@@ -7020,6 +7020,7 @@ dependencies = [ + "tracing-subscriber", + "url", + "wasmtimer", ++ "webpki-roots 0.26.11", + ] + + [[package]] +diff --git a/common/http-api-client/Cargo.toml b/common/http-api-client/Cargo.toml +index 6c28d77..3f577c5 100644 +--- a/common/http-api-client/Cargo.toml ++++ b/common/http-api-client/Cargo.toml +@@ -38,7 +38,10 @@ itertools = { workspace = true } + inventory = { workspace = true } + fastrand = { workspace = true } + tokio = { workspace = true, features = ["rt", "macros", "time"] } +-rustls = { workspace=true } ++rustls = { workspace = true, features = ["aws_lc_rs"] } ++# Android: preconfigured webpki roots replace the JNI-bound platform verifier ++# (see registry.rs); a standalone sidecar process can't init the platform store. ++webpki-roots = { workspace = true } + # used for decoding text responses (they were already implicitly included) + bytes = { workspace = true } + encoding_rs = { workspace = true } +diff --git a/common/http-api-client/src/registry.rs b/common/http-api-client/src/registry.rs +index 4e09570..03cb945 100644 +--- a/common/http-api-client/src/registry.rs ++++ b/common/http-api-client/src/registry.rs +@@ -66,6 +66,28 @@ pub fn default_builder() -> ReqwestClientBuilder { + } + } + ++ // On Android the default rustls verifier (rustls-platform-verifier) reaches ++ // the system trust store through JNI and must be initialized with the app's ++ // Java context. A standalone client process (e.g. Goblin's bundled SOCKS5 ++ // sidecar) has no such context, so the verifier panics ++ // ("Expect rustls-platform-verifier to be initialized") the moment it makes ++ // its first HTTPS call to the nym-api. Per Nym's own troubleshooting docs, ++ // pin preconfigured webpki roots instead so HTTPS verifies without the ++ // platform store. Desktop/Windows keep the default verifier. ++ #[cfg(target_os = "android")] ++ { ++ let mut roots = rustls::RootCertStore::empty(); ++ roots.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned()); ++ let tls = rustls::ClientConfig::builder_with_provider(std::sync::Arc::new( ++ rustls::crypto::aws_lc_rs::default_provider(), ++ )) ++ .with_safe_default_protocol_versions() ++ .expect("aws-lc-rs provides the safe default protocol versions") ++ .with_root_certificates(roots) ++ .with_no_client_auth(); ++ b = b.use_preconfigured_tls(tls); ++ } ++ + b + } + +-- +2.54.0 + diff --git a/src/gui/views/goblin/mod.rs b/src/gui/views/goblin/mod.rs index e80497a..4ba5119 100644 --- a/src/gui/views/goblin/mod.rs +++ b/src/gui/views/goblin/mod.rs @@ -69,6 +69,10 @@ pub struct GoblinWalletView { import_nsec: Option, /// Amount being entered on the Pay tab. pay_amount: String, + /// When set, the over-balance "no" animation is playing: the start time (egui + /// input seconds) the user pressed Pay without enough funds. Drives a brief + /// red flash + horizontal shake of the amount, then clears itself. + pay_shake: Option, /// Amount being requested, shown on the Receive screen. request_amount: Option, /// Sub-page open inside the Settings tab. @@ -84,6 +88,8 @@ pub struct GoblinWalletView { receive_copied: Option<(u8, std::time::Instant)>, /// Avatar texture layer (disk cache + background fetches). avatars: avatars::AvatarTextures, + /// Manual slatepack page state (GRIM-native send/receive fallback). + slatepack: SlatepackManual, /// Profile-picture upload in flight. avatar_busy: bool, /// Upload worker result: (server hash, processed png) or error. @@ -100,6 +106,25 @@ enum SettingsPage { Relays, Nips, Pairing, + Slatepack, +} + +/// Inputs and last result for the manual slatepack page (GRIM's native flow, +/// surfaced as an advanced fallback under Settings → Wallet). +#[derive(Default)] +struct SlatepackManual { + /// Pasted incoming slatepack to receive/finalize. + paste: String, + /// Outgoing amount (human grin) for a manually-created payment. + amount: String, + /// Optional recipient slatepack address for the outgoing payment. + address: String, + /// Produced slatepack text to copy and hand over (send, or a receive reply). + result: String, + /// Transient status line (e.g. "Finalizing…") under the actions. + status: Option, + /// Last error to show in the danger color. + error: Option, } impl Default for GoblinWalletView { @@ -115,6 +140,7 @@ impl Default for GoblinWalletView { rotate: None, import_nsec: None, pay_amount: String::new(), + pay_shake: None, request_amount: None, settings_page: SettingsPage::Main, node_url_input: String::new(), @@ -122,6 +148,7 @@ impl Default for GoblinWalletView { relay_edit: Vec::new(), relay_input: String::new(), receive_copied: None, + slatepack: SlatepackManual::default(), avatars: avatars::AvatarTextures::default(), avatar_busy: false, avatar_slot: std::sync::Arc::new(std::sync::Mutex::new(None)), @@ -867,13 +894,13 @@ impl GoblinWalletView { // Scan-to-pay QR, top-right (mirrors the Home header scan puck): // open the scanner with the typed amount preserved. ui.with_layout(Layout::right_to_left(Align::Center), |ui| { - let (rect, resp) = ui.allocate_exact_size(Vec2::splat(36.0), Sense::click()); - ui.painter().circle_filled(rect.center(), 18.0, t.surface2); + let (rect, resp) = ui.allocate_exact_size(Vec2::splat(48.0), Sense::click()); + ui.painter().circle_filled(rect.center(), 24.0, t.surface2); ui.painter().text( rect.center(), egui::Align2::CENTER_CENTER, QR_CODE, - FontId::new(17.0, fonts::regular()), + FontId::new(23.0, fonts::regular()), t.surface_text, ); if resp @@ -897,19 +924,30 @@ 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). + // Over-balance is NOT shown while typing — requesting more than you hold is + // valid, and reddening digits mid-entry reads as an error when it isn't. + // The only feedback is on the Pay press: a brief red flash + shake + buzz + // (see the Pay button below). `spendable` is read there too. 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); + // Drive the "can't pay that" animation if it's running. + let now = ui.input(|i| i.time); + const SHAKE_DUR: f64 = 0.45; + if self.pay_shake.is_some_and(|s| now - s >= SHAKE_DUR) { + self.pay_shake = None; + } ui.add_space(if tall { 56.0 } else { 24.0 }); - if over { - w::amount_text_centered_ink(ui, &display, 76.0, t.neg, t.neg); + if let Some(start) = self.pay_shake { + ui.ctx().request_repaint(); // keep the animation ticking + let p = ((now - start) / SHAKE_DUR).clamp(0.0, 1.0) as f32; + // Damped horizontal oscillation, amplitude decaying to zero. + let dx = 14.0 * (1.0 - p) * (p * std::f32::consts::PI * 9.0).sin(); + // Red flash that eases back to the normal ink over the shake. + let num = lerp_color(t.neg, t.text, p); + let mark = lerp_color(t.neg, t.text_dim, p); + w::amount_text_centered_shifted(ui, &display, 76.0, num, mark, dx); } else { w::amount_text_centered(ui, &display, 76.0); } @@ -925,16 +963,6 @@ 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), - ); - }); - } // Drop the keypad toward the bottom on phone layouts (thumb reach) so it // isn't stranded in the middle with a big empty gap below it. let narrow = ui.available_width() < 700.0; @@ -994,7 +1022,13 @@ impl GoblinWalletView { )), |ui| { if w::big_action(ui, "Pay", false).clicked() && valid { + let over = grin_core::core::amount_from_hr_string(&self.pay_amount) + .map(|a| a > spendable) + .unwrap_or(false); if over { + // "No, you can't pay that": shake + flash the amount red + // and buzz the phone. Nothing is reddened while typing. + self.pay_shake = Some(now); cb.vibrate_error(); } else { let mut f = SendFlow::default(); @@ -1700,6 +1734,7 @@ impl GoblinWalletView { SettingsPage::Relays => return self.relays_ui(ui, wallet, cb), SettingsPage::Nips => return self.nips_ui(ui), SettingsPage::Pairing => return self.pairing_settings_ui(ui), + SettingsPage::Slatepack => return self.slatepack_ui(ui, wallet, cb), SettingsPage::Main => {} } ui.add_space(8.0); @@ -1930,6 +1965,7 @@ impl GoblinWalletView { ui.add_space(16.0); let mut open_relays = false; let mut open_node = false; + let mut open_slatepack = false; settings_group(ui, "Wallet", |ui| { settings_row(ui, "Display unit", "ツ (grin)"); if settings_row_nav(ui, "Relays", &relay_summary(wallet)) { @@ -1938,10 +1974,19 @@ impl GoblinWalletView { if settings_row_nav(ui, "Node", &node_summary(wallet)) { open_node = true; } + // GRIM's native by-hand slatepack exchange, for when a payment + // can't go through a @username. + if settings_row_nav(ui, "Slatepacks", "Manual") { + open_slatepack = true; + } if settings_row_btn(ui, "Lock wallet", crate::gui::icons::LOCK) { wallet.close(); } }); + if open_slatepack { + self.slatepack = SlatepackManual::default(); + self.settings_page = SettingsPage::Slatepack; + } if open_relays { self.relay_edit = wallet .nostr_service() @@ -1959,11 +2004,9 @@ impl GoblinWalletView { ui.add_space(16.0); let mut open_pairing = false; settings_group(ui, "Privacy", |ui| { - settings_row( - ui, - "Mixnet routing", - "All traffic routed over the Nym mixnet", - ); + // Always on — flagged in the privacy color so it reads as the + // headline guarantee, not a setting you might have left off. + settings_row_ink(ui, "Mixnet routing", "All traffic", theme::tokens().neg); // Tap to cycle the incoming-payment accept policy. if settings_row_btn(ui, "Auto-accept", accept_policy_label(wallet)) { cycle_accept_policy(wallet); @@ -2030,8 +2073,10 @@ impl GoblinWalletView { ui.add_space(16.0); settings_group(ui, "About", |ui| { - settings_row(ui, "Goblin", &format!("Build {}", crate::BUILD)); - settings_row(ui, "Network", "Mimblewimble · no address on chain"); + if settings_row_nav(ui, "Goblin", &format!("Build {}", crate::BUILD)) { + open_url(ui, "https://github.com/2ro/goblin/releases"); + } + settings_row(ui, "Network", "MW + Nym mixnet + nostr"); }); ui.add_space(16.0); @@ -2046,7 +2091,7 @@ impl GoblinWalletView { if settings_row_nav(ui, "nostr-sdk", "0.44") { open_url(ui, "https://github.com/rust-nostr/nostr"); } - if settings_row_nav(ui, "Nym mixnet", "socks5") { + if settings_row_nav(ui, "Nym mixnet", "sdk 1.21") { open_url(ui, "https://nym.com"); } if settings_row_nav(ui, "egui", "0.33") { @@ -2345,6 +2390,188 @@ impl GoblinWalletView { }); } + /// Manual slatepack exchange — GRIM's native by-hand flow, exposed as an + /// advanced fallback for when a payment can't ride a @username. + fn slatepack_ui(&mut self, ui: &mut egui::Ui, wallet: &Wallet, cb: &dyn PlatformCallbacks) { + let t = theme::tokens(); + if self.sub_header(ui, "Slatepacks") { + self.settings_page = SettingsPage::Main; + return; + } + ScrollArea::vertical() + .auto_shrink([false; 2]) + .scroll_bar_visibility(egui::scroll_area::ScrollBarVisibility::AlwaysHidden) + .show(ui, |ui| { + ui.label( + RichText::new( + "Advanced — exchange raw slatepacks by hand, the way GRIM does. \ + Use this only when you can't pay or get paid through a @username.", + ) + .font(FontId::new(13.0, fonts::regular())) + .color(t.text_dim), + ); + ui.add_space(14.0); + + // Receive / continue: paste a slatepack, let the wallet route it. + let mut do_process = false; + settings_group(ui, "Receive or finalize", |ui| { + ui.label( + RichText::new( + "Paste a slatepack someone gave you. Goblin receives the \ + payment, pays the invoice, or finalizes and posts it.", + ) + .font(FontId::new(12.5, fonts::regular())) + .color(t.surface_text_dim), + ); + ui.add_space(8.0); + TextEdit::new(egui::Id::from("sp_paste")) + .focus(false) + .paste() + .scan_qr() + .hint_text("BEGINSLATEPACK. … ENDSLATEPACK.") + .text_color(t.surface_text) + .body() + .ui(ui, &mut self.slatepack.paste, cb); + }); + ui.add_space(10.0); + if w::big_action(ui, "Process slatepack", false).clicked() { + do_process = true; + } + if do_process { + let text = self.slatepack.paste.trim().to_string(); + if text.is_empty() { + self.slatepack.error = Some("Paste a slatepack first.".to_string()); + self.slatepack.status = None; + } else { + use crate::wallet::types::ManualSlatepackOutcome as Out; + match wallet.manual_process_slatepack(&text) { + Ok(Out::Response(reply)) => { + self.slatepack.result = reply; + self.slatepack.status = + Some("Reply ready — send it back to the sender.".to_string()); + self.slatepack.error = None; + self.slatepack.paste.clear(); + } + Ok(Out::Finalizing) => { + self.slatepack.result.clear(); + self.slatepack.status = + Some("Finalizing and posting to the chain…".to_string()); + self.slatepack.error = None; + self.slatepack.paste.clear(); + } + Err(e) => { + self.slatepack.error = Some(e.to_string()); + self.slatepack.status = None; + } + } + } + } + + // Send: create a slatepack to hand over out-of-band. + ui.add_space(16.0); + let mut do_send = false; + settings_group(ui, "Create a payment", |ui| { + ui.label( + RichText::new( + "Make a slatepack to hand to someone. They receive it, send \ + the reply back, and you finalize it above.", + ) + .font(FontId::new(12.5, fonts::regular())) + .color(t.surface_text_dim), + ); + ui.add_space(8.0); + TextEdit::new(egui::Id::from("sp_amount")) + .focus(false) + .numeric() + .hint_text("Amount in grin") + .text_color(t.surface_text) + .body() + .ui(ui, &mut self.slatepack.amount, cb); + ui.add_space(8.0); + TextEdit::new(egui::Id::from("sp_addr")) + .focus(false) + .hint_text("Recipient address (optional)") + .text_color(t.surface_text) + .body() + .ui(ui, &mut self.slatepack.address, cb); + }); + ui.add_space(10.0); + if w::big_action(ui, "Create slatepack", false).clicked() { + do_send = true; + } + if do_send { + match grin_core::core::amount_from_hr_string(self.slatepack.amount.trim()) { + Ok(a) if a > 0 => { + let s = self.slatepack.address.trim(); + let dest = if s.is_empty() { + None + } else { + Some(s.to_string()) + }; + match wallet.manual_send_slatepack(a, dest) { + Ok(text) => { + self.slatepack.result = text; + self.slatepack.status = Some( + "Slatepack ready — hand it to the recipient.".to_string(), + ); + self.slatepack.error = None; + } + Err(e) => { + self.slatepack.error = Some(e.to_string()); + self.slatepack.status = None; + } + } + } + _ => { + self.slatepack.error = + Some("Enter an amount greater than zero.".to_string()); + self.slatepack.status = None; + } + } + } + + // Status, error, and the produced slatepack (copyable). + if let Some(err) = self.slatepack.error.clone() { + ui.add_space(10.0); + ui.label( + RichText::new(err) + .font(FontId::new(13.0, fonts::regular())) + .color(t.neg), + ); + } + if let Some(status) = self.slatepack.status.clone() { + ui.add_space(10.0); + ui.label( + RichText::new(status) + .font(FontId::new(13.0, fonts::regular())) + .color(t.text_dim), + ); + } + let result = self.slatepack.result.clone(); + if !result.is_empty() { + ui.add_space(14.0); + settings_group(ui, "Slatepack to send", |ui| { + let preview: String = result.chars().take(120).collect(); + let preview = if result.chars().count() > 120 { + format!("{preview}…") + } else { + preview + }; + ui.label( + RichText::new(preview) + .font(FontId::new(12.0, fonts::mono())) + .color(t.surface_text_dim), + ); + }); + ui.add_space(10.0); + if w::big_action(ui, "Copy slatepack", false).clicked() { + cb.copy_string_to_buffer(result); + } + } + ui.add_space(16.0); + }); + } + /// What-is-nostr explainer and tappable NIP reference list. fn nips_ui(&mut self, ui: &mut egui::Ui) { let t = theme::tokens(); @@ -3011,24 +3238,20 @@ impl GoblinWalletView { .color(t.surface_text), ); ui.add_space(8.0); - ui.horizontal(|ui| { - ui.label( - RichText::new("@") - .font(FontId::new(16.0, fonts::semibold())) - .color(t.surface_text), - ); - let before = claim.input.clone(); - TextEdit::new(egui::Id::from("settings_claim")) - .focus(false) - .hint_text("yourname") - .text_color(t.surface_text) - .body() - .ui(ui, &mut claim.input, cb); - if claim.input != before { - claim.available = None; - claim.message = None; - } - }); + // The "@" lives inside the field as the placeholder ("@yourname"), + // not as a cramped glyph outside it. A leading "@" the user types is + // stripped when the name is read below. + let before = claim.input.clone(); + TextEdit::new(egui::Id::from("settings_claim")) + .focus(false) + .hint_text("@yourname") + .text_color(t.surface_text) + .body() + .ui(ui, &mut claim.input, cb); + if claim.input != before { + claim.available = None; + claim.message = None; + } ui.add_space(4.0); ui.label( RichText::new("Shown as @you. Public on goblin.st. Payments stay encrypted.") @@ -3048,7 +3271,7 @@ impl GoblinWalletView { ); } ui.add_space(10.0); - let name = claim.input.trim().to_lowercase(); + let name = claim.input.trim().trim_start_matches('@').to_lowercase(); let valid = name.len() >= 3 && name.len() <= 30; if claim.checking { ui.horizontal(|ui| { @@ -3254,6 +3477,12 @@ fn settings_row_toggle(ui: &mut egui::Ui, label: &str, sub: &str, on: bool) -> O } fn settings_row(ui: &mut egui::Ui, label: &str, value: &str) { + settings_row_ink(ui, label, value, theme::tokens().surface_text_dim); +} + +/// Like [`settings_row`] but the value is drawn in an explicit ink — used to flag +/// the always-on mixnet routing in the privacy color. +fn settings_row_ink(ui: &mut egui::Ui, label: &str, value: &str, value_ink: Color32) { let t = theme::tokens(); ui.horizontal(|ui| { ui.label( @@ -3265,7 +3494,7 @@ fn settings_row(ui: &mut egui::Ui, label: &str, value: &str) { ui.label( RichText::new(value) .font(FontId::new(13.0, fonts::regular())) - .color(t.surface_text_dim), + .color(value_ink), ); }); }); @@ -3350,6 +3579,14 @@ fn open_url(ui: &egui::Ui, url: &str) { ui.ctx().open_url(egui::OpenUrl::new_tab(url)); } +/// Linear blend between two colors (`p` 0→`a`, 1→`b`). Used by the Pay-screen +/// over-balance flash to ease the digits from red back to normal ink. +fn lerp_color(a: Color32, b: Color32, p: f32) -> Color32 { + let p = p.clamp(0.0, 1.0); + let mix = |x: u8, y: u8| (x as f32 + (y as f32 - x as f32) * p).round() as u8; + Color32::from_rgb(mix(a.r(), b.r()), mix(a.g(), b.g()), mix(a.b(), b.b())) +} + fn approve_button(ui: &mut egui::Ui) -> bool { w::big_action(ui, "Approve", false).clicked() } diff --git a/src/gui/views/goblin/widgets.rs b/src/gui/views/goblin/widgets.rs index 6a7faed..de698ff 100644 --- a/src/gui/views/goblin/widgets.rs +++ b/src/gui/views/goblin/widgets.rs @@ -175,6 +175,19 @@ pub fn amount_text_centered_ink( size: f32, num_ink: Color32, mark_ink: Color32, +) { + amount_text_centered_shifted(ui, value, size, num_ink, mark_ink, 0.0); +} + +/// Like [`amount_text_centered_ink`] but nudged horizontally by `dx` pixels — the +/// hook for the "can't pay that" shake on the Pay screen. +pub fn amount_text_centered_shifted( + ui: &mut Ui, + value: &str, + size: f32, + num_ink: Color32, + mark_ink: Color32, + dx: f32, ) { let avail = ui.available_width(); let measure = |ui: &Ui, sz: f32| -> f32 { @@ -199,7 +212,7 @@ pub fn amount_text_centered_ink( let total = measure(ui, size); ui.horizontal(|ui| { ui.spacing_mut().item_spacing.x = 0.0; - ui.add_space(((ui.available_width() - total) / 2.0).max(0.0)); + ui.add_space(((ui.available_width() - total) / 2.0 + dx).max(0.0)); ui.label( RichText::new(value) .font(FontId::new(size, fonts::bold())) diff --git a/src/wallet/types.rs b/src/wallet/types.rs index 990433c..e699623 100644 --- a/src/wallet/types.rs +++ b/src/wallet/types.rs @@ -387,6 +387,16 @@ impl WalletTx { } } +/// Result of [`crate::wallet::Wallet::manual_process_slatepack`]: either a reply +/// slatepack the user must hand back to the counterparty, or a returned slate now +/// being finalized and posted on the worker thread. +pub enum ManualSlatepackOutcome { + /// A reply slatepack to send back (e.g. the receiver's response to a payment). + Response(String), + /// A returned slate is being finalized and posted to the chain. + Finalizing, +} + /// Task for the wallet. #[derive(Clone)] pub enum WalletTask { diff --git a/src/wallet/wallet.rs b/src/wallet/wallet.rs index a2c395f..fb599ba 100644 --- a/src/wallet/wallet.rs +++ b/src/wallet/wallet.rs @@ -18,8 +18,8 @@ use crate::nostr::{NostrConfig, NostrIdentity, NostrService, NostrStore}; use crate::wallet::seed::WalletSeed; use crate::wallet::store::TxHeightStore; use crate::wallet::types::{ - ConnectionMethod, PhraseMode, WalletAccount, WalletData, WalletInstance, WalletTask, WalletTx, - WalletTxAction, + ConnectionMethod, ManualSlatepackOutcome, PhraseMode, WalletAccount, WalletData, + WalletInstance, WalletTask, WalletTx, WalletTxAction, }; use crate::wallet::{ConnectionsConfig, Mnemonic, WalletConfig}; @@ -1192,6 +1192,62 @@ impl Wallet { self.retrieve_tx_by_id(None, Some(*slate_id)).is_some() } + /// Manual slatepack send (the GRIM-native flow, exposed for the advanced + /// Settings page): build a Standard1 payment of `amount` nanogrin to an + /// optional recipient slatepack address, locking the inputs, and return the + /// armored slatepack text to hand to the recipient out-of-band. + pub fn manual_send_slatepack( + &self, + amount: u64, + dest: Option, + ) -> Result { + let dest = match dest { + Some(a) => Some( + SlatepackAddress::try_from(a.trim()) + .map_err(|_| Error::GenericError("Invalid recipient address".to_string()))?, + ), + None => None, + }; + let slate = self.send(amount, dest)?; + self.read_slatepack_text(slate.id, &slate.state) + .ok_or_else(|| Error::GenericError("Slatepack message missing".to_string())) + } + + /// Manual slatepack ingest mirroring [`WalletTask::OpenMessage`]'s routing: + /// receiving a Standard1 or paying an Invoice1 is node-free, so it runs inline + /// and returns the reply slatepack to send back; finalizing a returned slate + /// (Standard2/Invoice2) posts to the node, so it's handed to the worker. + pub fn manual_process_slatepack(&self, text: &String) -> Result { + let (slate, dest) = self + .parse_slatepack(text) + .map_err(|e| Error::GenericError(e.to_string()))?; + match slate.state { + SlateState::Standard1 => { + let reply = self.receive(&slate, dest)?; + let text = self + .read_slatepack_text(reply.id, &reply.state) + .ok_or_else(|| Error::GenericError("Reply slatepack missing".to_string()))?; + Ok(ManualSlatepackOutcome::Response(text)) + } + SlateState::Invoice1 => { + let reply = self.pay(&slate)?; + let text = self + .read_slatepack_text(reply.id, &reply.state) + .ok_or_else(|| Error::GenericError("Reply slatepack missing".to_string()))?; + Ok(ManualSlatepackOutcome::Response(text)) + } + SlateState::Standard2 | SlateState::Invoice2 => { + // Finalize + post hits the node; let the worker handle it (GRIM's + // OpenMessage does exactly this routing). + self.task(WalletTask::OpenMessage(text.clone())); + Ok(ManualSlatepackOutcome::Finalizing) + } + _ => Err(Error::GenericError( + "This slatepack is already complete or isn't one Goblin can continue.".to_string(), + )), + } + } + /// Guarded nostr ingest: receive an incoming Standard1 payment and return /// the S2 reply slate with its slatepack text. Receiving only creates an /// output and signs — it never spends funds.