diff --git a/src/api/capabilities.rs b/src/api/capabilities.rs index b5ce111..abac6ed 100644 --- a/src/api/capabilities.rs +++ b/src/api/capabilities.rs @@ -123,6 +123,19 @@ pub struct PremiumCapability { pub plans: Vec, /// The relay premium posting targets (mirrors `messaging.relay_url`). pub relay_url: String, + /// Ways to pay, primary first. Pass `id` as `rail` to `/premium/invoice`. + pub rails: Vec, +} + +/// One way to pay for premium. +#[derive(Debug, Serialize, utoipa::ToSchema)] +pub struct PremiumRailInfo { + /// Rail id, e.g. `btcpay`, `xmrcheckout`, `wowcheckout`. + pub id: String, + /// Assets this rail takes, e.g. `["BTC","LTC"]`. Empty means the operator + /// did not declare them; the wallet should then show the rail unlabelled + /// rather than guess. + pub assets: Vec, } /// Public curated feed details (present only when `features.feed`). A read-only @@ -375,6 +388,7 @@ pub fn effective_capabilities(config: &Config) -> CapabilitiesResponse { }) .collect(), relay_url: config.messaging.relay.advertised_url.clone(), + rails: premium_rails(config), }), feed: feed_advertised(config).then(|| { let f = &config.feed; @@ -399,6 +413,21 @@ fn relay_advertised(config: &Config) -> bool { /// Whether the premium tier is enabled AND the relay it gates is advertised /// (config validation already couples premium to relay + the premium-post policy). +/// The premium rails, derived from config alone so capabilities never depends +/// on a processor being reachable. Mirrors `payment::rails_from_config` order. +fn premium_rails(config: &Config) -> Vec { + let primary = PremiumRailInfo { + id: config.registration.payment.provider.clone(), + assets: config.premium.primary_assets.clone(), + }; + std::iter::once(primary) + .chain(config.premium.rails.iter().map(|r| PremiumRailInfo { + id: r.kind.to_string(), + assets: vec![r.asset.to_string()], + })) + .collect() +} + fn premium_advertised(config: &Config) -> bool { config.premium.enabled && relay_advertised(config) } diff --git a/src/api/openapi.rs b/src/api/openapi.rs index 236fbf3..c5d693c 100644 --- a/src/api/openapi.rs +++ b/src/api/openapi.rs @@ -94,6 +94,7 @@ use utoipa::OpenApi; crate::api::capabilities::MessagingCapability, crate::api::capabilities::PremiumCapability, crate::api::capabilities::PremiumPlanInfo, + crate::api::capabilities::PremiumRailInfo, crate::api::capabilities::FeedCapability, crate::api::prices::PricesResponse, crate::api::auth::AuthResponse, diff --git a/src/api/premium.rs b/src/api/premium.rs index 6bc0080..93e911d 100644 --- a/src/api/premium.rs +++ b/src/api/premium.rs @@ -26,6 +26,10 @@ use crate::AppState; pub struct InvoiceReq { /// Plan id (see `/capabilities` → `premium.plans`), e.g. `quarter`. pub plan: String, + /// Payment rail (see `/capabilities` → `premium.rails[].id`). Omitted means + /// the primary rail, which is how clients before rails behaved. + #[serde(default)] + pub rail: Option, } #[derive(Debug, Serialize, utoipa::ToSchema)] @@ -36,6 +40,8 @@ pub struct InvoiceResp { pub plan: String, pub amount: String, pub currency: String, + /// The rail the invoice was minted on. + pub rail: String, } #[derive(Debug, Deserialize, utoipa::ToSchema)] @@ -146,10 +152,16 @@ pub async fn invoice( )); } - let provider = state - .payment - .as_ref() - .ok_or_else(|| AppError::Internal("payment provider not configured".into()))?; + let rail = state + .payment_rails + .select(req.rail.as_deref().map(str::trim)) + .ok_or_else(|| match req.rail { + // Named a rail this instance does not offer: refuse rather than mint + // an invoice in a coin the user did not choose. + Some(_) => AppError::ValidationError("That payment method is not available.".into()), + None => AppError::Internal("payment provider not configured".into()), + })?; + let provider = &rail.provider; let pay = &state.cfg().registration.payment; let currency = state.cfg().premium.currency.clone(); @@ -182,6 +194,7 @@ pub async fn invoice( plan: plan.id.clone(), amount: plan.amount.clone(), currency, + rail: provider.kind().to_string(), })) } @@ -207,11 +220,6 @@ pub async fn activate( let user_id = extract_user_id_from_token(&state, &headers).await?; ensure_enabled(&state)?; - let provider = state - .payment - .as_ref() - .ok_or_else(|| AppError::Internal("payment provider not configured".into()))?; - let id = req.invoice_id.trim(); let uid = user_id.to_string(); @@ -231,8 +239,19 @@ pub async fn activate( )); } - // Source of truth: the processor. Only Settled grants (paid in full per policy). - let inv = provider.get_invoice(id).await?; + // Source of truth: the processor THAT MINTED IT. Only Settled grants. + let rail = state + .payment_rails + .for_invoice(&row.provider) + .ok_or_else(|| { + // The rail was switched off after this invoice was minted. Refuse + // and keep the invoice unconsumed, so re-enabling the rail lets the + // user redeem it rather than losing what they paid. + AppError::ValidationError( + "This invoice's payment method is currently unavailable. Try again later.".into(), + ) + })?; + let inv = rail.provider.get_invoice(id).await?; if inv.status != InvoiceStatus::Settled { return Err(AppError::ValidationError( "Payment not yet confirmed. Complete the payment and retry.".into(), diff --git a/src/config.rs b/src/config.rs index 0d6ef84..c1e2c44 100644 --- a/src/config.rs +++ b/src/config.rs @@ -692,6 +692,96 @@ pub struct PremiumConfig { pub enabled: bool, pub currency: String, pub plans: Vec, + /// Assets the PRIMARY processor (`registration.payment`) accepts, as the + /// wallet should label it (`PAYMENT_ASSETS`, e.g. `BTC,LTC,GRIN`). Display + /// only: the processor decides what it will actually take. + pub primary_assets: Vec, + /// Additional Greenfield-compatible processors premium may invoice through, + /// one per rail. Registration never uses these; it keeps the primary. + pub rails: Vec, +} + +/// One additional BTCPay-Greenfield-compatible processor, for premium only. +/// +/// xmrcheckout and wowcheckout implement the same two invoice routes as BTCPay +/// Server, so the existing adapter serves them unchanged. They exist because +/// BTCPay itself cannot take Wownero, and this operator runs Monero through its +/// own checkout rather than the BTCPay plugin. Each rail is its own store with +/// its own key: sharing one across processors would couple their rotation. +#[derive(Clone)] +pub struct PaymentRailConfig { + /// Stable rail id, persisted on the invoice row so activation polls the + /// processor that minted it. + pub kind: &'static str, + /// The one asset this rail accepts, as the wallet should label it. + pub asset: &'static str, + pub provider_url: String, + pub store_id: String, + pub api_key: String, +} + +/// The checkout rails an operator can enable, as (kind, asset, env prefix). +/// The kind strings are persisted on invoice rows: never rename one. +pub const CHECKOUT_RAILS: &[(&str, &str, &str)] = &[ + ("xmrcheckout", "XMR", "PAYMENT_XMRCHECKOUT"), + ("wowcheckout", "WOW", "PAYMENT_WOWCHECKOUT"), +]; + +/// Refuse a checkout rail that cannot work. All three values or none, https in +/// production, and a key that is neither short nor a placeholder. Each refusal +/// names the rail's variables, so the operator knows which rail is broken. +fn validate_rail(rail: &PaymentRailConfig, prod: bool) -> Result<(), AppError> { + let prefix = CHECKOUT_RAILS + .iter() + .find(|(k, _, _)| *k == rail.kind) + .map(|(_, _, p)| *p) + .unwrap_or("PAYMENT_RAIL"); + if rail.provider_url.trim().is_empty() || rail.store_id.trim().is_empty() { + return Err(cfg_err(format!( + "{prefix}_URL and {prefix}_STORE_ID must both be set to enable the {} rail, \ + or {prefix}_* left entirely unset", + rail.kind + ))); + } + if prod && !rail.provider_url.starts_with("https://") && !is_loopback_url(&rail.provider_url) { + return Err(cfg_err(format!( + "{prefix}_URL must be https:// in production (loopback exempt)" + ))); + } + if rail.api_key.len() < 8 { + return Err(cfg_err(format!( + "{prefix}_API_KEY must be set and at least 8 bytes" + ))); + } + if prod && looks_placeholder(&rail.api_key) { + return Err(cfg_err(format!( + "{prefix}_API_KEY looks like a placeholder; set a real value" + ))); + } + Ok(()) +} + +/// Read the checkout rails from `_URL`, `_STORE_ID` and +/// `_API_KEY`. A rail with none of the three is simply off. A rail with +/// SOME of them is kept here so `validate()` can refuse it by name: a partial +/// rail is an operator mistake, and dropping it silently would advertise a coin +/// that then cannot be paid. +fn parse_checkout_rails() -> Vec { + CHECKOUT_RAILS + .iter() + .filter_map(|&(kind, asset, prefix)| { + let url = env_or(&format!("{prefix}_URL"), ""); + let store = env_or(&format!("{prefix}_STORE_ID"), ""); + let key = env_or(&format!("{prefix}_API_KEY"), ""); + (!url.is_empty() || !store.is_empty() || !key.is_empty()).then_some(PaymentRailConfig { + kind, + asset, + provider_url: url, + store_id: store, + api_key: key, + }) + }) + .collect() } /// Parse `PREMIUM_PLANS` = comma-separated `id:days:amount` entries (e.g. @@ -1018,6 +1108,11 @@ impl Config { enabled: env_bool("PREMIUM_ENABLED", false), currency: env_or("PREMIUM_CURRENCY", "").to_uppercase(), plans: parse_premium_plans(&env_or("PREMIUM_PLANS", "")), + primary_assets: env_list("PAYMENT_ASSETS") + .into_iter() + .map(|a| a.to_uppercase()) + .collect(), + rails: parse_checkout_rails(), }, feed: FeedConfig { enabled: env_bool("FEED_ENABLED", false), @@ -1493,6 +1588,9 @@ impl Config { ))); } } + for rail in &self.premium.rails { + validate_rail(rail, prod)?; + } } Ok(()) @@ -1663,6 +1761,8 @@ impl Config { enabled: false, currency: String::new(), plans: Vec::new(), + primary_assets: Vec::new(), + rails: Vec::new(), }, feed: FeedConfig { enabled: false, @@ -2219,6 +2319,79 @@ mod tests { assert!(parse_premium_plans("").is_empty()); } + fn checkout_rail(kind: &'static str) -> PaymentRailConfig { + PaymentRailConfig { + kind, + asset: "XMR", + provider_url: "https://checkout.example.org".into(), + store_id: "merchant-1".into(), + api_key: "xmrcheckout_realkeyvalue0123456789".into(), + } + } + + #[test] + fn a_complete_checkout_rail_passes() { + let mut c = valid(); + wire_premium(&mut c); + c.premium.rails = vec![checkout_rail("xmrcheckout"), checkout_rail("wowcheckout")]; + assert!(c.validate().is_ok()); + } + + #[test] + fn a_half_configured_rail_is_refused_by_name() { + // A rail with a key but no URL would be advertised and then fail every + // invoice. The refusal names the rail so the operator knows which one. + for strip in ["url", "store", "key"] { + let mut c = valid(); + wire_premium(&mut c); + let mut r = checkout_rail("wowcheckout"); + match strip { + "url" => r.provider_url.clear(), + "store" => r.store_id.clear(), + _ => r.api_key.clear(), + } + c.premium.rails = vec![r]; + let err = c + .validate() + .err() + .map(|e| e.to_string()) + .unwrap_or_default(); + assert!(err.contains("WOWCHECKOUT"), "missing {strip}: {err}"); + } + } + + #[test] + fn a_rail_is_https_in_production_only() { + let mut r = checkout_rail("xmrcheckout"); + r.provider_url = "http://checkout.example.org".into(); + let err = validate_rail(&r, true) + .err() + .map(|e| e.to_string()) + .unwrap_or_default(); + assert!(err.contains("XMRCHECKOUT_URL"), "{err}"); + assert!( + validate_rail(&r, false).is_ok(), + "plain http is fine off production" + ); + r.provider_url = "http://127.0.0.1:8080".into(); + assert!(validate_rail(&r, true).is_ok(), "loopback is exempt"); + } + + #[test] + fn rail_kinds_are_unique_and_stable() { + // Kinds are persisted on invoice rows; a duplicate would route one + // processor's invoices to another. + let mut kinds: Vec<_> = CHECKOUT_RAILS.iter().map(|(k, _, _)| *k).collect(); + let n = kinds.len(); + kinds.sort(); + kinds.dedup(); + assert_eq!(kinds.len(), n); + assert!( + !kinds.contains(&"btcpay"), + "a checkout rail must not collide with the primary" + ); + } + #[test] fn premium_enabled_full_config_passes() { let mut c = valid(); diff --git a/src/infra/db/premium_invoices.rs b/src/infra/db/premium_invoices.rs index 19318e8..162f0e9 100644 --- a/src/infra/db/premium_invoices.rs +++ b/src/infra/db/premium_invoices.rs @@ -23,6 +23,9 @@ pub struct PremiumInvoiceRow { pub period_days: i32, pub amount: String, pub currency: String, + /// The rail that minted it (`btcpay`, `xmrcheckout`, ...): activation polls + /// this processor, never whichever one happens to be primary today. + pub provider: String, /// `None` while unspent; set once when the invoice extends premium. pub consumed_at: Option>, } @@ -73,17 +76,27 @@ impl Database { i32, String, String, + String, Option>, ), >( - "SELECT invoice_id, user_id, plan_id, period_days, amount, currency, consumed_at \ + "SELECT invoice_id, user_id, plan_id, period_days, amount, currency, provider, consumed_at \ FROM premium_invoices WHERE invoice_id = $1", ) .bind(invoice_id) .fetch_optional(self.pool()) .await?; Ok(row.map( - |(invoice_id, user_id, plan_id, period_days, amount, currency, consumed_at)| { + |( + invoice_id, + user_id, + plan_id, + period_days, + amount, + currency, + provider, + consumed_at, + )| { PremiumInvoiceRow { invoice_id, user_id, @@ -91,6 +104,7 @@ impl Database { period_days, amount, currency, + provider, consumed_at, } }, diff --git a/src/infra/payment/btcpay.rs b/src/infra/payment/btcpay.rs index 39b3087..1da8823 100644 --- a/src/infra/payment/btcpay.rs +++ b/src/infra/payment/btcpay.rs @@ -20,7 +20,7 @@ use serde::{de::DeserializeOwned, Deserialize, Serialize}; use tracing::instrument; use super::{Invoice, InvoiceRequest, InvoiceStatus, PaymentProvider}; -use crate::config::PaymentConfig; +use crate::config::{PaymentConfig, PaymentRailConfig}; use crate::core::secret::Secret; use crate::error::AppError; @@ -32,6 +32,12 @@ const MAX_BODY_BYTES: usize = 1024 * 1024; /// A BTCPay-compatible invoice processor (one store). #[derive(Clone)] pub struct BtcPayProvider { + /// Rail id persisted on invoice rows (`btcpay`, `xmrcheckout`, ...). + kind: &'static str, + /// Also send the exact `checkout.confirmationsRequired`. The checkout apps + /// honour an exact count; BTCPay Server has only `speedPolicy` buckets, so + /// it is not sent there rather than trusting BTCPay to ignore it. + exact_confirmations: bool, /// Base URL, no trailing slash (e.g. `https://pay.example.org`). base_url: String, store_id: String, @@ -43,15 +49,45 @@ impl BtcPayProvider { /// Build from the pay-to-register config. Assumes the config was validated /// (non-empty URL/store/key); a malformed HTTP client build fails closed. pub fn new(cfg: &PaymentConfig) -> Result { + Self::build( + "btcpay", + false, + &cfg.provider_url, + &cfg.store_id, + &cfg.api_key, + ) + } + + /// Build a checkout rail (xmrcheckout / wowcheckout): same Greenfield + /// routes, its own store and key, and an exact confirmation count. + pub fn checkout_rail(rail: &PaymentRailConfig) -> Result { + Self::build( + rail.kind, + true, + &rail.provider_url, + &rail.store_id, + &rail.api_key, + ) + } + + fn build( + kind: &'static str, + exact_confirmations: bool, + url: &str, + store_id: &str, + api_key: &str, + ) -> Result { let http = reqwest::Client::builder() .timeout(REQUEST_TIMEOUT) .connect_timeout(CONNECT_TIMEOUT) .build() .map_err(|_| AppError::ConfigError("failed to build payment HTTP client".into()))?; Ok(Self { - base_url: cfg.provider_url.trim_end_matches('/').to_string(), - store_id: cfg.store_id.clone(), - api_key: Secret::new(cfg.api_key.clone()), + kind, + exact_confirmations, + base_url: url.trim_end_matches('/').to_string(), + store_id: store_id.to_string(), + api_key: Secret::new(api_key.to_string()), http, }) } @@ -161,7 +197,7 @@ impl BtcPayProvider { #[async_trait::async_trait] impl PaymentProvider for BtcPayProvider { fn kind(&self) -> &'static str { - "btcpay" + self.kind } #[instrument(skip(self, req), fields(store = %self.store_id))] @@ -174,6 +210,7 @@ impl PaymentProvider for BtcPayProvider { checkout: BtcPayCheckout { speed_policy: Self::speed_policy(req.confirmations), expiration_minutes: req.expires_minutes, + confirmations_required: self.exact_confirmations.then_some(req.confirmations), }, }; let resp: BtcPayInvoiceResp = self.post_json(url, "create_invoice", &body).await?; @@ -209,6 +246,8 @@ struct BtcPayCreateReq<'a> { struct BtcPayCheckout { speed_policy: &'static str, expiration_minutes: u32, + #[serde(skip_serializing_if = "Option::is_none")] + confirmations_required: Option, } /// The subset of a BTCPay invoice we consume. Extra fields are ignored. @@ -359,6 +398,7 @@ mod tests { checkout: BtcPayCheckout { speed_policy: "MediumSpeed", expiration_minutes: 60, + confirmations_required: None, }, }; let v = serde_json::to_value(&body).unwrap(); @@ -368,4 +408,47 @@ mod tests { assert_eq!(v["checkout"]["speedPolicy"], "MediumSpeed"); assert_eq!(v["checkout"]["expirationMinutes"], 60); } + + #[test] + fn btcpay_server_is_not_sent_an_exact_confirmation_count() { + // BTCPay Server expresses finality only as speedPolicy buckets, so the + // field is omitted rather than trusting BTCPay to ignore it. + let c = BtcPayCheckout { + speed_policy: "MediumSpeed", + expiration_minutes: 60, + confirmations_required: None, + }; + let v = serde_json::to_value(&c).unwrap(); + assert!(v.get("confirmationsRequired").is_none()); + } + + #[test] + fn a_checkout_rail_is_sent_the_exact_count() { + let c = BtcPayCheckout { + speed_policy: "LowMediumSpeed", + expiration_minutes: 60, + confirmations_required: Some(3), + }; + let v = serde_json::to_value(&c).unwrap(); + assert_eq!(v["confirmationsRequired"], 3); + // speedPolicy still rides along; the checkouts accept and ignore it. + assert!(v.get("speedPolicy").is_some()); + } + + #[test] + fn each_rail_reports_its_own_kind() { + // The kind is persisted on the invoice row and routes activation, so two + // rails must never report the same one. + let rail = |kind| crate::config::PaymentRailConfig { + kind, + asset: "XMR", + provider_url: "https://pay.example.org".into(), + store_id: "s".into(), + api_key: "xmrcheckout_secretkey".into(), + }; + let x = BtcPayProvider::checkout_rail(&rail("xmrcheckout")).unwrap(); + let w = BtcPayProvider::checkout_rail(&rail("wowcheckout")).unwrap(); + assert_ne!(x.kind(), w.kind()); + assert!(x.exact_confirmations && w.exact_confirmations); + } } diff --git a/src/infra/payment/mod.rs b/src/infra/payment/mod.rs index 590cadb..34e8c9a 100644 --- a/src/infra/payment/mod.rs +++ b/src/infra/payment/mod.rs @@ -127,3 +127,130 @@ pub fn from_config(cfg: &Config) -> Result>, App }; Ok(Some(provider)) } + +/// One way to pay for premium: a processor plus the assets it takes. +#[derive(Clone)] +pub struct PaymentRail { + pub provider: Arc, + /// Asset codes as the wallet should label them. + pub assets: Vec, +} + +impl PaymentRail { + pub fn kind(&self) -> &'static str { + self.provider.kind() + } +} + +/// Every processor premium may invoice through, primary first. +/// +/// The primary is the same provider the registration gate uses, so an instance +/// with no checkout rails behaves exactly as before. Rails are looked up by the +/// kind persisted on the invoice row, so an invoice is always polled at the +/// processor that minted it, never at whichever one is first today. +#[derive(Clone, Default)] +pub struct PaymentRails { + rails: Vec, +} + +impl PaymentRails { + /// The rails in advertisement order, primary first. + pub fn all(&self) -> &[PaymentRail] { + &self.rails + } + + /// The rail a new invoice uses: the one named, or the primary when none is. + /// An unknown name is refused rather than defaulted, so a client that asked + /// for Monero is never silently handed a Bitcoin invoice. + pub fn select(&self, kind: Option<&str>) -> Option<&PaymentRail> { + match kind { + None => self.rails.first(), + Some(k) => self.rails.iter().find(|r| r.kind() == k), + } + } + + /// The rail that minted a stored invoice. + pub fn for_invoice(&self, provider_kind: &str) -> Option<&PaymentRail> { + self.rails.iter().find(|r| r.kind() == provider_kind) + } +} + +/// Build premium's rails: the primary provider (if any) followed by each +/// configured checkout rail. Empty when premium is off. +pub fn rails_from_config( + cfg: &Config, + primary: Option<&Arc>, +) -> Result { + if !cfg.premium.enabled { + return Ok(PaymentRails::default()); + } + let mut rails = Vec::new(); + if let Some(p) = primary { + rails.push(PaymentRail { + provider: Arc::clone(p), + assets: cfg.premium.primary_assets.clone(), + }); + } + for rail in &cfg.premium.rails { + rails.push(PaymentRail { + provider: Arc::new(BtcPayProvider::checkout_rail(rail)?), + assets: vec![rail.asset.to_string()], + }); + } + Ok(PaymentRails { rails }) +} + +#[cfg(test)] +mod rail_tests { + use super::*; + + struct Fake(&'static str); + + #[async_trait] + impl PaymentProvider for Fake { + fn kind(&self) -> &'static str { + self.0 + } + async fn create_invoice(&self, _: &InvoiceRequest) -> Result { + unreachable!() + } + async fn get_invoice(&self, _: &str) -> Result { + unreachable!() + } + } + + fn rails(kinds: &[&'static str]) -> PaymentRails { + PaymentRails { + rails: kinds + .iter() + .map(|k| PaymentRail { + provider: Arc::new(Fake(k)), + assets: vec![], + }) + .collect(), + } + } + + #[test] + fn no_choice_means_the_primary() { + let r = rails(&["btcpay", "xmrcheckout"]); + assert_eq!(r.select(None).map(|x| x.kind()), Some("btcpay")); + } + + #[test] + fn an_unknown_rail_is_refused_not_defaulted() { + // Asking for a coin this instance cannot take must not quietly mint an + // invoice in a different coin. + let r = rails(&["btcpay", "xmrcheckout"]); + assert!(r.select(Some("wowcheckout")).is_none()); + } + + #[test] + fn an_invoice_is_polled_where_it_was_minted() { + let r = rails(&["btcpay", "xmrcheckout", "wowcheckout"]); + for k in ["btcpay", "xmrcheckout", "wowcheckout"] { + assert_eq!(r.for_invoice(k).map(|x| x.kind()), Some(k)); + } + assert!(r.for_invoice("retired-processor").is_none()); + } +} diff --git a/src/lib.rs b/src/lib.rs index c1412e9..c57badc 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -73,6 +73,9 @@ pub struct AppState { /// Payment processor backing the optional pay-to-register gate; `Some` only /// when `REGISTRATION_REQUIRE_PAYMENT` is on. pub payment: Option>, + /// Every processor premium may invoice through, primary first. Empty unless + /// premium is on. + pub payment_rails: crate::infra::payment::PaymentRails, /// Optional first-party Nostr relay (messaging plane); `Some` only when /// `RELAY_ENABLED` is on. Advertised via `/capabilities` + NIP-05 hints. pub relay: Option>, diff --git a/src/main.rs b/src/main.rs index 6c83dd6..147ff3a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -66,6 +66,8 @@ async fn main() -> Result<(), Box> { let chains = ChainClients::from_config(&config)?; // Payment processor for the pay-to-register gate (None unless it's enabled). let payment = smirk_backend_core::infra::payment::from_config(&config)?; + let payment_rails = + smirk_backend_core::infra::payment::rails_from_config(&config, payment.as_ref())?; // Optional Nostr relay (messaging plane); None unless RELAY_ENABLED. let relay = smirk_backend_core::infra::relay::from_config(&config)?; @@ -129,6 +131,7 @@ async fn main() -> Result<(), Box> { sessions, chains, payment, + payment_rails, relay, web_challenges: Arc::default(), prices: prices_cache, diff --git a/tests/common/mod.rs b/tests/common/mod.rs index 891bac3..5d9a737 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -79,6 +79,9 @@ pub async fn try_app_with(mutate: impl FnOnce(&mut Config)) -> Option { let chains = ChainClients::from_config(&config).expect("build chain clients"); let payment = smirk_backend_core::infra::payment::from_config(&config).expect("build payment provider"); + let payment_rails = + smirk_backend_core::infra::payment::rails_from_config(&config, payment.as_ref()) + .expect("build payment rails"); let relay = smirk_backend_core::infra::relay::from_config(&config).expect("build relay provider"); let prices = Arc::new(tokio::sync::RwLock::new( @@ -98,6 +101,7 @@ pub async fn try_app_with(mutate: impl FnOnce(&mut Config)) -> Option { sessions, chains, payment, + payment_rails, relay, web_challenges: Arc::default(), prices,