diff --git a/locales/de.yml b/locales/de.yml index 1b465010..9204e4ae 100644 --- a/locales/de.yml +++ b/locales/de.yml @@ -679,6 +679,10 @@ goblin: n59_blurb: "Verpackt Nachrichten, sodass Relays nicht sehen können, wer mit wem kommuniziert." n98_title: "HTTP-Auth" n98_blurb: "Signiert die Benutzernamen-Registrierungsanfrage an goblin.st." + batch: + title: "%{n} Rechnungen bestätigen" + blurb: "Stelle %{n} Zahlungsanforderungen über je %{amount} an %{name} — insgesamt %{total}. Jede Anforderung erhält ihre eigene neue Zahlungsadresse." + approve: "Bestätigen" identities: title: "Identitäten" switch_hint: "Identität wechseln" diff --git a/locales/en.yml b/locales/en.yml index 88013268..0f7fe7b2 100644 --- a/locales/en.yml +++ b/locales/en.yml @@ -679,6 +679,10 @@ goblin: n59_blurb: "Wraps messages so relays can't see who is talking to whom." n98_title: "HTTP auth" n98_blurb: "Signs the username registration request to goblin.st." + batch: + title: "Approve %{n} invoices" + blurb: "Issue %{n} payment requests of %{amount} each to %{name} — %{total} in total. Each request gets its own fresh payment address." + approve: "Approve" identities: title: "Identities" switch_hint: "Switch identity" diff --git a/locales/fr.yml b/locales/fr.yml index c7a65698..4ee1f455 100644 --- a/locales/fr.yml +++ b/locales/fr.yml @@ -679,6 +679,10 @@ goblin: n59_blurb: "Enveloppe les messages pour que les relais ne voient pas qui parle à qui." n98_title: "Auth HTTP" n98_blurb: "Signe la demande d'enregistrement du nom d'utilisateur auprès de goblin.st." + batch: + title: "Approuver %{n} factures" + blurb: "Émettre %{n} demandes de paiement de %{amount} chacune à %{name} — %{total} au total. Chaque demande reçoit sa propre adresse de paiement." + approve: "Approuver" identities: title: "Identités" switch_hint: "Changer d'identité" diff --git a/locales/ru.yml b/locales/ru.yml index 72c85c05..5b0460dd 100644 --- a/locales/ru.yml +++ b/locales/ru.yml @@ -679,6 +679,10 @@ goblin: n59_blurb: "Оборачивает сообщения, чтобы реле не видели, кто с кем общается." n98_title: "HTTP-авторизация" n98_blurb: "Подписывает запрос регистрации имени на goblin.st." + batch: + title: "Подтвердить %{n} счетов" + blurb: "Выставить %{n} запросов на оплату по %{amount} каждый для %{name} — всего %{total}. Каждый запрос получает свой новый платёжный адрес." + approve: "Подтвердить" identities: title: "Личности" switch_hint: "Сменить личность" diff --git a/locales/tr.yml b/locales/tr.yml index d2c53e01..35d7cbf3 100644 --- a/locales/tr.yml +++ b/locales/tr.yml @@ -679,6 +679,10 @@ goblin: n59_blurb: "Mesajları sarar, böylece relaylar kimin kiminle konuştuğunu göremez." n98_title: "HTTP kimlik doğrulama" n98_blurb: "goblin.st'ye gönderilen kullanıcı adı kayıt isteğini imzalar." + batch: + title: "%{n} faturayı onayla" + blurb: "%{name} için her biri %{amount} olan %{n} ödeme isteği düzenle — toplam %{total}. Her istek kendi yeni ödeme adresini alır." + approve: "Onayla" identities: title: "Kimlikler" switch_hint: "Kimlik değiştir" diff --git a/locales/zh-CN.yml b/locales/zh-CN.yml index f68140bc..379156e9 100644 --- a/locales/zh-CN.yml +++ b/locales/zh-CN.yml @@ -679,6 +679,10 @@ goblin: n59_blurb: "包装消息,使中继无法看到通信双方是谁。" n98_title: "HTTP 认证" n98_blurb: "为向 goblin.st 注册用户名的请求签名。" + batch: + title: "批准 %{n} 张发票" + blurb: "向 %{name} 发出 %{n} 个付款请求,每个 %{amount},共 %{total}。每个请求都有自己全新的收款地址。" + approve: "批准" identities: title: "身份" switch_hint: "切换身份" diff --git a/src/gui/views/goblin/mod.rs b/src/gui/views/goblin/mod.rs index 94f27ccf..9b91f773 100644 --- a/src/gui/views/goblin/mod.rs +++ b/src/gui/views/goblin/mod.rs @@ -135,6 +135,8 @@ pub struct GoblinWalletView { /// When the first back of the double-back was pressed at Home, for the /// brief "press back again for the wallet switcher" hint. Display only. back_hint: Option, + /// A batch invoice request awaiting approval (count=N deep link). + batch_invoice: Option, } /// Whether the per-identity cue is drawn on activity rows (owner-approved). The @@ -266,6 +268,7 @@ impl Default for GoblinWalletView { wipe_confirm: false, min_conf_edit: String::new(), back_hint: None, + batch_invoice: None, } } } @@ -341,6 +344,10 @@ const IDENTITY_DELETE_MODAL: &str = "goblin_identity_delete_modal"; /// Cancel/Save), opened from the Settings wallet group. const MIN_CONF_MODAL: &str = "goblin_min_conf_modal"; +/// Id of the batch-invoice approval modal (a `count=N` invoice-request URI): +/// one approval for N payment requests, each on its own fresh proof address. +const BATCH_INVOICE_MODAL: &str = "goblin_batch_invoice_modal"; + /// A password-gated identity action the modal executes. Switching no longer uses /// the modal (it is instant and local); only these need the wallet password. #[derive(Clone)] @@ -389,6 +396,20 @@ struct IdentitySwitchState { result: std::sync::Arc>>>, } +/// A batch invoice request (`count=N` URI) awaiting its one approval. +struct BatchInvoiceState { + /// Receiver public key, hex (URI recipient must be a direct npub/nprofile). + hex: String, + /// Relay hints from the nprofile, if any. + relay_hints: Vec, + /// Amount PER invoice, raw decimal-GRIN string from the URI. + amount: String, + /// Optional memo threaded onto every request. + memo: Option, + /// How many requests to issue (2..=MAX_BATCH_COUNT). + count: u32, +} + /// Inline "change name authority" (federation) editor state. #[derive(Default)] struct NameAuthorityState { @@ -602,10 +623,49 @@ impl GoblinWalletView { // A pending payment deep link (`goblin:` / `nostr:` pay URI, routed here // from an OS launch/open) opens a prefilled send-review flow — the exact - // destination a scanned checkout QR lands on. + // destination a scanned checkout QR lands on. A BATCH invoice-request + // (`count=N`, N >= 2, direct key + amount) opens the one batch-approval + // modal instead; anything else (including count on a name that needs + // discovery) degrades to the single flow, count ignored. if let Some(uri) = crate::take_pending_pay_uri() { - let now = ui.input(|i| i.time); - self.send = Some(SendFlow::from_deeplink(&uri, wallet, now)); + let pay = crate::nostr::payuri::parse(&uri); + let batch = if pay.count >= 2 { + match ( + send::decode_recipient_key(&pay.recipient), + pay.amount.clone(), + ) { + (Some((hex, relay_hints)), Some(amount)) => Some(BatchInvoiceState { + hex, + relay_hints, + amount, + memo: pay.memo.clone(), + count: pay.count, + }), + _ => None, + } + } else { + None + }; + match batch { + Some(b) => { + let n = b.count; + self.batch_invoice = Some(b); + Modal::new(BATCH_INVOICE_MODAL) + .position(ModalPosition::CenterTop) + .title(t!("goblin.batch.title", n => n.to_string())) + .show(); + } + None => { + let now = ui.input(|i| i.time); + self.send = Some(SendFlow::from_deeplink(&uri, wallet, now)); + } + } + } + // Batch-invoice approval modal (locks the surface behind it). + if Modal::opened() == Some(BATCH_INVOICE_MODAL) { + Modal::ui(ui.ctx(), cb, |ui, _modal, _cb| { + self.batch_invoice_modal_content(ui, wallet); + }); } // Send flow takes the full surface when active. @@ -5718,6 +5778,109 @@ impl GoblinWalletView { }); } + /// Content of the batch-invoice approval modal: ONE approval for N payment + /// requests to the same payer, each request minted its own fresh per-sale + /// proof address so no two sales share an address. Approve fires the same + /// request task the single flow uses, N times; the requests then appear in + /// activity as they dispatch, exactly like single requests. + fn batch_invoice_modal_content(&mut self, ui: &mut egui::Ui, wallet: &Wallet) { + let Some(b) = &self.batch_invoice else { + Modal::close(); + return; + }; + let name = wallet + .nostr_service() + .map(|s| data::contact_title(&s.store, &b.hex)) + .unwrap_or_else(|| data::short_npub(&b.hex)); + let each = grin_core::core::amount_from_hr_string(&b.amount).unwrap_or(0); + let total = each.saturating_mul(b.count as u64); + ui.vertical_centered(|ui| { + ui.add_space(6.0); + ui.label( + RichText::new(t!( + "goblin.batch.blurb", + n => b.count.to_string(), + name => name, + amount => format!("{}{}", w::amount_str(each), w::TSU), + total => format!("{}{}", w::amount_str(total), w::TSU) + )) + .size(16.0) + .color(Colors::gray()), + ); + if let Some(memo) = &b.memo { + ui.add_space(6.0); + ui.label( + RichText::new(format!("\u{201C}{}\u{201D}", memo)) + .size(15.0) + .color(Colors::gray()), + ); + } + ui.add_space(12.0); + }); + let mut cancel = false; + let mut approve = false; + ui.scope(|ui| { + ui.spacing_mut().item_spacing = egui::Vec2::new(8.0, 0.0); + ui.columns(2, |columns| { + columns[0].vertical_centered_justified(|ui| { + View::button( + ui, + t!("modal.cancel"), + Colors::white_or_black(false), + || { + cancel = true; + }, + ); + }); + columns[1].vertical_centered_justified(|ui| { + View::button( + ui, + t!("goblin.batch.approve"), + Colors::white_or_black(false), + || { + approve = true; + }, + ); + }); + }); + ui.add_space(6.0); + }); + if cancel { + self.batch_invoice = None; + Modal::close(); + } + if approve { + if let Some(b) = self.batch_invoice.take() { + if let Some(service) = wallet.nostr_service() { + service.set_send_phase(crate::nostr::send_phase::WORKING); + } + let w = wallet.clone(); + std::thread::spawn(move || { + for _ in 0..b.count { + // Each request gets its OWN fresh proof address; a mint + // failure stops the batch (already-issued requests stand). + match w.mint_proof_address() { + Ok((_index, addr)) => { + w.task(crate::wallet::types::WalletTask::NostrRequest( + each, + b.hex.clone(), + b.memo.clone(), + b.relay_hints.clone(), + Some(addr), + )); + } + Err(e) => { + log::error!("batch invoice: mint failed: {e}"); + break; + } + } + } + }); + } + Modal::close(); + } + } + /// Inline username-claim widget (availability check + registration). fn claim_ui(&mut self, ui: &mut egui::Ui, wallet: &Wallet, cb: &dyn PlatformCallbacks) { let t = theme::tokens(); diff --git a/src/gui/views/goblin/send.rs b/src/gui/views/goblin/send.rs index 2b81583e..05605091 100644 --- a/src/gui/views/goblin/send.rs +++ b/src/gui/views/goblin/send.rs @@ -1366,6 +1366,7 @@ impl SendFlow { recipient.npub.clone(), note, recipient.relay_hints.clone(), + None, )); } else { wallet.task(WalletTask::NostrSend( @@ -1643,7 +1644,7 @@ enum ScanPrefill { /// optional `nostr:` prefix) to a `(pubkey hex, relay hints)` pair. `None` for a /// name / `@handle` (which needs a NIP-05 network resolution) or garbage — those /// still go through the search box. Pure: no I/O. -fn decode_recipient_key(input: &str) -> Option<(String, Vec)> { +pub(super) fn decode_recipient_key(input: &str) -> Option<(String, Vec)> { use nostr_sdk::nips::nip19::Nip19Profile; use nostr_sdk::{FromBech32, PublicKey}; let key = input.trim().strip_prefix("nostr:").unwrap_or(input.trim()); diff --git a/src/nostr/client.rs b/src/nostr/client.rs index 8561d824..1f9486ae 100644 --- a/src/nostr/client.rs +++ b/src/nostr/client.rs @@ -2241,6 +2241,7 @@ async fn handle_wrap(svc: &Arc, wallet: &Wallet, event: Event) { // opened it), NOT necessarily the active one. All identities // redeem into the one grin balance; this records provenance. recipient_pubkey: recipient_hex.clone(), + proof_address: None, }); // Commit dedup markers now the receive is durable, BEFORE // the reply + sync tail. A crash there must not let this @@ -2517,6 +2518,7 @@ mod tests { proof_delivered: false, receipt_sent: false, recipient_pubkey: String::new(), + proof_address: None, } } diff --git a/src/nostr/ingest.rs b/src/nostr/ingest.rs index 33e903bc..06b582a4 100644 --- a/src/nostr/ingest.rs +++ b/src/nostr/ingest.rs @@ -174,6 +174,7 @@ mod tests { proof_delivered: false, receipt_sent: false, recipient_pubkey: String::new(), + proof_address: None, } } diff --git a/src/nostr/payuri.rs b/src/nostr/payuri.rs index 42e97ca6..7428e4af 100644 --- a/src/nostr/payuri.rs +++ b/src/nostr/payuri.rs @@ -83,6 +83,11 @@ pub struct PayUri { /// delivery to. Dropped if it is not a valid npub; absence simply means no /// encrypted delivery target (the plain receipt still publishes). pub notify: Option, + /// Batch size for an invoice-request URI: how many payment requests the + /// wallet is asked to issue, each with its own fresh per-sale proof + /// address. Default 1 (single flow, unchanged when absent); capped at + /// [`MAX_BATCH_COUNT`]; fail-closed to 1 on anything unparseable or zero. + pub count: u32, } impl PayUri { @@ -95,10 +100,15 @@ impl PayUri { proof: None, order: None, notify: None, + count: 1, } } } +/// Cap on the `count` batch parameter: the most invoices one URI may ask the +/// wallet to issue in a single approval. +pub const MAX_BATCH_COUNT: u32 = 20; + /// Parse a scanned payload into a [`PayUri`]. Pure and total: never panics, /// never performs I/O, always returns a value. On any problem it falls back to /// recipient-only (fail-closed). @@ -132,6 +142,7 @@ pub fn parse(scanned: &str) -> PayUri { let mut proof = None; let mut order = None; let mut notify = None; + let mut count = None; if let Some(query) = query { for pair in query.split('&') { let Some((key, val)) = pair.split_once('=') else { @@ -148,6 +159,7 @@ pub fn parse(scanned: &str) -> PayUri { "proof" if proof.is_none() => proof = validate_proof(val), "order" if order.is_none() => order = validate_order(val), "notify" if notify.is_none() => notify = validate_notify(val), + "count" if count.is_none() => count = validate_count(val), // Unknown params are ignored for forward-compat. _ => {} } @@ -161,6 +173,17 @@ pub fn parse(scanned: &str) -> PayUri { proof, order, notify, + count: count.unwrap_or(1), + } +} + +/// Validate a `count` value: a positive integer, clamped to [`MAX_BATCH_COUNT`]. +/// Fail-closed: unparseable or zero drops to `None` (treated as 1, the single +/// flow), never blocking the URI. +fn validate_count(raw: &str) -> Option { + match raw.trim().parse::() { + Ok(0) | Err(_) => None, + Ok(n) => Some(n.min(MAX_BATCH_COUNT)), } } @@ -357,6 +380,28 @@ fn hex_val(b: u8) -> Option { mod tests { use super::*; + #[test] + fn count_parses_defaults_and_caps() { + const NPUB: &str = "npub15gsytqvs5c78u83yv2agl4twjkk6qgem7gtwe2agu7s90tkelxys0xxely"; + // Absent -> 1 (single flow unchanged). + assert_eq!(parse(&format!("goblin:{NPUB}?amount=1.5")).count, 1); + // Present -> parsed. + assert_eq!(parse(&format!("goblin:{NPUB}?amount=1.5&count=5")).count, 5); + // Capped at MAX_BATCH_COUNT. + assert_eq!( + parse(&format!("goblin:{NPUB}?amount=1.5&count=999")).count, + MAX_BATCH_COUNT + ); + // Fail-closed: zero or garbage -> 1. + assert_eq!(parse(&format!("goblin:{NPUB}?amount=1.5&count=0")).count, 1); + assert_eq!( + parse(&format!("goblin:{NPUB}?amount=1.5&count=abc")).count, + 1 + ); + // First occurrence wins. + assert_eq!(parse(&format!("goblin:{NPUB}?count=3&count=9")).count, 3); + } + const NPROFILE: &str = "nprofile1qqsw3v0m5v6h9q8n0hkxg6l4l5xk2z7z0n6f6q9m8x0q5v4l3k2j1h0gpz3mhxue69uhhyetvv9uju"; diff --git a/src/nostr/types.rs b/src/nostr/types.rs index b3235060..dc06a9de 100644 --- a/src/nostr/types.rs +++ b/src/nostr/types.rs @@ -126,6 +126,11 @@ pub struct TxNostrMeta { /// pre-feature rows, which are treated as identity #1 (the primary). #[serde(default)] pub recipient_pubkey: String, + /// The fresh per-sale proof/slatepack address minted for this OUTGOING + /// payment request (batch invoicing), so each sale has its own address. + /// `None` for ordinary sends/requests. + #[serde(default)] + pub proof_address: Option, } /// A contact: another nostr user we can pay. diff --git a/src/wallet/types.rs b/src/wallet/types.rs index 8333837a..ebec2c45 100644 --- a/src/wallet/types.rs +++ b/src/wallet/types.rs @@ -459,7 +459,9 @@ pub enum WalletTask { /// * receiver public key (hex) /// * optional note (subject line) /// * relay hints - NostrRequest(u64, String, Option, Vec), + /// * optional fresh per-sale proof address minted for this request (batch + /// invoicing), recorded on the request's metadata + NostrRequest(u64, String, Option, Vec, Option), /// 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 fcb3a264..bbd31ca9 100644 --- a/src/wallet/wallet.rs +++ b/src/wallet/wallet.rs @@ -3005,6 +3005,7 @@ async fn handle_task(w: &Wallet, t: WalletTask) { proof_delivered: false, receipt_sent: false, recipient_pubkey: service.public_key().to_hex(), + proof_address: None, }); let tx = w.retrieve_tx_by_id(None, Some(s.id)); w.send_creating.store(false, Ordering::Relaxed); @@ -3105,7 +3106,7 @@ async fn handle_task(w: &Wallet, t: WalletTask) { } } } - WalletTask::NostrRequest(a, receiver, note, relay_hints) => { + WalletTask::NostrRequest(a, receiver, note, relay_hints, proof_address) => { let Some(service) = w.nostr_service() else { error!("nostr request: service not available"); return; @@ -3146,6 +3147,9 @@ async fn handle_task(w: &Wallet, t: WalletTask) { proof_delivered: false, receipt_sent: false, recipient_pubkey: service.public_key().to_hex(), + // The fresh per-sale address minted for this request (batch + // invoicing); None for an ordinary request. + proof_address: proof_address.clone(), }); let tx = w.retrieve_tx_by_id(None, Some(s.id)); w.invoice_creating.store(false, Ordering::Relaxed); @@ -3398,6 +3402,7 @@ async fn handle_task(w: &Wallet, t: WalletTask) { proof_delivered: false, receipt_sent: false, recipient_pubkey: service.public_key().to_hex(), + proof_address: None, }); match service .send_payment_dm(&request.npub, &text, None, &[]) @@ -3449,6 +3454,7 @@ async fn handle_task(w: &Wallet, t: WalletTask) { proof_delivered: false, receipt_sent: false, recipient_pubkey: service.public_key().to_hex(), + proof_address: None, }); match service .send_payment_dm(&request.npub, &text, None, &[])