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
26pub 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, skip_blockhash_check: false,
63 payer: None,
64 }
65 }
66}
67
68impl SurfnetBuilder {
69 pub fn offline(mut self, offline: bool) -> Self {
71 self.offline_mode = offline;
72 self
73 }
74
75 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 pub fn block_production_mode(mut self, mode: BlockProductionMode) -> Self {
84 self.block_production_mode = mode;
85 self
86 }
87
88 pub fn slot_time_ms(mut self, ms: u64) -> Self {
90 self.slot_time_ms = ms;
91 self
92 }
93
94 pub fn airdrop_addresses(mut self, addresses: Vec<Pubkey>) -> Self {
96 self.airdrop_addresses = addresses;
97 self
98 }
99
100 pub fn airdrop_sol(mut self, lamports: u64) -> Self {
103 self.airdrop_lamports = lamports;
104 self
105 }
106
107 pub fn skip_blockhash_check(mut self, skip: bool) -> Self {
109 self.skip_blockhash_check = skip;
110 self
111 }
112
113 pub fn payer(mut self, keypair: Keypair) -> Self {
115 self.payer = Some(keypair);
116 self
117 }
118
119 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_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
217pub 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)] svm_locker: SurfnetSvmLocker,
233 instance_id: String,
234}
235
236impl Surfnet {
237 pub async fn start() -> SurfnetResult<Self> {
239 SurfnetBuilder::default().start().await
240 }
241
242 pub fn builder() -> SurfnetBuilder {
244 SurfnetBuilder::default()
245 }
246
247 pub fn rpc_url(&self) -> &str {
249 &self.rpc_url
250 }
251
252 pub fn ws_url(&self) -> &str {
254 &self.ws_url
255 }
256
257 pub fn rpc_client(&self) -> RpcClient {
259 RpcClient::new(&self.rpc_url)
260 }
261
262 pub fn payer(&self) -> &Keypair {
264 &self.payer
265 }
266
267 pub fn cheatcodes(&self) -> Cheatcodes<'_> {
269 Cheatcodes::new(&self.rpc_url)
270 }
271
272 pub fn events(&self) -> &Receiver<SimnetEvent> {
274 &self.simnet_events_rx
275 }
276
277 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 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}