Skip to main content

surfpool_sdk/
surfnet.rs

1use std::{
2    net::TcpListener,
3    thread::sleep,
4    time::{Duration, Instant},
5};
6
7use crossbeam_channel::{Receiver, Sender};
8use solana_commitment_config::CommitmentConfig;
9use solana_keypair::Keypair;
10use solana_pubkey::Pubkey;
11use solana_rpc_client::rpc_client::RpcClient;
12use solana_signer::Signer;
13use surfpool_core::surfnet::{
14    locker::SurfnetSvmLocker,
15    svm::{SurfnetSvm, SurfnetSvmConfig},
16};
17use surfpool_types::{
18    BlockProductionMode, RpcConfig, SimnetCommand, SimnetConfig, SimnetEvent, SurfpoolConfig,
19};
20
21use crate::{
22    Cheatcodes,
23    error::{SurfnetError, SurfnetResult},
24};
25
26/// Builder for configuring a [`Surfnet`] instance before starting it.
27///
28/// ```rust
29/// use surfpool_sdk::{Surfnet, BlockProductionMode};
30///
31/// # async fn example() {
32/// let surfnet = Surfnet::builder()
33///     .offline(true)
34///     .block_production_mode(BlockProductionMode::Transaction)
35///     .skip_blockhash_check(true)
36///     .airdrop_sol(10_000_000_000)
37///     .start()
38///     .await
39///     .unwrap();
40/// # }
41/// ```
42pub struct SurfnetBuilder {
43    offline_mode: bool,
44    remote_rpc_url: Option<String>,
45    block_production_mode: BlockProductionMode,
46    slot_time_ms: u64,
47    airdrop_addresses: Vec<Pubkey>,
48    airdrop_lamports: u64,
49    skip_blockhash_check: bool,
50    payer: Option<Keypair>,
51}
52
53impl Default for SurfnetBuilder {
54    fn default() -> Self {
55        Self {
56            offline_mode: true,
57            remote_rpc_url: None,
58            block_production_mode: BlockProductionMode::Transaction,
59            slot_time_ms: 1,
60            airdrop_addresses: vec![],
61            airdrop_lamports: 10_000_000_000, // 10 SOL
62            skip_blockhash_check: false,
63            payer: None,
64        }
65    }
66}
67
68impl SurfnetBuilder {
69    /// Run in offline mode (no mainnet RPC fallback). Default: `true`.
70    pub fn offline(mut self, offline: bool) -> Self {
71        self.offline_mode = offline;
72        self
73    }
74
75    /// Set a remote RPC URL for account fallback (implies `offline(false)`).
76    pub fn remote_rpc_url(mut self, url: impl Into<String>) -> Self {
77        self.remote_rpc_url = Some(url.into());
78        self.offline_mode = false;
79        self
80    }
81
82    /// How blocks are produced. Default: `Transaction` (advance on each tx).
83    pub fn block_production_mode(mut self, mode: BlockProductionMode) -> Self {
84        self.block_production_mode = mode;
85        self
86    }
87
88    /// Slot time in milliseconds. Default: `1` (fast for tests).
89    pub fn slot_time_ms(mut self, ms: u64) -> Self {
90        self.slot_time_ms = ms;
91        self
92    }
93
94    /// Additional addresses to airdrop SOL to at startup.
95    pub fn airdrop_addresses(mut self, addresses: Vec<Pubkey>) -> Self {
96        self.airdrop_addresses = addresses;
97        self
98    }
99
100    /// Amount of lamports to airdrop to the payer (and additional addresses) at startup.
101    /// Default: 10 SOL.
102    pub fn airdrop_sol(mut self, lamports: u64) -> Self {
103        self.airdrop_lamports = lamports;
104        self
105    }
106
107    /// Skip blockhash validation for all transactions in this surfnet instance.
108    pub fn skip_blockhash_check(mut self, skip: bool) -> Self {
109        self.skip_blockhash_check = skip;
110        self
111    }
112
113    /// Use a specific keypair as the payer. If not set, a random one is generated.
114    pub fn payer(mut self, keypair: Keypair) -> Self {
115        self.payer = Some(keypair);
116        self
117    }
118
119    /// Start the surfnet with the configured options.
120    pub async fn start(self) -> SurfnetResult<Surfnet> {
121        let SurfnetBuilder {
122            offline_mode,
123            remote_rpc_url,
124            block_production_mode,
125            slot_time_ms,
126            airdrop_addresses,
127            airdrop_lamports,
128            skip_blockhash_check,
129            payer,
130        } = self;
131        let payer = payer.unwrap_or_else(Keypair::new);
132
133        let bind_port = get_free_port()?;
134        let ws_port = get_free_port()?;
135        let bind_host = "127.0.0.1".to_string();
136
137        let mut startup_airdrop_addresses = vec![payer.pubkey()];
138        startup_airdrop_addresses.extend(airdrop_addresses);
139        let startup_airdrop_addresses_for_rpc = startup_airdrop_addresses.clone();
140
141        let surfpool_config = SurfpoolConfig {
142            simnets: vec![SimnetConfig {
143                offline_mode,
144                remote_rpc_url,
145                slot_time: slot_time_ms,
146                block_production_mode,
147                airdrop_addresses: startup_airdrop_addresses,
148                airdrop_token_amount: airdrop_lamports,
149                skip_blockhash_check,
150                ..Default::default()
151            }],
152            rpc: RpcConfig {
153                bind_host: bind_host.clone(),
154                bind_port,
155                ws_port,
156                ..Default::default()
157            },
158            ..Default::default()
159        };
160
161        let rpc_url = format!("http://{bind_host}:{bind_port}");
162        let ws_url = format!("ws://{bind_host}:{ws_port}");
163
164        let svm_config = SurfnetSvmConfig {
165            surfnet_id: surfpool_config.simnets[0].surfnet_id.clone(),
166            slot_time: surfpool_config.simnets[0].slot_time,
167            instruction_profiling_enabled: surfpool_config.simnets[0].instruction_profiling_enabled,
168            max_profiles: surfpool_config.simnets[0].max_profiles,
169            log_bytes_limit: surfpool_config.simnets[0].log_bytes_limit,
170            feature_config: surfpool_types::SvmFeatureConfig::default(),
171            skip_blockhash_check,
172        };
173        let (surfnet_svm, simnet_events_rx, geyser_events_rx) = SurfnetSvm::new(svm_config)
174            .map_err(|e| SurfnetError::Runtime(format!("failed to initialize Surfnet SVM: {e}")))?;
175        let (simnet_commands_tx, simnet_commands_rx) = crossbeam_channel::unbounded();
176
177        let svm_locker = SurfnetSvmLocker::new(surfnet_svm);
178        let svm_locker_clone = svm_locker.clone();
179        let simnet_commands_tx_clone = simnet_commands_tx.clone();
180
181        let _handle = std::thread::Builder::new()
182            .name("surfnet-sdk".into())
183            .spawn(move || {
184                let future = surfpool_core::runloops::start_local_surfnet_runloop(
185                    svm_locker_clone,
186                    surfpool_config,
187                    simnet_commands_tx_clone,
188                    simnet_commands_rx,
189                    geyser_events_rx,
190                );
191                if let Err(e) = hiro_system_kit::nestable_block_on(future) {
192                    log::error!("Surfnet exited with error: {e}");
193                }
194            })
195            .map_err(|e| SurfnetError::Runtime(e.to_string()))?;
196
197        // Wait for the runtime to signal ready
198        wait_for_ready(&simnet_events_rx)?;
199        wait_for_startup_airdrops(
200            &rpc_url,
201            &startup_airdrop_addresses_for_rpc,
202            airdrop_lamports,
203        )?;
204
205        Ok(Surfnet {
206            rpc_url,
207            ws_url,
208            payer,
209            simnet_commands_tx,
210            simnet_events_rx,
211            svm_locker,
212            instance_id: uuid::Uuid::new_v4().to_string(),
213        })
214    }
215}
216
217/// A running Surfpool instance with RPC/WS endpoints on dynamic ports.
218///
219/// Provides:
220/// - Pre-funded payer keypair
221/// - [`RpcClient`] connected to the local instance
222/// - [`Cheatcodes`] for direct state manipulation (fund accounts, set token balances, etc.)
223///
224/// The instance is shut down when dropped.
225pub struct Surfnet {
226    rpc_url: String,
227    ws_url: String,
228    payer: Keypair,
229    simnet_commands_tx: Sender<SimnetCommand>,
230    simnet_events_rx: Receiver<SimnetEvent>,
231    #[allow(dead_code)] // retained for future direct profiling access
232    svm_locker: SurfnetSvmLocker,
233    instance_id: String,
234}
235
236impl Surfnet {
237    /// Start a surfnet with default settings (offline, transaction-mode blocks, 10 SOL payer).
238    pub async fn start() -> SurfnetResult<Self> {
239        SurfnetBuilder::default().start().await
240    }
241
242    /// Create a builder for custom configuration.
243    pub fn builder() -> SurfnetBuilder {
244        SurfnetBuilder::default()
245    }
246
247    /// The HTTP RPC URL (e.g. `http://127.0.0.1:12345`).
248    pub fn rpc_url(&self) -> &str {
249        &self.rpc_url
250    }
251
252    /// The WebSocket URL (e.g. `ws://127.0.0.1:12346`).
253    pub fn ws_url(&self) -> &str {
254        &self.ws_url
255    }
256
257    /// Create a new [`RpcClient`] connected to this surfnet.
258    pub fn rpc_client(&self) -> RpcClient {
259        RpcClient::new(&self.rpc_url)
260    }
261
262    /// The pre-funded payer keypair.
263    pub fn payer(&self) -> &Keypair {
264        &self.payer
265    }
266
267    /// Access cheatcode helpers for direct state manipulation.
268    pub fn cheatcodes(&self) -> Cheatcodes<'_> {
269        Cheatcodes::new(&self.rpc_url)
270    }
271
272    /// Get a reference to the simnet events receiver for observing runtime events.
273    pub fn events(&self) -> &Receiver<SimnetEvent> {
274        &self.simnet_events_rx
275    }
276
277    /// Send a command to the simnet runtime.
278    pub fn send_command(&self, command: SimnetCommand) -> SurfnetResult<()> {
279        self.simnet_commands_tx
280            .send(command)
281            .map_err(|e| SurfnetError::Runtime(format!("failed to send command: {e}")))
282    }
283
284    /// The unique instance ID for this surfnet.
285    pub fn instance_id(&self) -> &str {
286        &self.instance_id
287    }
288}
289
290impl Drop for Surfnet {
291    fn drop(&mut self) {
292        let _ = self.simnet_commands_tx.send(SimnetCommand::Terminate(None));
293    }
294}
295
296fn get_free_port() -> SurfnetResult<u16> {
297    let listener = TcpListener::bind("127.0.0.1:0")
298        .map_err(|e| SurfnetError::PortAllocation(e.to_string()))?;
299    let port = listener
300        .local_addr()
301        .map_err(|e| SurfnetError::PortAllocation(e.to_string()))?
302        .port();
303    drop(listener);
304    Ok(port)
305}
306
307fn wait_for_ready(events_rx: &Receiver<SimnetEvent>) -> SurfnetResult<()> {
308    loop {
309        match events_rx.recv() {
310            Ok(SimnetEvent::Ready(_)) => return Ok(()),
311            Ok(SimnetEvent::Aborted(err)) => return Err(SurfnetError::Aborted(err)),
312            Ok(SimnetEvent::Shutdown) => {
313                return Err(SurfnetError::Aborted(
314                    "surfnet shut down during startup".into(),
315                ));
316            }
317            Ok(_) => continue,
318            Err(e) => {
319                return Err(SurfnetError::Startup(format!(
320                    "events channel closed unexpectedly: {e}"
321                )));
322            }
323        }
324    }
325}
326
327fn wait_for_startup_airdrops(
328    rpc_url: &str,
329    addresses: &[Pubkey],
330    expected_lamports: u64,
331) -> SurfnetResult<()> {
332    let rpc_client = RpcClient::new(rpc_url.to_string());
333    let deadline = Instant::now() + Duration::from_secs(5);
334    let mut last_error = None;
335    let mut last_balances = vec![];
336
337    while Instant::now() < deadline {
338        last_balances.clear();
339        let mut all_match = true;
340
341        for address in addresses {
342            match rpc_client.get_balance_with_commitment(address, CommitmentConfig::processed()) {
343                Ok(response) => {
344                    last_balances.push((address.to_string(), response.value));
345                    if response.value != expected_lamports {
346                        all_match = false;
347                    }
348                }
349                Err(err) => {
350                    last_error = Some(err.to_string());
351                    all_match = false;
352                    break;
353                }
354            }
355        }
356
357        if all_match {
358            return Ok(());
359        }
360
361        sleep(Duration::from_millis(25));
362    }
363
364    let balance_summary = if last_balances.is_empty() {
365        "no balances observed".to_string()
366    } else {
367        last_balances
368            .iter()
369            .map(|(address, balance)| format!("{address}={balance}"))
370            .collect::<Vec<_>>()
371            .join(", ")
372    };
373
374    Err(SurfnetError::Startup(format!(
375        "startup balances not visible over RPC within timeout (expected {expected_lamports}); last balances: {balance_summary}; last error: {}",
376        last_error.unwrap_or_else(|| "none".to_string())
377    )))
378}
379
380#[cfg(test)]
381mod tests {
382    use super::*;
383
384    #[test]
385    fn surfnet_builder_skip_blockhash_check_defaults_to_false() {
386        let builder = SurfnetBuilder::default();
387        assert!(!builder.skip_blockhash_check);
388    }
389
390    #[test]
391    fn surfnet_builder_skip_blockhash_check_setter_updates_builder() {
392        let builder = SurfnetBuilder::default().skip_blockhash_check(true);
393        assert!(builder.skip_blockhash_check);
394    }
395}