1
0
forked from GRIN/grim

Fix functional bugs found in UI audit

Send flow:
- "Sent" success now gates on the real dispatch result (NostrService send
  phase Working/Sent/Failed) instead of send_creating, which cleared before
  the DM left. Added a Failed stage with Try again / Close so a failed send
  is no longer shown as success.
- NIP-05 handle resolution moved off the UI thread (was .join()-blocking the
  render thread for the Tor timeout); now a worker thread writes into a polled
  slot with an inline "Looking up…" spinner.
- Desktop amount entry no longer steals keystrokes from the Note field (guards
  on the note TextEdit focus).
- Review screen now shows a network-fee line.

Requests / identity / archive:
- Approve is now double-tap safe (per-session approving set + disabled button),
  closing a double-pay window.
- "Backup nostr key" copied the public npub; split into "Copy npub (public)"
  and "Back up secret key (nsec)" which copies the real secret.
- Added inline username claim (availability check + NIP-98 register over Tor,
  off-thread) for anonymous users; persists nip05 + republishes identity.
- Added archive Export / Wipe history controls.
- Receive "Slatepack" button now copies the grin1 address; "Scan" tab relabeled
  "Receive" (no scanner was wired).
- Goblin view resets on wallet switch so a half-filled send can't leak across.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Claude
2026-06-10 02:26:54 -04:00
parent ce1c071f3c
commit aa9847bb41
5 changed files with 434 additions and 41 deletions
+291 -20
View File
@@ -21,7 +21,7 @@ pub mod widgets;
use eframe::epaint::{FontId, Stroke};
use egui::{Align, Layout, Margin, RichText, ScrollArea, Sense, Vec2};
use crate::gui::icons::{CLOCK, COPY, SCAN, USER_CIRCLE, WALLET};
use crate::gui::icons::{ARROW_DOWN, CLOCK, COPY, USER_CIRCLE, WALLET};
use crate::gui::platform::PlatformCallbacks;
use crate::gui::theme::{self, fonts};
use crate::gui::views::{Content, View};
@@ -45,6 +45,12 @@ pub enum Tab {
pub struct GoblinWalletView {
tab: Tab,
send: Option<SendFlow>,
/// Request ids already approved this session (double-tap guard).
approving: std::collections::HashSet<String>,
/// Identifier of the wallet this view is bound to (reset on change).
wallet_id: Option<String>,
/// Inline username-claim state for the Me tab.
claim: Option<ClaimState>,
}
impl Default for GoblinWalletView {
@@ -52,6 +58,36 @@ impl Default for GoblinWalletView {
Self {
tab: Tab::Home,
send: None,
approving: std::collections::HashSet::new(),
wallet_id: None,
claim: None,
}
}
}
/// Inline username-claim widget state.
struct ClaimState {
input: String,
checking: bool,
available: Option<bool>,
result: std::sync::Arc<std::sync::Mutex<Option<ClaimMsg>>>,
message: Option<String>,
}
enum ClaimMsg {
Availability(bool),
Registered(String),
Error(String),
}
impl Default for ClaimState {
fn default() -> Self {
Self {
input: String::new(),
checking: false,
available: None,
result: std::sync::Arc::new(std::sync::Mutex::new(None)),
message: None,
}
}
}
@@ -79,6 +115,17 @@ impl GoblinWalletView {
pub fn ui(&mut self, ui: &mut egui::Ui, wallet: &Wallet, cb: &dyn PlatformCallbacks) {
let t = theme::tokens();
// Reset transient UI state when the bound wallet changes, so a
// half-filled send or claim never leaks across a wallet switch.
let id = wallet.identifier();
if self.wallet_id.as_deref() != Some(id.as_str()) {
self.wallet_id = Some(id);
self.tab = Tab::Home;
self.send = None;
self.claim = None;
self.approving.clear();
}
// Send flow takes the full surface when active.
if let Some(send) = &mut self.send {
let done = send.ui(ui, wallet, cb);
@@ -137,7 +184,7 @@ impl GoblinWalletView {
let tabs = [
(Tab::Home, WALLET, "Wallet", false),
(Tab::Activity, CLOCK, "Activity", has_requests),
(Tab::Receive, SCAN, "Scan", false),
(Tab::Receive, ARROW_DOWN, "Receive", false),
(Tab::Me, USER_CIRCLE, "Me", false),
];
ui.horizontal(|ui| {
@@ -165,12 +212,7 @@ impl GoblinWalletView {
.circle_filled(rect.center() + Vec2::new(12.0, -10.0), 4.0, t.neg);
}
if resp.clicked() {
if tab == Tab::Receive {
// "Scan" routes to receive screen for now.
self.tab = Tab::Receive;
} else {
self.tab = tab;
}
self.tab = tab;
}
}
});
@@ -379,11 +421,17 @@ impl GoblinWalletView {
Vec2::new(half, 44.0),
)),
|ui| {
if approve_button(ui) {
wallet.task(crate::wallet::types::WalletTask::NostrPayRequest(
req.rumor_id.clone(),
));
}
let already = self.approving.contains(&req.rumor_id);
ui.add_enabled_ui(!already, |ui| {
if approve_button(ui) {
// Guard against double-tap: only enqueue the
// payment once per request id this session.
self.approving.insert(req.rumor_id.clone());
wallet.task(crate::wallet::types::WalletTask::NostrPayRequest(
req.rumor_id.clone(),
));
}
});
},
);
});
@@ -459,9 +507,11 @@ impl GoblinWalletView {
Vec2::new(half, 56.0),
)),
|ui| {
if w::big_action(ui, "Slatepack", false).clicked() {
// Fall back to manual slatepack receive via legacy flow.
self.tab = Tab::Activity;
// Copy the grin1 slatepack address for manual/Tor exchange.
if w::big_action(ui, "Address", false).clicked() {
if let Some(addr) = wallet.slatepack_address() {
cb.copy_string_to_buffer(addr);
}
}
},
);
@@ -534,15 +584,62 @@ impl GoblinWalletView {
settings_row(ui, "Auto-accept", accept_policy_label(wallet));
});
ui.add_space(16.0);
settings_group(ui, "Wallet", |ui| {
settings_row(ui, "Display unit", "ツ (grin)");
settings_row(ui, "Relays", &relay_summary(wallet));
// Identity: claim a username, copy npub, back up the secret.
let is_anon = wallet
.nostr_service()
.map(|s| s.identity.read().anonymous)
.unwrap_or(true);
w::kicker(ui, "Identity");
ui.add_space(8.0);
w::card(ui, |ui| {
if is_anon {
if settings_row_btn(ui, "Claim a username", USER_CIRCLE) {
self.claim = Some(ClaimState::default());
}
} else {
settings_row(ui, "Username", &handle);
}
if !npub.is_empty() {
if settings_row_btn(ui, "Backup nostr key", COPY) {
if settings_row_btn(ui, "Copy npub (public)", COPY) {
cb.copy_string_to_buffer(npub.clone());
}
// A real backup is the SECRET key (nsec), not the npub.
if settings_row_btn(ui, "Back up secret key (nsec)", COPY) {
if let Some(nsec) = wallet.nostr_service().and_then(|s| s.nsec()) {
cb.copy_string_to_buffer(nsec);
}
}
}
});
if self.claim.is_some() {
ui.add_space(8.0);
self.claim_ui(ui, wallet);
}
ui.add_space(16.0);
settings_group(ui, "Wallet", |ui| {
settings_row(ui, "Display unit", "ツ (grin)");
settings_row(ui, "Relays", &relay_summary(wallet));
});
ui.add_space(16.0);
w::kicker(ui, "Archive");
ui.add_space(8.0);
w::card(ui, |ui| {
if settings_row_btn(ui, "Export archive", COPY) {
if let Some(s) = wallet.nostr_service() {
let json = s.store.export_json(&s.npub());
cb.copy_string_to_buffer(json);
}
}
if settings_row_btn(ui, "Wipe payment history", crate::gui::icons::X) {
if let Some(s) = wallet.nostr_service() {
s.store.wipe_archive();
}
}
});
ui.add_space(16.0);
settings_group(ui, "About", |ui| {
settings_row(ui, "Network", "Mimblewimble · no address on chain");
@@ -551,6 +648,180 @@ impl GoblinWalletView {
ui.add_space(16.0);
});
}
/// Inline username-claim widget (availability check + register over Tor).
fn claim_ui(&mut self, ui: &mut egui::Ui, wallet: &Wallet) {
let t = theme::tokens();
let claim = self.claim.as_mut().unwrap();
// Poll the worker result.
if let Some(msg) = claim.result.lock().unwrap().take() {
claim.checking = false;
match msg {
ClaimMsg::Availability(ok) => {
claim.available = Some(ok);
claim.message = Some(if ok {
"Available!".into()
} else {
"Taken".into()
});
}
ClaimMsg::Registered(nip05) => {
claim.message = Some(format!("Registered {}", nip05));
// Persist nip05 on the identity and republish.
if let Some(s) = wallet.nostr_service() {
{
let mut id = s.identity.write();
id.nip05 = Some(nip05.clone());
id.anonymous = false;
}
s.save_identity();
}
}
ClaimMsg::Error(e) => claim.message = Some(e),
}
}
w::card(ui, |ui| {
ui.label(
RichText::new("Pick a username")
.font(FontId::new(15.0, fonts::semibold()))
.color(t.text),
);
ui.add_space(8.0);
ui.horizontal(|ui| {
ui.label(RichText::new("@").font(FontId::new(16.0, fonts::semibold())));
let edit = egui::TextEdit::singleline(&mut claim.input)
.hint_text("yourname")
.desired_width(ui.available_width() - 20.0)
.frame(false);
if ui.add(edit).changed() {
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.")
.font(FontId::new(12.0, fonts::regular()))
.color(t.text_mute),
);
if let Some(msg) = &claim.message {
ui.add_space(6.0);
ui.label(
RichText::new(msg)
.font(FontId::new(13.0, fonts::regular()))
.color(if claim.available == Some(false) {
t.neg
} else {
t.pos
}),
);
}
ui.add_space(10.0);
let name = claim.input.trim().to_lowercase();
let valid = name.len() >= 3 && name.len() <= 30;
if claim.checking {
ui.horizontal(|ui| {
View::small_loading_spinner(ui);
ui.add_space(8.0);
ui.label(RichText::new("Working…").color(t.text_dim));
});
ui.ctx().request_repaint();
} else {
ui.horizontal(|ui| {
let half = (ui.available_width() - 10.0) / 2.0;
ui.scope_builder(
egui::UiBuilder::new().max_rect(egui::Rect::from_min_size(
ui.cursor().min,
Vec2::new(half, 44.0),
)),
|ui| {
ui.add_enabled_ui(valid, |ui| {
if w::big_action(ui, "Check", true).clicked() {
start_claim_check(claim, &name, wallet);
}
});
},
);
ui.add_space(10.0);
ui.scope_builder(
egui::UiBuilder::new().max_rect(egui::Rect::from_min_size(
ui.cursor().min,
Vec2::new(half, 44.0),
)),
|ui| {
ui.add_enabled_ui(valid && claim.available == Some(true), |ui| {
if w::big_action(ui, "Claim", false).clicked() {
start_claim_register(claim, &name, wallet);
}
});
},
);
});
}
});
}
}
/// Spawn the availability check on a worker thread.
fn start_claim_check(claim: &mut ClaimState, name: &str, wallet: &Wallet) {
let server = wallet
.nostr_service()
.map(|s| s.config.read().nip05_server())
.unwrap_or_default();
claim.checking = true;
let slot = claim.result.clone();
let name = name.to_string();
std::thread::spawn(move || {
let rt = match tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
{
Ok(rt) => rt,
Err(_) => return,
};
let avail = rt.block_on(crate::nostr::nip05::check_availability(&server, &name));
let msg = ClaimMsg::Availability(avail == crate::nostr::nip05::Availability::Available);
*slot.lock().unwrap() = Some(msg);
});
}
/// Spawn the NIP-98 registration on a worker thread.
fn start_claim_register(claim: &mut ClaimState, name: &str, wallet: &Wallet) {
let Some(service) = wallet.nostr_service() else {
return;
};
let server = service.config.read().nip05_server();
let keys = match service
.nsec()
.and_then(|nsec| nostr_sdk::Keys::parse(&nsec).ok())
{
Some(k) => k,
None => return,
};
claim.checking = true;
let slot = claim.result.clone();
let name = name.to_string();
std::thread::spawn(move || {
let rt = match tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
{
Ok(rt) => rt,
Err(_) => return,
};
let res = rt.block_on(crate::nostr::nip05::register(&server, &name, &keys));
let msg = match res {
crate::nostr::nip05::RegisterResult::Ok(nip05) => ClaimMsg::Registered(nip05),
crate::nostr::nip05::RegisterResult::Conflict(_) => {
ClaimMsg::Error("That username was just taken".into())
}
crate::nostr::nip05::RegisterResult::Rejected(e) => ClaimMsg::Error(e),
crate::nostr::nip05::RegisterResult::Network => {
ClaimMsg::Error("Network error — try again".into())
}
};
*slot.lock().unwrap() = Some(msg);
});
}
/// Draw the small Goblin mascot mark.
+108 -19
View File
@@ -37,6 +37,7 @@ enum Stage {
Review,
Sending,
Success,
Failed,
}
/// A resolved recipient.
@@ -47,6 +48,9 @@ struct Recipient {
hue: usize,
}
/// Outcome of an async NIP-05 resolution: (pubkey hex, optional nip05 id).
type ResolveOutcome = Result<(String, Option<String>), String>;
/// The send flow state.
pub struct SendFlow {
stage: Stage,
@@ -56,6 +60,8 @@ pub struct SendFlow {
note: String,
hold: HoldToSend,
error: Option<String>,
/// Async NIP-05 resolution result, written by a worker thread.
resolve_slot: std::sync::Arc<std::sync::Mutex<Option<ResolveOutcome>>>,
/// Resolution in flight (bech32/nip05).
resolving: bool,
}
@@ -70,6 +76,7 @@ impl Default for SendFlow {
note: String::new(),
hold: HoldToSend::default(),
error: None,
resolve_slot: std::sync::Arc::new(std::sync::Mutex::new(None)),
resolving: false,
}
}
@@ -112,6 +119,7 @@ impl SendFlow {
Stage::Review => done = self.review_ui(ui, wallet),
Stage::Sending => self.sending_ui(ui, wallet),
Stage::Success => done = self.success_ui(ui),
Stage::Failed => done = self.failed_ui(ui, wallet),
},
);
});
@@ -177,9 +185,23 @@ impl SendFlow {
View::horizontal_line(ui, t.line);
ui.add_space(12.0);
// Poll the async resolution result (set by a worker thread).
self.poll_resolution(wallet);
// Resolve button when input looks like a handle/npub.
if !self.search.trim().is_empty() {
if w::big_action(ui, "Find recipient", false).clicked() {
if self.resolving {
ui.horizontal(|ui| {
View::small_loading_spinner(ui);
ui.add_space(8.0);
ui.label(
RichText::new("Looking up handle…")
.font(FontId::new(14.0, fonts::regular()))
.color(t.text_dim),
);
});
ui.ctx().request_repaint();
} else if w::big_action(ui, "Find recipient", false).clicked() {
self.resolve_search(wallet);
}
if let Some(err) = &self.error {
@@ -227,32 +249,48 @@ impl SendFlow {
fn resolve_search(&mut self, wallet: &Wallet) {
let input = self.search.trim().to_string();
self.resolving = true;
// Try bech32 npub directly.
self.error = None;
// Instant cases (no network): bech32 npub or raw hex pubkey.
use nostr_sdk::{FromBech32, PublicKey};
if let Ok(pk) = PublicKey::from_bech32(&input) {
self.set_recipient_from_hex(wallet, &pk.to_hex(), None);
self.resolving = false;
return;
}
if input.len() == 64 && input.chars().all(|c| c.is_ascii_hexdigit()) {
self.set_recipient_from_hex(wallet, &input.to_lowercase(), None);
self.resolving = false;
return;
}
// Try NIP-05 resolution (user@domain or @user) over Tor.
// NIP-05 resolution over Tor: run on a worker thread, never block the
// UI. The result is written to `resolve_slot` and polled each frame.
if let Some((name, domain)) = nip05::split_identifier(&input) {
match resolve_nip05_blocking(&name, &domain) {
Some(res) => {
let nip05_id = format!("{}@{}", name, domain);
self.set_recipient_from_hex(wallet, &res.pubkey.to_hex(), Some(nip05_id));
}
None => self.error = Some("Couldn't find that handle".to_string()),
}
self.resolving = true;
*self.resolve_slot.lock().unwrap() = None;
let slot = self.resolve_slot.clone();
std::thread::spawn(move || {
let outcome: ResolveOutcome = match resolve_nip05_blocking(&name, &domain) {
Some(res) => Ok((res.pubkey.to_hex(), Some(format!("{}@{}", name, domain)))),
None => Err("Couldn't find that handle".to_string()),
};
*slot.lock().unwrap() = Some(outcome);
});
} else {
self.error = Some("Enter an @handle, npub, or name".to_string());
}
self.resolving = false;
}
/// Apply an async NIP-05 resolution result once the worker finishes.
fn poll_resolution(&mut self, wallet: &Wallet) {
if !self.resolving {
return;
}
let outcome = self.resolve_slot.lock().unwrap().take();
if let Some(outcome) = outcome {
self.resolving = false;
match outcome {
Ok((hex, nip05)) => self.set_recipient_from_hex(wallet, &hex, nip05),
Err(e) => self.error = Some(e),
}
}
}
fn set_recipient_from_hex(&mut self, wallet: &Wallet, hex: &str, nip05: Option<String>) {
@@ -345,6 +383,7 @@ impl SendFlow {
ui.add_space(16.0);
// Note field.
let mut note_focused = false;
w::card(ui, |ui| {
ui.horizontal(|ui| {
ui.label(
@@ -357,7 +396,7 @@ impl SendFlow {
.hint_text("Add a note…")
.desired_width(ui.available_width())
.frame(false);
ui.add(edit);
note_focused = ui.add(edit).has_focus();
});
});
ui.add_space(12.0);
@@ -365,7 +404,9 @@ impl SendFlow {
// Numpad (mobile) — desktop also accepts keyboard via egui events.
if !View::is_desktop() {
w::numpad(ui, &mut self.amount);
} else {
} else if !note_focused {
// Only consume keystrokes for the amount when the note field is
// not focused, so typing a note doesn't also edit the amount.
ui.input(|i| {
for ev in &i.events {
if let egui::Event::Text(txt) = ev {
@@ -431,6 +472,7 @@ impl SendFlow {
if !self.note.trim().is_empty() {
w::info_row(ui, "Note", &format!("\u{201C}{}\u{201D}", self.note.trim()));
}
w::info_row(ui, "Network fee", "deducted from your balance");
w::info_row(ui, "Privacy", "Mimblewimble");
w::info_row(ui, "Delivery", "Encrypted nostr DM over Tor");
ui.add_space(16.0);
@@ -459,6 +501,11 @@ impl SendFlow {
} else {
Some(self.note.trim().to_string())
};
// Reset the send phase to Working so sending_ui waits for the real
// dispatch result rather than flipping to Success prematurely.
if let Some(service) = wallet.nostr_service() {
service.set_send_phase(crate::nostr::send_phase::WORKING);
}
wallet.task(WalletTask::NostrSend(amount, recipient.npub.clone(), note));
}
}
@@ -475,13 +522,55 @@ impl SendFlow {
.color(t.text),
);
});
// Move to success once the wallet is no longer creating the send.
if !wallet.send_creating() {
self.stage = Stage::Success;
// Advance only on the real dispatch result: the payment DM must have
// actually been sent (or failed) over the relay, not merely created.
let phase = wallet
.nostr_service()
.map(|s| s.send_phase())
.unwrap_or(crate::nostr::send_phase::FAILED);
match phase {
crate::nostr::send_phase::SENT => self.stage = Stage::Success,
crate::nostr::send_phase::FAILED => self.stage = Stage::Failed,
_ => {}
}
ui.ctx().request_repaint();
}
fn failed_ui(&mut self, ui: &mut egui::Ui, wallet: &Wallet) -> bool {
let t = theme::tokens();
ui.add_space(80.0);
let mut done = false;
ui.vertical_centered(|ui| {
ui.label(
RichText::new(crate::gui::icons::WARNING_CIRCLE)
.font(FontId::new(56.0, fonts::regular()))
.color(t.neg),
);
ui.add_space(16.0);
ui.label(
RichText::new("Couldn't send")
.font(FontId::new(22.0, fonts::bold()))
.color(t.text),
);
ui.add_space(6.0);
ui.label(
RichText::new("The payment wasn't delivered. Your grin is safe — try again.")
.font(FontId::new(14.0, fonts::regular()))
.color(t.text_dim),
);
});
ui.add_space(24.0);
if w::big_action(ui, "Try again", false).clicked() {
self.dispatch(wallet);
self.stage = Stage::Sending;
}
ui.add_space(10.0);
if w::big_action(ui, "Close", true).clicked() {
done = true;
}
done
}
fn success_ui(&mut self, ui: &mut egui::Ui) -> bool {
let t = theme::tokens();
let recipient = self.recipient.clone().unwrap();
+27 -1
View File
@@ -18,7 +18,7 @@
use log::{error, info, warn};
use nostr_sdk::{
Client, Event, EventBuilder, Filter, Keys, Kind, Metadata, PublicKey, RelayPoolNotification,
RelayStatus, Tag, TagKind, Timestamp,
RelayStatus, Tag, TagKind, Timestamp, ToBech32,
};
use parking_lot::{Mutex, RwLock};
use std::collections::HashMap;
@@ -75,6 +75,16 @@ pub struct NostrService {
pub has_new_requests: AtomicBool,
/// Per-sender rate limiting state (unix seconds of accepted events).
rate: Mutex<HashMap<String, Vec<i64>>>,
/// Current outgoing-send phase for the UI (see [`SendPhase`]).
send_phase: std::sync::atomic::AtomicU8,
}
/// Phase of the most recent outgoing send, polled by the send UI.
pub mod send_phase {
pub const IDLE: u8 = 0;
pub const WORKING: u8 = 1;
pub const SENT: u8 = 2;
pub const FAILED: u8 = 3;
}
impl NostrService {
@@ -98,6 +108,7 @@ impl NostrService {
connected: AtomicBool::new(false),
has_new_requests: AtomicBool::new(false),
rate: Mutex::new(HashMap::new()),
send_phase: std::sync::atomic::AtomicU8::new(send_phase::IDLE),
})
}
@@ -111,6 +122,21 @@ impl NostrService {
self.identity.read().npub.clone()
}
/// Own nsec (secret key) bech32 — for explicit user backup only.
pub fn nsec(&self) -> Option<String> {
self.keys.secret_key().to_bech32().ok()
}
/// Read the current outgoing-send phase (see [`send_phase`]).
pub fn send_phase(&self) -> u8 {
self.send_phase.load(Ordering::Relaxed)
}
/// Set the outgoing-send phase (called by the send task + UI).
pub fn set_send_phase(&self, phase: u8) {
self.send_phase.store(phase, Ordering::Relaxed);
}
/// Whether at least one relay is connected.
pub fn is_connected(&self) -> bool {
self.connected.load(Ordering::Relaxed)
+1 -1
View File
@@ -37,6 +37,6 @@ pub mod ingest;
pub use ingest::*;
mod client;
pub use client::NostrService;
pub use client::{NostrService, send_phase};
pub mod nip05;
+7
View File
@@ -2123,6 +2123,7 @@ async fn handle_task(w: &Wallet, t: WalletTask) {
return;
};
w.send_creating.store(true, Ordering::Relaxed);
service.set_send_phase(crate::nostr::send_phase::WORKING);
match w.send(*a, None) {
Ok(s) => {
sync_wallet_data(&w, false);
@@ -2161,6 +2162,7 @@ async fn handle_task(w: &Wallet, t: WalletTask) {
contact.unknown = false;
service.store.save_contact(&contact);
}
service.set_send_phase(crate::nostr::send_phase::SENT);
}
Err(e) => {
error!("nostr send dispatch failed: {e}");
@@ -2176,14 +2178,19 @@ async fn handle_task(w: &Wallet, t: WalletTask) {
))),
);
}
service.set_send_phase(crate::nostr::send_phase::FAILED);
}
}
} else {
// No slatepack text produced — treat as failure.
service.set_send_phase(crate::nostr::send_phase::FAILED);
}
w.on_task_result(tx, &t);
}
Err(e) => {
error!("nostr send error: {:?}", e);
w.send_creating.store(false, Ordering::Relaxed);
service.set_send_phase(crate::nostr::send_phase::FAILED);
}
}
}