1
0
forked from GRIN/grim

Build 67: Pay over-balance shake, manual slatepacks, green CI, slimmer builds

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.
This commit is contained in:
2ro
2026-06-14 06:25:44 -04:00
parent 2578a35cf7
commit bb3b8c4ecc
9 changed files with 503 additions and 53 deletions
+31
View File
@@ -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)"
+11 -1
View File
@@ -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
+5
View File
@@ -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}"
+6
View File
@@ -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
+82
View File
@@ -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
+286 -49
View File
@@ -69,6 +69,10 @@ pub struct GoblinWalletView {
import_nsec: Option<ImportState>,
/// 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<f64>,
/// Amount being requested, shown on the Receive screen.
request_amount: Option<String>,
/// 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<String>,
/// Last error to show in the danger color.
error: Option<String>,
}
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()
}
+14 -1
View File
@@ -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()))
+10
View File
@@ -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 {
+58 -2
View File
@@ -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<String>,
) -> Result<String, Error> {
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<ManualSlatepackOutcome, Error> {
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.