M8: bundled relay — RelayMode::Bundled runs a co-located nostr-rs-relay

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.
This commit is contained in:
2ro
2026-07-03 03:22:29 -04:00
parent b8434fdd36
commit c32ddfa9ff
8 changed files with 162 additions and 29 deletions
Generated
+1 -1
View File
@@ -5733,7 +5733,7 @@ dependencies = [
[[package]] [[package]]
name = "nip44" name = "nip44"
version = "0.1.0" version = "0.3.0"
dependencies = [ dependencies = [
"base64 0.22.1", "base64 0.22.1",
"chacha20 0.9.1", "chacha20 0.9.1",
+35 -3
View File
@@ -29,6 +29,14 @@ pub const DEFAULT_DATA_DIR: &str = "./gp-data";
/// Nostr gift-wrap layer in gp-nostr). /// Nostr gift-wrap layer in gp-nostr).
pub const DEFAULT_NODE_URL: &str = "https://main.gri.mw"; 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://<domain>` 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. /// TLS mode for the HTTP server.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")] #[serde(rename_all = "lowercase")]
@@ -53,7 +61,11 @@ pub enum Chain {
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")] #[serde(rename_all = "lowercase")]
pub enum RelayMode { 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, Bundled,
/// Only external relays from `GP_RELAYS` are used. /// Only external relays from `GP_RELAYS` are used.
External, External,
@@ -130,8 +142,14 @@ pub struct Config {
pub relay_mode: RelayMode, pub relay_mode: RelayMode,
/// External relays (`GP_RELAYS`, comma separated). /// External relays (`GP_RELAYS`, comma separated).
pub relays: Vec<String>, pub relays: Vec<String>,
/// 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`, /// 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, pub nym: bool,
/// Run the Nostr ingest service (`GP_INGEST`: `on` or `off`, default on). /// Run the Nostr ingest service (`GP_INGEST`: `on` or `off`, default on).
/// When on, the wallet and identity secrets are required at boot. /// When on, the wallet and identity secrets are required at boot.
@@ -235,6 +253,7 @@ impl Default for Config {
chain: Chain::Mainnet, chain: Chain::Mainnet,
relay_mode: RelayMode::Bundled, relay_mode: RelayMode::Bundled,
relays: Vec::new(), relays: Vec::new(),
bundled_relay_url: DEFAULT_BUNDLED_RELAY.into(),
nym: true, nym: true,
ingest: true, ingest: true,
checkout_nostr: true, checkout_nostr: true,
@@ -320,6 +339,10 @@ impl Config {
.map(|s| s.trim().to_string()) .map(|s| s.trim().to_string())
.filter(|s| !s.is_empty()) .filter(|s| !s.is_empty())
.collect::<Vec<_>>(); .collect::<Vec<_>>();
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") { let nym = match get("GP_NYM").as_deref().unwrap_or("on") {
"on" => true, "on" => true,
@@ -406,6 +429,7 @@ impl Config {
chain, chain,
relay_mode, relay_mode,
relays, relays,
bundled_relay_url,
nym, nym,
ingest, ingest,
checkout_nostr, checkout_nostr,
@@ -473,6 +497,9 @@ impl Config {
if self.relay_mode == RelayMode::External && self.relays.is_empty() { if self.relay_mode == RelayMode::External && self.relays.is_empty() {
return Err("GP_RELAY_MODE=external requires GP_RELAYS".into()); 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() { if self.nsec.is_some() && self.ncryptsec.is_some() {
return Err("set only one of GP_NSEC and GP_NCRYPTSEC".into()); 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" }; let set = |o: bool| if o { "set" } else { "unset" };
format!( format!(
"bind={} tls={} db={} data_dir={} node={} chain={:?} relay_mode={:?} \ "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={} \ wallet_password={} \
nsec={} ncryptsec={} public_url={} api_token={} admin_token={} webhook_url={} \ nsec={} ncryptsec={} public_url={} api_token={} admin_token={} webhook_url={} \
webhook_secret={} qr_logo={} merchant_npub={} notify_merchant_dm={} \ webhook_secret={} qr_logo={} merchant_npub={} notify_merchant_dm={} \
@@ -525,6 +553,7 @@ impl Config {
self.chain, self.chain,
self.relay_mode, self.relay_mode,
self.relays, self.relays,
self.bundled_relay_url,
if self.nym { "on" } else { "off" }, if self.nym { "on" } else { "off" },
if self.ingest { "on" } else { "off" }, if self.ingest { "on" } else { "off" },
self.checkout_methods_str(), self.checkout_methods_str(),
@@ -657,6 +686,7 @@ mod tests {
assert_eq!(cfg.chain, Chain::Mainnet); assert_eq!(cfg.chain, Chain::Mainnet);
assert_eq!(cfg.relay_mode, RelayMode::Bundled); assert_eq!(cfg.relay_mode, RelayMode::Bundled);
assert!(cfg.relays.is_empty()); assert!(cfg.relays.is_empty());
assert_eq!(cfg.bundled_relay_url, DEFAULT_BUNDLED_RELAY);
assert!(cfg.nym); assert!(cfg.nym);
assert!(cfg.ingest); assert!(cfg.ingest);
assert_eq!(cfg.match_mode, MatchMode::Memo); assert_eq!(cfg.match_mode, MatchMode::Memo);
@@ -676,6 +706,7 @@ mod tests {
("GP_CHAIN", "testnet"), ("GP_CHAIN", "testnet"),
("GP_RELAY_MODE", "external"), ("GP_RELAY_MODE", "external"),
("GP_RELAYS", "wss://relay.example, wss://relay2.example ,"), ("GP_RELAYS", "wss://relay.example, wss://relay2.example ,"),
("GP_BUNDLED_RELAY_URL", "wss://relay.mystore.example"),
("GP_NYM", "off"), ("GP_NYM", "off"),
("GP_INGEST", "off"), ("GP_INGEST", "off"),
("GP_MATCH_MODE", "derived"), ("GP_MATCH_MODE", "derived"),
@@ -691,6 +722,7 @@ mod tests {
cfg.relays, cfg.relays,
vec!["wss://relay.example", "wss://relay2.example"] vec!["wss://relay.example", "wss://relay2.example"]
); );
assert_eq!(cfg.bundled_relay_url, "wss://relay.mystore.example");
assert!(!cfg.nym); assert!(!cfg.nym);
assert!(!cfg.ingest); assert!(!cfg.ingest);
assert_eq!(cfg.match_mode, MatchMode::Derived); assert_eq!(cfg.match_mode, MatchMode::Derived);
+75 -18
View File
@@ -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 use gp_core::config::RelayMode;
/// 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",
];
/// Maximum relays published in the kind 10050 DM relay list (NIP-17 /// Maximum relays published in the kind 10050 DM relay list (NIP-17
/// guidance) and read from a payer's list. /// guidance) and read from a payer's list.
pub const MAX_DM_RELAYS: usize = 3; pub const MAX_DM_RELAYS: usize = 3;
/// The relay set to run with: the configured external list, else defaults. /// The relay set to listen on, publish to, and advertise in the `nprofile`.
pub fn resolve(configured: &[String]) -> Vec<String> { ///
if configured.is_empty() { /// In `bundled` mode the co-located `bundled_url` comes first (so it heads the
DEFAULT_RELAYS.iter().map(|s| s.to_string()).collect() /// advertised kind 10050 / `nprofile` hints), followed by any `configured`
} else { /// redundancy relays, de-duplicated. In `external` mode only the `configured`
configured.to_vec() /// relays are used.
pub fn resolve(mode: RelayMode, bundled_url: &str, configured: &[String]) -> Vec<String> {
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::*; use super::*;
#[test] #[test]
fn resolves_defaults_and_overrides() { fn bundled_leads_with_the_bundled_relay() {
assert_eq!(resolve(&[]), DEFAULT_RELAYS.to_vec()); // 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()]; 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
);
} }
} }
+7 -3
View File
@@ -41,8 +41,9 @@ const NYM_WARM_WAIT: Duration = Duration::from_secs(30);
pub struct ServiceOptions { pub struct ServiceOptions {
/// Relay set to listen on and publish to. /// Relay set to listen on and publish to.
pub relays: Vec<String>, pub relays: Vec<String>,
/// Route everything over the Nym mixnet (default on; clearnet is a /// Route everything over the Nym mixnet (default on). `off` is a supported
/// debugging escape hatch only). /// 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, pub nym: bool,
/// Optional NIP-17 payment DMs (milestone 6, all off by default). /// Optional NIP-17 payment DMs (milestone 6, all off by default).
pub notify: NotifyOptions, pub notify: NotifyOptions,
@@ -141,7 +142,10 @@ pub async fn run<R: SlatepackReceiver>(
.websocket_transport(NymWebSocketTransport) .websocket_transport(NymWebSocketTransport)
.build() .build()
} else { } 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() Client::builder().build()
}; };
+3 -2
View File
@@ -105,7 +105,8 @@ async fn dashboard(
match_mode: format!("{:?}", cfg.match_mode).to_lowercase(), match_mode: format!("{:?}", cfg.match_mode).to_lowercase(),
nym: cfg.nym, nym: cfg.nym,
ingest: cfg.ingest, 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(), webhook_configured: cfg.webhook_url.is_some(),
pending_webhooks, pending_webhooks,
rotate_interval: cfg.endpub_rotate_interval, 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 { 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) { let (npub, nprofile, qr) = match PublicKey::from_hex(pubkey) {
Ok(pk) => ( Ok(pk) => (
gp_nostr::npub_of(pk), gp_nostr::npub_of(pk),
+1 -1
View File
@@ -56,7 +56,7 @@ pub struct CheckoutInfo {
/// caller does not surface the Slatepack option (e.g. the JSON connector API), /// caller does not surface the Slatepack option (e.g. the JSON connector API),
/// in which case no Slatepack address or QR is produced. /// in which case no Slatepack address or QR is produced.
pub fn build_info(inv: &Invoice, cfg: &Config, slatepack_addr: Option<&str>) -> CheckoutInfo { 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(); let recipient_pubkey = inv.recipient_pubkey.clone().unwrap_or_default();
// The Nostr (Goblin Wallet) method is only surfaced when the operator has it // The Nostr (Goblin Wallet) method is only surfaced when the operator has it
// enabled (`GP_CHECKOUT_METHODS`). Disabled, the nprofile/npub/QR are left // enabled (`GP_CHECKOUT_METHODS`). Disabled, the nprofile/npub/QR are left
+1 -1
View File
@@ -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"); eprintln!("warning: GP_NOTIFY_MERCHANT_DM=on but GP_MERCHANT_NPUB is unset/invalid");
} }
let opts = gp_nostr::service::ServiceOptions { 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, nym: cfg.nym,
notify: gp_nostr::service::NotifyOptions { notify: gp_nostr::service::NotifyOptions {
merchant, merchant,
+39
View File
@@ -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