1
0
forked from GRIN/grim
Files
goblin/src/nostr/types.rs
T
2ro 67ee779b50 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.
2026-07-06 03:04:41 -04:00

243 lines
9.6 KiB
Rust

// Copyright 2026 The Goblin Developers
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//! Shared types of the nostr payment-messaging subsystem.
use serde_derive::{Deserialize, Serialize};
/// Direction of a nostr-transported transaction relative to this wallet.
#[derive(Serialize, Deserialize, Clone, Copy, PartialEq, Eq, Debug)]
pub enum NostrTxDirection {
/// We sent funds (Standard flow, we created S1).
Sent,
/// We received funds (Standard flow, we replied S2).
Received,
/// We issued an invoice / requested funds (Invoice flow, we created I1).
RequestedByUs,
/// Someone requested funds of us (Invoice flow, we may pay I1).
RequestedOfUs,
}
/// Lifecycle status of a nostr-transported transaction.
#[derive(Serialize, Deserialize, Clone, Copy, PartialEq, Eq, Debug)]
pub enum NostrSendStatus {
/// Slate created locally, DM not dispatched yet.
Created,
/// S1 DM dispatched, waiting for the S2 reply.
AwaitingS2,
/// Incoming S1 processed, S2 reply not yet dispatched (crash recovery).
ReceivedNoReply,
/// S2 reply dispatched for a received payment.
RepliedS2,
/// I1 request dispatched, waiting for the I2 reply.
AwaitingI2,
/// We paid an invoice (I2 reply sent), their side finalizes.
PaidAwaitingFinalize,
/// Transaction finalized and posted.
Finalized,
/// DM dispatch failed, retry possible.
SendFailed,
/// Cancelled locally.
Cancelled,
}
/// Outcome of a manual payment-cancel, surfaced transiently on the receipt so
/// the user knows whether their funds came back or the payment had already
/// completed in the race window.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum CancelOutcome {
/// The pending payment was cancelled and the locked funds released.
Cancelled,
/// The payment had already gone through; nothing was cancelled.
AlreadyCompleted,
}
/// Per-transaction nostr metadata, joined to wallet txs by slate id.
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct TxNostrMeta {
pub ver: u8,
/// Slate UUID string.
pub slate_id: String,
/// Counterparty public key, hex.
pub npub: String,
pub direction: NostrTxDirection,
/// Sanitized user note (subject line).
pub note: Option<String>,
pub status: NostrSendStatus,
/// Gift wrap event id of our outgoing message, hex.
pub sent_event_id: Option<String>,
/// Rumor id of the counterparty message we processed, hex.
pub received_rumor_id: Option<String>,
pub created_at: i64,
pub updated_at: i64,
/// Proof-on-request (frozen contract section 4.3): a proof address was
/// threaded into this send, so on finalize the wallet must produce and
/// deliver a real Grin payment proof. `false` for every ordinary send, so a
/// person-to-person payment carries and delivers nothing extra.
#[serde(default)]
pub proof_mode: bool,
/// The opaque order handle (the `order=` URI param, magick's `MM-<hex>`
/// invoice number). Echoed verbatim into the delivery events'
/// `payment-request` tag. The wallet never learns magick's internal orderId.
#[serde(default)]
pub proof_order: Option<String>,
/// The watcher's npub (the `notify=` URI param): the gift-wrap target for the
/// full proof delivery. `None` = no encrypted delivery target (plain receipt
/// still publishes).
#[serde(default)]
pub proof_notify: Option<String>,
/// The payment amount in integer nanogrin, persisted so the crash-safe
/// reconcile pass can rebuild the delivery-event `amount` tag without the
/// slate.
#[serde(default)]
pub proof_amount: Option<u64>,
/// Set once the encrypted proof delivery (frozen contract 4.3.2) has been
/// accepted by a relay. The plain receipt is tracked separately by
/// `receipt_sent`, since the two now publish at different lifecycle points
/// (receipt at dispatch, proof at finalize). Until then the reconcile pass
/// retries the proof delivery.
#[serde(default)]
pub proof_delivered: bool,
/// Set once the plain "payment sent" receipt (frozen contract 4.3.1) has been
/// accepted by a relay. Published at S1 DISPATCH, the moment the payment
/// envelope is accepted and the wallet UI flips to "sent", NOT at finalize,
/// so the buyer's order page loses its scannable QR the instant they pay and
/// the double-send window closes. One receipt per tx: this flag is the guard
/// against a duplicate at finalize. `false` for every ordinary (non-order)
/// send, which publishes no receipt at all.
#[serde(default)]
pub receipt_sent: bool,
/// The wallet's OWN nostr identity (pubkey hex) that was ACTIVE when this row
/// was created — the "front door" the payment came in / went out on. One
/// wallet can hold several identities that all redeem into the single shared
/// grin balance; this tags which one so activity can be shown per identity and
/// a later per-identity accounting split has the data. Serde-default empty on
/// 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.
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct Contact {
pub ver: u8,
/// Public key, hex.
pub npub: String,
/// Local petname, overrides any resolved name.
pub petname: Option<String>,
/// NIP-05 identifier (user@domain).
pub nip05: Option<String>,
/// Unix time of last successful NIP-05 verification.
pub nip05_verified_at: Option<i64>,
/// Known DM relays (kind 10050) of the contact.
pub relays: Vec<String>,
/// The contact advertises NIP-44 v3 in the `encryption` tag of the same
/// kind 10050 the relays come from (NIP-17 backward-compat extension).
/// Absent tag = v2 only, hence the conservative default.
#[serde(default)]
pub nip44_v3: bool,
/// Avatar palette index.
pub hue: u8,
/// Auto-added from an incoming payment, not yet confirmed by the user.
pub unknown: bool,
pub added_at: i64,
pub last_paid_at: Option<i64>,
/// Blocked at the nostr level: their incoming messages are dropped on
/// ingest, as if muted on nostr (which is what this is).
#[serde(default)]
pub blocked: bool,
}
/// Status of an incoming payment request (Invoice1).
#[derive(Serialize, Deserialize, Clone, Copy, PartialEq, Eq, Debug)]
pub enum RequestStatus {
Pending,
Approved,
Declined,
Expired,
/// Withdrawn by the requester (we received their cancel control message).
Cancelled,
}
/// An incoming Invoice1 payment request awaiting explicit user approval.
/// NEVER paid automatically.
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct PaymentRequest {
pub ver: u8,
/// Rumor event id, hex (storage key).
pub rumor_id: String,
/// Slate UUID string.
pub slate_id: String,
/// Raw slatepack armor to pay on approval.
pub slatepack: String,
/// Requester public key, hex.
pub npub: String,
/// Requested amount in atomic units.
pub amount: u64,
/// Sanitized note.
pub note: Option<String>,
pub received_at: i64,
pub status: RequestStatus,
}
/// A cached news post (kind 30023 long-form) from the Goblin news key, shown
/// in the Home news panel. Only the fields the panel needs are persisted.
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
pub struct NewsItem {
/// The addressable `d` tag (replaceable-event identifier); dedupe key.
pub d: String,
/// Event `created_at` (seconds); newest per `d` wins, newest overall shows.
pub created_at: i64,
/// The post `title` tag. May carry a trailing `[xx]` language marker, which
/// the Home panel strips for display (see `data::news_display_title`).
pub title: String,
/// Plain-text summary (the `summary` tag, or a stripped content fallback).
pub summary: String,
/// Article language as a lower-case ISO 639-1 code, taken from an event
/// language tag (`l` / `lang`) when present. `None` falls back to the
/// title-suffix marker, then to English. `#[serde(default)]` so posts cached
/// before this field existed still deserialize.
#[serde(default)]
pub lang: Option<String>,
/// Optional NIP-23 `published_at` tag (unix seconds). When present the Home
/// panel dates the article by this rather than `created_at` (which tracks the
/// event's last edit). `#[serde(default)]` so posts cached before this field
/// existed still deserialize.
#[serde(default)]
pub published_at: Option<i64>,
}
/// Whether the plain "payment sent" receipt (frozen contract 4.3.1) is due at
/// S1 dispatch for this send. True only for an order-carrying send in proof
/// mode: a person-to-person send (no `order=` context) publishes no receipt at
/// all, at any lifecycle point. The receipt is the buyer's routing key back to
/// the market (the `payment-request` tag echoes the order handle), so without an
/// order handle there is nothing the market could match and nothing to publish.
pub fn receipt_due_at_dispatch(proof_mode: bool, order: Option<&str>) -> bool {
proof_mode && order.is_some_and(|o| !o.trim().is_empty())
}
/// Current unix time in seconds.
pub fn unix_time() -> i64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0)
}