From afccc653882d2de135fd7683125759079334a3cf Mon Sep 17 00:00:00 2001 From: Bojay Liu <189326887+BojayL@users.noreply.github.com> Date: Fri, 19 Jun 2026 21:09:59 +0800 Subject: [PATCH 1/2] Add Render network passphrase config --- README.md | 11 +++++++ crates/shared/src/config.rs | 59 +++++++++++++++++++++++++++++++++- docs/configuration/env-vars.md | 2 +- docs/deployment/render.md | 9 +++++- render.yaml | 2 ++ 5 files changed, 80 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 351345e..93b7363 100644 --- a/README.md +++ b/README.md @@ -56,6 +56,17 @@ cargo run -p waveflow-api - [Implementation Roadmap](docs/ROADMAP.md) - [Core loop walkthrough](docs/core-loop.md) +## Production configuration + +Set `SOROBAN_RPC_URL` and `NETWORK_PASSPHRASE` together when deploying the gateway: + +| Network | `SOROBAN_RPC_URL` | `NETWORK_PASSPHRASE` | +|---------|-------------------|----------------------| +| Testnet | `https://soroban-testnet.stellar.org` | `Test SDF Network ; September 2015` | +| Mainnet | Your mainnet Soroban RPC endpoint | `Public Global Stellar Network ; September 2015` | + +The gateway validates obvious testnet/mainnet mismatches at startup so production does not silently sign transactions for the wrong Stellar network. + ## License MIT diff --git a/crates/shared/src/config.rs b/crates/shared/src/config.rs index 2e4d988..3a3be7b 100644 --- a/crates/shared/src/config.rs +++ b/crates/shared/src/config.rs @@ -3,6 +3,9 @@ use std::env; use crate::error::{WaveFlowError, WaveFlowResult}; +pub const TESTNET_NETWORK_PASSPHRASE: &str = "Test SDF Network ; September 2015"; +pub const PUBLIC_NETWORK_PASSPHRASE: &str = "Public Global Stellar Network ; September 2015"; + #[derive(Debug, Clone)] pub struct AppConfig { pub database_url: String, @@ -25,7 +28,8 @@ impl AppConfig { let soroban_rpc_url = env::var("SOROBAN_RPC_URL") .unwrap_or_else(|_| "https://soroban-testnet.stellar.org".into()); let network_passphrase = env::var("NETWORK_PASSPHRASE") - .unwrap_or_else(|_| "Test SDF Network ; September 2015".into()); + .unwrap_or_else(|_| TESTNET_NETWORK_PASSPHRASE.into()); + validate_rpc_network_pair(&soroban_rpc_url, &network_passphrase)?; let escrow_contract_id = env::var("ESCROW_CONTRACT_ID").ok(); let gateway_secret_key = env::var("GATEWAY_SECRET_KEY").ok(); let api_admin_keys = env::var("API_ADMIN_KEYS") @@ -59,6 +63,24 @@ impl AppConfig { } } +fn validate_rpc_network_pair(soroban_rpc_url: &str, network_passphrase: &str) -> WaveFlowResult<()> { + let lower_url = soroban_rpc_url.to_ascii_lowercase(); + + if lower_url.contains("mainnet") && network_passphrase != PUBLIC_NETWORK_PASSPHRASE { + return Err(WaveFlowError::Config(format!( + "NETWORK_PASSPHRASE must be `{PUBLIC_NETWORK_PASSPHRASE}` when SOROBAN_RPC_URL points to mainnet" + ))); + } + + if lower_url.contains("testnet") && network_passphrase != TESTNET_NETWORK_PASSPHRASE { + return Err(WaveFlowError::Config(format!( + "NETWORK_PASSPHRASE must be `{TESTNET_NETWORK_PASSPHRASE}` when SOROBAN_RPC_URL points to testnet" + ))); + } + + Ok(()) +} + #[cfg(test)] mod tests { use super::*; @@ -67,6 +89,8 @@ mod tests { fn parses_admin_keys_from_comma_list() { std::env::set_var("DATABASE_URL", "postgres://localhost/waveflow"); std::env::set_var("GITHUB_WEBHOOK_SECRET", "secret"); + std::env::set_var("SOROBAN_RPC_URL", "https://soroban-testnet.stellar.org"); + std::env::set_var("NETWORK_PASSPHRASE", TESTNET_NETWORK_PASSPHRASE); std::env::set_var("API_ADMIN_KEYS", "key-a, key-b"); let cfg = AppConfig::from_env().expect("config"); @@ -74,6 +98,39 @@ mod tests { std::env::remove_var("DATABASE_URL"); std::env::remove_var("GITHUB_WEBHOOK_SECRET"); + std::env::remove_var("SOROBAN_RPC_URL"); + std::env::remove_var("NETWORK_PASSPHRASE"); std::env::remove_var("API_ADMIN_KEYS"); } + + #[test] + fn rejects_mainnet_rpc_with_testnet_passphrase() { + let err = validate_rpc_network_pair( + "https://soroban-mainnet.stellar.org", + TESTNET_NETWORK_PASSPHRASE, + ) + .expect_err("mainnet RPC requires public passphrase"); + + assert!(err.to_string().contains(PUBLIC_NETWORK_PASSPHRASE)); + } + + #[test] + fn rejects_testnet_rpc_with_public_passphrase() { + let err = validate_rpc_network_pair( + "https://soroban-testnet.stellar.org", + PUBLIC_NETWORK_PASSPHRASE, + ) + .expect_err("testnet RPC requires testnet passphrase"); + + assert!(err.to_string().contains(TESTNET_NETWORK_PASSPHRASE)); + } + + #[test] + fn accepts_matching_mainnet_rpc_and_passphrase() { + validate_rpc_network_pair( + "https://soroban-mainnet.stellar.org", + PUBLIC_NETWORK_PASSPHRASE, + ) + .expect("mainnet RPC with public passphrase"); + } } diff --git a/docs/configuration/env-vars.md b/docs/configuration/env-vars.md index e7dab11..f79f523 100644 --- a/docs/configuration/env-vars.md +++ b/docs/configuration/env-vars.md @@ -14,7 +14,7 @@ Loaded by `crates/shared/src/config.rs` via `AppConfig::from_env()`. | Variable | Default | Description | |----------|---------|-------------| | `SOROBAN_RPC_URL` | `https://soroban-testnet.stellar.org` | RPC endpoint | -| `NETWORK_PASSPHRASE` | Testnet passphrase | Network identifier | +| `NETWORK_PASSPHRASE` | `Test SDF Network ; September 2015` | Network identifier. Use `Public Global Stellar Network ; September 2015` with mainnet RPC. | | `ESCROW_CONTRACT_ID` | none | Deployed contract ID (required for live attestation) | | `GATEWAY_SECRET_KEY` | none | Stellar secret key for gateway signing | diff --git a/docs/deployment/render.md b/docs/deployment/render.md index f15a6a2..2c259d6 100644 --- a/docs/deployment/render.md +++ b/docs/deployment/render.md @@ -33,7 +33,14 @@ Both services receive `DATABASE_URL` from the Render Postgres attachment. ## Soroban -Default blueprint sets `SOROBAN_RPC_URL` to testnet. Override for mainnet when ready. +Default blueprint sets `SOROBAN_RPC_URL` and `NETWORK_PASSPHRASE` to testnet: + +| Network | `SOROBAN_RPC_URL` | `NETWORK_PASSPHRASE` | +|---------|-------------------|----------------------| +| Testnet | `https://soroban-testnet.stellar.org` | `Test SDF Network ; September 2015` | +| Mainnet | Your mainnet Soroban RPC endpoint | `Public Global Stellar Network ; September 2015` | + +Override both values together when moving the gateway to mainnet. The gateway rejects obvious testnet/mainnet RPC and passphrase mismatches during startup. ## Local parity diff --git a/render.yaml b/render.yaml index 6e56aa3..ec814c0 100644 --- a/render.yaml +++ b/render.yaml @@ -14,6 +14,8 @@ services: sync: false - key: SOROBAN_RPC_URL value: https://soroban-testnet.stellar.org + - key: NETWORK_PASSPHRASE + value: Test SDF Network ; September 2015 - key: ESCROW_CONTRACT_ID sync: false - key: GATEWAY_SECRET_KEY From 2afe0e352f08b5cdb2ceb7bf34af34f13e65c85c Mon Sep 17 00:00:00 2001 From: Bojay Liu <189326887+BojayL@users.noreply.github.com> Date: Fri, 19 Jun 2026 23:45:33 +0800 Subject: [PATCH 2/2] Format network passphrase config --- crates/shared/src/config.rs | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/crates/shared/src/config.rs b/crates/shared/src/config.rs index 3a3be7b..0f0646b 100644 --- a/crates/shared/src/config.rs +++ b/crates/shared/src/config.rs @@ -27,8 +27,8 @@ impl AppConfig { .map_err(|_| WaveFlowError::Config("GITHUB_WEBHOOK_SECRET is required".into()))?; let soroban_rpc_url = env::var("SOROBAN_RPC_URL") .unwrap_or_else(|_| "https://soroban-testnet.stellar.org".into()); - let network_passphrase = env::var("NETWORK_PASSPHRASE") - .unwrap_or_else(|_| TESTNET_NETWORK_PASSPHRASE.into()); + let network_passphrase = + env::var("NETWORK_PASSPHRASE").unwrap_or_else(|_| TESTNET_NETWORK_PASSPHRASE.into()); validate_rpc_network_pair(&soroban_rpc_url, &network_passphrase)?; let escrow_contract_id = env::var("ESCROW_CONTRACT_ID").ok(); let gateway_secret_key = env::var("GATEWAY_SECRET_KEY").ok(); @@ -63,7 +63,10 @@ impl AppConfig { } } -fn validate_rpc_network_pair(soroban_rpc_url: &str, network_passphrase: &str) -> WaveFlowResult<()> { +fn validate_rpc_network_pair( + soroban_rpc_url: &str, + network_passphrase: &str, +) -> WaveFlowResult<()> { let lower_url = soroban_rpc_url.to_ascii_lowercase(); if lower_url.contains("mainnet") && network_passphrase != PUBLIC_NETWORK_PASSPHRASE {