diff --git a/.env.example b/.env.example index c6e9a11..20ce4ac 100644 --- a/.env.example +++ b/.env.example @@ -5,9 +5,10 @@ PORT=3000 STELLAR_NETWORK=testnet STELLAR_HORIZON_URL=https://horizon-testnet.stellar.org -# Gateway wallet — the account that receives payments. +# Gateway wallet — the account that receives payments. The gateway only ever +# watches this address for incoming payments; it never signs or submits +# transactions, so no secret key is needed or accepted. STELLAR_GATEWAY_PUBLIC=GXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX -STELLAR_GATEWAY_SECRET=REPLACE_ME_stellar_secret_key # USDC issuer (testnet issuer shown; use the public-network issuer in production) USDC_ISSUER=GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5 diff --git a/README.md b/README.md index d25567e..8732ffe 100644 --- a/README.md +++ b/README.md @@ -68,7 +68,6 @@ cp .env.example .env | `STELLAR_NETWORK` | `testnet` or `public` | `testnet` | | `STELLAR_HORIZON_URL` | Horizon endpoint | testnet | | `STELLAR_GATEWAY_PUBLIC` | Your gateway wallet public key (`G...`). Validated as a Stellar strkey at startup; an invalid value aborts boot. | — | -| `STELLAR_GATEWAY_SECRET` | Your gateway wallet secret key | — | | `ACCEPTED_ASSETS` | Comma-separated assets to accept. Format: `CODE` for native (e.g. `XLM`) or `CODE:ISSUER` for non-native (e.g. `USDC:GISSUER`). Adding an asset is config-only — no code changes needed. Each `ISSUER` is validated as a Stellar strkey at startup. | `XLM,USDC:` | | `STELLAR_LISTENER_MODE` | `stream` (SSE + poller reconciler) or `poll` (interval only) | `stream` | | `POLL_INTERVAL_SECS` | How often the Horizon poller reconciles | `10` | @@ -94,6 +93,12 @@ cp .env.example .env > The poller pages forward through payments from a cursor persisted in the > database, so it never misses an intent regardless of on-chain volume and > resumes from where it left off after a restart. +> +> The gateway never holds a secret key and never signs or submits Stellar +> transactions — it only watches `STELLAR_GATEWAY_PUBLIC` for incoming payments. +> Overpayment refunds are the merchant's responsibility, triggered by the +> `payment.overpaid` webhook event (see below); the gateway does not perform them +> automatically. ### Run diff --git a/src/config.rs b/src/config.rs index 7ecd5b9..4431cff 100644 --- a/src/config.rs +++ b/src/config.rs @@ -85,7 +85,6 @@ pub struct Config { pub network: String, pub horizon_url: String, pub gateway_public: String, - pub gateway_secret: String, /// Assets the gateway will accept, validated on POST /payments and in verify(). /// Configure via ACCEPTED_ASSETS=XLM,USDC:GISSUER (comma-separated). pub accepted_assets: Vec, @@ -159,10 +158,6 @@ impl Config { .unwrap_or_else(|_| "https://horizon-testnet.stellar.org".to_string()); let gateway_public = std::env::var("STELLAR_GATEWAY_PUBLIC").unwrap_or_else(|_| "UNCONFIGURED".to_string()); - let gateway_secret = Self::validate_gateway_secret( - std::env::var("STELLAR_GATEWAY_SECRET").unwrap_or_default(), - &gateway_public, - )?; let webhook_secret = Self::validate_webhook_secret(std::env::var("WEBHOOK_SECRET"))?; let cors_allowed_origins: Vec = { @@ -194,7 +189,6 @@ impl Config { network, horizon_url, gateway_public, - gateway_secret, accepted_assets: { let raw = std::env::var("ACCEPTED_ASSETS").unwrap_or_default(); if raw.is_empty() { @@ -364,45 +358,6 @@ impl Config { Ok(secret) } - - /// Validate `STELLAR_GATEWAY_SECRET` at boot. - /// - /// - Empty is allowed when the gateway public key is also unconfigured - /// (development / read-only mode). - /// - The placeholder value from `.env.example` (`SXXX…` or `REPLACE_ME_*`) is always - /// rejected — it would silently sign nothing but gives operators false - /// confidence that the key is set. - fn validate_gateway_secret(secret: String, gateway_public: &str) -> Result { - let configured = !gateway_public.is_empty() && gateway_public != "UNCONFIGURED"; - - // Reject the classic .env.example placeholder: starts with 'S' and - // the rest are all 'X's (e.g. SXXXXXXX…56 chars). - if !secret.is_empty() && secret.starts_with('S') && secret.chars().skip(1).all(|c| c == 'X') { - return Err(anyhow::anyhow!( - "STELLAR_GATEWAY_SECRET is set to a placeholder value from .env.example. \ - Replace it with your real Stellar secret key." - )); - } - - // Reject any REPLACE_ME_ placeholder. - if secret.starts_with("REPLACE_ME_") { - return Err(anyhow::anyhow!( - "STELLAR_GATEWAY_SECRET is set to a placeholder value ({:?}). \ - Replace it with your real Stellar secret key.", - secret - )); - } - - // If a real public key has been configured, a secret key must also be present. - if configured && secret.is_empty() { - return Err(anyhow::anyhow!( - "STELLAR_GATEWAY_SECRET is required when STELLAR_GATEWAY_PUBLIC is set. \ - Set STELLAR_GATEWAY_SECRET to the corresponding secret key." - )); - } - - Ok(secret) - } } impl std::fmt::Debug for Config { @@ -413,7 +368,6 @@ impl std::fmt::Debug for Config { .field("network", &self.network) .field("horizon_url", &self.horizon_url) .field("gateway_public", &self.gateway_public) - .field("gateway_secret", &"***") .field("accepted_assets", &self.accepted_assets) .field("webhook_secret", &"***") .field("webhook_retry_attempts", &self.webhook_retry_attempts) @@ -494,7 +448,6 @@ mod tests { network: "testnet".into(), horizon_url: "https://horizon-testnet.stellar.org".into(), gateway_public: "GPUBLIC".into(), - gateway_secret: "super-secret-key".into(), accepted_assets: AcceptedAsset::default_list(), webhook_secret: "webhook-hmac-secret".into(), webhook_retry_attempts: 3, @@ -516,10 +469,6 @@ mod tests { request_timeout_secs: 30, }; let output = format!("{cfg:?}"); - assert!( - !output.contains("super-secret-key"), - "gateway_secret must not appear in Debug output" - ); assert!( !output.contains("webhook-hmac-secret"), "webhook_secret must not appear in Debug output" @@ -568,7 +517,6 @@ mod tests { network: "testnet".into(), horizon_url: "https://horizon-testnet.stellar.org".into(), gateway_public: "UNCONFIGURED".into(), - gateway_secret: String::new(), accepted_assets: AcceptedAsset::default_list(), webhook_secret: String::new(), webhook_retry_attempts: 3, @@ -751,10 +699,6 @@ mod tests { "STELLAR_GATEWAY_PUBLIC", Some("GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5"), ), - ( - "STELLAR_GATEWAY_SECRET", - Some("SCZANGBA5RLKJHTBF4RJNRJMZWI4VKTHCRKOVAH7LRZZPZHHZWATAWBN"), - ), ], || { let cfg = Config::from_env().unwrap(); @@ -896,45 +840,4 @@ mod tests { "error should echo the bad value; got: {err}" ); } - - // ── validate_gateway_secret ────────────────────────────────────────────── - - #[test] - fn gateway_secret_empty_allowed_when_unconfigured() { - let res = Config::validate_gateway_secret(String::new(), "UNCONFIGURED"); - assert!(res.is_ok()); - } - - #[test] - fn gateway_secret_placeholder_rejected() { - let placeholder = "S".to_string() + &"X".repeat(55); - let err = Config::validate_gateway_secret(placeholder, "UNCONFIGURED") - .unwrap_err() - .to_string(); - assert!(err.contains("placeholder value"), "got: {err}"); - } - - #[test] - fn gateway_secret_required_when_public_key_set() { - let err = Config::validate_gateway_secret( - String::new(), - "GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5", - ) - .unwrap_err() - .to_string(); - assert!( - err.contains("STELLAR_GATEWAY_SECRET is required"), - "got: {err}" - ); - } - - #[test] - fn gateway_secret_valid_accepted() { - // A real-looking secret key (not all-X after S) - let res = Config::validate_gateway_secret( - "SCZANGBA5RLKJHTBF4RJNRJMZWI4VKTHCRKOVAH7LRZZPZHHZWATAWBN".into(), - "GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5", - ); - assert!(res.is_ok()); - } } diff --git a/src/expiry.rs b/src/expiry.rs index 488573c..d9b18c5 100644 --- a/src/expiry.rs +++ b/src/expiry.rs @@ -72,7 +72,6 @@ mod tests { network: "testnet".into(), horizon_url: String::new(), gateway_public: "GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5".into(), - gateway_secret: String::new(), accepted_assets: AcceptedAsset::default_list(), webhook_secret: "a-very-long-and-secure-webhook-signing-secret-32-chars".into(), webhook_retry_attempts: 1, diff --git a/tests/api_tests.rs b/tests/api_tests.rs index be2e53c..5300439 100644 --- a/tests/api_tests.rs +++ b/tests/api_tests.rs @@ -18,7 +18,6 @@ fn make_config() -> Config { network: "testnet".into(), horizon_url: String::new(), gateway_public: "UNCONFIGURED".into(), - gateway_secret: String::new(), accepted_assets: stellargate::config::AcceptedAsset::default_list(), webhook_secret: String::new(), webhook_retry_attempts: 1, diff --git a/tests/concurrency_tests.rs b/tests/concurrency_tests.rs index c4f71a6..6ed2230 100644 --- a/tests/concurrency_tests.rs +++ b/tests/concurrency_tests.rs @@ -72,7 +72,6 @@ fn make_state(pool: db::Db, _webhook_url: Option) -> Arc { horizon_url: String::new(), // A real-looking Stellar strkey so Config::validate_addresses passes. gateway_public: "GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5".into(), - gateway_secret: String::new(), accepted_assets, webhook_secret: "a-very-long-and-secure-webhook-signing-secret-32-chars".into(), webhook_retry_attempts: 1, diff --git a/tests/rate_limit_tests.rs b/tests/rate_limit_tests.rs index 86348cc..a762bdf 100644 --- a/tests/rate_limit_tests.rs +++ b/tests/rate_limit_tests.rs @@ -22,7 +22,6 @@ fn make_config(rate_limit_requests_per_sec: u32) -> Config { network: "testnet".into(), horizon_url: String::new(), gateway_public: "UNCONFIGURED".into(), - gateway_secret: String::new(), accepted_assets: stellargate::config::AcceptedAsset::default_list(), webhook_secret: String::new(), webhook_retry_attempts: 1, diff --git a/tests/trustline_tests.rs b/tests/trustline_tests.rs index 72c1acf..3eb1b31 100644 --- a/tests/trustline_tests.rs +++ b/tests/trustline_tests.rs @@ -40,7 +40,6 @@ async fn make_state(horizon_url: String) -> Arc { network: "testnet".into(), horizon_url, gateway_public: GATEWAY.into(), - gateway_secret: String::new(), accepted_assets: vec![ AcceptedAsset { code: "XLM".into(), diff --git a/tests/webhook_dispatch_tests.rs b/tests/webhook_dispatch_tests.rs index a894331..9b27220 100644 --- a/tests/webhook_dispatch_tests.rs +++ b/tests/webhook_dispatch_tests.rs @@ -25,7 +25,6 @@ fn make_config(webhook_secret: &str, retry_attempts: u32) -> Config { network: "testnet".into(), horizon_url: String::new(), gateway_public: "GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5".into(), - gateway_secret: String::new(), accepted_assets: AcceptedAsset::default_list(), webhook_secret: webhook_secret.into(), webhook_retry_attempts: retry_attempts,