Skip to main content

sdkt_core/
network_safety.rs

1//! Mainnet-safety guards for mutating RPC commands ().
2//!
3//! These helpers are deliberately small and pure: they consume the already
4//! resolved [`NetworkConfig`] (whose precedence — explicit flags, then a saved
5//! profile, then `.sdkt.toml`, then built-in defaults — is computed entirely by
6//! the CLI's existing resolution path) and enforce a single conservative
7//! rule on mutating operations.
8//!
9//! A mutating command may only touch mainnet when the operator has explicitly
10//! named the target network. Silently combining a mainnet RPC endpoint with the
11//! default testnet passphrase (the classic foot-gun that signs an envelope for
12//! the wrong network) is rejected with a clear, actionable error.
13//!
14//! No networking and no precedence logic lives here — this is a guard layered
15//! on top of the existing resolution, not a replacement for it.
16
17use crate::config::NetworkConfig;
18use std::fmt;
19
20/// Network passphrase of the public Stellar mainnet.
21pub const MAINNET_PASSPHRASE: &str = "Public Global Stellar Network ; September 2015";
22
23/// Network passphrase of the SDF testnet (also the [`NetworkConfig`] default).
24pub const TESTNET_PASSPHRASE: &str = "Test SDF Network ; September 2015";
25
26/// Error returned when a mutating command is refused by the mainnet-safety guard.
27#[derive(Debug, Clone, PartialEq, Eq)]
28pub struct MainnetSafetyError(pub String);
29
30impl fmt::Display for MainnetSafetyError {
31    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
32        f.write_str(&self.0)
33    }
34}
35
36impl std::error::Error for MainnetSafetyError {}
37
38/// Heuristic: does the RPC URL point at the public Stellar mainnet?
39///
40/// SDF mainnet endpoints live under `stellar.org` without the `testnet` /
41/// `futurenet` markers used by the other public networks. This is intentionally
42/// conservative — an unknown host is *not* treated as mainnet, so the guard only
43/// fires on the well-known public mainnet RPC.
44fn rpc_host_is_mainnet(rpc_url: &str) -> bool {
45    let url = rpc_url.to_ascii_lowercase();
46    if !url.contains("stellar.org") {
47        return false;
48    }
49    if url.contains("testnet") || url.contains("futurenet") {
50        return false;
51    }
52    true
53}
54
55/// Guard a *mutating* RPC operation against an unsafe mainnet configuration.
56///
57/// `network_explicit` is true when the operator named the target network through
58/// an explicit flag (`--rpc-url`, `--network-passphrase`) or a saved
59/// `--network-profile`. It is false when the resolved [`NetworkConfig`] came
60/// entirely from built-in defaults (testnet).
61///
62/// The guard refuses in two clearly-wrong situations:
63///
64/// 1. The effective passphrase is the mainnet passphrase but the network was
65///    *not* explicitly selected (e.g. a stray default that happens to match
66///    mainnet). Operators must opt in deliberately.
67/// 2. The RPC URL points at mainnet while the passphrase is *not* the mainnet
68///    passphrase — i.e. someone aimed the tool at mainnet but forgot to set the
69///    matching passphrase, which would sign an envelope for the wrong network.
70///
71/// Everything else (testnet by default, or mainnet with both an explicit,
72/// matching passphrase) is allowed through.
73pub fn guard_mutating_network(
74    config: &NetworkConfig,
75    network_explicit: bool,
76) -> Result<(), MainnetSafetyError> {
77    let passphrase_is_mainnet = config.passphrase == MAINNET_PASSPHRASE;
78    let rpc_is_mainnet = rpc_host_is_mainnet(&config.rpc_url);
79
80    if passphrase_is_mainnet && !network_explicit {
81        return Err(MainnetSafetyError(
82            "Refusing mutating operation on mainnet: the network was not explicitly selected. \
83             Pass --network-passphrase 'Public Global Stellar Network ; September 2015' \
84             (or use --network-profile) to target mainnet deliberately."
85                .to_string(),
86        ));
87    }
88
89    if rpc_is_mainnet && !passphrase_is_mainnet {
90        return Err(MainnetSafetyError(
91            "Refusing mutating operation: the RPC URL targets mainnet but the network passphrase \
92             is not the mainnet passphrase (likely the testnet default). Set --network-passphrase \
93             'Public Global Stellar Network ; September 2015' to match the endpoint."
94                .to_string(),
95        ));
96    }
97
98    Ok(())
99}
100
101#[cfg(test)]
102mod tests {
103    use super::*;
104    use crate::config::NetworkConfig;
105
106    fn testnet_default() -> NetworkConfig {
107        NetworkConfig::default()
108    }
109
110    fn mainnet_cfg() -> NetworkConfig {
111        NetworkConfig {
112            rpc_url: "https://soroban-rpc.stellar.org".to_string(),
113            passphrase: MAINNET_PASSPHRASE.to_string(),
114            timeout_secs: Some(15),
115            pool_max_idle_per_host: Some(100),
116        }
117    }
118
119    #[test]
120    fn testnet_default_is_allowed() {
121        let cfg = testnet_default();
122        assert!(guard_mutating_network(&cfg, false).is_ok());
123        assert!(guard_mutating_network(&cfg, true).is_ok());
124    }
125
126    #[test]
127    fn mainnet_explicit_passphrase_is_allowed() {
128        let cfg = mainnet_cfg();
129        // Operator explicitly selected the network (flag/profile).
130        assert!(guard_mutating_network(&cfg, true).is_ok());
131    }
132
133    #[test]
134    fn mainnet_passphrase_without_explicit_opt_in_is_refused() {
135        let cfg = mainnet_cfg();
136        let err = guard_mutating_network(&cfg, false).unwrap_err();
137        assert!(err.to_string().contains("not explicitly selected"));
138    }
139
140    #[test]
141    fn mainnet_rpc_with_testnet_passphrase_is_refused() {
142        let cfg = NetworkConfig {
143            rpc_url: "https://soroban-rpc.stellar.org".to_string(),
144            passphrase: TESTNET_PASSPHRASE.to_string(),
145            timeout_secs: Some(15),
146            pool_max_idle_per_host: Some(100),
147        };
148        // No explicit flag needed to trigger: the mismatch itself is the foot-gun.
149        let err = guard_mutating_network(&cfg, true).unwrap_err();
150        assert!(err.to_string().contains("not the mainnet passphrase"));
151    }
152
153    #[test]
154    fn mainnet_rpc_with_testnet_default_is_refused() {
155        // User passed --rpc-url mainnet but forgot --network-passphrase.
156        let cfg = NetworkConfig {
157            rpc_url: "https://soroban-rpc.stellar.org".to_string(),
158            passphrase: TESTNET_PASSPHRASE.to_string(),
159            timeout_secs: Some(15),
160            pool_max_idle_per_host: Some(100),
161        };
162        let err = guard_mutating_network(&cfg, false).unwrap_err();
163        assert!(err.to_string().contains("not the mainnet passphrase"));
164    }
165
166    #[test]
167    fn unknown_rpc_host_is_not_treated_as_mainnet() {
168        let cfg = NetworkConfig {
169            rpc_url: "https://my-custom-rpc.example.com".to_string(),
170            passphrase: TESTNET_PASSPHRASE.to_string(),
171            timeout_secs: Some(15),
172            pool_max_idle_per_host: Some(100),
173        };
174        assert!(guard_mutating_network(&cfg, true).is_ok());
175    }
176
177    #[test]
178    fn testnet_rpc_url_is_not_mainnet() {
179        let cfg = NetworkConfig {
180            rpc_url: "https://soroban-testnet.stellar.org".to_string(),
181            passphrase: TESTNET_PASSPHRASE.to_string(),
182            timeout_secs: Some(15),
183            pool_max_idle_per_host: Some(100),
184        };
185        assert!(guard_mutating_network(&cfg, true).is_ok());
186    }
187}