From f714e4498a2680a2e7da2903bd138a76c131d4c3 Mon Sep 17 00:00:00 2001 From: 2ro <17595647+2ro@users.noreply.github.com> Date: Sat, 13 Jun 2026 05:08:51 -0400 Subject: [PATCH] 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). --- .github/workflows/release.yml | 24 +++-- src/gui/views/goblin/mod.rs | 56 ++++++++++- src/gui/views/goblin/send.rs | 163 ++++++++++++++++++++++++++------ src/gui/views/goblin/widgets.rs | 24 +++++ src/nostr/client.rs | 39 +++++++- src/nostr/config.rs | 14 +++ src/nostr/ingest.rs | 15 +++ src/wallet/types.rs | 10 ++ src/wallet/wallet.rs | 79 ++++++++++++++++ 9 files changed, 383 insertions(+), 41 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4f782881..2494dac1 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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 diff --git a/src/gui/views/goblin/mod.rs b/src/gui/views/goblin/mod.rs index a1b548d6..b695fa7d 100644 --- a/src/gui/views/goblin/mod.rs +++ b/src/gui/views/goblin/mod.rs @@ -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 { + 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| { diff --git a/src/gui/views/goblin/send.rs b/src/gui/views/goblin/send.rs index c6a4025f..799176d2 100644 --- a/src/gui/views/goblin/send.rs +++ b/src/gui/views/goblin/send.rs @@ -119,6 +119,9 @@ pub struct SendFlow { scan: Option, /// 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), ); diff --git a/src/gui/views/goblin/widgets.rs b/src/gui/views/goblin/widgets.rs index 5d756dde..8cfd3a60 100644 --- a/src/gui/views/goblin/widgets.rs +++ b/src/gui/views/goblin/widgets.rs @@ -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(); diff --git a/src/nostr/client.rs b/src/nostr/client.rs index edd83e90..1396fbaa 100644 --- a/src/nostr/client.rs +++ b/src/nostr/client.rs @@ -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 { + 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) { + 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, 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, 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, wallet: &Wallet, client: &Client, tx_exists, is_contact, accept, + allow_requests, }); info!( "nostr: wrap {} slate {} state {} from {}…: {:?}", diff --git a/src/nostr/config.rs b/src/nostr/config.rs index 1bc8efbb..a395ab86 100644 --- a/src/nostr/config.rs +++ b/src/nostr/config.rs @@ -44,6 +44,10 @@ pub struct NostrConfig { nip05_server: Option, /// Days after which a pending outgoing payment is shown as expired. request_expiry_days: Option, + /// 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, /// 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(); + } } diff --git a/src/nostr/ingest.rs b/src/nostr/ingest.rs index 8bf8c32f..9991af8d 100644 --- a/src/nostr/ingest.rs +++ b/src/nostr/ingest.rs @@ -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( diff --git a/src/wallet/types.rs b/src/wallet/types.rs index 853a6c3b..18541dcc 100644 --- a/src/wallet/types.rs +++ b/src/wallet/types.rs @@ -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, Vec), + /// Republish our kind-0 profile (e.g. after toggling the incoming-requests + /// preference) so the change propagates to relays immediately. + NostrRepublishProfile, } diff --git a/src/wallet/wallet.rs b/src/wallet/wallet.rs index 7b24d237..c2003ecc 100644 --- a/src/wallet/wallet.rs +++ b/src/wallet/wallet.rs @@ -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;