Build 43: real payment requests + incoming-requests opt-out

Wire GRIM's receiver-initiated invoice (Invoice1) over nostr so the Pay-tab
"Request" button actually asks someone for money: pick a contact, issue an
Invoice1, DM it; they get the existing approve-to-pay card. New
WalletTask::NostrRequest mirrors NostrSend (issue_invoice + RequestedByUs /
AwaitingI2, reusing send_payment_dm); the send flow gains a request mode
(Request from -> Confirm request -> Send request -> Requested) with no balance
guard, since requesting isn't spending.

Incoming requests are now opt-out (Settings -> Requests, on by default): when
off, an incoming Invoice1 is dropped and the preference is advertised in the
kind-0 profile (goblin_accepts_requests) so a requester sees "Could not
request" before sending. Adds a Cash App-style toggle switch widget.

Also enlarge the center Pay puck in the floating nav, and make the macOS
release build recur: release.yml now triggers on release publish (macOS only,
since Linux/Windows/Android/AppImage are built locally).
This commit is contained in:
2ro
2026-06-13 05:08:51 -04:00
parent 2e8829ef83
commit f714e4498a
9 changed files with 383 additions and 41 deletions
+17 -7
View File
@@ -8,6 +8,12 @@
name: Release
on:
# Auto-build on every published release so macOS — which can't be built on the
# Linux release box — is produced for each build without manual steps. The
# Linux/Windows jobs are gated to manual dispatch only (those are built locally
# and uploaded with the release), so a publish event builds just macOS.
release:
types: [published]
workflow_dispatch:
inputs:
tag:
@@ -18,16 +24,18 @@ permissions:
contents: write
env:
TAG: ${{ inputs.tag }}
TAG: ${{ inputs.tag || github.event.release.tag_name }}
jobs:
linux:
name: Linux x86_64
runs-on: ubuntu-latest
# Built locally and uploaded with the release; only run on manual dispatch.
if: github.event_name == 'workflow_dispatch'
steps:
- uses: actions/checkout@v6
with:
ref: ${{ inputs.tag }}
ref: ${{ inputs.tag || github.event.release.tag_name }}
submodules: recursive
- name: Build
run: cargo build --release
@@ -37,7 +45,7 @@ jobs:
sha256sum "goblin-$TAG-linux-x86_64.tar.gz" > "goblin-$TAG-linux-x86_64-sha256sum.txt"
- uses: softprops/action-gh-release@v2
with:
tag_name: ${{ inputs.tag }}
tag_name: ${{ inputs.tag || github.event.release.tag_name }}
files: |
goblin-${{ inputs.tag }}-linux-x86_64.tar.gz
goblin-${{ inputs.tag }}-linux-x86_64-sha256sum.txt
@@ -45,10 +53,12 @@ jobs:
windows:
name: Windows x86_64 (MSVC)
runs-on: windows-latest
# Built locally and uploaded with the release; only run on manual dispatch.
if: github.event_name == 'workflow_dispatch'
steps:
- uses: actions/checkout@v6
with:
ref: ${{ inputs.tag }}
ref: ${{ inputs.tag || github.event.release.tag_name }}
submodules: recursive
- name: Build
run: cargo build --release
@@ -59,7 +69,7 @@ jobs:
sha256sum "goblin-$TAG-win-x86_64.zip" > "goblin-$TAG-win-x86_64-sha256sum.txt"
- uses: softprops/action-gh-release@v2
with:
tag_name: ${{ inputs.tag }}
tag_name: ${{ inputs.tag || github.event.release.tag_name }}
files: |
goblin-${{ inputs.tag }}-win-x86_64.zip
goblin-${{ inputs.tag }}-win-x86_64-sha256sum.txt
@@ -70,7 +80,7 @@ jobs:
steps:
- uses: actions/checkout@v6
with:
ref: ${{ inputs.tag }}
ref: ${{ inputs.tag || github.event.release.tag_name }}
submodules: recursive
- name: Build both architectures
run: |
@@ -86,7 +96,7 @@ jobs:
shasum -a 256 "goblin-$TAG-macos-universal.zip" > "goblin-$TAG-macos-universal-sha256sum.txt"
- uses: softprops/action-gh-release@v2
with:
tag_name: ${{ inputs.tag }}
tag_name: ${{ inputs.tag || github.event.release.tag_name }}
files: |
goblin-${{ inputs.tag }}-macos-universal.zip
goblin-${{ inputs.tag }}-macos-universal-sha256sum.txt
+53 -3
View File
@@ -409,7 +409,7 @@ impl GoblinWalletView {
egui::Align2::CENTER_CENTER,
w::TSU,
// Gamja Flower's ツ — the cute smiley shape — only here.
FontId::new(26.0, egui::FontFamily::Name("gamja-tsu".into())),
FontId::new(31.0, egui::FontFamily::Name("gamja-tsu".into())),
t.accent_ink,
);
}
@@ -922,9 +922,11 @@ impl GoblinWalletView {
)),
|ui| {
if w::big_action(ui, "Request", true).clicked() && valid {
self.request_amount = Some(self.pay_amount.clone());
// Open the request flow: pick a contact, then DM them a
// grin Invoice1 they can approve to pay.
let f = SendFlow::new_request(self.pay_amount.clone());
self.pay_amount.clear();
self.tab = Tab::Receive;
self.send = Some(f);
}
},
);
@@ -1531,6 +1533,26 @@ impl GoblinWalletView {
self.settings_page = SettingsPage::Pairing;
}
ui.add_space(16.0);
settings_group(ui, "Requests", |ui| {
let allow = wallet
.nostr_service()
.map(|s| s.config.read().allow_incoming_requests())
.unwrap_or(true);
if let Some(v) = settings_row_toggle(
ui,
"Incoming requests",
"Let others request money from you",
allow,
) {
if let Some(s) = wallet.nostr_service() {
s.config.write().set_allow_incoming_requests(v);
}
// Advertise the change so requesters see it before asking.
wallet.task(crate::wallet::types::WalletTask::NostrRepublishProfile);
}
});
ui.add_space(16.0);
w::kicker(ui, "Appearance");
ui.add_space(8.0);
@@ -2759,6 +2781,34 @@ fn settings_group(ui: &mut egui::Ui, title: &str, add: impl FnOnce(&mut egui::Ui
w::card(ui, |ui| add(ui));
}
/// A settings row: label + subtitle on the left, an on/off switch on the right.
/// Returns `Some(new_value)` on the frame it is toggled.
fn settings_row_toggle(ui: &mut egui::Ui, label: &str, sub: &str, on: bool) -> Option<bool> {
let t = theme::tokens();
let mut toggled = None;
ui.horizontal(|ui| {
ui.vertical(|ui| {
ui.label(
RichText::new(label)
.font(FontId::new(15.0, fonts::medium()))
.color(t.surface_text),
);
ui.label(
RichText::new(sub)
.font(FontId::new(13.0, fonts::regular()))
.color(t.surface_text_dim),
);
});
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
if w::toggle(ui, on).clicked() {
toggled = Some(!on);
}
});
});
ui.add_space(10.0);
toggled
}
fn settings_row(ui: &mut egui::Ui, label: &str, value: &str) {
let t = theme::tokens();
ui.horizontal(|ui| {
+133 -30
View File
@@ -119,6 +119,9 @@ pub struct SendFlow {
scan: Option<CameraContent>,
/// Start scanning on the next recipient frame (entry from header icon).
start_scan: bool,
/// Request mode: issue an Invoice1 to the recipient (ask them to pay) rather
/// than sending them money. Reuses the recipient picker; no balance guard.
request: bool,
}
impl Default for SendFlow {
@@ -139,6 +142,7 @@ impl Default for SendFlow {
confirm_unverified: None,
scan: None,
start_scan: false,
request: false,
}
}
}
@@ -163,6 +167,17 @@ impl SendFlow {
self.stage = Stage::Recipient;
}
/// Open the flow as a money REQUEST for `amount`: pick a recipient, then
/// issue them a grin Invoice1 over nostr (they approve to pay). The amount is
/// fixed up front, so resolving a recipient jumps straight to confirmation.
pub fn new_request(amount: String) -> Self {
Self {
request: true,
amount,
..Self::default()
}
}
/// Open the recipient picker with the QR scanner active (header entry).
pub fn request_scan(&mut self) {
self.start_scan = true;
@@ -254,7 +269,14 @@ impl SendFlow {
// Scanner mode: the camera feed replaces the picker until cancelled.
if self.scan.is_some() {
if self.back_header(ui, "Scan to pay") {
if self.back_header(
ui,
if self.request {
"Scan to request"
} else {
"Scan to pay"
},
) {
cb.stop_camera();
self.scan = None;
return false;
@@ -263,7 +285,14 @@ impl SendFlow {
return false;
}
if self.back_header(ui, "Send to") {
if self.back_header(
ui,
if self.request {
"Request from"
} else {
"Send to"
},
) {
return true;
}
@@ -860,8 +889,21 @@ impl SendFlow {
avatars: &mut AvatarTextures,
) -> bool {
let t = theme::tokens();
if self.back_header(ui, "Review") {
self.stage = Stage::Amount;
if self.back_header(
ui,
if self.request {
"Confirm request"
} else {
"Review"
},
) {
// Requests fix the amount on the Pay tab, so back returns to the
// recipient picker rather than the send-style amount step.
self.stage = if self.request {
Stage::Recipient
} else {
Stage::Amount
};
return false;
}
let recipient = self.recipient.clone().unwrap();
@@ -876,14 +918,20 @@ impl SendFlow {
.get_data()
.map(|d| d.info.amount_currently_spendable)
.unwrap_or(0);
let over = amount_from_hr_string(&amount)
.map(|a| a > spendable)
.unwrap_or(false);
// Requests never spend our balance, so the guard does not apply to them.
let over = !self.request
&& amount_from_hr_string(&amount)
.map(|a| a > spendable)
.unwrap_or(false);
w::card(ui, |ui| {
ui.set_min_width(ui.available_width());
ui.add_space(8.0);
let label = format!("You're sending {}", recipient.name);
let label = if self.request {
format!("Requesting from {}", recipient.name)
} else {
format!("You're sending {}", recipient.name)
};
let galley = ui.painter().layout_no_wrap(
label.clone(),
FontId::new(14.0, fonts::regular()),
@@ -906,15 +954,41 @@ impl SendFlow {
});
ui.add_space(16.0);
w::info_row(ui, "To", &recipient.name);
w::info_row(
ui,
if self.request { "From" } else { "To" },
&recipient.name,
);
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");
if self.request {
w::info_row(ui, "They pay", "Only if they approve");
w::info_row(ui, "Delivery", "Encrypted nostr DM over Tor");
} else {
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);
// Requests are not a spend: one tap sends the ask, no hold-to-confirm.
if self.request {
if w::big_action(ui, "Send request", false).clicked() {
self.dispatch(wallet);
self.stage = Stage::Sending;
}
ui.add_space(6.0);
ui.vertical_centered(|ui| {
ui.label(
RichText::new("They'll get a request to approve")
.font(FontId::new(12.0, fonts::regular()))
.color(t.text_mute),
);
});
return false;
}
if over {
ui.vertical_centered(|ui| {
ui.label(
@@ -957,17 +1031,28 @@ 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.
// Clear any stale picker error so the failure screen shows the right
// message, and reset the send phase to Working so sending_ui waits for
// the real dispatch result rather than flipping to Success prematurely.
self.error = None;
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,
recipient.relay_hints.clone(),
));
if self.request {
wallet.task(WalletTask::NostrRequest(
amount,
recipient.npub.clone(),
note,
recipient.relay_hints.clone(),
));
} else {
wallet.task(WalletTask::NostrSend(
amount,
recipient.npub.clone(),
note,
recipient.relay_hints.clone(),
));
}
}
}
@@ -978,9 +1063,13 @@ impl SendFlow {
View::big_loading_spinner(ui);
ui.add_space(16.0);
ui.label(
RichText::new("Sending…")
.font(FontId::new(18.0, fonts::semibold()))
.color(t.text),
RichText::new(if self.request {
"Requesting…"
} else {
"Sending…"
})
.font(FontId::new(18.0, fonts::semibold()))
.color(t.text),
);
});
// Advance only on the real dispatch result: the payment DM must have
@@ -991,6 +1080,10 @@ impl SendFlow {
.unwrap_or(crate::nostr::send_phase::FAILED);
match phase {
crate::nostr::send_phase::SENT => self.stage = Stage::Success,
crate::nostr::send_phase::REQUEST_BLOCKED => {
self.error = Some("They're not accepting requests right now.".to_string());
self.stage = Stage::Failed;
}
crate::nostr::send_phase::FAILED => self.stage = Stage::Failed,
_ => {}
}
@@ -1009,15 +1102,25 @@ impl SendFlow {
);
ui.add_space(16.0);
ui.label(
RichText::new("Couldn't send")
.font(FontId::new(22.0, fonts::bold()))
.color(t.text),
RichText::new(if self.request {
"Couldn't request"
} else {
"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),
RichText::new(self.error.clone().unwrap_or_else(|| {
if self.request {
"The request wasn't delivered — try again.".to_string()
} else {
"The payment wasn't delivered. Your grin is safe — try again.".to_string()
}
}))
.font(FontId::new(14.0, fonts::regular()))
.color(t.text_dim),
);
});
ui.add_space(24.0);
@@ -1050,7 +1153,7 @@ impl SendFlow {
);
ui.add_space(24.0);
ui.label(
RichText::new("Sent")
RichText::new(if self.request { "Requested" } else { "Sent" })
.font(FontId::new(34.0, fonts::bold()))
.color(t.accent_ink),
);
+24
View File
@@ -143,6 +143,30 @@ pub fn kicker(ui: &mut Ui, text: &str) {
);
}
/// A Cash-App-style on/off switch. Yellow (brand accent) when on, neutral track
/// when off. Returns the response — the caller flips the bound state on click.
pub fn toggle(ui: &mut Ui, on: bool) -> Response {
let t = theme::tokens();
let (rect, resp) = ui.allocate_exact_size(Vec2::new(46.0, 28.0), Sense::click());
let track = if on { t.accent } else { t.surface2 };
ui.painter()
.rect_filled(rect, CornerRadius::same(14), track);
let knob_r = 11.0;
let knob_x = if on {
rect.right() - knob_r - 3.0
} else {
rect.left() + knob_r + 3.0
};
let knob = if on {
t.accent_ink
} else {
t.surface_text_mute
};
ui.painter()
.circle_filled(egui::pos2(knob_x, rect.center().y), knob_r, knob);
resp.on_hover_cursor(egui::CursorIcon::PointingHand)
}
/// Big primary/secondary action button (56px, radius 14).
pub fn big_action(ui: &mut Ui, label: &str, secondary: bool) -> Response {
let t = theme::tokens();
+38 -1
View File
@@ -92,6 +92,9 @@ pub mod send_phase {
pub const WORKING: u8 = 1;
pub const SENT: u8 = 2;
pub const FAILED: u8 = 3;
/// A request was refused up front because the recipient advertises that
/// they are not accepting incoming requests ("Could not request").
pub const REQUEST_BLOCKED: u8 = 4;
}
impl NostrService {
@@ -183,6 +186,33 @@ impl NostrService {
})
}
/// Best-effort read of a pubkey's published "accepts requests" preference.
/// `Some(false)` = explicitly not accepting; `Some(true)`/`None` (no profile,
/// field absent, or relays unreachable) = treat as accepting. Async — safe to
/// call from the service runtime. Fail-open: only `Some(false)` blocks.
pub async fn accepts_requests(&self, hex: &str) -> Option<bool> {
let client = self.client.read().clone()?;
let pk = PublicKey::from_hex(hex).ok()?;
let filter = Filter::new().kind(Kind::Metadata).author(pk).limit(1);
let events = client
.fetch_events(filter, Duration::from_secs(8))
.await
.ok()?;
let md: Metadata = serde_json::from_str(&events.first()?.content).ok()?;
md.custom
.get("goblin_accepts_requests")
.and_then(|v| v.as_bool())
}
/// Republish our kind-0 profile + kind-10050 DM relays (e.g. after toggling
/// the incoming-requests preference) so the change propagates immediately.
pub async fn republish_identity(self: &Arc<Self>) {
let client = { self.client.read().clone() };
if let Some(client) = client {
publish_identity(self, &client).await;
}
}
/// Read the current outgoing-send phase (see [`send_phase`]).
pub fn send_phase(&self) -> u8 {
self.send_phase.load(Ordering::Relaxed)
@@ -512,7 +542,12 @@ async fn publish_identity(svc: &Arc<NostrService>, client: &Client) {
if !anonymous {
if let Some(nip05) = nip05 {
let name = nip05.split('@').next().unwrap_or_default().to_string();
let metadata = Metadata::new().name(name).nip05(nip05);
// Advertise the request opt-out so requesters see it before sending.
let allow_requests = svc.config.read().allow_incoming_requests();
let metadata = Metadata::new()
.name(name)
.nip05(nip05)
.custom_field("goblin_accepts_requests", allow_requests);
let builder = EventBuilder::metadata(&metadata);
if let Err(e) = client.send_event_builder(builder).await {
warn!("nostr: publish kind 0 failed: {e}");
@@ -674,6 +709,7 @@ async fn handle_wrap(svc: &Arc<NostrService>, wallet: &Wallet, client: &Client,
let meta = svc.store.tx_meta(&slate.id.to_string());
let tx_exists = wallet.has_tx_for_slate(&slate.id);
let accept = svc.config.read().accept_from();
let allow_requests = svc.config.read().allow_incoming_requests();
let decision = decide(&IngestContext {
state: slate.state.clone(),
amount: slate.amount,
@@ -682,6 +718,7 @@ async fn handle_wrap(svc: &Arc<NostrService>, wallet: &Wallet, client: &Client,
tx_exists,
is_contact,
accept,
allow_requests,
});
info!(
"nostr: wrap {} slate {} state {} from {}…: {:?}",
+14
View File
@@ -44,6 +44,10 @@ pub struct NostrConfig {
nip05_server: Option<String>,
/// Days after which a pending outgoing payment is shown as expired.
request_expiry_days: Option<u64>,
/// Whether incoming payment requests (Invoice1) are accepted. Opt-out: on
/// by default. When off, incoming requests are dropped and the preference is
/// advertised in our kind-0 profile so requesters see it before sending.
allow_incoming_requests: Option<bool>,
/// Path of the config file, not serialized.
#[serde(skip)]
@@ -58,6 +62,7 @@ impl Default for NostrConfig {
accept_from: None,
nip05_server: None,
request_expiry_days: None,
allow_incoming_requests: None,
path: None,
}
}
@@ -122,4 +127,13 @@ impl NostrConfig {
pub fn request_expiry_days(&self) -> u64 {
self.request_expiry_days.unwrap_or(7)
}
pub fn allow_incoming_requests(&self) -> bool {
self.allow_incoming_requests.unwrap_or(true)
}
pub fn set_allow_incoming_requests(&mut self, allow: bool) {
self.allow_incoming_requests = Some(allow);
self.save();
}
}
+15
View File
@@ -56,6 +56,8 @@ pub struct IngestContext<'a> {
pub is_contact: bool,
/// Accept policy from wallet config.
pub accept: AcceptPolicy,
/// Whether incoming payment requests (Invoice1) are accepted (opt-out).
pub allow_requests: bool,
}
/// Pure policy function — unit tested, no side effects.
@@ -115,6 +117,11 @@ pub fn decide(ctx: &IngestContext) -> IngestDecision {
if ctx.tx_exists || ctx.meta.is_some() {
return IngestDecision::Drop("slate already known");
}
// Honour the opt-out: when incoming requests are off, drop them.
// (Requesters also see this advertised in our profile beforehand.)
if !ctx.allow_requests {
return IngestDecision::Drop("incoming requests disabled");
}
// NEVER pay automatically.
IngestDecision::SurfaceRequest
}
@@ -178,6 +185,7 @@ mod tests {
tx_exists,
is_contact: false,
accept: AcceptPolicy::Everyone,
allow_requests: true,
}
}
@@ -284,6 +292,13 @@ mod tests {
assert_eq!(decide(&c), IngestDecision::SurfaceRequest);
}
#[test]
fn i1_dropped_when_requests_disabled() {
let mut c = ctx(SlateState::Invoice1, 100, ALICE, None, false);
c.allow_requests = false;
assert!(matches!(decide(&c), IngestDecision::Drop(_)));
}
#[test]
fn i2_finalizes_only_matching_request() {
let m = meta(
+10
View File
@@ -444,4 +444,14 @@ pub enum WalletTask {
/// Pay an APPROVED incoming payment request (explicit user action).
/// * request id (rumor event id hex)
NostrPayRequest(String),
/// Request an amount FROM a nostr contact: issue a grin Invoice1 slate and
/// deliver it as a NIP-17 DM. The recipient sees an approve-to-pay card.
/// * amount
/// * receiver public key (hex)
/// * optional note (subject line)
/// * relay hints
NostrRequest(u64, String, Option<String>, Vec<String>),
/// Republish our kind-0 profile (e.g. after toggling the incoming-requests
/// preference) so the change propagates to relays immediately.
NostrRepublishProfile,
}
+79
View File
@@ -2332,6 +2332,85 @@ async fn handle_task(w: &Wallet, t: WalletTask) {
}
}
}
WalletTask::NostrRequest(a, receiver, note, relay_hints) => {
let Some(service) = w.nostr_service() else {
error!("nostr request: service not available");
return;
};
service.set_send_phase(crate::nostr::send_phase::WORKING);
// Respect the recipient's published opt-out (fail-open: only an
// explicit "not accepting" blocks; unknown/unreachable still sends).
if service.accepts_requests(receiver).await == Some(false) {
service.set_send_phase(crate::nostr::send_phase::REQUEST_BLOCKED);
return;
}
w.invoice_creating.store(true, Ordering::Relaxed);
// Issue a grin Invoice1 (receiver-built slate, amount baked in). This
// never spends — it only proposes a payment to the contact.
match w.issue_invoice(*a) {
Ok(s) => {
sync_wallet_data(&w, false);
let now = crate::nostr::unix_time();
// Record intent BEFORE dispatch so a crash is recovered by the
// service reconcile pass (RequestedByUs/AwaitingI2).
service.store.save_tx_meta(&crate::nostr::TxNostrMeta {
ver: 1,
slate_id: s.id.to_string(),
npub: receiver.clone(),
direction: crate::nostr::NostrTxDirection::RequestedByUs,
note: note.clone().and_then(|n| crate::nostr::sanitize_note(&n)),
status: crate::nostr::NostrSendStatus::Created,
sent_event_id: None,
received_rumor_id: None,
created_at: now,
updated_at: now,
});
let tx = w.retrieve_tx_by_id(None, Some(s.id));
w.invoice_creating.store(false, Ordering::Relaxed);
if let Some(text) = w.read_slatepack_text(s.id, &s.state) {
match service
.send_payment_dm(receiver, &text, note.as_deref(), relay_hints)
.await
{
Ok(event_id) => {
if let Some(mut meta) = service.store.tx_meta(&s.id.to_string()) {
meta.status = crate::nostr::NostrSendStatus::AwaitingI2;
meta.sent_event_id = Some(event_id);
meta.updated_at = crate::nostr::unix_time();
service.store.save_tx_meta(&meta);
}
if let Some(mut contact) = service.store.contact(receiver) {
contact.unknown = false;
service.store.save_contact(&contact);
}
service.set_send_phase(crate::nostr::send_phase::SENT);
}
Err(e) => {
error!("nostr request dispatch failed: {e}");
service.store.update_tx_status(
&s.id.to_string(),
crate::nostr::NostrSendStatus::SendFailed,
);
service.set_send_phase(crate::nostr::send_phase::FAILED);
}
}
} else {
service.set_send_phase(crate::nostr::send_phase::FAILED);
}
w.on_task_result(tx, &t);
}
Err(e) => {
error!("nostr request error: {:?}", e);
w.invoice_creating.store(false, Ordering::Relaxed);
service.set_send_phase(crate::nostr::send_phase::FAILED);
}
}
}
WalletTask::NostrRepublishProfile => {
if let Some(service) = w.nostr_service() {
service.republish_identity().await;
}
}
WalletTask::NostrResend(id) => {
let Some(service) = w.nostr_service() else {
return;