mirror of
https://code.gri.mw/GUI/grim.git
synced 2026-07-12 09:48:55 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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
@@ -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),
|
||||
);
|
||||
|
||||
@@ -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
@@ -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 {}…: {:?}",
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user