From c32ddfa9ffb3105c2780ed72ca67eec74961b1f4 Mon Sep 17 00:00:00 2001 From: 2ro <17595647+2ro@users.noreply.github.com> Date: Fri, 3 Jul 2026 03:22:29 -0400 Subject: [PATCH] =?UTF-8?q?M8:=20bundled=20relay=20=E2=80=94=20RelayMode::?= =?UTF-8?q?Bundled=20runs=20a=20co-located=20nostr-rs-relay?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Make bundled mode actually self-contained: resolve() now leads the relay set with GP_BUNDLED_RELAY_URL (default ws://127.0.0.1:7777), which the checkout nprofile advertises, so a merchant needs no third-party relay. External mode uses only GP_RELAYS. Ship the relay as a vendored, unmodified nostr-rs-relay config (deploy/relay/nostr-rs-relay.toml); the compose service arrives with the deploy pipeline. Fix the stale "bundled is a later milestone" comment and reconcile the GP_NYM=off wording to a supported server-side-clearnet posture. --- Cargo.lock | 2 +- crates/gp-core/src/config.rs | 38 +++++++++++-- crates/gp-nostr/src/relays.rs | 93 +++++++++++++++++++++++++------- crates/gp-nostr/src/service.rs | 10 ++-- crates/gp-server/src/admin.rs | 5 +- crates/gp-server/src/checkout.rs | 2 +- crates/gp-server/src/main.rs | 2 +- deploy/relay/nostr-rs-relay.toml | 39 ++++++++++++++ 8 files changed, 162 insertions(+), 29 deletions(-) create mode 100644 deploy/relay/nostr-rs-relay.toml diff --git a/Cargo.lock b/Cargo.lock index 5cf71f3..c7956fb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5733,7 +5733,7 @@ dependencies = [ [[package]] name = "nip44" -version = "0.1.0" +version = "0.3.0" dependencies = [ "base64 0.22.1", "chacha20 0.9.1", diff --git a/crates/gp-core/src/config.rs b/crates/gp-core/src/config.rs index ac7af39..b55a2fa 100644 --- a/crates/gp-core/src/config.rs +++ b/crates/gp-core/src/config.rs @@ -29,6 +29,14 @@ pub const DEFAULT_DATA_DIR: &str = "./gp-data"; /// Nostr gift-wrap layer in gp-nostr). pub const DEFAULT_NODE_URL: &str = "https://main.gri.mw"; +/// Default URL of the bundled relay in `bundled` relay mode: the co-located +/// relay GoblinPay ships in `deploy/docker-compose.yml` (a vendored +/// nostr-rs-relay), so a merchant needs no third-party relay. Override with +/// `GP_BUNDLED_RELAY_URL`. In a public deployment set this to the relay's +/// publicly reachable `wss://` URL, because the same value is both +/// dialed by the server AND advertised to payers in the checkout `nprofile`. +pub const DEFAULT_BUNDLED_RELAY: &str = "ws://127.0.0.1:7777"; + /// TLS mode for the HTTP server. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] @@ -53,7 +61,11 @@ pub enum Chain { #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] pub enum RelayMode { - /// GoblinPay supervises its own relay (default; see module design 3). + /// GoblinPay talks to its own co-located relay (default): the bundled + /// nostr-rs-relay from `deploy/docker-compose.yml`, reached at + /// `GP_BUNDLED_RELAY_URL`. That relay is what the checkout `nprofile` + /// advertises, so a merchant needs no third-party relay. Any `GP_RELAYS` + /// are added alongside it for redundancy. Bundled, /// Only external relays from `GP_RELAYS` are used. External, @@ -130,8 +142,14 @@ pub struct Config { pub relay_mode: RelayMode, /// External relays (`GP_RELAYS`, comma separated). pub relays: Vec, + /// URL of the bundled relay used in `bundled` relay mode + /// (`GP_BUNDLED_RELAY_URL`, default `ws://127.0.0.1:7777`). Both dialed by + /// the ingest service and advertised to payers in the checkout `nprofile`. + pub bundled_relay_url: String, /// Route Nostr traffic over the Nym mixnet (`GP_NYM`: `on` or `off`, - /// default on; clearnet is a debugging escape hatch only). + /// default on). Production may deliberately run `off` (server-side + /// clearnet): the payer's Goblin Wallet still provides sender privacy over + /// its own mixnet, and the payload is gift-wrapped end to end regardless. pub nym: bool, /// Run the Nostr ingest service (`GP_INGEST`: `on` or `off`, default on). /// When on, the wallet and identity secrets are required at boot. @@ -235,6 +253,7 @@ impl Default for Config { chain: Chain::Mainnet, relay_mode: RelayMode::Bundled, relays: Vec::new(), + bundled_relay_url: DEFAULT_BUNDLED_RELAY.into(), nym: true, ingest: true, checkout_nostr: true, @@ -320,6 +339,10 @@ impl Config { .map(|s| s.trim().to_string()) .filter(|s| !s.is_empty()) .collect::>(); + let bundled_relay_url = get("GP_BUNDLED_RELAY_URL") + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + .unwrap_or(defaults.bundled_relay_url); let nym = match get("GP_NYM").as_deref().unwrap_or("on") { "on" => true, @@ -406,6 +429,7 @@ impl Config { chain, relay_mode, relays, + bundled_relay_url, nym, ingest, checkout_nostr, @@ -473,6 +497,9 @@ impl Config { if self.relay_mode == RelayMode::External && self.relays.is_empty() { return Err("GP_RELAY_MODE=external requires GP_RELAYS".into()); } + if self.relay_mode == RelayMode::Bundled && self.bundled_relay_url.trim().is_empty() { + return Err("GP_RELAY_MODE=bundled requires a non-empty GP_BUNDLED_RELAY_URL".into()); + } if self.nsec.is_some() && self.ncryptsec.is_some() { return Err("set only one of GP_NSEC and GP_NCRYPTSEC".into()); } @@ -507,7 +534,8 @@ impl Config { let set = |o: bool| if o { "set" } else { "unset" }; format!( "bind={} tls={} db={} data_dir={} node={} chain={:?} relay_mode={:?} \ - relays={:?} nym={} ingest={} checkout_methods={} match_mode={:?} mnemonic={} \ + relays={:?} bundled_relay={} nym={} ingest={} checkout_methods={} match_mode={:?} \ + mnemonic={} \ wallet_password={} \ nsec={} ncryptsec={} public_url={} api_token={} admin_token={} webhook_url={} \ webhook_secret={} qr_logo={} merchant_npub={} notify_merchant_dm={} \ @@ -525,6 +553,7 @@ impl Config { self.chain, self.relay_mode, self.relays, + self.bundled_relay_url, if self.nym { "on" } else { "off" }, if self.ingest { "on" } else { "off" }, self.checkout_methods_str(), @@ -657,6 +686,7 @@ mod tests { assert_eq!(cfg.chain, Chain::Mainnet); assert_eq!(cfg.relay_mode, RelayMode::Bundled); assert!(cfg.relays.is_empty()); + assert_eq!(cfg.bundled_relay_url, DEFAULT_BUNDLED_RELAY); assert!(cfg.nym); assert!(cfg.ingest); assert_eq!(cfg.match_mode, MatchMode::Memo); @@ -676,6 +706,7 @@ mod tests { ("GP_CHAIN", "testnet"), ("GP_RELAY_MODE", "external"), ("GP_RELAYS", "wss://relay.example, wss://relay2.example ,"), + ("GP_BUNDLED_RELAY_URL", "wss://relay.mystore.example"), ("GP_NYM", "off"), ("GP_INGEST", "off"), ("GP_MATCH_MODE", "derived"), @@ -691,6 +722,7 @@ mod tests { cfg.relays, vec!["wss://relay.example", "wss://relay2.example"] ); + assert_eq!(cfg.bundled_relay_url, "wss://relay.mystore.example"); assert!(!cfg.nym); assert!(!cfg.ingest); assert_eq!(cfg.match_mode, MatchMode::Derived); diff --git a/crates/gp-nostr/src/relays.rs b/crates/gp-nostr/src/relays.rs index aaf8e03..ed8e3a9 100644 --- a/crates/gp-nostr/src/relays.rs +++ b/crates/gp-nostr/src/relays.rs @@ -1,24 +1,48 @@ -//! Default relay set and helpers (mirrors `goblin/src/nostr/relays.rs`). +//! Relay set resolution. +//! +//! GoblinPay runs in one of two relay modes (`GP_RELAY_MODE`, see +//! [`gp_core::config::RelayMode`]): +//! +//! - `bundled` (default): GoblinPay talks to its own co-located relay, the +//! nostr-rs-relay shipped as the `relay` service in +//! `deploy/docker-compose.yml`. Its URL is `GP_BUNDLED_RELAY_URL` (default +//! `ws://127.0.0.1:7777`). Because the resolved set is exactly what the +//! checkout `nprofile` advertises to payers, a merchant needs no third-party +//! relay: the payer's Goblin Wallet is told to deliver the gift-wrapped +//! slatepack to the merchant's own relay. Extra relays listed in `GP_RELAYS` +//! are appended for redundancy (and advertised alongside the bundled one). +//! - `external`: only the relays listed in `GP_RELAYS` are used (no bundled +//! relay); config validation requires at least one. +//! +//! The bundled relay is a vendored, unmodified nostr-rs-relay (config only, no +//! fork) rather than a relay written from scratch: it is a small, SQLite-backed +//! Rust relay that fits a single-merchant till, and reusing it keeps the money +//! path off any third-party infrastructure. -/// Default DM relays: the Goblin relay plus large public relays for -/// redundancy. Used when `GP_RELAYS` is unset (the bundled relay is a later -/// milestone; until then `bundled` mode serves this set too). -pub const DEFAULT_RELAYS: &[&str] = &[ - "wss://relay.goblin.st", - "wss://relay.damus.io", - "wss://nos.lol", -]; +use gp_core::config::RelayMode; /// Maximum relays published in the kind 10050 DM relay list (NIP-17 /// guidance) and read from a payer's list. pub const MAX_DM_RELAYS: usize = 3; -/// The relay set to run with: the configured external list, else defaults. -pub fn resolve(configured: &[String]) -> Vec { - if configured.is_empty() { - DEFAULT_RELAYS.iter().map(|s| s.to_string()).collect() - } else { - configured.to_vec() +/// The relay set to listen on, publish to, and advertise in the `nprofile`. +/// +/// In `bundled` mode the co-located `bundled_url` comes first (so it heads the +/// advertised kind 10050 / `nprofile` hints), followed by any `configured` +/// redundancy relays, de-duplicated. In `external` mode only the `configured` +/// relays are used. +pub fn resolve(mode: RelayMode, bundled_url: &str, configured: &[String]) -> Vec { + match mode { + RelayMode::Bundled => { + let mut relays = vec![bundled_url.to_string()]; + for relay in configured { + if !relays.iter().any(|r| r == relay) { + relays.push(relay.clone()); + } + } + relays + } + RelayMode::External => configured.to_vec(), } } @@ -27,9 +51,42 @@ mod tests { use super::*; #[test] - fn resolves_defaults_and_overrides() { - assert_eq!(resolve(&[]), DEFAULT_RELAYS.to_vec()); + fn bundled_leads_with_the_bundled_relay() { + // No extras: just the bundled relay, so the nprofile advertises it and + // nothing third-party is involved. + assert_eq!( + resolve(RelayMode::Bundled, "ws://127.0.0.1:7777", &[]), + vec!["ws://127.0.0.1:7777".to_string()] + ); + // Extras are appended for redundancy; the bundled relay stays first. + let extras = vec!["wss://relay.damus.io".to_string()]; + assert_eq!( + resolve(RelayMode::Bundled, "ws://127.0.0.1:7777", &extras), + vec![ + "ws://127.0.0.1:7777".to_string(), + "wss://relay.damus.io".to_string(), + ] + ); + // A configured relay equal to the bundled one is not added twice. + let dup = vec![ + "ws://127.0.0.1:7777".to_string(), + "wss://r.example".to_string(), + ]; + assert_eq!( + resolve(RelayMode::Bundled, "ws://127.0.0.1:7777", &dup), + vec![ + "ws://127.0.0.1:7777".to_string(), + "wss://r.example".to_string(), + ] + ); + } + + #[test] + fn external_uses_only_configured() { let own = vec!["wss://relay.example".to_string()]; - assert_eq!(resolve(&own), own); + assert_eq!( + resolve(RelayMode::External, "ws://127.0.0.1:7777", &own), + own + ); } } diff --git a/crates/gp-nostr/src/service.rs b/crates/gp-nostr/src/service.rs index 4918159..5748d21 100644 --- a/crates/gp-nostr/src/service.rs +++ b/crates/gp-nostr/src/service.rs @@ -41,8 +41,9 @@ const NYM_WARM_WAIT: Duration = Duration::from_secs(30); pub struct ServiceOptions { /// Relay set to listen on and publish to. pub relays: Vec, - /// Route everything over the Nym mixnet (default on; clearnet is a - /// debugging escape hatch only). + /// Route everything over the Nym mixnet (default on). `off` is a supported + /// production posture (server-side clearnet): the payer's Goblin Wallet + /// still rides its own mixnet, and the payload is gift-wrapped end to end. pub nym: bool, /// Optional NIP-17 payment DMs (milestone 6, all off by default). pub notify: NotifyOptions, @@ -141,7 +142,10 @@ pub async fn run( .websocket_transport(NymWebSocketTransport) .build() } else { - warn!("nostr: GP_NYM=off — relay traffic goes CLEARNET (debugging only)"); + warn!( + "nostr: GP_NYM=off — this server's relay traffic goes CLEARNET (supported: the \ + payer's wallet still provides sender privacy; the payload stays gift-wrapped)" + ); Client::builder().build() }; diff --git a/crates/gp-server/src/admin.rs b/crates/gp-server/src/admin.rs index 7a70640..663bb4c 100644 --- a/crates/gp-server/src/admin.rs +++ b/crates/gp-server/src/admin.rs @@ -105,7 +105,8 @@ async fn dashboard( match_mode: format!("{:?}", cfg.match_mode).to_lowercase(), nym: cfg.nym, ingest: cfg.ingest, - relay_count: gp_nostr::relays::resolve(&cfg.relays).len(), + relay_count: gp_nostr::relays::resolve(cfg.relay_mode, &cfg.bundled_relay_url, &cfg.relays) + .len(), webhook_configured: cfg.webhook_url.is_some(), pending_webhooks, rotate_interval: cfg.endpub_rotate_interval, @@ -206,7 +207,7 @@ struct CreateUserBody { } fn endpub_json(cfg: &Config, user_id: &str, epoch: i64, pubkey: &str) -> serde_json::Value { - let relays = gp_nostr::relays::resolve(&cfg.relays); + let relays = gp_nostr::relays::resolve(cfg.relay_mode, &cfg.bundled_relay_url, &cfg.relays); let (npub, nprofile, qr) = match PublicKey::from_hex(pubkey) { Ok(pk) => ( gp_nostr::npub_of(pk), diff --git a/crates/gp-server/src/checkout.rs b/crates/gp-server/src/checkout.rs index 300979d..9464984 100644 --- a/crates/gp-server/src/checkout.rs +++ b/crates/gp-server/src/checkout.rs @@ -56,7 +56,7 @@ pub struct CheckoutInfo { /// caller does not surface the Slatepack option (e.g. the JSON connector API), /// in which case no Slatepack address or QR is produced. pub fn build_info(inv: &Invoice, cfg: &Config, slatepack_addr: Option<&str>) -> CheckoutInfo { - let relays = gp_nostr::relays::resolve(&cfg.relays); + let relays = gp_nostr::relays::resolve(cfg.relay_mode, &cfg.bundled_relay_url, &cfg.relays); let recipient_pubkey = inv.recipient_pubkey.clone().unwrap_or_default(); // The Nostr (Goblin Wallet) method is only surfaced when the operator has it // enabled (`GP_CHECKOUT_METHODS`). Disabled, the nprofile/npub/QR are left diff --git a/crates/gp-server/src/main.rs b/crates/gp-server/src/main.rs index 3168330..3f7f5a1 100644 --- a/crates/gp-server/src/main.rs +++ b/crates/gp-server/src/main.rs @@ -132,7 +132,7 @@ async fn start_ingest(cfg: &Config, pool: sqlx::SqlitePool) -> (Keys, GpWallet) eprintln!("warning: GP_NOTIFY_MERCHANT_DM=on but GP_MERCHANT_NPUB is unset/invalid"); } let opts = gp_nostr::service::ServiceOptions { - relays: gp_nostr::relays::resolve(&cfg.relays), + relays: gp_nostr::relays::resolve(cfg.relay_mode, &cfg.bundled_relay_url, &cfg.relays), nym: cfg.nym, notify: gp_nostr::service::NotifyOptions { merchant, diff --git a/deploy/relay/nostr-rs-relay.toml b/deploy/relay/nostr-rs-relay.toml new file mode 100644 index 0000000..d440c53 --- /dev/null +++ b/deploy/relay/nostr-rs-relay.toml @@ -0,0 +1,39 @@ +# Configuration for the BUNDLED GoblinPay relay: a stock, unmodified +# nostr-rs-relay (https://github.com/scsibug/nostr-rs-relay) run as the `relay` +# service in docker-compose.yml. This is the self-contained relay that +# `GP_RELAY_MODE=bundled` (the default) points at, so a merchant needs no +# third-party relay: GoblinPay dials it, and the checkout `nprofile` advertises +# it to payers, who deliver their gift-wrapped slatepack straight to the +# merchant's own relay. +# +# nostr-rs-relay is a small, SQLite-backed Rust relay: a good fit for a +# single-merchant till, and vendored as-is (config only, no fork). + +[info] +# Set this to the relay's PUBLIC wss URL (the same value you put in +# GP_BUNDLED_RELAY_URL). Payers connect here. +relay_url = "wss://pay.example/" +name = "GoblinPay bundled relay" +description = "Co-located Nostr relay for a GoblinPay merchant till." + +[database] +data_directory = "/usr/src/app/db" + +[network] +# Inside the container. Caddy terminates TLS and proxies wss -> here. +address = "0.0.0.0" +port = 7777 + +[limits] +# Bound the footprint so an unauthenticated ingest/subscription flood cannot +# starve the till (mirrors the reasoning behind the bundled strfry limits in +# goblin-nip05d). Payers publish NIP-59 gift wraps from random ephemeral keys, +# so a pubkey allowlist is intentionally NOT used (it would block payments). +messages_per_sec = 10 +subscriptions_per_min = 60 +max_event_bytes = 131072 +max_ws_message_bytes = 262144 +max_subscriptions = 20 + +[options] +reject_future_seconds = 1800