1
0
forked from GRIN/grim

Build 18: move the integrated node off the wallet list into the cog

- Remove the Node status chip from the wallet-list header; the integrated
  node now lives in the cog's settings as a status + Enable/Autorun section,
  with a "Node settings" button into the full stats/mining/tuning panel.
- Stop the desktop two-column rail from auto-docking the node beside the
  settings screen, so the node has a single home in the cog at every width
  (the full panel opens only on demand). Fixes the wide-window double-exposure.
- Prove NIP-17 payments transit the top public relays: parameterize the
  nostr e2e roundtrip and add damus.io + nos.lol proofs (3/3 green).
- Add DEVELOPING.md documenting how we iterate and test (Xwayland :2 recipe,
  Build N cadence, gui-sweep, e2e tests, live infra) — no secrets.

Verified live on :2: chip gone from the list; cog shows the node once
(single column, no left rail); "Node settings" opens the full panel on
demand. 34 lib tests green; nip17 roundtrip green over nrelay + damus + nos.lol.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Claude
2026-06-11 20:21:13 -04:00
parent 86f042facb
commit 0438d70cae
5 changed files with 293 additions and 48 deletions
+206
View File
@@ -0,0 +1,206 @@
# Developing & testing Goblin
How this fork is actually iterated on, built, driven, and verified — so you can
pick development back up after a `/clear` without re-deriving the loop. This is
the *dev/test workflow*, not an architecture tour (for that see `HANDOFF.md`,
the `~/.claude/.../memory/` notes, and the code map in
`goblin-implementation-state`).
> **Secrets stay out of this file.** Test-wallet seed phrases and passwords are
> never committed. They live in the local handoff notes only. This doc refers
> to wallets by role.
---
## 1. Workspace layout
Everything lives under `…/Goblin Project/`:
| Dir | What it is |
| --- | --- |
| `goblin/` | **The fork you edit.** Crate name `grim`, binary `goblin`. |
| `goblin-nip05d/` | The NIP-05 identity server (axum + sqlite), deployed at goblin.st. |
| `grim/`, `grin/`, `egui/`, `android-activity/`, `nokhwa/` | Reference clones — **not build inputs**, just there to read upstream source. |
Inside `goblin/src/gui/views/`:
- `goblin/` — the Cash App-style surfaces (home/activity/send/receive/me, onboarding, the Goblin settings pages, widgets).
- `wallets/` — the wallet list + open-wallet host (returning-user list lives in `wallets/content.rs`).
- `network/` — the integrated GRIN node panel (stats/mining/tuning/recovery).
- `settings/` — the app-level cog settings (`settings/content.rs`).
First build needs submodules: `git submodule update --init --recursive` inside
`goblin/` (else `grin_api` fails). As **butler**, the rustup toolchain is in
`$HOME`, so `cargo`/`rustc`/`rustfmt` just work — no env overrides.
---
## 2. Build & versioning
```sh
cd goblin
cargo build # ~15s incremental debug; clean release ~2m
```
**Builds are numbered, not semver'd.** `build.rs` computes
`Build N = git rev-list --count b51a46b..HEAD` (the GRIM fork base) and exposes
it as the `GOBLIN_BUILD` env var baked into the binary. Each meaningful change
is committed with the message `Build N`. To see the next number:
```sh
git rev-list --count b51a46b..HEAD
```
A pre-commit hook runs `rustfmt --check` and aborts if anything is unformatted
(it reformats first). Always run `rustfmt --edition 2024 <changed files>`
before committing. Pushing `origin master` is the owner's job.
---
## 3. Driving the real app for screenshots (Xwayland :2)
The desktop session is **KDE Wayland**, where `xdotool` input injection does
**not** work (and Parsec may hold the live display). The validated recipe is a
throwaway rootful Xwayland server on display `:2`:
```sh
# 1. Stand up an isolated X server (once per session; no window manager inside).
Xwayland :2 -geometry 1600x1000 -decorate -noreset &
# 2. Launch Goblin onto it. MUST unset WAYLAND_DISPLAY or winit picks Wayland.
env -u WAYLAND_DISPLAY DISPLAY=:2 ./target/debug/goblin &
# 3. Find the window, focus it (no WM => keys go nowhere until you focus once).
WID=$(DISPLAY=:2 xdotool search --name Goblin | head -1)
DISPLAY=:2 xdotool windowfocus "$WID"
# 4. Drive it.
DISPLAY=:2 xdotool mousemove <x> <y> click 1
DISPLAY=:2 xdotool type 'hello'
DISPLAY=:2 xdotool key Return
# 5. Capture (ImageMagick). No WM => window sits at 0,0, so image coords == click coords.
DISPLAY=:2 import -window "$WID" /tmp/shot.png
```
Form factors — resize the window to test responsive layouts:
```sh
DISPLAY=:2 xdotool windowsize "$WID" 390 844 # mobile (bottom tabs)
DISPLAY=:2 xdotool windowsize "$WID" 1280 800 # desktop sidebar (>=720px wide)
```
The layout flips between mobile bottom-tabs and desktop sidebar around 700-720px.
Kill stale instances by exact name only — **`pgrep -x goblin`, never `-f`**
(`-f` would match this checkout's paths and your editor). Data dir is `~/.goblin`.
---
## 4. The `goblin-gui-sweep` workflow (run after every UI change)
The saved multi-agent workflow at `.claude/workflows/goblin-gui-sweep.js`
(project root) is the standard post-UI-change check. Invoke it via the Workflow
tool by name `goblin-gui-sweep`. It:
1. **Capture** — stands up `:2` if dead, builds, kills stale instances, launches,
unlocks the test wallet, and drives **every** surface (desktop + mobile
widths, all three themes, settings sub-pages, a send-to-review via a local
npub, the ~700px responsive flip), screenshotting to `/tmp/goblin-gui-sweep/`.
2. **Review** — fans out reviewer agents (rendering / accessibility / design /
copy) over the screenshot groups.
3. **Verify** — adversarially re-checks each finding to drop false positives.
4. **Report** — writes `/tmp/goblin-gui-sweep/report.md` + `collage.png`.
The driver always finishes its rotation (it restores the Dark theme and locks
back), and known-normal states are allowlisted in the reviewer prompts so they
aren't re-flagged.
> Immediate-mode caveat: egui lags one frame on click, so a nav highlight may be
> missing in the exact frame you clicked. Verify highlights on the *next*
> capture, not the click frame — several "bugs" have been this artifact.
---
## 5. Test wallets
Live testing uses real GRIN on mainnet, so there are dedicated wallets:
- A **local dev wallet** in `~/.goblin` for GUI drives (used by the sweep).
- **Two funded mainnet wallets** imported for end-to-end send/receive — referred
to by role: **Wallet A** and **Wallet B (`@fartmuncher22`)**. Wallet B holds a
small amount of grin for full live payment tests.
**Their seed phrases and passwords are deliberately kept out of the repo** — see
the local handoff notes. Never paste them into code, logs, commits, or galleries.
---
## 6. Identity / relay / node infra
The live backend runs on **`us-ea.st`** (`ssh us-east`):
- **Relay** — `wss://nrelay.us-ea.st` (the Goblin relay, docker). The relay we
control; always keep it in the relay set for deliverability.
- **NIP-05 server** — `goblin-nip05d` (axum + sqlite) at `/opt/goblin/nip05d`,
serving `https://goblin.st` (registration, avatars, `.well-known/nostr.json`).
- **DNS** — PowerDNS; **nginx** fronts goblin.st and hosts the `/review` gallery.
- **GRIN node** — mainnet node at `127.0.0.1:3413`.
Payments are NIP-17 gift wraps (kind 1059). `DEFAULT_RELAYS` (`src/nostr/relays.rs`)
is `nrelay.us-ea.st` + `relay.damus.io` + `nos.lol`; the recipient's kind-10050
DM-relay list plus the sender's own relays decide where a payment is delivered,
so **both parties must share at least one reachable relay**. The relay set is
user-editable in **Goblin → Settings → Relays** (`gui/views/goblin/mod.rs`,
`relays_ui`); Save & reconnect rewrites kind-10050 and restarts the service.
---
## 7. Tests
```sh
cd goblin
cargo test --lib # fast unit tests, offline
# Live network e2e (opt-in, hit real relays/server) — tests/nostr_e2e.rs:
cargo test --test nostr_e2e nip17_slatepack_roundtrip -- --ignored --nocapture # our relay
cargo test --test nostr_e2e nip17_roundtrip -- --ignored --nocapture # + damus.io + nos.lol
cargo test --test nostr_e2e nip05_registration -- --ignored --nocapture # goblin.st register/resolve
cargo test --test nostr_e2e avatar -- --ignored --nocapture # avatar upload/serve/limit
cargo test --test replay_check -- --ignored --nocapture # server replay/squat guards
```
The `nip17_roundtrip_*` tests prove a gift-wrapped Goblin payment actually
transits each relay: Bob advertises a kind-10050 DM relay and subscribes; Alice
sends; Bob unwraps, checks the seal author, and extracts the slatepack. The
`--ignored` roundtrips can transiently time out on public-relay latency — re-run
before treating a timeout as a regression.
---
## 8. Review galleries (goblin.st/review)
Screenshot galleries from a sweep are published for the owner by copying the
collage/shots to `us-ea.st` under the nginx `/review/` location (note the
trailing slash — bare `/review` is 301'd to `/review/`). Galleries are put up
time-boxed (e.g. a 1- or 24-hour expiry) and taken down after.
---
## 9. Gotchas worth keeping
- **`View::title_button_big` paints dark-on-yellow ink** — invisible on a dark
surface. When you need an icon button on a dark background (e.g. the wallet-list
gear), draw it manually with `ui.painter().text(... Colors::text(false))`.
- **`xdotool` onboarding drives desync** when the layout shifts mid-flow (the
password-match step re-flows). Fresh-launch clean fields are reliable; the
word-confirm steps drift — don't trust a blind coordinate script across a
re-flow, re-screenshot and re-find.
- **Camera/QR:** open the device by `devices[pos].index()`, not the nostr
list-position — they differ and the wrong one opens the wrong `/dev/video*`.
- **Tor needs Go at build time.** `build.rs` builds the webtunnel bridge via
`scripts/webtunnel.sh`, which silently no-ops if Go is missing, embedding a
0-byte bridge → Tor never bootstraps. If relay/NIP-05 stay "connecting…":
`pacman -S go`, force a `build.rs` rerun (touch `build.rs`), rebuild.
- **`git` "dubious ownership"** after a prior root session:
`git config --global --add safe.directory "$PWD"`; root-owned `target/`
artifacts → `sudo chown -R butler:butler goblin/target` or `cargo clean`.
+6 -1
View File
@@ -102,8 +102,13 @@ impl ContentContainer for Content {
// the panel opens only when explicitly toggled, never forced open by
// dual-panel mode (otherwise GRIM's node column dominates the list).
let list_screen = self.wallets.wallet_list_screen();
// The app-settings (cog) screen owns the node now: it lives in the
// cog's own section, and the full panel opens only when explicitly
// requested from there — never auto-docked beside settings, which
// would expose the node twice on a wide screen.
let app_settings = self.wallets.showing_settings();
let show_network = !wallet_open
&& if list_screen {
&& if list_screen || app_settings {
Self::is_network_panel_open()
} else {
is_panel_open
+49 -2
View File
@@ -14,11 +14,13 @@
use crate::AppConfig;
use crate::gui::Colors;
use crate::gui::icons::GLOBE_SIMPLE;
use crate::gui::icons::{DATABASE, FADERS, GLOBE_SIMPLE, POWER};
use crate::gui::platform::PlatformCallbacks;
use crate::gui::views::View;
use crate::gui::views::network::NetworkContent;
use crate::gui::views::settings::{InterfaceSettingsContent, NetworkSettingsContent};
use crate::gui::views::types::ContentContainer;
use crate::gui::views::{Content, View};
use crate::node::Node;
/// Application settings content.
pub struct SettingsContent {
@@ -64,6 +66,51 @@ impl SettingsContent {
self.network_settings.ui(ui, cb);
ui.add_space(8.0);
// Integrated node — relocated here from the wallet-list chip so the
// list stays uncluttered. Quick status + enable/autorun, plus a button
// into the full node panel (stats, mining, tuning, recovery).
View::horizontal_line(ui, Colors::stroke());
ui.add_space(6.0);
View::sub_title(ui, format!("{} {}", DATABASE, t!("network.node")));
View::horizontal_line(ui, Colors::stroke());
ui.add_space(8.0);
let running = Node::is_running();
let (status_color, status_text) = if !running {
(Colors::gray(), "Disabled")
} else if Node::not_syncing() {
(Colors::pos(), "Running · synced")
} else {
(Colors::gold(), "Running · syncing…")
};
ui.vertical_centered(|ui| {
ui.label(
egui::RichText::new(status_text)
.size(15.0)
.color(status_color),
);
});
ui.add_space(8.0);
if !running {
View::action_button(
ui,
format!("{} {}", POWER, t!("network.enable_node")),
|| {
Node::start();
},
);
ui.add_space(4.0);
}
NetworkContent::autorun_node_ui(ui);
ui.add_space(8.0);
View::action_button(ui, format!("{} {}", FADERS, t!("network.settings")), || {
if !Content::is_network_panel_open() {
Content::toggle_network_panel();
}
});
ui.add_space(8.0);
// Do not show Tor settings on Android.
// let os = OperatingSystem::from_target_os();
// let show_tor = os != OperatingSystem::Android;
+3 -40
View File
@@ -574,49 +574,12 @@ impl WalletsContent {
}
}
/// Slim header for the wallet-list surface: a tappable integrated-node
/// status chip (opens the full node panel — every feature intact, just
/// opt-in) on the left, and the app-settings gear on the right.
/// Slim header for the wallet-list surface: just the app-settings gear,
/// right-aligned. The integrated node moved into the gear's settings — out
/// of the list, every node feature still intact.
fn wallet_list_header_ui(&mut self, ui: &mut egui::Ui) {
use crate::node::Node;
ui.add_space(6.0);
ui.horizontal(|ui| {
// Node status chip.
let running = Node::is_running();
let synced = running && Node::not_syncing();
let (dot, label) = if !running {
(Colors::gray(), "Node")
} else if synced {
(Colors::pos(), "Node synced")
} else {
(Colors::gold(), "Syncing…")
};
let galley = ui.painter().layout_no_wrap(
label.to_string(),
eframe::epaint::FontId::proportional(14.0),
Colors::text(false),
);
let pad = egui::Vec2::new(12.0, 7.0);
let chip_w = 10.0 + 8.0 + galley.size().x + pad.x * 2.0;
let (rect, resp) =
ui.allocate_exact_size(egui::Vec2::new(chip_w, 34.0), Sense::click());
ui.painter()
.rect_filled(rect, CornerRadius::same(17), Colors::fill_lite());
let dot_c = egui::pos2(rect.min.x + pad.x + 5.0, rect.center().y);
ui.painter().circle_filled(dot_c, 4.0, dot);
ui.painter().galley(
egui::pos2(dot_c.x + 13.0, rect.center().y - galley.size().y / 2.0),
galley,
Colors::text(false),
);
if resp
.on_hover_cursor(CursorIcon::PointingHand)
.on_hover_text("Node settings")
.clicked()
{
Content::toggle_network_panel();
}
// App-settings gear, right-aligned. Drawn manually (the title-bar
// button uses dark-on-yellow ink, invisible on this dark surface).
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
+29 -5
View File
@@ -21,6 +21,30 @@ const SLATEPACK: &str = "BEGINSLATEPACK. 4H1qx1wHe668tFW yC2gfL8PPd8kSgv \
#[tokio::test]
#[ignore]
async fn nip17_slatepack_roundtrip() {
nip17_roundtrip_over(RELAY).await;
}
/// Same NIP-17 payment roundtrip over relay.damus.io — proves Goblin gift
/// wraps transit a top public relay, not only the relay we run.
/// Run: cargo test --test nostr_e2e nip17_roundtrip_damus -- --ignored --nocapture
#[tokio::test]
#[ignore]
async fn nip17_roundtrip_damus() {
nip17_roundtrip_over("wss://relay.damus.io").await;
}
/// And over nos.lol, the other large public relay in DEFAULT_RELAYS.
/// Run: cargo test --test nostr_e2e nip17_roundtrip_nos_lol -- --ignored --nocapture
#[tokio::test]
#[ignore]
async fn nip17_roundtrip_nos_lol() {
nip17_roundtrip_over("wss://nos.lol").await;
}
/// The shared roundtrip, parameterized by relay: Bob advertises a kind-10050
/// DM relay and subscribes to gift wraps; Alice sends a NIP-17 payment DM; Bob
/// unwraps it, verifies the seal author, and extracts the slatepack + subject.
async fn nip17_roundtrip_over(relay: &str) {
let alice = Keys::generate();
let bob = Keys::generate();
println!("alice: {}", alice.public_key().to_bech32().unwrap());
@@ -28,13 +52,13 @@ async fn nip17_slatepack_roundtrip() {
// Bob's client: connect, advertise DM relays, subscribe to gift wraps.
let bob_client = Client::new(bob.clone());
bob_client.add_relay(RELAY).await.unwrap();
bob_client.add_relay(relay).await.unwrap();
bob_client.connect().await;
tokio::time::sleep(Duration::from_secs(2)).await;
// Publish Bob's kind-10050 DM relay list so senders find this relay.
let dm_relays = EventBuilder::new(Kind::InboxRelays, "")
.tag(Tag::custom(TagKind::custom("relay"), [RELAY.to_string()]));
.tag(Tag::custom(TagKind::custom("relay"), [relay.to_string()]));
bob_client.send_event_builder(dm_relays).await.unwrap();
let filter = Filter::new()
@@ -45,14 +69,14 @@ async fn nip17_slatepack_roundtrip() {
// Alice's client: connect and send a NIP-17 payment DM to Bob.
let alice_client = Client::new(alice.clone());
alice_client.add_relay(RELAY).await.unwrap();
alice_client.add_relay(relay).await.unwrap();
alice_client.connect().await;
tokio::time::sleep(Duration::from_secs(2)).await;
let content = protocol::build_payment_content(SLATEPACK);
let tags = protocol::build_rumor_tags(Some("lunch :)"));
alice_client
.send_private_msg_to([RELAY], bob.public_key(), content, tags)
.send_private_msg_to([relay], bob.public_key(), content, tags)
.await
.unwrap();
println!("alice sent gift-wrapped payment DM");
@@ -91,7 +115,7 @@ async fn nip17_slatepack_roundtrip() {
let subject = protocol::extract_subject(&result.rumor.tags);
assert_eq!(subject.as_deref(), Some("lunch :)"));
println!("✓ NIP-17 slatepack roundtrip verified over {RELAY}");
println!("✓ NIP-17 slatepack roundtrip verified over {relay}");
bob_client.disconnect().await;
alice_client.disconnect().await;
}