Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 6 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:<testnet-issuer>` |
| `STELLAR_LISTENER_MODE` | `stream` (SSE + poller reconciler) or `poll` (interval only) | `stream` |
| `POLL_INTERVAL_SECS` | How often the Horizon poller reconciles | `10` |
Expand All @@ -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

Expand Down
97 changes: 0 additions & 97 deletions src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<AcceptedAsset>,
Expand Down Expand Up @@ -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<String> = {
Expand Down Expand Up @@ -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() {
Expand Down Expand Up @@ -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<String> {
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 {
Expand All @@ -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)
Expand Down Expand Up @@ -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,
Expand All @@ -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"
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -751,10 +699,6 @@ mod tests {
"STELLAR_GATEWAY_PUBLIC",
Some("GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5"),
),
(
"STELLAR_GATEWAY_SECRET",
Some("SCZANGBA5RLKJHTBF4RJNRJMZWI4VKTHCRKOVAH7LRZZPZHHZWATAWBN"),
),
],
|| {
let cfg = Config::from_env().unwrap();
Expand Down Expand Up @@ -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());
}
}
1 change: 0 additions & 1 deletion src/expiry.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
1 change: 0 additions & 1 deletion tests/api_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
1 change: 0 additions & 1 deletion tests/concurrency_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,6 @@ fn make_state(pool: db::Db, _webhook_url: Option<String>) -> Arc<AppState> {
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,
Expand Down
1 change: 0 additions & 1 deletion tests/rate_limit_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
1 change: 0 additions & 1 deletion tests/trustline_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,6 @@ async fn make_state(horizon_url: String) -> Arc<AppState> {
network: "testnet".into(),
horizon_url,
gateway_public: GATEWAY.into(),
gateway_secret: String::new(),
accepted_assets: vec![
AcceptedAsset {
code: "XLM".into(),
Expand Down
1 change: 0 additions & 1 deletion tests/webhook_dispatch_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading