goblin: batch invoice URI with one approval, per-sale addresses (Build 149)

URI schema: pay/invoice URIs gain an optional `count=N` parameter
(goblin:<npub|nprofile>?amount=X&count=N[&memo=...]) asking the wallet to
issue N payment requests in one go. Default 1 when absent (single flow
unchanged), capped at MAX_BATCH_COUNT = 20, fail-closed to 1 on zero/garbage,
first occurrence wins — all payuri conventions, unit-tested.

A count>=2 deep link whose recipient is a direct key (npub/nprofile) with an
amount opens ONE batch-approval modal — "Approve N invoices", with the payer,
per-invoice amount, total, and memo — instead of the send flow. On approve,
the wallet mints N fresh per-sale proof addresses (Wallet::mint_proof_address;
a mint failure stops the batch, already-issued requests stand) and fires the
same NostrRequest task the single flow uses, once per invoice; the requests
appear in activity as they dispatch. Anything else (count on a name that
needs discovery, or no amount) degrades to the single flow with count
ignored. The minted address is recorded on each request's metadata
(TxNostrMeta::proof_address, serde-default None) so the per-sale machinery
can read it; WalletTask::NostrRequest gains the optional address and the
single flow passes None. New goblin.batch.* strings in all six locales.
This commit is contained in:
2ro
2026-07-06 02:03:00 -04:00
parent ea07fd2081
commit 3c09baf25a
14 changed files with 255 additions and 6 deletions
+4
View File
@@ -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"
+4
View File
@@ -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"
+4
View File
@@ -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é"
+4
View File
@@ -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: "Сменить личность"
+4
View File
@@ -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"
+4
View File
@@ -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: "切换身份"
+166 -3
View File
@@ -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<std::time::Instant>,
/// A batch invoice request awaiting approval (count=N deep link).
batch_invoice: Option<BatchInvoiceState>,
}
/// 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<std::sync::Mutex<Option<Result<String, String>>>>,
}
/// 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<String>,
/// Amount PER invoice, raw decimal-GRIN string from the URI.
amount: String,
/// Optional memo threaded onto every request.
memo: Option<String>,
/// 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();
+2 -1
View File
@@ -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<String>)> {
pub(super) fn decode_recipient_key(input: &str) -> Option<(String, Vec<String>)> {
use nostr_sdk::nips::nip19::Nip19Profile;
use nostr_sdk::{FromBech32, PublicKey};
let key = input.trim().strip_prefix("nostr:").unwrap_or(input.trim());
+2
View File
@@ -2241,6 +2241,7 @@ async fn handle_wrap(svc: &Arc<NostrService>, 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,
}
}
+1
View File
@@ -174,6 +174,7 @@ mod tests {
proof_delivered: false,
receipt_sent: false,
recipient_pubkey: String::new(),
proof_address: None,
}
}
+45
View File
@@ -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<String>,
/// 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<u32> {
match raw.trim().parse::<u32>() {
Ok(0) | Err(_) => None,
Ok(n) => Some(n.min(MAX_BATCH_COUNT)),
}
}
@@ -357,6 +380,28 @@ fn hex_val(b: u8) -> Option<u8> {
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";
+5
View File
@@ -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<String>,
}
/// A contact: another nostr user we can pay.
+3 -1
View File
@@ -459,7 +459,9 @@ pub enum WalletTask {
/// * receiver public key (hex)
/// * optional note (subject line)
/// * relay hints
NostrRequest(u64, String, Option<String>, Vec<String>),
/// * optional fresh per-sale proof address minted for this request (batch
/// invoicing), recorded on the request's metadata
NostrRequest(u64, String, Option<String>, Vec<String>, Option<String>),
/// Republish our kind-0 profile (e.g. after toggling the incoming-requests
/// preference) so the change propagates to relays immediately.
NostrRepublishProfile,
+7 -1
View File
@@ -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, &[])