From 87a7f50afeebd284e9bcfa1a44d8a7629d7b7367 Mon Sep 17 00:00:00 2001 From: Ludo Galabru Date: Sat, 28 Mar 2026 17:55:04 -0400 Subject: [PATCH 01/13] feat: draft surfpool-sdk --- Cargo.lock | 28 ++++ Cargo.toml | 2 + crates/sdk/Cargo.toml | 41 ++++++ crates/sdk/src/cheatcodes.rs | 148 +++++++++++++++++++ crates/sdk/src/error.rs | 31 ++++ crates/sdk/src/lib.rs | 33 +++++ crates/sdk/src/surfnet.rs | 271 +++++++++++++++++++++++++++++++++++ 7 files changed, 554 insertions(+) create mode 100644 crates/sdk/Cargo.toml create mode 100644 crates/sdk/src/cheatcodes.rs create mode 100644 crates/sdk/src/error.rs create mode 100644 crates/sdk/src/lib.rs create mode 100644 crates/sdk/src/surfnet.rs diff --git a/Cargo.lock b/Cargo.lock index 95cc1068..d9ac5176 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -11296,6 +11296,34 @@ dependencies = [ "tracing", ] +[[package]] +name = "surfpool-sdk" +version = "1.1.1" +dependencies = [ + "crossbeam-channel", + "hiro-system-kit", + "log 0.4.29", + "serde_json", + "solana-account 3.3.0", + "solana-client", + "solana-clock 3.0.0", + "solana-commitment-config", + "solana-hash 3.1.0", + "solana-keypair", + "solana-message 3.0.1", + "solana-pubkey 3.0.0", + "solana-rpc-client", + "solana-signature", + "solana-signer", + "solana-system-interface 2.0.0", + "solana-transaction", + "spl-associated-token-account-interface", + "surfpool-core", + "surfpool-types", + "thiserror 2.0.17", + "tokio", +] + [[package]] name = "surfpool-studio-ui" version = "0.1.1" diff --git a/Cargo.toml b/Cargo.toml index 652fe03b..e52d785a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,6 +15,7 @@ members = [ "crates/core", "crates/db", "crates/mcp", + "crates/sdk", "crates/studio", "crates/types", ] @@ -160,6 +161,7 @@ zip = { version = "0.6", features = ["deflate"], default-features = false } sha2 = { version = "0.10" } surfpool-core = { path = "crates/core", default-features = false, version = "1.1.1"} +surfpool-sdk = { path = "crates/sdk", default-features = false, version = "1.1.1" } surfpool-db = { path = "crates/db", version = "1.1.1" } surfpool-mcp = { path = "crates/mcp", default-features = false, version = "1.1.1" } surfpool-studio-ui = { path = "crates/studio", default-features = false, version = "0.1.1" } diff --git a/crates/sdk/Cargo.toml b/crates/sdk/Cargo.toml new file mode 100644 index 00000000..630ce026 --- /dev/null +++ b/crates/sdk/Cargo.toml @@ -0,0 +1,41 @@ +[package] +name = "surfpool-sdk" +description = "SDK for embedding Surfpool in Rust integration tests" +version = { workspace = true } +readme = { workspace = true } +edition = { workspace = true } +license = { workspace = true } +repository = { workspace = true } +keywords = { workspace = true } +categories = { workspace = true } + +[lib] +path = "src/lib.rs" + +[dependencies] +crossbeam-channel = { workspace = true } +hiro-system-kit = { workspace = true } +log = { workspace = true } +solana-account = { workspace = true } +solana-client = { workspace = true } +solana-clock = { workspace = true } +solana-keypair = { workspace = true } +solana-pubkey = { workspace = true } +solana-rpc-client = { workspace = true } +solana-signature = { workspace = true } +solana-signer = { workspace = true } +spl-associated-token-account-interface = { workspace = true } +thiserror = { workspace = true } +tokio = { workspace = true } +serde_json = { workspace = true } + +surfpool-core = { workspace = true } +surfpool-types = { workspace = true } + +[dev-dependencies] +solana-system-interface = { workspace = true } +solana-transaction = { workspace = true } +solana-message = { workspace = true } +solana-hash = { workspace = true } +solana-commitment-config = { workspace = true } +tokio = { workspace = true, features = ["full"] } diff --git a/crates/sdk/src/cheatcodes.rs b/crates/sdk/src/cheatcodes.rs new file mode 100644 index 00000000..f7d33cf1 --- /dev/null +++ b/crates/sdk/src/cheatcodes.rs @@ -0,0 +1,148 @@ +use solana_pubkey::Pubkey; +use solana_client::rpc_request::RpcRequest; +use solana_rpc_client::rpc_client::RpcClient; +use spl_associated_token_account_interface::address::get_associated_token_address_with_program_id; + +use crate::error::{SurfnetError, SurfnetResult}; + +/// Direct state manipulation helpers for a running Surfnet. +/// +/// These bypass normal transaction flow to instantly set account state — +/// perfect for test setup (funding wallets, minting tokens, etc.). +/// +/// ```rust,no_run +/// use surfpool_sdk::{Surfnet, Pubkey}; +/// +/// # async fn example() { +/// let surfnet = Surfnet::start().await.unwrap(); +/// let cheats = surfnet.cheatcodes(); +/// +/// // Fund an account with 5 SOL +/// let alice: Pubkey = "...".parse().unwrap(); +/// cheats.fund_sol(&alice, 5_000_000_000).unwrap(); +/// +/// // Fund a token account with 1000 USDC +/// let usdc_mint: Pubkey = "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v".parse().unwrap(); +/// cheats.fund_token(&alice, &usdc_mint, 1_000_000_000, None).unwrap(); +/// # } +/// ``` +pub struct Cheatcodes<'a> { + rpc_url: &'a str, +} + +impl<'a> Cheatcodes<'a> { + pub(crate) fn new(rpc_url: &'a str) -> Self { + Self { rpc_url } + } + + fn rpc_client(&self) -> RpcClient { + RpcClient::new(self.rpc_url) + } + + /// Set the SOL balance (in lamports) for an account. + pub fn fund_sol(&self, address: &Pubkey, lamports: u64) -> SurfnetResult<()> { + let params = serde_json::json!([ + address.to_string(), + { "lamports": lamports } + ]); + self.call_cheatcode("surfnet_setAccount", params) + } + + /// Set arbitrary account data. + pub fn set_account( + &self, + address: &Pubkey, + lamports: u64, + data: &[u8], + owner: &Pubkey, + ) -> SurfnetResult<()> { + let params = serde_json::json!([ + address.to_string(), + { + "lamports": lamports, + "data": data, + "owner": owner.to_string() + } + ]); + self.call_cheatcode("surfnet_setAccount", params) + } + + /// Fund a token account (creates the ATA if needed). + /// + /// Uses `spl_token` program by default. Pass `token_program` to use Token-2022. + pub fn fund_token( + &self, + owner: &Pubkey, + mint: &Pubkey, + amount: u64, + token_program: Option<&Pubkey>, + ) -> SurfnetResult<()> { + let program = token_program.copied().unwrap_or(spl_token_program_id()); + let params = serde_json::json!([ + owner.to_string(), + mint.to_string(), + { "amount": amount }, + program.to_string() + ]); + self.call_cheatcode("surfnet_setTokenAccount", params) + } + + /// Set a token account balance for a specific ATA address. + pub fn set_token_balance( + &self, + owner: &Pubkey, + mint: &Pubkey, + amount: u64, + token_program: Option<&Pubkey>, + ) -> SurfnetResult<()> { + self.fund_token(owner, mint, amount, token_program) + } + + /// Get the associated token address for a wallet/mint pair. + pub fn get_ata(&self, owner: &Pubkey, mint: &Pubkey, token_program: Option<&Pubkey>) -> Pubkey { + let program = token_program.copied().unwrap_or(spl_token_program_id()); + get_associated_token_address_with_program_id(owner, mint, &program) + } + + /// Fund multiple accounts with SOL in one call. + pub fn fund_sol_many(&self, accounts: &[(&Pubkey, u64)]) -> SurfnetResult<()> { + for (address, lamports) in accounts { + self.fund_sol(address, *lamports)?; + } + Ok(()) + } + + /// Fund multiple wallets with the same token and amount. + pub fn fund_token_many( + &self, + owners: &[&Pubkey], + mint: &Pubkey, + amount: u64, + token_program: Option<&Pubkey>, + ) -> SurfnetResult<()> { + for owner in owners { + self.fund_token(owner, mint, amount, token_program)?; + } + Ok(()) + } + + fn call_cheatcode( + &self, + method: &'static str, + params: serde_json::Value, + ) -> SurfnetResult<()> { + let client = self.rpc_client(); + client + .send::( + RpcRequest::Custom { method }, + params, + ) + .map_err(|e| SurfnetError::Cheatcode(format!("{method}: {e}")))?; + Ok(()) + } +} + +fn spl_token_program_id() -> Pubkey { + // spl_token::id() = TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA + Pubkey::from_str_const("TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA") +} diff --git a/crates/sdk/src/error.rs b/crates/sdk/src/error.rs new file mode 100644 index 00000000..15bff69f --- /dev/null +++ b/crates/sdk/src/error.rs @@ -0,0 +1,31 @@ +use std::fmt; + +pub type SurfnetResult = Result; + +#[derive(Debug)] +pub enum SurfnetError { + /// Failed to bind to a free port + PortAllocation(String), + /// The surfnet runtime failed to start + Startup(String), + /// The surfnet runtime thread panicked or exited unexpectedly + Runtime(String), + /// An RPC cheatcode call failed + Cheatcode(String), + /// The surfnet was shut down or aborted during startup + Aborted(String), +} + +impl fmt::Display for SurfnetError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + SurfnetError::PortAllocation(msg) => write!(f, "port allocation failed: {msg}"), + SurfnetError::Startup(msg) => write!(f, "surfnet startup failed: {msg}"), + SurfnetError::Runtime(msg) => write!(f, "surfnet runtime error: {msg}"), + SurfnetError::Cheatcode(msg) => write!(f, "cheatcode failed: {msg}"), + SurfnetError::Aborted(msg) => write!(f, "surfnet aborted: {msg}"), + } + } +} + +impl std::error::Error for SurfnetError {} diff --git a/crates/sdk/src/lib.rs b/crates/sdk/src/lib.rs new file mode 100644 index 00000000..6ab8c334 --- /dev/null +++ b/crates/sdk/src/lib.rs @@ -0,0 +1,33 @@ +//! # Surfpool SDK +//! +//! Embed a full Surfpool instance directly in your Rust integration tests. +//! No external process needed — just spin up a `Surfnet`, point your RPC client at it, +//! and test against a real Solana-compatible runtime. +//! +//! ```rust,no_run +//! use surfpool_sdk::Surfnet; +//! +//! #[tokio::test] +//! async fn my_test() { +//! let surfnet = Surfnet::start().await.unwrap(); +//! +//! let rpc = surfnet.rpc_client(); +//! let balance = rpc.get_balance(&surfnet.payer().pubkey()).unwrap(); +//! assert!(balance > 0); +//! } +//! ``` + +mod cheatcodes; +mod error; +mod surfnet; + +pub use cheatcodes::Cheatcodes; +pub use error::{SurfnetError, SurfnetResult}; +pub use surfnet::{Surfnet, SurfnetBuilder}; + +// Re-export key Solana types for convenience +pub use solana_keypair::Keypair; +pub use solana_pubkey::Pubkey; +pub use solana_rpc_client::rpc_client::RpcClient; +pub use solana_signer::Signer; +pub use surfpool_types::BlockProductionMode; diff --git a/crates/sdk/src/surfnet.rs b/crates/sdk/src/surfnet.rs new file mode 100644 index 00000000..7f3436d9 --- /dev/null +++ b/crates/sdk/src/surfnet.rs @@ -0,0 +1,271 @@ +use std::net::TcpListener; + +use crossbeam_channel::{Receiver, Sender}; +use solana_keypair::Keypair; +use solana_pubkey::Pubkey; +use solana_rpc_client::rpc_client::RpcClient; +use solana_signer::Signer; +use surfpool_core::surfnet::{locker::SurfnetSvmLocker, svm::SurfnetSvm}; +use surfpool_types::{ + BlockProductionMode, RpcConfig, SimnetCommand, SimnetConfig, SimnetEvent, SurfpoolConfig, +}; + +use crate::{ + Cheatcodes, + error::{SurfnetError, SurfnetResult}, +}; + +/// Builder for configuring a [`Surfnet`] instance before starting it. +/// +/// ```rust,no_run +/// use surfpool_sdk::{Surfnet, BlockProductionMode}; +/// +/// # async fn example() { +/// let surfnet = Surfnet::builder() +/// .offline(true) +/// .block_production_mode(BlockProductionMode::Transaction) +/// .airdrop_sol(10_000_000_000) +/// .start() +/// .await +/// .unwrap(); +/// # } +/// ``` +pub struct SurfnetBuilder { + offline_mode: bool, + remote_rpc_url: Option, + block_production_mode: BlockProductionMode, + slot_time_ms: u64, + airdrop_addresses: Vec, + airdrop_lamports: u64, + payer: Option, +} + +impl Default for SurfnetBuilder { + fn default() -> Self { + Self { + offline_mode: true, + remote_rpc_url: None, + block_production_mode: BlockProductionMode::Transaction, + slot_time_ms: 1, + airdrop_addresses: vec![], + airdrop_lamports: 10_000_000_000, // 10 SOL + payer: None, + } + } +} + +impl SurfnetBuilder { + /// Run in offline mode (no mainnet RPC fallback). Default: `true`. + pub fn offline(mut self, offline: bool) -> Self { + self.offline_mode = offline; + self + } + + /// Set a remote RPC URL for account fallback (implies `offline(false)`). + pub fn remote_rpc_url(mut self, url: impl Into) -> Self { + self.remote_rpc_url = Some(url.into()); + self.offline_mode = false; + self + } + + /// How blocks are produced. Default: `Transaction` (advance on each tx). + pub fn block_production_mode(mut self, mode: BlockProductionMode) -> Self { + self.block_production_mode = mode; + self + } + + /// Slot time in milliseconds. Default: `1` (fast for tests). + pub fn slot_time_ms(mut self, ms: u64) -> Self { + self.slot_time_ms = ms; + self + } + + /// Additional addresses to airdrop SOL to at startup. + pub fn airdrop_addresses(mut self, addresses: Vec) -> Self { + self.airdrop_addresses = addresses; + self + } + + /// Amount of lamports to airdrop to the payer (and additional addresses) at startup. + /// Default: 10 SOL. + pub fn airdrop_sol(mut self, lamports: u64) -> Self { + self.airdrop_lamports = lamports; + self + } + + /// Use a specific keypair as the payer. If not set, a random one is generated. + pub fn payer(mut self, keypair: Keypair) -> Self { + self.payer = Some(keypair); + self + } + + /// Start the surfnet with the configured options. + pub async fn start(self) -> SurfnetResult { + let payer = self.payer.unwrap_or_else(Keypair::new); + + let bind_port = get_free_port()?; + let ws_port = get_free_port()?; + let bind_host = "127.0.0.1".to_string(); + + let mut airdrop_addresses = vec![payer.pubkey()]; + airdrop_addresses.extend(self.airdrop_addresses); + + let surfpool_config = SurfpoolConfig { + simnets: vec![SimnetConfig { + offline_mode: self.offline_mode, + remote_rpc_url: self.remote_rpc_url, + slot_time: self.slot_time_ms, + block_production_mode: self.block_production_mode, + airdrop_addresses, + airdrop_token_amount: self.airdrop_lamports, + ..Default::default() + }], + rpc: RpcConfig { + bind_host: bind_host.clone(), + bind_port, + ws_port, + ..Default::default() + }, + ..Default::default() + }; + + let rpc_url = format!("http://{bind_host}:{bind_port}"); + let ws_url = format!("ws://{bind_host}:{ws_port}"); + + let (surfnet_svm, simnet_events_rx, geyser_events_rx) = SurfnetSvm::default(); + let (simnet_commands_tx, simnet_commands_rx) = crossbeam_channel::unbounded(); + + let svm_locker = SurfnetSvmLocker::new(surfnet_svm); + let svm_locker_clone = svm_locker.clone(); + let simnet_commands_tx_clone = simnet_commands_tx.clone(); + + let _handle = std::thread::Builder::new() + .name("surfnet-sdk".into()) + .spawn(move || { + let future = surfpool_core::runloops::start_local_surfnet_runloop( + svm_locker_clone, + surfpool_config, + simnet_commands_tx_clone, + simnet_commands_rx, + geyser_events_rx, + ); + if let Err(e) = hiro_system_kit::nestable_block_on(future) { + log::error!("Surfnet exited with error: {e}"); + } + }) + .map_err(|e| SurfnetError::Runtime(e.to_string()))?; + + // Wait for the runtime to signal ready + wait_for_ready(&simnet_events_rx)?; + + Ok(Surfnet { + rpc_url, + ws_url, + payer, + simnet_commands_tx, + simnet_events_rx, + }) + } +} + +/// A running Surfpool instance with RPC/WS endpoints on dynamic ports. +/// +/// Provides: +/// - Pre-funded payer keypair +/// - [`RpcClient`] connected to the local instance +/// - [`Cheatcodes`] for direct state manipulation (fund accounts, set token balances, etc.) +/// +/// The instance is shut down when dropped. +pub struct Surfnet { + rpc_url: String, + ws_url: String, + payer: Keypair, + simnet_commands_tx: Sender, + simnet_events_rx: Receiver, +} + +impl Surfnet { + /// Start a surfnet with default settings (offline, transaction-mode blocks, 10 SOL payer). + pub async fn start() -> SurfnetResult { + SurfnetBuilder::default().start().await + } + + /// Create a builder for custom configuration. + pub fn builder() -> SurfnetBuilder { + SurfnetBuilder::default() + } + + /// The HTTP RPC URL (e.g. `http://127.0.0.1:12345`). + pub fn rpc_url(&self) -> &str { + &self.rpc_url + } + + /// The WebSocket URL (e.g. `ws://127.0.0.1:12346`). + pub fn ws_url(&self) -> &str { + &self.ws_url + } + + /// Create a new [`RpcClient`] connected to this surfnet. + pub fn rpc_client(&self) -> RpcClient { + RpcClient::new(&self.rpc_url) + } + + /// The pre-funded payer keypair. + pub fn payer(&self) -> &Keypair { + &self.payer + } + + /// Access cheatcode helpers for direct state manipulation. + pub fn cheatcodes(&self) -> Cheatcodes<'_> { + Cheatcodes::new(&self.rpc_url) + } + + /// Get a reference to the simnet events receiver for observing runtime events. + pub fn events(&self) -> &Receiver { + &self.simnet_events_rx + } + + /// Send a command to the simnet runtime. + pub fn send_command(&self, command: SimnetCommand) -> SurfnetResult<()> { + self.simnet_commands_tx + .send(command) + .map_err(|e| SurfnetError::Runtime(format!("failed to send command: {e}"))) + } +} + +impl Drop for Surfnet { + fn drop(&mut self) { + let _ = self.simnet_commands_tx.send(SimnetCommand::Terminate(None)); + } +} + +fn get_free_port() -> SurfnetResult { + let listener = TcpListener::bind("127.0.0.1:0") + .map_err(|e| SurfnetError::PortAllocation(e.to_string()))?; + let port = listener + .local_addr() + .map_err(|e| SurfnetError::PortAllocation(e.to_string()))? + .port(); + drop(listener); + Ok(port) +} + +fn wait_for_ready(events_rx: &Receiver) -> SurfnetResult<()> { + loop { + match events_rx.recv() { + Ok(SimnetEvent::Ready(_)) => return Ok(()), + Ok(SimnetEvent::Aborted(err)) => return Err(SurfnetError::Aborted(err)), + Ok(SimnetEvent::Shutdown) => { + return Err(SurfnetError::Aborted( + "surfnet shut down during startup".into(), + )) + } + Ok(_) => continue, + Err(e) => { + return Err(SurfnetError::Startup(format!( + "events channel closed unexpectedly: {e}" + ))) + } + } + } +} From 5a5188422caae2ab27b47338cbeee212eddf7303 Mon Sep 17 00:00:00 2001 From: Ludo Galabru Date: Sat, 28 Mar 2026 21:42:42 -0400 Subject: [PATCH 02/13] feat(sdk): add test report generation and cheatcode improvements - Add report module: collect transaction profiles from each Surfnet instance, consolidate across parallel tests, and generate a self-contained HTML report with transaction inspection - Report triggered via SURFPOOL_REPORT=1 env var or .report(true) builder - Auto-detect test function names from thread names - Rich HTML report: Surfpool-branded UI with inline transaction tables, per-instruction CU breakdown, parsed JSON + hex byte comparison with diff highlighting, account state diffs - Add SurfpoolReport::from_directory() / generate_html() / write_html() - Add uuid, chrono, serde dependencies to SDK --- Cargo.lock | 3 + crates/sdk/Cargo.toml | 5 +- crates/sdk/src/cheatcodes.rs | 13 +- crates/sdk/src/error.rs | 3 + crates/sdk/src/lib.rs | 17 +- crates/sdk/src/report.rs | 660 +++++++++++++++++++++++++++++++++++ crates/sdk/src/surfnet.rs | 216 +++++++++++- 7 files changed, 900 insertions(+), 17 deletions(-) create mode 100644 crates/sdk/src/report.rs diff --git a/Cargo.lock b/Cargo.lock index d9ac5176..db0f9ce5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -11300,9 +11300,11 @@ dependencies = [ name = "surfpool-sdk" version = "1.1.1" dependencies = [ + "chrono", "crossbeam-channel", "hiro-system-kit", "log 0.4.29", + "serde", "serde_json", "solana-account 3.3.0", "solana-client", @@ -11322,6 +11324,7 @@ dependencies = [ "surfpool-types", "thiserror 2.0.17", "tokio", + "uuid", ] [[package]] diff --git a/crates/sdk/Cargo.toml b/crates/sdk/Cargo.toml index 630ce026..2cace033 100644 --- a/crates/sdk/Cargo.toml +++ b/crates/sdk/Cargo.toml @@ -13,9 +13,12 @@ categories = { workspace = true } path = "src/lib.rs" [dependencies] +chrono = { workspace = true } crossbeam-channel = { workspace = true } hiro-system-kit = { workspace = true } log = { workspace = true } +serde = { workspace = true, features = ["derive"] } +serde_json = { workspace = true } solana-account = { workspace = true } solana-client = { workspace = true } solana-clock = { workspace = true } @@ -27,7 +30,7 @@ solana-signer = { workspace = true } spl-associated-token-account-interface = { workspace = true } thiserror = { workspace = true } tokio = { workspace = true } -serde_json = { workspace = true } +uuid = { workspace = true, features = ["v4"] } surfpool-core = { workspace = true } surfpool-types = { workspace = true } diff --git a/crates/sdk/src/cheatcodes.rs b/crates/sdk/src/cheatcodes.rs index f7d33cf1..a7c7944b 100644 --- a/crates/sdk/src/cheatcodes.rs +++ b/crates/sdk/src/cheatcodes.rs @@ -1,5 +1,5 @@ -use solana_pubkey::Pubkey; use solana_client::rpc_request::RpcRequest; +use solana_pubkey::Pubkey; use solana_rpc_client::rpc_client::RpcClient; use spl_associated_token_account_interface::address::get_associated_token_address_with_program_id; @@ -126,17 +126,10 @@ impl<'a> Cheatcodes<'a> { Ok(()) } - fn call_cheatcode( - &self, - method: &'static str, - params: serde_json::Value, - ) -> SurfnetResult<()> { + fn call_cheatcode(&self, method: &'static str, params: serde_json::Value) -> SurfnetResult<()> { let client = self.rpc_client(); client - .send::( - RpcRequest::Custom { method }, - params, - ) + .send::(RpcRequest::Custom { method }, params) .map_err(|e| SurfnetError::Cheatcode(format!("{method}: {e}")))?; Ok(()) } diff --git a/crates/sdk/src/error.rs b/crates/sdk/src/error.rs index 15bff69f..008d7aea 100644 --- a/crates/sdk/src/error.rs +++ b/crates/sdk/src/error.rs @@ -14,6 +14,8 @@ pub enum SurfnetError { Cheatcode(String), /// The surfnet was shut down or aborted during startup Aborted(String), + /// A report operation failed + Report(String), } impl fmt::Display for SurfnetError { @@ -24,6 +26,7 @@ impl fmt::Display for SurfnetError { SurfnetError::Runtime(msg) => write!(f, "surfnet runtime error: {msg}"), SurfnetError::Cheatcode(msg) => write!(f, "cheatcode failed: {msg}"), SurfnetError::Aborted(msg) => write!(f, "surfnet aborted: {msg}"), + SurfnetError::Report(msg) => write!(f, "report error: {msg}"), } } } diff --git a/crates/sdk/src/lib.rs b/crates/sdk/src/lib.rs index 6ab8c334..033a7e25 100644 --- a/crates/sdk/src/lib.rs +++ b/crates/sdk/src/lib.rs @@ -16,18 +16,31 @@ //! assert!(balance > 0); //! } //! ``` +//! +//! ## Reporting +//! +//! Set `SURFPOOL_REPORT=1` to automatically export transaction data on drop, +//! then generate a consolidated HTML report: +//! +//! ```rust,no_run +//! use surfpool_sdk::report::SurfpoolReport; +//! +//! // After tests complete: +//! let report = SurfpoolReport::from_directory("target/surfpool-reports").unwrap(); +//! report.write_html("target/surfpool-report.html").unwrap(); +//! ``` mod cheatcodes; mod error; +pub mod report; mod surfnet; pub use cheatcodes::Cheatcodes; pub use error::{SurfnetError, SurfnetResult}; -pub use surfnet::{Surfnet, SurfnetBuilder}; - // Re-export key Solana types for convenience pub use solana_keypair::Keypair; pub use solana_pubkey::Pubkey; pub use solana_rpc_client::rpc_client::RpcClient; pub use solana_signer::Signer; +pub use surfnet::{Surfnet, SurfnetBuilder}; pub use surfpool_types::BlockProductionMode; diff --git a/crates/sdk/src/report.rs b/crates/sdk/src/report.rs new file mode 100644 index 00000000..20e38b0e --- /dev/null +++ b/crates/sdk/src/report.rs @@ -0,0 +1,660 @@ +use std::path::Path; + +use serde::{Deserialize, Serialize}; + +use crate::error::{SurfnetError, SurfnetResult}; + +/// Transaction data from a single Surfnet instance. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SurfnetReportData { + pub instance_id: String, + pub test_name: Option, + pub rpc_url: String, + pub transactions: Vec, + pub timestamp: String, +} + +/// A single transaction with its full profile data. +/// +/// Profiles are stored as raw JSON values in both `jsonParsed` and `base64` +/// encodings — the same format the studio UI consumes. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TransactionReportEntry { + pub signature: String, + pub slot: u64, + pub error: Option, + pub logs: Vec, + pub profile_json_parsed: Option, + pub profile_base64: Option, +} + +/// A consolidated test report covering all Surfnet instances from a test suite. +/// +/// ```rust,no_run +/// use surfpool_sdk::report::SurfpoolReport; +/// +/// // After tests complete: +/// let report = SurfpoolReport::from_directory("target/surfpool-reports").unwrap(); +/// report.write_html("target/surfpool-report.html").unwrap(); +/// ``` +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SurfpoolReport { + pub instances: Vec, + pub generated_at: String, +} + +impl SurfpoolReport { + /// Read and consolidate all report JSON files from a directory. + /// + /// Each file was written by a `Surfnet` instance via `write_report_data()`. + pub fn from_directory(dir: impl AsRef) -> SurfnetResult { + let dir = dir.as_ref(); + + if !dir.exists() { + return Err(SurfnetError::Report(format!( + "report directory does not exist: {}", + dir.display() + ))); + } + + let mut instances = Vec::new(); + + let mut entries: Vec<_> = std::fs::read_dir(dir) + .map_err(|e| SurfnetError::Report(format!("failed to read report dir: {e}")))? + .filter_map(|e| e.ok()) + .filter(|e| e.path().extension().is_some_and(|ext| ext == "json")) + .collect(); + + // Sort by filename for deterministic ordering + entries.sort_by_key(|e| e.file_name()); + + for entry in entries { + let path = entry.path(); + let content = std::fs::read_to_string(&path).map_err(|e| { + SurfnetError::Report(format!("failed to read {}: {e}", path.display())) + })?; + + let data: SurfnetReportData = serde_json::from_str(&content).map_err(|e| { + SurfnetError::Report(format!("failed to parse {}: {e}", path.display())) + })?; + + instances.push(data); + } + + Ok(SurfpoolReport { + instances, + generated_at: chrono::Utc::now().to_rfc3339(), + }) + } + + /// Total number of transactions across all instances. + pub fn total_transactions(&self) -> usize { + self.instances.iter().map(|i| i.transactions.len()).sum() + } + + /// Number of failed transactions across all instances. + pub fn failed_transactions(&self) -> usize { + self.instances + .iter() + .flat_map(|i| &i.transactions) + .filter(|t| t.error.is_some()) + .count() + } + + /// Generate the HTML report as a string. + /// + /// This produces a self-contained HTML page with all transaction data + /// embedded as JSON, rendered by the Surfpool report UI. + pub fn generate_html(&self) -> SurfnetResult { + let json = serde_json::to_string(self) + .map_err(|e| SurfnetError::Report(format!("failed to serialize report: {e}")))?; + + // For now, generate a standalone HTML report with embedded data. + // Once the studio report UI template is built and embedded via build.rs, + // this will use: REPORT_TEMPLATE.replace("__REPORT_DATA_PLACEHOLDER__", &json) + Ok(generate_standalone_html(&json)) + } + + /// Generate the HTML report and write it to a file. + pub fn write_html(&self, path: impl AsRef) -> SurfnetResult<()> { + let html = self.generate_html()?; + let path = path.as_ref(); + + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent) + .map_err(|e| SurfnetError::Report(format!("failed to create output dir: {e}")))?; + } + + std::fs::write(path, html) + .map_err(|e| SurfnetError::Report(format!("failed to write report: {e}")))?; + + log::info!("Surfpool report written to {}", path.display()); + Ok(()) + } +} + +/// Generate a standalone HTML report styled to match surfpool.run. +fn generate_standalone_html(report_json: &str) -> String { + format!( + r##" + + + + +Surfpool Test Report + + + + + +
+ + +"## + ) +} diff --git a/crates/sdk/src/surfnet.rs b/crates/sdk/src/surfnet.rs index 7f3436d9..5fea7952 100644 --- a/crates/sdk/src/surfnet.rs +++ b/crates/sdk/src/surfnet.rs @@ -1,6 +1,7 @@ -use std::net::TcpListener; +use std::{net::TcpListener, path::PathBuf}; use crossbeam_channel::{Receiver, Sender}; +use solana_client::rpc_request::RpcRequest; use solana_keypair::Keypair; use solana_pubkey::Pubkey; use solana_rpc_client::rpc_client::RpcClient; @@ -13,8 +14,11 @@ use surfpool_types::{ use crate::{ Cheatcodes, error::{SurfnetError, SurfnetResult}, + report::{SurfnetReportData, TransactionReportEntry}, }; +const DEFAULT_REPORT_DIR: &str = "target/surfpool-reports"; + /// Builder for configuring a [`Surfnet`] instance before starting it. /// /// ```rust,no_run @@ -38,6 +42,9 @@ pub struct SurfnetBuilder { airdrop_addresses: Vec, airdrop_lamports: u64, payer: Option, + report: Option, + report_dir: Option, + test_name: Option, } impl Default for SurfnetBuilder { @@ -50,6 +57,9 @@ impl Default for SurfnetBuilder { airdrop_addresses: vec![], airdrop_lamports: 10_000_000_000, // 10 SOL payer: None, + report: None, + report_dir: None, + test_name: detect_test_name(), } } } @@ -99,6 +109,25 @@ impl SurfnetBuilder { self } + /// Enable or disable report data export on drop. + /// Overrides the `SURFPOOL_REPORT` env var. + pub fn report(mut self, enabled: bool) -> Self { + self.report = Some(enabled); + self + } + + /// Set the directory for report data files. Default: `target/surfpool-reports`. + pub fn report_dir(mut self, dir: impl Into) -> Self { + self.report_dir = Some(dir.into()); + self + } + + /// Set a label for this instance in the report (e.g. the test name). + pub fn test_name(mut self, name: impl Into) -> Self { + self.test_name = Some(name.into()); + self + } + /// Start the surfnet with the configured options. pub async fn start(self) -> SurfnetResult { let payer = self.payer.unwrap_or_else(Keypair::new); @@ -158,12 +187,33 @@ impl SurfnetBuilder { // Wait for the runtime to signal ready wait_for_ready(&simnet_events_rx)?; + // Resolve report settings: builder override > env var + let report_enabled = self.report.unwrap_or_else(|| { + std::env::var("SURFPOOL_REPORT") + .map(|v| !v.is_empty() && v != "0" && v.to_lowercase() != "false") + .unwrap_or(false) + }); + + let report_dir = self.report_dir.unwrap_or_else(|| { + // If SURFPOOL_REPORT is a path (not "1" or "true"), use it as the directory + std::env::var("SURFPOOL_REPORT") + .ok() + .filter(|v| v != "1" && v.to_lowercase() != "true" && !v.is_empty()) + .map(PathBuf::from) + .unwrap_or_else(|| PathBuf::from(DEFAULT_REPORT_DIR)) + }); + Ok(Surfnet { rpc_url, ws_url, payer, simnet_commands_tx, simnet_events_rx, + svm_locker, + instance_id: uuid::Uuid::new_v4().to_string(), + report_enabled, + report_dir, + test_name: self.test_name, }) } } @@ -174,14 +224,23 @@ impl SurfnetBuilder { /// - Pre-funded payer keypair /// - [`RpcClient`] connected to the local instance /// - [`Cheatcodes`] for direct state manipulation (fund accounts, set token balances, etc.) +/// - Report data export for test result visualization /// -/// The instance is shut down when dropped. +/// The instance is shut down when dropped. If reporting is enabled +/// (via `SURFPOOL_REPORT=1` or `.report(true)`), transaction profiles +/// are exported to disk before shutdown. pub struct Surfnet { rpc_url: String, ws_url: String, payer: Keypair, simnet_commands_tx: Sender, simnet_events_rx: Receiver, + #[allow(dead_code)] // retained for future direct profiling access + svm_locker: SurfnetSvmLocker, + instance_id: String, + report_enabled: bool, + report_dir: PathBuf, + test_name: Option, } impl Surfnet { @@ -231,10 +290,133 @@ impl Surfnet { .send(command) .map_err(|e| SurfnetError::Runtime(format!("failed to send command: {e}"))) } + + /// The unique instance ID for this surfnet. + pub fn instance_id(&self) -> &str { + &self.instance_id + } + + /// Export all transaction profiles from this instance. + pub fn export_report_data(&self) -> SurfnetResult { + let client = self.rpc_client(); + + // Fetch all local signatures + let signatures_response: serde_json::Value = client + .send( + RpcRequest::Custom { + method: "surfnet_getLocalSignatures", + }, + serde_json::json!([200]), + ) + .map_err(|e| SurfnetError::Report(format!("failed to fetch signatures: {e}")))?; + + let entries = signatures_response + .get("value") + .and_then(|v| v.as_array()) + .cloned() + .unwrap_or_default(); + + let mut transactions = Vec::with_capacity(entries.len()); + + for entry in &entries { + let signature = entry + .get("signature") + .and_then(|s| s.as_str()) + .unwrap_or_default() + .to_string(); + + let error = entry + .get("err") + .filter(|e| !e.is_null()) + .map(|e| e.to_string()); + + let logs = entry + .get("logs") + .and_then(|l| l.as_array()) + .map(|arr| { + arr.iter() + .filter_map(|v| v.as_str().map(String::from)) + .collect() + }) + .unwrap_or_default(); + + // Fetch full profile with jsonParsed encoding + let profile_json_parsed = client + .send::( + RpcRequest::Custom { + method: "surfnet_getTransactionProfile", + }, + serde_json::json!([signature, { "encoding": "jsonParsed" }]), + ) + .ok() + .and_then(|resp| resp.get("value").cloned()) + .filter(|v| !v.is_null()); + + // Fetch full profile with base64 encoding + let profile_base64 = client + .send::( + RpcRequest::Custom { + method: "surfnet_getTransactionProfile", + }, + serde_json::json!([signature, { "encoding": "base64" }]), + ) + .ok() + .and_then(|resp| resp.get("value").cloned()) + .filter(|v| !v.is_null()); + + let slot = profile_json_parsed + .as_ref() + .and_then(|p| p.get("slot")) + .and_then(|s| s.as_u64()) + .unwrap_or(0); + + transactions.push(TransactionReportEntry { + signature, + slot, + error, + logs, + profile_json_parsed, + profile_base64, + }); + } + + Ok(SurfnetReportData { + instance_id: self.instance_id.clone(), + test_name: self.test_name.clone(), + rpc_url: self.rpc_url.clone(), + transactions, + timestamp: chrono::Utc::now().to_rfc3339(), + }) + } + + /// Export report data and write it to the report directory. + /// Returns the path to the written JSON file. + pub fn write_report_data(&self) -> SurfnetResult { + let data = self.export_report_data()?; + let dir = &self.report_dir; + + std::fs::create_dir_all(dir) + .map_err(|e| SurfnetError::Report(format!("failed to create report dir: {e}")))?; + + let path = dir.join(format!("{}.json", self.instance_id)); + let json = serde_json::to_string_pretty(&data) + .map_err(|e| SurfnetError::Report(format!("failed to serialize report data: {e}")))?; + + std::fs::write(&path, json) + .map_err(|e| SurfnetError::Report(format!("failed to write report file: {e}")))?; + + log::info!("Surfnet report data written to {}", path.display()); + Ok(path) + } } impl Drop for Surfnet { fn drop(&mut self) { + if self.report_enabled { + if let Err(e) = self.write_report_data() { + log::warn!("Failed to write surfnet report data: {e}"); + } + } let _ = self.simnet_commands_tx.send(SimnetCommand::Terminate(None)); } } @@ -258,14 +440,40 @@ fn wait_for_ready(events_rx: &Receiver) -> SurfnetResult<()> { Ok(SimnetEvent::Shutdown) => { return Err(SurfnetError::Aborted( "surfnet shut down during startup".into(), - )) + )); } Ok(_) => continue, Err(e) => { return Err(SurfnetError::Startup(format!( "events channel closed unexpectedly: {e}" - ))) + ))); } } } } + +/// Try to extract the test function name from the current thread. +/// +/// Rust's test harness names each test thread after the test function +/// (e.g. `my_module::my_test`). This works reliably for `#[test]` and +/// `#[tokio::test]` with `current_thread`. For `multi_thread` tokio tests +/// the builder is often constructed on a worker thread — we detect and +/// skip those names. +fn detect_test_name() -> Option { + let thread = std::thread::current(); + let name = thread.name()?; + + // Filter out names that aren't test functions + if name == "main" + || name.starts_with("tokio-runtime") + || name.starts_with("surfnet") + || name.starts_with("Thread-") + { + return None; + } + + // Rust test names look like "module::test_name" or just "test_name". + // Take the last segment for a clean display name. + let short = name.rsplit("::").next().unwrap_or(name); + Some(short.to_string()) +} From 97fbcf2d0bd89abdaafbf5de0b07583f9a2f979d Mon Sep 17 00:00:00 2001 From: Ludo Galabru Date: Sun, 29 Mar 2026 00:11:43 -0400 Subject: [PATCH 03/13] feat(sdk-node): Node.js bindings for surfpool-sdk via napi-rs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Native Node.js bindings using napi-rs, following the same pattern as litesvm's node-litesvm crate. Exposes: - Surfnet.start() / Surfnet.startWithConfig() — spin up embedded surfpool - surfnet.rpcUrl / wsUrl / payer / payerSecretKey — accessors - surfnet.fundSol() / fundToken() / setAccount() / getAta() — cheatcodes - Surfnet.newKeypair() — generate random keypair TypeScript wrapper in surfpool-sdk/index.ts provides clean DX. Build: npx napi build --platform surfpool-sdk --- Cargo.lock | 94 ++++++++++- Cargo.toml | 1 + crates/sdk-node/.gitignore | 3 + crates/sdk-node/Cargo.toml | 23 +++ crates/sdk-node/build.rs | 5 + crates/sdk-node/package-lock.json | 48 ++++++ crates/sdk-node/package.json | 35 ++++ crates/sdk-node/src/lib.rs | 203 ++++++++++++++++++++++++ crates/sdk-node/surfpool-sdk/.gitignore | 3 + crates/sdk-node/surfpool-sdk/index.ts | 95 +++++++++++ crates/sdk-node/tsconfig.json | 15 ++ 11 files changed, 524 insertions(+), 1 deletion(-) create mode 100644 crates/sdk-node/.gitignore create mode 100644 crates/sdk-node/Cargo.toml create mode 100644 crates/sdk-node/build.rs create mode 100644 crates/sdk-node/package-lock.json create mode 100644 crates/sdk-node/package.json create mode 100644 crates/sdk-node/src/lib.rs create mode 100644 crates/sdk-node/surfpool-sdk/.gitignore create mode 100644 crates/sdk-node/surfpool-sdk/index.ts create mode 100644 crates/sdk-node/tsconfig.json diff --git a/Cargo.lock b/Cargo.lock index db0f9ce5..7cded1b0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2130,6 +2130,16 @@ dependencies = [ "subtle", ] +[[package]] +name = "ctor" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a2785755761f3ddc1492979ce1e48d2c00d09311c39e4466429188f3dd6501" +dependencies = [ + "quote", + "syn 2.0.114", +] + [[package]] name = "ctr" version = "0.9.2" @@ -4596,6 +4606,16 @@ dependencies = [ "winapi 0.3.9", ] +[[package]] +name = "libloading" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" +dependencies = [ + "cfg-if 1.0.4", + "windows-link", +] + [[package]] name = "libm" version = "0.2.15" @@ -5133,6 +5153,63 @@ dependencies = [ "serde", ] +[[package]] +name = "napi" +version = "2.16.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55740c4ae1d8696773c78fdafd5d0e5fe9bc9f1b071c7ba493ba5c413a9184f3" +dependencies = [ + "bitflags 2.10.0", + "ctor", + "napi-derive", + "napi-sys", + "once_cell", +] + +[[package]] +name = "napi-build" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d376940fd5b723c6893cd1ee3f33abbfd86acb1cd1ec079f3ab04a2a3bc4d3b1" + +[[package]] +name = "napi-derive" +version = "2.16.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cbe2585d8ac223f7d34f13701434b9d5f4eb9c332cccce8dee57ea18ab8ab0c" +dependencies = [ + "cfg-if 1.0.4", + "convert_case 0.6.0", + "napi-derive-backend", + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "napi-derive-backend" +version = "1.0.75" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1639aaa9eeb76e91c6ae66da8ce3e89e921cd3885e99ec85f4abacae72fc91bf" +dependencies = [ + "convert_case 0.6.0", + "once_cell", + "proc-macro2", + "quote", + "regex", + "semver", + "syn 2.0.114", +] + +[[package]] +name = "napi-sys" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "427802e8ec3a734331fec1035594a210ce1ff4dc5bc1950530920ab717964ea3" +dependencies = [ + "libloading 0.8.9", +] + [[package]] name = "native-tls" version = "0.2.14" @@ -11198,7 +11275,7 @@ dependencies = [ "jsonrpc-pubsub", "jsonrpc-ws-server", "lazy_static", - "libloading", + "libloading 0.7.4", "litesvm", "litesvm-token", "log 0.4.29", @@ -11327,6 +11404,21 @@ dependencies = [ "uuid", ] +[[package]] +name = "surfpool-sdk-node" +version = "0.1.0" +dependencies = [ + "hiro-system-kit", + "napi", + "napi-build", + "napi-derive", + "serde_json", + "solana-keypair", + "solana-pubkey 3.0.0", + "solana-signer", + "surfpool-sdk", +] + [[package]] name = "surfpool-studio-ui" version = "0.1.1" diff --git a/Cargo.toml b/Cargo.toml index e52d785a..4a13ae42 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -16,6 +16,7 @@ members = [ "crates/db", "crates/mcp", "crates/sdk", + "crates/sdk-node", "crates/studio", "crates/types", ] diff --git a/crates/sdk-node/.gitignore b/crates/sdk-node/.gitignore new file mode 100644 index 00000000..95375d85 --- /dev/null +++ b/crates/sdk-node/.gitignore @@ -0,0 +1,3 @@ +surfpool-sdk.*.node +node_modules/ +dist/ diff --git a/crates/sdk-node/Cargo.toml b/crates/sdk-node/Cargo.toml new file mode 100644 index 00000000..59ffdfb7 --- /dev/null +++ b/crates/sdk-node/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "surfpool-sdk-node" +version = "0.1.0" +edition = "2024" +license = "Apache-2.0" +repository = "https://github.com/solana-foundation/surfpool" +include = ["/src", "build.rs"] + +[lib] +crate-type = ["cdylib"] + +[dependencies] +napi = { version = "2", features = ["napi4", "napi6"] } +napi-derive = "2.16" +hiro-system-kit = { workspace = true } +surfpool-sdk = { workspace = true } +solana-keypair = { workspace = true } +solana-pubkey = { workspace = true } +solana-signer = { workspace = true } +serde_json = { workspace = true } + +[build-dependencies] +napi-build = "2" diff --git a/crates/sdk-node/build.rs b/crates/sdk-node/build.rs new file mode 100644 index 00000000..9fc23678 --- /dev/null +++ b/crates/sdk-node/build.rs @@ -0,0 +1,5 @@ +extern crate napi_build; + +fn main() { + napi_build::setup(); +} diff --git a/crates/sdk-node/package-lock.json b/crates/sdk-node/package-lock.json new file mode 100644 index 00000000..14e72082 --- /dev/null +++ b/crates/sdk-node/package-lock.json @@ -0,0 +1,48 @@ +{ + "name": "surfpool-sdk", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "surfpool-sdk", + "version": "0.1.0", + "license": "Apache-2.0", + "devDependencies": { + "@napi-rs/cli": "^2.18.4", + "typescript": "^5.7.0" + } + }, + "node_modules/@napi-rs/cli": { + "version": "2.18.4", + "resolved": "https://registry.npmjs.org/@napi-rs/cli/-/cli-2.18.4.tgz", + "integrity": "sha512-SgJeA4df9DE2iAEpr3M2H0OKl/yjtg1BnRI5/JyowS71tUWhrfSu2LT0V3vlHET+g1hBVlrO60PmEXwUEKp8Mg==", + "dev": true, + "license": "MIT", + "bin": { + "napi": "scripts/index.js" + }, + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + } + } +} diff --git a/crates/sdk-node/package.json b/crates/sdk-node/package.json new file mode 100644 index 00000000..241bae20 --- /dev/null +++ b/crates/sdk-node/package.json @@ -0,0 +1,35 @@ +{ + "name": "surfpool-sdk", + "version": "0.1.0", + "description": "Embed a Surfpool Solana runtime in your Node.js tests", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "files": [ + "dist/" + ], + "napi": { + "name": "surfpool-sdk", + "triples": { + "defaults": false, + "additional": [ + "x86_64-apple-darwin", + "aarch64-apple-darwin", + "x86_64-unknown-linux-gnu", + "x86_64-unknown-linux-musl", + "universal-apple-darwin" + ] + } + }, + "license": "Apache-2.0", + "scripts": { + "build": "napi build --platform --release surfpool-sdk --dts internal.d.ts --js internal.js", + "build:debug": "napi build --platform surfpool-sdk --dts internal.d.ts --js internal.js", + "artifacts": "napi artifacts", + "tsc": "tsc && cp surfpool-sdk/internal.d.ts dist/internal.d.ts", + "prepublishOnly": "napi prepublish -t npm" + }, + "devDependencies": { + "@napi-rs/cli": "^2.18.4", + "typescript": "^5.7.0" + } +} diff --git a/crates/sdk-node/src/lib.rs b/crates/sdk-node/src/lib.rs new file mode 100644 index 00000000..8012dc15 --- /dev/null +++ b/crates/sdk-node/src/lib.rs @@ -0,0 +1,203 @@ +#[macro_use] +extern crate napi_derive; + +use napi::{Error, Result, Status}; +use solana_keypair::Keypair; +use solana_pubkey::Pubkey; +use solana_signer::Signer; +use surfpool_sdk::BlockProductionMode; + +/// A running Surfpool instance with RPC/WS endpoints on dynamic ports. +#[napi] +pub struct Surfnet { + inner: surfpool_sdk::Surfnet, +} + +#[napi] +impl Surfnet { + /// Start a surfnet with default settings (offline, transaction-mode blocks, 10 SOL payer). + #[napi(factory)] + pub fn start() -> Result { + let inner = hiro_system_kit::nestable_block_on(surfpool_sdk::Surfnet::start()) + .map_err(|e| Error::new(Status::GenericFailure, e.to_string()))?; + Ok(Self { inner }) + } + + /// Start a surfnet with custom configuration. + #[napi(factory)] + pub fn start_with_config(config: SurfnetConfig) -> Result { + let mut builder = surfpool_sdk::Surfnet::builder(); + + if let Some(offline) = config.offline { + builder = builder.offline(offline); + } + if let Some(url) = config.remote_rpc_url { + builder = builder.remote_rpc_url(url); + } + if let Some(mode) = config.block_production_mode.as_deref() { + builder = builder.block_production_mode(match mode { + "clock" => BlockProductionMode::Clock, + "manual" => BlockProductionMode::Manual, + _ => BlockProductionMode::Transaction, + }); + } + if let Some(ms) = config.slot_time_ms { + builder = builder.slot_time_ms(ms as u64); + } + if let Some(lamports) = config.airdrop_sol { + builder = builder.airdrop_sol(lamports as u64); + } + if let Some(name) = config.test_name { + builder = builder.test_name(name); + } + if let Some(report) = config.report { + builder = builder.report(report); + } + + let inner = hiro_system_kit::nestable_block_on(builder.start()) + .map_err(|e| Error::new(Status::GenericFailure, e.to_string()))?; + Ok(Self { inner }) + } + + /// The HTTP RPC URL (e.g. "http://127.0.0.1:12345"). + #[napi(getter)] + pub fn rpc_url(&self) -> String { + self.inner.rpc_url().to_string() + } + + /// The WebSocket URL (e.g. "ws://127.0.0.1:12346"). + #[napi(getter)] + pub fn ws_url(&self) -> String { + self.inner.ws_url().to_string() + } + + /// The pre-funded payer public key as base58 string. + #[napi(getter)] + pub fn payer(&self) -> String { + self.inner.payer().pubkey().to_string() + } + + /// The pre-funded payer secret key as a 64-byte Uint8Array. + #[napi(getter)] + pub fn payer_secret_key(&self) -> Vec { + self.inner.payer().to_bytes().to_vec() + } + + /// Fund a SOL account with lamports. + #[napi] + pub fn fund_sol(&self, address: String, lamports: f64) -> Result<()> { + let pubkey: Pubkey = address + .parse() + .map_err(|e| Error::new(Status::InvalidArg, format!("Invalid address: {e}")))?; + self.inner + .cheatcodes() + .fund_sol(&pubkey, lamports as u64) + .map_err(|e| Error::new(Status::GenericFailure, e.to_string())) + } + + /// Fund a token account (creates the ATA if needed). + /// Uses spl_token program by default. Pass token_program for Token-2022. + #[napi] + pub fn fund_token( + &self, + owner: String, + mint: String, + amount: f64, + token_program: Option, + ) -> Result<()> { + let owner_pk: Pubkey = owner + .parse() + .map_err(|e| Error::new(Status::InvalidArg, format!("Invalid owner: {e}")))?; + let mint_pk: Pubkey = mint + .parse() + .map_err(|e| Error::new(Status::InvalidArg, format!("Invalid mint: {e}")))?; + let tp = token_program + .map(|s| { + s.parse::().map_err(|e| { + Error::new(Status::InvalidArg, format!("Invalid token program: {e}")) + }) + }) + .transpose()?; + + self.inner + .cheatcodes() + .fund_token(&owner_pk, &mint_pk, amount as u64, tp.as_ref()) + .map_err(|e| Error::new(Status::GenericFailure, e.to_string())) + } + + /// Set arbitrary account data. + #[napi] + pub fn set_account( + &self, + address: String, + lamports: f64, + data: Vec, + owner: String, + ) -> Result<()> { + let addr: Pubkey = address + .parse() + .map_err(|e| Error::new(Status::InvalidArg, format!("Invalid address: {e}")))?; + let owner_pk: Pubkey = owner + .parse() + .map_err(|e| Error::new(Status::InvalidArg, format!("Invalid owner: {e}")))?; + self.inner + .cheatcodes() + .set_account(&addr, lamports as u64, &data, &owner_pk) + .map_err(|e| Error::new(Status::GenericFailure, e.to_string())) + } + + /// Get the associated token address for a wallet/mint pair. + #[napi] + pub fn get_ata( + &self, + owner: String, + mint: String, + token_program: Option, + ) -> Result { + let owner_pk: Pubkey = owner + .parse() + .map_err(|e| Error::new(Status::InvalidArg, format!("Invalid owner: {e}")))?; + let mint_pk: Pubkey = mint + .parse() + .map_err(|e| Error::new(Status::InvalidArg, format!("Invalid mint: {e}")))?; + let tp = token_program + .map(|s| { + s.parse::().map_err(|e| { + Error::new(Status::InvalidArg, format!("Invalid token program: {e}")) + }) + }) + .transpose()?; + Ok(self + .inner + .cheatcodes() + .get_ata(&owner_pk, &mint_pk, tp.as_ref()) + .to_string()) + } + + /// Generate a new random keypair. Returns [publicKey, secretKey] as base58 and bytes. + #[napi] + pub fn new_keypair() -> KeypairInfo { + let kp = Keypair::new(); + KeypairInfo { + public_key: kp.pubkey().to_string(), + secret_key: kp.to_bytes().to_vec(), + } + } +} + +#[napi(object)] +pub struct SurfnetConfig { + pub offline: Option, + pub remote_rpc_url: Option, + pub block_production_mode: Option, + pub slot_time_ms: Option, + pub airdrop_sol: Option, + pub test_name: Option, + pub report: Option, +} + +#[napi(object)] +pub struct KeypairInfo { + pub public_key: String, + pub secret_key: Vec, +} diff --git a/crates/sdk-node/surfpool-sdk/.gitignore b/crates/sdk-node/surfpool-sdk/.gitignore new file mode 100644 index 00000000..485d9295 --- /dev/null +++ b/crates/sdk-node/surfpool-sdk/.gitignore @@ -0,0 +1,3 @@ +internal.d.ts +internal.js +*.node diff --git a/crates/sdk-node/surfpool-sdk/index.ts b/crates/sdk-node/surfpool-sdk/index.ts new file mode 100644 index 00000000..1582134b --- /dev/null +++ b/crates/sdk-node/surfpool-sdk/index.ts @@ -0,0 +1,95 @@ +import { + Surfnet as SurfnetInner, + SurfnetConfig, + KeypairInfo, +} from "./internal"; + +export { SurfnetConfig, KeypairInfo } from "./internal"; + +/** + * A running Surfpool instance with RPC/WS endpoints on dynamic ports. + * + * @example + * ```ts + * const surfnet = Surfnet.start(); + * console.log(surfnet.rpcUrl); // http://127.0.0.1:xxxxx + * + * surfnet.fundSol(address, 5_000_000_000); // 5 SOL + * surfnet.fundToken(address, usdcMint, 1_000_000); // 1 USDC + * ``` + */ +export class Surfnet { + private inner: SurfnetInner; + + private constructor(inner: SurfnetInner) { + this.inner = inner; + } + + /** Start a surfnet with default settings (offline, tx-mode blocks, 10 SOL payer). */ + static start(): Surfnet { + return new Surfnet(SurfnetInner.start()); + } + + /** Start a surfnet with custom configuration. */ + static startWithConfig(config: SurfnetConfig): Surfnet { + return new Surfnet(SurfnetInner.startWithConfig(config)); + } + + /** The HTTP RPC URL (e.g. "http://127.0.0.1:12345"). */ + get rpcUrl(): string { + return this.inner.rpcUrl; + } + + /** The WebSocket URL (e.g. "ws://127.0.0.1:12346"). */ + get wsUrl(): string { + return this.inner.wsUrl; + } + + /** The pre-funded payer public key as a base58 string. */ + get payer(): string { + return this.inner.payer; + } + + /** The pre-funded payer secret key as a 64-byte Uint8Array. */ + get payerSecretKey(): Uint8Array { + return this.inner.payerSecretKey; + } + + /** Fund a SOL account with lamports. */ + fundSol(address: string, lamports: number): void { + this.inner.fundSol(address, lamports); + } + + /** + * Fund a token account (creates the ATA if needed). + * Uses spl_token program by default. Pass tokenProgram for Token-2022. + */ + fundToken( + owner: string, + mint: string, + amount: number, + tokenProgram?: string + ): void { + this.inner.fundToken(owner, mint, amount, tokenProgram ?? null); + } + + /** Set arbitrary account data. */ + setAccount( + address: string, + lamports: number, + data: Uint8Array, + owner: string + ): void { + this.inner.setAccount(address, lamports, Array.from(data), owner); + } + + /** Get the associated token address for a wallet/mint pair. */ + getAta(owner: string, mint: string, tokenProgram?: string): string { + return this.inner.getAta(owner, mint, tokenProgram ?? null); + } + + /** Generate a new random keypair. */ + static newKeypair(): KeypairInfo { + return SurfnetInner.newKeypair(); + } +} diff --git a/crates/sdk-node/tsconfig.json b/crates/sdk-node/tsconfig.json new file mode 100644 index 00000000..1029d42e --- /dev/null +++ b/crates/sdk-node/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "commonjs", + "moduleResolution": "node", + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "outDir": "dist", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true + }, + "include": ["surfpool-sdk/index.ts"] +} From a91f9fb2b517b05520ee6f8cc80a14f55fc98649 Mon Sep 17 00:00:00 2001 From: Ludo Galabru Date: Sun, 29 Mar 2026 00:48:22 -0400 Subject: [PATCH 04/13] feat: reusable GitHub Actions for test reports and coverage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two composite actions for use by any repo: .github/actions/report — Surfpool Test Report - Upload report HTML as artifact - Deploy to GitHub Pages under configurable path (per-PR by default) - Post sticky PR comment with direct link Inputs: report-dir, output-path, generate-command, pages-path .github/actions/coverage — Coverage Report - Accept cargo-llvm-cov JSON and/or vitest coverage-summary.json - Render unified table with color badges (green ≥90%, yellow ≥80%, red <80%) - Post as sticky PR comment Inputs: rust-coverage-json, ts-coverage-json, thresholds Usage: uses: solana-foundation/surfpool/.github/actions/report@feat/sdk uses: solana-foundation/surfpool/.github/actions/coverage@feat/sdk --- .github/actions/coverage/action.yml | 85 +++++++++++++++++++++++++++++ .github/actions/report/action.yml | 83 ++++++++++++++++++++++++++++ 2 files changed, 168 insertions(+) create mode 100644 .github/actions/coverage/action.yml create mode 100644 .github/actions/report/action.yml diff --git a/.github/actions/coverage/action.yml b/.github/actions/coverage/action.yml new file mode 100644 index 00000000..4c9015ac --- /dev/null +++ b/.github/actions/coverage/action.yml @@ -0,0 +1,85 @@ +name: Surfpool Coverage Report +description: > + Post a unified Rust + TypeScript code coverage table as a sticky PR comment. + Accepts coverage artifacts from both languages and renders a single table. + +inputs: + rust-coverage-json: + description: Path to cargo-llvm-cov JSON output + required: false + ts-coverage-json: + description: Path to vitest coverage-summary.json + required: false + comment-header: + description: Sticky comment header ID + default: coverage + green-threshold: + description: Line coverage % for green badge + default: "90" + yellow-threshold: + description: Line coverage % for yellow badge + default: "80" + +runs: + using: composite + steps: + - name: Build coverage comment + id: cov + shell: bash + env: + RS_JSON: ${{ inputs.rust-coverage-json }} + TS_JSON: ${{ inputs.ts-coverage-json }} + GREEN: ${{ inputs.green-threshold }} + YELLOW: ${{ inputs.yellow-threshold }} + run: | + badge() { + local val=$1 + if [ "$(echo "$val >= $GREEN" | bc -l 2>/dev/null)" = "1" ]; then echo "🟢" + elif [ "$(echo "$val >= $YELLOW" | bc -l 2>/dev/null)" = "1" ]; then echo "🟡" + else echo "🔴"; fi + } + + ROWS="" + + # ── TypeScript ── + if [ -n "$TS_JSON" ] && [ -f "$TS_JSON" ]; then + TS_STMTS=$(jq -r '.total.statements.pct' "$TS_JSON") + TS_BRANCH=$(jq -r '.total.branches.pct' "$TS_JSON") + TS_FUNC=$(jq -r '.total.functions.pct' "$TS_JSON") + TS_LINES=$(jq -r '.total.lines.pct' "$TS_JSON") + TS_BADGE=$(badge "$TS_LINES") + ROWS="${ROWS}| ${TS_BADGE} **TypeScript** | ${TS_STMTS}% | ${TS_BRANCH}% | ${TS_FUNC}% | ${TS_LINES}% | + " + fi + + # ── Rust ── + if [ -n "$RS_JSON" ] && [ -f "$RS_JSON" ]; then + RS_LINES=$(jq -r '[.data[0].totals.lines.percent] | .[0] | . * 10 | round / 10' "$RS_JSON") + RS_FUNC=$(jq -r '[.data[0].totals.functions.percent] | .[0] | . * 10 | round / 10' "$RS_JSON") + RS_REGIONS=$(jq -r '[.data[0].totals.regions.percent] | .[0] | . * 10 | round / 10' "$RS_JSON") + RS_BADGE=$(badge "$RS_LINES") + ROWS="${ROWS}| ${RS_BADGE} **Rust** | ${RS_REGIONS}% | — | ${RS_FUNC}% | ${RS_LINES}% | + " + fi + + if [ -z "$ROWS" ]; then + ROWS="| ⚪ No coverage data | — | — | — | — | + " + fi + + { + echo "body<> "$GITHUB_OUTPUT" + + - name: Post coverage comment + if: github.event_name == 'pull_request' + uses: marocchino/sticky-pull-request-comment@v2 + with: + header: ${{ inputs.comment-header }} + message: ${{ steps.cov.outputs.body }} diff --git a/.github/actions/report/action.yml b/.github/actions/report/action.yml new file mode 100644 index 00000000..21b32d87 --- /dev/null +++ b/.github/actions/report/action.yml @@ -0,0 +1,83 @@ +name: Surfpool Test Report +description: > + Generate a Surfpool test report from SURFPOOL_REPORT data and publish it + to GitHub Pages. Posts a clickable link as a sticky PR comment. + +inputs: + report-dir: + description: Directory containing surfpool report JSON files + default: target/surfpool-reports + output-path: + description: Path for the generated HTML report + default: target/surfpool-report.html + generate-command: + description: Command to generate the HTML report from JSON data (if needed) + required: false + pages-path: + description: Path prefix on GitHub Pages (e.g. "pr/1") + default: pr/${{ github.event.pull_request.number }} + comment-header: + description: Sticky comment header ID + default: surfpool-report + +runs: + using: composite + steps: + - name: Generate report + if: inputs.generate-command != '' + shell: bash + run: ${{ inputs.generate-command }} + + - name: Upload report artifact + if: always() + uses: actions/upload-artifact@v4 + with: + name: surfpool-test-report + path: ${{ inputs.output-path }} + + - name: Deploy to GitHub Pages + if: always() && github.event_name == 'pull_request' + shell: bash + env: + REPORT_FILE: ${{ inputs.output-path }} + PAGES_PATH: ${{ inputs.pages-path }} + run: | + if [ ! -f "${REPORT_FILE}" ]; then + echo "No report file found, skipping deploy" + exit 0 + fi + + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + + cp "${REPORT_FILE}" /tmp/surfpool-report.html + + if git ls-remote --exit-code origin gh-pages >/dev/null 2>&1; then + git fetch origin gh-pages + git checkout gh-pages + else + git checkout --orphan gh-pages + git rm -rf . 2>/dev/null || true + echo "# GitHub Pages" > README.md + git add README.md + git commit -m "init: gh-pages branch" + fi + + mkdir -p "${PAGES_PATH}" + cp /tmp/surfpool-report.html "${PAGES_PATH}/index.html" + + git add "${PAGES_PATH}/index.html" + git commit -m "deploy: surfpool report for ${PAGES_PATH}" || true + git push origin gh-pages + + git checkout - + + - name: Comment PR + if: always() && github.event_name == 'pull_request' + uses: marocchino/sticky-pull-request-comment@v2 + with: + header: ${{ inputs.comment-header }} + message: | + ## [Surfpool Test Report](https://${{ github.repository_owner }}.github.io/${{ github.event.repository.name }}/${{ inputs.pages-path }}/) + + View the full transaction report with instruction profiling, account state diffs, and byte comparison. From a477f3fcfb2d5533e05f680ca38eca0d4c450c3a Mon Sep 17 00:00:00 2001 From: Ludo Galabru Date: Sun, 29 Mar 2026 00:56:34 -0400 Subject: [PATCH 05/13] feat(sdk): add SurfpoolReport::generate() convenience method MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One-liner for test files: surfpool_sdk::report::SurfpoolReport::generate(None, None); Uses default paths (target/surfpool-reports → target/surfpool-report.html) and handles missing data gracefully with eprintln. --- crates/sdk/src/report.rs | 44 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/crates/sdk/src/report.rs b/crates/sdk/src/report.rs index 20e38b0e..5760b889 100644 --- a/crates/sdk/src/report.rs +++ b/crates/sdk/src/report.rs @@ -131,6 +131,50 @@ impl SurfpoolReport { log::info!("Surfpool report written to {}", path.display()); Ok(()) } + + /// One-liner: read report data from a directory and write an HTML report. + /// + /// Uses default paths if not specified: + /// - `report_dir`: `target/surfpool-reports` + /// - `output`: `target/surfpool-report.html` + /// + /// Intended for use in a `#[test]` function: + /// ```rust,no_run + /// #[test] + /// fn generate_report() { + /// surfpool_sdk::report::SurfpoolReport::generate(None, None); + /// } + /// ``` + pub fn generate(report_dir: Option<&str>, output: Option<&str>) { + let dir = report_dir.unwrap_or("target/surfpool-reports"); + let out = output.unwrap_or("target/surfpool-report.html"); + + if !std::path::Path::new(dir).exists() { + eprintln!("No report data at {dir}. Run tests with SURFPOOL_REPORT=1 first."); + return; + } + + let report = match Self::from_directory(dir) { + Ok(r) => r, + Err(e) => { + eprintln!("Failed to read report data: {e}"); + return; + } + }; + + println!( + "Surfpool report: {} instances, {} transactions ({} failed)", + report.instances.len(), + report.total_transactions(), + report.failed_transactions() + ); + + if let Err(e) = report.write_html(out) { + eprintln!("Failed to write report: {e}"); + } else { + println!("Report written to {out}"); + } + } } /// Generate a standalone HTML report styled to match surfpool.run. From 4a256ac0d01b68f13659cc173fa2a9668ad1a938 Mon Sep 17 00:00:00 2001 From: Ludo Galabru Date: Sun, 29 Mar 2026 01:07:49 -0400 Subject: [PATCH 06/13] fix(sdk-node): point main at internal.js for direct napi import MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Avoids needing a tsc build step — 'import { Surfnet } from surfpool-sdk' now resolves directly to the napi-generated internal.js binding. --- crates/sdk-node/package.json | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/crates/sdk-node/package.json b/crates/sdk-node/package.json index 241bae20..9191390e 100644 --- a/crates/sdk-node/package.json +++ b/crates/sdk-node/package.json @@ -2,10 +2,11 @@ "name": "surfpool-sdk", "version": "0.1.0", "description": "Embed a Surfpool Solana runtime in your Node.js tests", - "main": "dist/index.js", - "types": "dist/index.d.ts", + "main": "surfpool-sdk/internal.js", + "types": "surfpool-sdk/internal.d.ts", "files": [ - "dist/" + "dist/", + "surfpool-sdk/" ], "napi": { "name": "surfpool-sdk", From 1243cef6269dde1573d2942f1fdc8a5d3d29bc3d Mon Sep 17 00:00:00 2001 From: Ludo Galabru Date: Sun, 29 Mar 2026 01:12:18 -0400 Subject: [PATCH 07/13] refactor: single test-report action replacing coverage + report Merges the two separate actions into one that posts a single PR comment: - Coverage table (Rust + TS) at the top - Surfpool test report link below (separated by ----) - GitHub Pages deploy for the HTML report - Artifact upload Usage: uses: solana-foundation/surfpool/.github/actions/test-report@feat/sdk with: rust-coverage-json: rust-cov/coverage.json ts-coverage-json: ts-cov/coverage-summary.json report-path: surfpool-report.html --- .github/actions/coverage/action.yml | 85 -------------- .github/actions/report/action.yml | 83 -------------- .github/actions/test-report/action.yml | 149 +++++++++++++++++++++++++ 3 files changed, 149 insertions(+), 168 deletions(-) delete mode 100644 .github/actions/coverage/action.yml delete mode 100644 .github/actions/report/action.yml create mode 100644 .github/actions/test-report/action.yml diff --git a/.github/actions/coverage/action.yml b/.github/actions/coverage/action.yml deleted file mode 100644 index 4c9015ac..00000000 --- a/.github/actions/coverage/action.yml +++ /dev/null @@ -1,85 +0,0 @@ -name: Surfpool Coverage Report -description: > - Post a unified Rust + TypeScript code coverage table as a sticky PR comment. - Accepts coverage artifacts from both languages and renders a single table. - -inputs: - rust-coverage-json: - description: Path to cargo-llvm-cov JSON output - required: false - ts-coverage-json: - description: Path to vitest coverage-summary.json - required: false - comment-header: - description: Sticky comment header ID - default: coverage - green-threshold: - description: Line coverage % for green badge - default: "90" - yellow-threshold: - description: Line coverage % for yellow badge - default: "80" - -runs: - using: composite - steps: - - name: Build coverage comment - id: cov - shell: bash - env: - RS_JSON: ${{ inputs.rust-coverage-json }} - TS_JSON: ${{ inputs.ts-coverage-json }} - GREEN: ${{ inputs.green-threshold }} - YELLOW: ${{ inputs.yellow-threshold }} - run: | - badge() { - local val=$1 - if [ "$(echo "$val >= $GREEN" | bc -l 2>/dev/null)" = "1" ]; then echo "🟢" - elif [ "$(echo "$val >= $YELLOW" | bc -l 2>/dev/null)" = "1" ]; then echo "🟡" - else echo "🔴"; fi - } - - ROWS="" - - # ── TypeScript ── - if [ -n "$TS_JSON" ] && [ -f "$TS_JSON" ]; then - TS_STMTS=$(jq -r '.total.statements.pct' "$TS_JSON") - TS_BRANCH=$(jq -r '.total.branches.pct' "$TS_JSON") - TS_FUNC=$(jq -r '.total.functions.pct' "$TS_JSON") - TS_LINES=$(jq -r '.total.lines.pct' "$TS_JSON") - TS_BADGE=$(badge "$TS_LINES") - ROWS="${ROWS}| ${TS_BADGE} **TypeScript** | ${TS_STMTS}% | ${TS_BRANCH}% | ${TS_FUNC}% | ${TS_LINES}% | - " - fi - - # ── Rust ── - if [ -n "$RS_JSON" ] && [ -f "$RS_JSON" ]; then - RS_LINES=$(jq -r '[.data[0].totals.lines.percent] | .[0] | . * 10 | round / 10' "$RS_JSON") - RS_FUNC=$(jq -r '[.data[0].totals.functions.percent] | .[0] | . * 10 | round / 10' "$RS_JSON") - RS_REGIONS=$(jq -r '[.data[0].totals.regions.percent] | .[0] | . * 10 | round / 10' "$RS_JSON") - RS_BADGE=$(badge "$RS_LINES") - ROWS="${ROWS}| ${RS_BADGE} **Rust** | ${RS_REGIONS}% | — | ${RS_FUNC}% | ${RS_LINES}% | - " - fi - - if [ -z "$ROWS" ]; then - ROWS="| ⚪ No coverage data | — | — | — | — | - " - fi - - { - echo "body<> "$GITHUB_OUTPUT" - - - name: Post coverage comment - if: github.event_name == 'pull_request' - uses: marocchino/sticky-pull-request-comment@v2 - with: - header: ${{ inputs.comment-header }} - message: ${{ steps.cov.outputs.body }} diff --git a/.github/actions/report/action.yml b/.github/actions/report/action.yml deleted file mode 100644 index 21b32d87..00000000 --- a/.github/actions/report/action.yml +++ /dev/null @@ -1,83 +0,0 @@ -name: Surfpool Test Report -description: > - Generate a Surfpool test report from SURFPOOL_REPORT data and publish it - to GitHub Pages. Posts a clickable link as a sticky PR comment. - -inputs: - report-dir: - description: Directory containing surfpool report JSON files - default: target/surfpool-reports - output-path: - description: Path for the generated HTML report - default: target/surfpool-report.html - generate-command: - description: Command to generate the HTML report from JSON data (if needed) - required: false - pages-path: - description: Path prefix on GitHub Pages (e.g. "pr/1") - default: pr/${{ github.event.pull_request.number }} - comment-header: - description: Sticky comment header ID - default: surfpool-report - -runs: - using: composite - steps: - - name: Generate report - if: inputs.generate-command != '' - shell: bash - run: ${{ inputs.generate-command }} - - - name: Upload report artifact - if: always() - uses: actions/upload-artifact@v4 - with: - name: surfpool-test-report - path: ${{ inputs.output-path }} - - - name: Deploy to GitHub Pages - if: always() && github.event_name == 'pull_request' - shell: bash - env: - REPORT_FILE: ${{ inputs.output-path }} - PAGES_PATH: ${{ inputs.pages-path }} - run: | - if [ ! -f "${REPORT_FILE}" ]; then - echo "No report file found, skipping deploy" - exit 0 - fi - - git config user.name "github-actions[bot]" - git config user.email "github-actions[bot]@users.noreply.github.com" - - cp "${REPORT_FILE}" /tmp/surfpool-report.html - - if git ls-remote --exit-code origin gh-pages >/dev/null 2>&1; then - git fetch origin gh-pages - git checkout gh-pages - else - git checkout --orphan gh-pages - git rm -rf . 2>/dev/null || true - echo "# GitHub Pages" > README.md - git add README.md - git commit -m "init: gh-pages branch" - fi - - mkdir -p "${PAGES_PATH}" - cp /tmp/surfpool-report.html "${PAGES_PATH}/index.html" - - git add "${PAGES_PATH}/index.html" - git commit -m "deploy: surfpool report for ${PAGES_PATH}" || true - git push origin gh-pages - - git checkout - - - - name: Comment PR - if: always() && github.event_name == 'pull_request' - uses: marocchino/sticky-pull-request-comment@v2 - with: - header: ${{ inputs.comment-header }} - message: | - ## [Surfpool Test Report](https://${{ github.repository_owner }}.github.io/${{ github.event.repository.name }}/${{ inputs.pages-path }}/) - - View the full transaction report with instruction profiling, account state diffs, and byte comparison. diff --git a/.github/actions/test-report/action.yml b/.github/actions/test-report/action.yml new file mode 100644 index 00000000..6ae9ba0f --- /dev/null +++ b/.github/actions/test-report/action.yml @@ -0,0 +1,149 @@ +name: Surfpool Test Report +description: > + Post a single PR comment with code coverage table and a link to the + Surfpool transaction report on GitHub Pages. + +inputs: + rust-coverage-json: + description: Path to cargo-llvm-cov JSON output + required: false + ts-coverage-json: + description: Path to vitest coverage-summary.json + required: false + report-path: + description: Path to the Surfpool HTML report file + required: false + pages-path: + description: Path prefix on GitHub Pages (e.g. "pr/1") + default: pr/${{ github.event.pull_request.number }} + green-threshold: + description: Line coverage % for green badge + default: "90" + yellow-threshold: + description: Line coverage % for yellow badge + default: "80" + comment-header: + description: Sticky comment header ID + default: surfpool + +runs: + using: composite + steps: + # ── Deploy report to GitHub Pages ── + - name: Deploy report + if: inputs.report-path != '' && github.event_name == 'pull_request' + shell: bash + env: + REPORT_FILE: ${{ inputs.report-path }} + PAGES_PATH: ${{ inputs.pages-path }} + run: | + if [ ! -f "${REPORT_FILE}" ]; then + echo "No report file at ${REPORT_FILE}, skipping deploy" + exit 0 + fi + + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + + cp "${REPORT_FILE}" /tmp/surfpool-report.html + + if git ls-remote --exit-code origin gh-pages >/dev/null 2>&1; then + git fetch origin gh-pages + git checkout gh-pages + else + git checkout --orphan gh-pages + git rm -rf . 2>/dev/null || true + echo "# GitHub Pages" > README.md + git add README.md + git commit -m "init: gh-pages branch" + fi + + mkdir -p "${PAGES_PATH}" + cp /tmp/surfpool-report.html "${PAGES_PATH}/index.html" + + git add "${PAGES_PATH}/index.html" + git commit -m "deploy: surfpool report for ${PAGES_PATH}" || true + git push origin gh-pages + git checkout - + + - name: Upload report artifact + if: inputs.report-path != '' + uses: actions/upload-artifact@v4 + with: + name: surfpool-test-report + path: ${{ inputs.report-path }} + + # ── Build the unified comment ── + - name: Build comment + id: comment + shell: bash + env: + RS_JSON: ${{ inputs.rust-coverage-json }} + TS_JSON: ${{ inputs.ts-coverage-json }} + GREEN: ${{ inputs.green-threshold }} + YELLOW: ${{ inputs.yellow-threshold }} + REPORT_FILE: ${{ inputs.report-path }} + PAGES_PATH: ${{ inputs.pages-path }} + REPO_OWNER: ${{ github.repository_owner }} + REPO_NAME: ${{ github.event.repository.name }} + run: | + badge() { + local val=$1 + if [ "$(echo "$val >= $GREEN" | bc -l 2>/dev/null)" = "1" ]; then echo "🟢" + elif [ "$(echo "$val >= $YELLOW" | bc -l 2>/dev/null)" = "1" ]; then echo "🟡" + else echo "🔴"; fi + } + + { + echo "body<> "$GITHUB_OUTPUT" + + - name: Post comment + if: github.event_name == 'pull_request' + uses: marocchino/sticky-pull-request-comment@v2 + with: + header: ${{ inputs.comment-header }} + message: ${{ steps.comment.outputs.body }} From 3dcb436ace653f2eae5d03e35ab4251fed739c1e Mon Sep 17 00:00:00 2001 From: Ludo Galabru Date: Sun, 29 Mar 2026 01:14:09 -0400 Subject: [PATCH 08/13] =?UTF-8?q?rename:=20Surfpool=20Test=20Report=20?= =?UTF-8?q?=E2=86=92=20Surfpool=20Report?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/actions/test-report/action.yml | 6 +++--- crates/sdk/src/report.rs | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/actions/test-report/action.yml b/.github/actions/test-report/action.yml index 6ae9ba0f..aa5303d8 100644 --- a/.github/actions/test-report/action.yml +++ b/.github/actions/test-report/action.yml @@ -1,4 +1,4 @@ -name: Surfpool Test Report +name: Surfpool Report description: > Post a single PR comment with code coverage table and a link to the Surfpool transaction report on GitHub Pages. @@ -70,7 +70,7 @@ runs: if: inputs.report-path != '' uses: actions/upload-artifact@v4 with: - name: surfpool-test-report + name: surfpool-report path: ${{ inputs.report-path }} # ── Build the unified comment ── @@ -135,7 +135,7 @@ runs: echo "" echo "---" echo "" - echo "**[Surfpool Test Report](https://${REPO_OWNER}.github.io/${REPO_NAME}/${PAGES_PATH}/)** — transaction profiling, account state diffs, byte comparison" + echo "**[Surfpool Report](https://${REPO_OWNER}.github.io/${REPO_NAME}/${PAGES_PATH}/)** — transaction profiling, account state diffs, byte comparison" fi echo "EOFCOMMENT" diff --git a/crates/sdk/src/report.rs b/crates/sdk/src/report.rs index 5760b889..5a4b8fef 100644 --- a/crates/sdk/src/report.rs +++ b/crates/sdk/src/report.rs @@ -163,7 +163,7 @@ impl SurfpoolReport { }; println!( - "Surfpool report: {} instances, {} transactions ({} failed)", + "Surfpool: {} instances, {} transactions ({} failed)", report.instances.len(), report.total_transactions(), report.failed_transactions() @@ -185,7 +185,7 @@ fn generate_standalone_html(report_json: &str) -> String { -Surfpool Test Report +Surfpool Report - - -
- - -"## - ) + #[test] + #[ignore = "manual preview helper: writes a stable HTML file to target/ for browser inspection"] + fn generate_preview_report_file() { + let workspace_target = + std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../target"); + let report_dir = workspace_target.join("surfpool-preview-report-data"); + let output_path = workspace_target.join("surfpool-preview-report.html"); + + if report_dir.exists() { + fs::remove_dir_all(&report_dir).unwrap(); + } + fs::create_dir_all(&report_dir).unwrap(); + + let instance = sample_instance( + "preview-instance", + "sdk::report::preview", + vec![ + sample_transaction("preview-signature-1", 1, None), + sample_transaction("preview-signature-2", 2, Some("custom program error: 0x1")), + ], + ); + + fs::write( + report_dir.join("preview.json"), + serde_json::to_string_pretty(&instance).unwrap(), + ) + .unwrap(); + + let output = crate::report::generate(SurfpoolReportOptions { + report_dir, + output_path: output_path.clone(), + }) + .unwrap(); + + println!("Preview report written to {}", output.display()); + assert_eq!(output, output_path); + } } From b1c6030ba73724369ade1ccb04938400453535a2 Mon Sep 17 00:00:00 2001 From: Micaiah Reid Date: Fri, 3 Apr 2026 14:53:35 -0400 Subject: [PATCH 10/13] feat: enhance sdk cheatcodes; add builder pattern --- Cargo.lock | 4 + crates/sdk/Cargo.toml | 4 + crates/sdk/src/cheatcodes/builders/mod.rs | 9 + .../src/cheatcodes/builders/reset_account.rs | 38 +++ .../src/cheatcodes/builders/set_account.rs | 74 +++++ .../cheatcodes/builders/set_token_account.rs | 114 +++++++ .../src/cheatcodes/builders/stream_account.rs | 38 +++ .../src/{cheatcodes.rs => cheatcodes/mod.rs} | 39 ++- crates/sdk/src/cheatcodes/tests.rs | 299 ++++++++++++++++++ 9 files changed, 618 insertions(+), 1 deletion(-) create mode 100644 crates/sdk/src/cheatcodes/builders/mod.rs create mode 100644 crates/sdk/src/cheatcodes/builders/reset_account.rs create mode 100644 crates/sdk/src/cheatcodes/builders/set_account.rs create mode 100644 crates/sdk/src/cheatcodes/builders/set_token_account.rs create mode 100644 crates/sdk/src/cheatcodes/builders/stream_account.rs rename crates/sdk/src/{cheatcodes.rs => cheatcodes/mod.rs} (76%) create mode 100644 crates/sdk/src/cheatcodes/tests.rs diff --git a/Cargo.lock b/Cargo.lock index 1ca58268..c1e03d85 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -11379,6 +11379,7 @@ version = "1.1.1" dependencies = [ "chrono", "crossbeam-channel", + "hex", "hiro-system-kit", "log 0.4.29", "reqwest 0.12.28", @@ -11388,9 +11389,11 @@ dependencies = [ "solana-client", "solana-clock 3.0.0", "solana-commitment-config", + "solana-epoch-info", "solana-hash 3.1.0", "solana-keypair", "solana-message 3.0.1", + "solana-program-pack 3.0.0", "solana-pubkey 3.0.0", "solana-rpc-client", "solana-signature", @@ -11398,6 +11401,7 @@ dependencies = [ "solana-system-interface 2.0.0", "solana-transaction", "spl-associated-token-account-interface", + "spl-token-interface", "surfpool-core", "surfpool-types", "tempfile", diff --git a/crates/sdk/Cargo.toml b/crates/sdk/Cargo.toml index 2f6aa2bf..99014db8 100644 --- a/crates/sdk/Cargo.toml +++ b/crates/sdk/Cargo.toml @@ -15,6 +15,7 @@ path = "src/lib.rs" [dependencies] chrono = { workspace = true } crossbeam-channel = { workspace = true } +hex = { workspace = true } hiro-system-kit = { workspace = true } log = { workspace = true } serde = { workspace = true, features = ["derive"] } @@ -22,6 +23,7 @@ serde_json = { workspace = true } solana-account = { workspace = true } solana-client = { workspace = true } solana-clock = { workspace = true } +solana-epoch-info = { workspace = true } solana-keypair = { workspace = true } solana-pubkey = { workspace = true } solana-rpc-client = { workspace = true } @@ -40,10 +42,12 @@ reqwest = { workspace = true, features = ["blocking", "default-tls"] } zip = { workspace = true } [dev-dependencies] +solana-program-pack = { workspace = true } solana-system-interface = { workspace = true } solana-transaction = { workspace = true } solana-message = { workspace = true } solana-hash = { workspace = true } solana-commitment-config = { workspace = true } +spl-token-interface = { workspace = true } tempfile = { workspace = true } tokio = { workspace = true, features = ["full"] } diff --git a/crates/sdk/src/cheatcodes/builders/mod.rs b/crates/sdk/src/cheatcodes/builders/mod.rs new file mode 100644 index 00000000..22944eb7 --- /dev/null +++ b/crates/sdk/src/cheatcodes/builders/mod.rs @@ -0,0 +1,9 @@ +pub mod set_account; +pub mod set_token_account; +pub mod reset_account; +pub mod stream_account; + +pub trait CheatcodeBuilder { + const METHOD: &'static str; + fn build(self) -> serde_json::Value; +} diff --git a/crates/sdk/src/cheatcodes/builders/reset_account.rs b/crates/sdk/src/cheatcodes/builders/reset_account.rs new file mode 100644 index 00000000..d80cb1b6 --- /dev/null +++ b/crates/sdk/src/cheatcodes/builders/reset_account.rs @@ -0,0 +1,38 @@ +use solana_pubkey::Pubkey; + +use crate::cheatcodes::builders::CheatcodeBuilder; + +pub struct ResetAccount { + address: Pubkey, + include_owned_accounts: Option, +} + +impl ResetAccount { + pub fn new(address: Pubkey) -> Self { + Self { + address, + include_owned_accounts: None, + } + } + + pub fn include_owned_accounts(mut self, include_owned_accounts: bool) -> Self { + self.include_owned_accounts = Some(include_owned_accounts); + self + } +} + +impl CheatcodeBuilder for ResetAccount { + const METHOD: &'static str = "surfnet_resetAccount"; + + fn build(self) -> serde_json::Value { + let mut params = vec![self.address.to_string().into()]; + + if let Some(include_owned_accounts) = self.include_owned_accounts { + params.push(serde_json::json!({ + "includeOwnedAccounts": include_owned_accounts + })); + } + + serde_json::Value::Array(params) + } +} diff --git a/crates/sdk/src/cheatcodes/builders/set_account.rs b/crates/sdk/src/cheatcodes/builders/set_account.rs new file mode 100644 index 00000000..0e3bfa72 --- /dev/null +++ b/crates/sdk/src/cheatcodes/builders/set_account.rs @@ -0,0 +1,74 @@ +use solana_pubkey::Pubkey; + +use crate::cheatcodes::builders::CheatcodeBuilder; + +pub struct SetAccount { + address: Pubkey, + lamports: Option, + data: Option>, + owner: Option, + rent_epoch: Option, + executable: Option, +} + +impl SetAccount { + pub fn new(address: Pubkey) -> Self { + Self { + address, + lamports: None, + data: None, + owner: None, + rent_epoch: None, + executable: None, + } + } + + pub fn lamports(mut self, lamports: u64) -> Self { + self.lamports = Some(lamports); + self + } + + pub fn data(mut self, data: Vec) -> Self { + self.data = Some(data); + self + } + + pub fn owner(mut self, owner: Pubkey) -> Self { + self.owner = Some(owner); + self + } + + pub fn rent_epoch(mut self, rent_epoch: u64) -> Self { + self.rent_epoch = Some(rent_epoch); + self + } + + pub fn executable(mut self, executable: bool) -> Self { + self.executable = Some(executable); + self + } +} + +impl CheatcodeBuilder for SetAccount { + const METHOD: &'static str = "surfnet_setAccount"; + fn build(self) -> serde_json::Value { + let mut account_info = serde_json::Map::new(); + if let Some(lamports) = self.lamports { + account_info.insert("lamports".to_string(), lamports.into()); + } + if let Some(data) = self.data { + account_info.insert("data".to_string(), hex::encode(data).into()); + } + if let Some(owner) = self.owner { + account_info.insert("owner".to_string(), owner.to_string().into()); + } + if let Some(rent_epoch) = self.rent_epoch { + account_info.insert("rentEpoch".to_string(), rent_epoch.into()); + } + if let Some(executable) = self.executable { + account_info.insert("executable".to_string(), executable.into()); + } + + serde_json::json!([self.address.to_string(), account_info]) + } +} diff --git a/crates/sdk/src/cheatcodes/builders/set_token_account.rs b/crates/sdk/src/cheatcodes/builders/set_token_account.rs new file mode 100644 index 00000000..a2242f6f --- /dev/null +++ b/crates/sdk/src/cheatcodes/builders/set_token_account.rs @@ -0,0 +1,114 @@ +use solana_pubkey::Pubkey; + +use crate::cheatcodes::builders::CheatcodeBuilder; + +pub struct SetTokenAccount { + owner: Pubkey, + mint: Pubkey, + amount: Option, + delegate: Option>, + state: Option, + delegated_amount: Option, + close_authority: Option>, + token_program: Option, +} + +impl SetTokenAccount { + pub fn new(owner: Pubkey, mint: Pubkey) -> Self { + Self { + owner, + mint, + amount: None, + delegate: None, + state: None, + delegated_amount: None, + close_authority: None, + token_program: None, + } + } + + pub fn amount(mut self, amount: u64) -> Self { + self.amount = Some(amount); + self + } + + pub fn delegate(mut self, delegate: Pubkey) -> Self { + self.delegate = Some(Some(delegate)); + self + } + + pub fn clear_delegate(mut self) -> Self { + self.delegate = Some(None); + self + } + + pub fn state(mut self, state: impl Into) -> Self { + self.state = Some(state.into()); + self + } + + pub fn delegated_amount(mut self, delegated_amount: u64) -> Self { + self.delegated_amount = Some(delegated_amount); + self + } + + pub fn close_authority(mut self, close_authority: Pubkey) -> Self { + self.close_authority = Some(Some(close_authority)); + self + } + + pub fn clear_close_authority(mut self) -> Self { + self.close_authority = Some(None); + self + } + + pub fn token_program(mut self, token_program: Pubkey) -> Self { + self.token_program = Some(token_program); + self + } +} + +impl CheatcodeBuilder for SetTokenAccount { + const METHOD: &'static str = "surfnet_setTokenAccount"; + + fn build(self) -> serde_json::Value { + let mut update = serde_json::Map::new(); + if let Some(amount) = self.amount { + update.insert("amount".to_string(), amount.into()); + } + if let Some(delegate) = self.delegate { + update.insert( + "delegate".to_string(), + delegate + .map(|pubkey| pubkey.to_string().into()) + .unwrap_or_else(|| "null".into()), + ); + } + if let Some(state) = self.state { + update.insert("state".to_string(), state.into()); + } + if let Some(delegated_amount) = self.delegated_amount { + update.insert("delegatedAmount".to_string(), delegated_amount.into()); + } + if let Some(close_authority) = self.close_authority { + update.insert( + "closeAuthority".to_string(), + close_authority + .map(|pubkey| pubkey.to_string().into()) + .unwrap_or_else(|| "null".into()), + ); + } + + let mut params = vec![ + self.owner.to_string().into(), + self.mint.to_string().into(), + update.into(), + ]; + + if let Some(token_program) = self.token_program { + params.push(token_program.to_string().into()); + } + + serde_json::Value::Array(params) + } +} diff --git a/crates/sdk/src/cheatcodes/builders/stream_account.rs b/crates/sdk/src/cheatcodes/builders/stream_account.rs new file mode 100644 index 00000000..1f2890fd --- /dev/null +++ b/crates/sdk/src/cheatcodes/builders/stream_account.rs @@ -0,0 +1,38 @@ +use solana_pubkey::Pubkey; + +use crate::cheatcodes::builders::CheatcodeBuilder; + +pub struct StreamAccount { + address: Pubkey, + include_owned_accounts: Option, +} + +impl StreamAccount { + pub fn new(address: Pubkey) -> Self { + Self { + address, + include_owned_accounts: None, + } + } + + pub fn include_owned_accounts(mut self, include_owned_accounts: bool) -> Self { + self.include_owned_accounts = Some(include_owned_accounts); + self + } +} + +impl CheatcodeBuilder for StreamAccount { + const METHOD: &'static str = "surfnet_streamAccount"; + + fn build(self) -> serde_json::Value { + let mut params = vec![self.address.to_string().into()]; + + if let Some(include_owned_accounts) = self.include_owned_accounts { + params.push(serde_json::json!({ + "includeOwnedAccounts": include_owned_accounts + })); + } + + serde_json::Value::Array(params) + } +} diff --git a/crates/sdk/src/cheatcodes.rs b/crates/sdk/src/cheatcodes/mod.rs similarity index 76% rename from crates/sdk/src/cheatcodes.rs rename to crates/sdk/src/cheatcodes/mod.rs index a7c7944b..93bf9cea 100644 --- a/crates/sdk/src/cheatcodes.rs +++ b/crates/sdk/src/cheatcodes/mod.rs @@ -1,9 +1,12 @@ use solana_client::rpc_request::RpcRequest; +use solana_epoch_info::EpochInfo; use solana_pubkey::Pubkey; use solana_rpc_client::rpc_client::RpcClient; use spl_associated_token_account_interface::address::get_associated_token_address_with_program_id; use crate::error::{SurfnetError, SurfnetResult}; +pub mod builders; +use builders::CheatcodeBuilder; /// Direct state manipulation helpers for a running Surfnet. /// @@ -60,7 +63,7 @@ impl<'a> Cheatcodes<'a> { address.to_string(), { "lamports": lamports, - "data": data, + "data": hex::encode(data), "owner": owner.to_string() } ]); @@ -126,6 +129,37 @@ impl<'a> Cheatcodes<'a> { Ok(()) } + /// Move Surfnet time forward to an absolute epoch. + pub fn time_travel_to_epoch(&self, epoch: u64) -> SurfnetResult { + self.time_travel(serde_json::json!([{ "absoluteEpoch": epoch }])) + } + + /// Move Surfnet time forward to an absolute slot. + pub fn time_travel_to_slot(&self, slot: u64) -> SurfnetResult { + self.time_travel(serde_json::json!([{ "absoluteSlot": slot }])) + } + + /// Move Surfnet time forward to an absolute Unix timestamp in milliseconds. + pub fn time_travel_to_timestamp(&self, timestamp: u64) -> SurfnetResult { + self.time_travel(serde_json::json!([{ "absoluteTimestamp": timestamp }])) + } + + pub fn execute(&self, builder: B) -> SurfnetResult<()> { + self.call_cheatcode(B::METHOD, builder.build()) + } + + fn time_travel(&self, params: serde_json::Value) -> SurfnetResult { + let client = self.rpc_client(); + client + .send::( + RpcRequest::Custom { + method: "surfnet_timeTravel", + }, + params, + ) + .map_err(|e| SurfnetError::Cheatcode(format!("surfnet_timeTravel: {e}"))) + } + fn call_cheatcode(&self, method: &'static str, params: serde_json::Value) -> SurfnetResult<()> { let client = self.rpc_client(); client @@ -139,3 +173,6 @@ fn spl_token_program_id() -> Pubkey { // spl_token::id() = TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA Pubkey::from_str_const("TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA") } + +#[cfg(test)] +mod tests; diff --git a/crates/sdk/src/cheatcodes/tests.rs b/crates/sdk/src/cheatcodes/tests.rs new file mode 100644 index 00000000..f309f3f6 --- /dev/null +++ b/crates/sdk/src/cheatcodes/tests.rs @@ -0,0 +1,299 @@ +use solana_program_pack::Pack; +use solana_pubkey::Pubkey; +use solana_rpc_client::rpc_client::RpcClient; +use spl_associated_token_account_interface::address::get_associated_token_address_with_program_id; +use spl_token_interface::state::{Account as TokenAccount, Mint}; +use std::sync::{Mutex, MutexGuard, OnceLock}; + +use super::{ + Cheatcodes, + builders::{ + reset_account::ResetAccount, set_account::SetAccount, set_token_account::SetTokenAccount, + stream_account::StreamAccount, + }, + spl_token_program_id, +}; +use crate::{Surfnet, error::SurfnetResult}; + +fn create_test_mint(cheats: &Cheatcodes<'_>, mint: &Pubkey) -> SurfnetResult<()> { + let mut mint_data = [0u8; Mint::LEN]; + let mint_state = Mint { + decimals: 6, + supply: 1_000_000_000, + is_initialized: true, + ..Mint::default() + }; + mint_state.pack_into_slice(&mut mint_data); + + cheats.set_account(mint, 1_461_600, &mint_data, &spl_token_program_id()) +} + +fn read_token_amount(cheats: &Cheatcodes<'_>, owner: &Pubkey, mint: &Pubkey) -> u64 { + let ata = cheats.get_ata(owner, mint, None); + let account = cheats.rpc_client().get_account(&ata).unwrap(); + let token_account = TokenAccount::unpack(&account.data).unwrap(); + token_account.amount +} + +fn rpc_client(cheats: &Cheatcodes<'_>) -> RpcClient { + cheats.rpc_client() +} + +fn test_lock() -> MutexGuard<'static, ()> { + static LOCK: OnceLock> = OnceLock::new(); + LOCK.get_or_init(|| Mutex::new(())).lock().unwrap() +} + +#[tokio::test(flavor = "multi_thread")] +async fn fund_sol_executes_against_surfnet() { + let _guard = test_lock(); + let surfnet = Surfnet::start().await.unwrap(); + let cheats = surfnet.cheatcodes(); + let address = Pubkey::new_unique(); + + cheats.fund_sol(&address, 123_456_789).unwrap(); + + let balance = cheats.rpc_client().get_balance(&address).unwrap(); + assert_eq!(balance, 123_456_789); +} + +#[tokio::test(flavor = "multi_thread")] +async fn set_account_executes_against_surfnet() { + let _guard = test_lock(); + let surfnet = Surfnet::start().await.unwrap(); + let cheats = surfnet.cheatcodes(); + let address = Pubkey::new_unique(); + let owner = Pubkey::new_unique(); + let data = vec![1, 2, 3, 4, 5]; + + cheats + .set_account(&address, 424_242, &data, &owner) + .unwrap(); + + let account = cheats.rpc_client().get_account(&address).unwrap(); + assert_eq!(account.lamports, 424_242); + assert_eq!(account.owner, owner); + assert_eq!(account.data, data); +} + +#[tokio::test(flavor = "multi_thread")] +async fn set_account_builder_executes_against_surfnet() { + let _guard = test_lock(); + let surfnet = Surfnet::start().await.unwrap(); + let cheats = surfnet.cheatcodes(); + let address = Pubkey::new_unique(); + let owner = Pubkey::new_unique(); + let data = vec![0xAA, 0xBB, 0xCC, 0xDD]; + + cheats + .execute( + SetAccount::new(address) + .lamports(808_080) + .data(data.clone()) + .owner(owner) + .rent_epoch(42) + .executable(false), + ) + .unwrap(); + + let account = cheats.rpc_client().get_account(&address).unwrap(); + assert_eq!(account.lamports, 808_080); + assert_eq!(account.owner, owner); + assert_eq!(account.data, data); + assert_eq!(account.rent_epoch, 42); + assert!(!account.executable); +} + +#[tokio::test(flavor = "multi_thread")] +async fn fund_sol_many_executes_against_surfnet() { + let _guard = test_lock(); + let surfnet = Surfnet::start().await.unwrap(); + let cheats = surfnet.cheatcodes(); + let first = Pubkey::new_unique(); + let second = Pubkey::new_unique(); + + cheats + .fund_sol_many(&[(&first, 10_000), (&second, 20_000)]) + .unwrap(); + + assert_eq!(cheats.rpc_client().get_balance(&first).unwrap(), 10_000); + assert_eq!(cheats.rpc_client().get_balance(&second).unwrap(), 20_000); +} + +#[tokio::test(flavor = "multi_thread")] +async fn fund_token_executes_against_surfnet() { + let _guard = test_lock(); + let surfnet = Surfnet::start().await.unwrap(); + let cheats = surfnet.cheatcodes(); + let owner = Pubkey::new_unique(); + let mint = Pubkey::new_unique(); + + create_test_mint(&cheats, &mint).unwrap(); + cheats.fund_token(&owner, &mint, 55, None).unwrap(); + + assert_eq!(read_token_amount(&cheats, &owner, &mint), 55); +} + +#[tokio::test(flavor = "multi_thread")] +async fn set_token_account_builder_executes_against_surfnet() { + let _guard = test_lock(); + let surfnet = Surfnet::start().await.unwrap(); + let cheats = surfnet.cheatcodes(); + let owner = Pubkey::new_unique(); + let mint = Pubkey::new_unique(); + + create_test_mint(&cheats, &mint).unwrap(); + cheats + .execute(SetTokenAccount::new(owner, mint).amount(321)) + .unwrap(); + + assert_eq!(read_token_amount(&cheats, &owner, &mint), 321); +} + +#[tokio::test(flavor = "multi_thread")] +async fn set_token_balance_executes_against_surfnet() { + let _guard = test_lock(); + let surfnet = Surfnet::start().await.unwrap(); + let cheats = surfnet.cheatcodes(); + let owner = Pubkey::new_unique(); + let mint = Pubkey::new_unique(); + + create_test_mint(&cheats, &mint).unwrap(); + cheats.fund_token(&owner, &mint, 10, None).unwrap(); + cheats.set_token_balance(&owner, &mint, 777, None).unwrap(); + + assert_eq!(read_token_amount(&cheats, &owner, &mint), 777); +} + +#[tokio::test(flavor = "multi_thread")] +async fn fund_token_many_executes_against_surfnet() { + let _guard = test_lock(); + let surfnet = Surfnet::start().await.unwrap(); + let cheats = surfnet.cheatcodes(); + let mint = Pubkey::new_unique(); + let first = Pubkey::new_unique(); + let second = Pubkey::new_unique(); + + create_test_mint(&cheats, &mint).unwrap(); + cheats + .fund_token_many(&[&first, &second], &mint, 999, None) + .unwrap(); + + assert_eq!(read_token_amount(&cheats, &first, &mint), 999); + assert_eq!(read_token_amount(&cheats, &second, &mint), 999); +} + +#[tokio::test(flavor = "multi_thread")] +async fn time_travel_to_slot_executes_against_surfnet() { + let _guard = test_lock(); + let surfnet = Surfnet::start().await.unwrap(); + let cheats = surfnet.cheatcodes(); + let client = rpc_client(&cheats); + let before = client.get_epoch_info().unwrap(); + let target_slot = before.absolute_slot + 100; + + let after = cheats.time_travel_to_slot(target_slot).unwrap(); + assert!(after.absolute_slot >= target_slot); +} + +#[tokio::test(flavor = "multi_thread")] +async fn time_travel_to_epoch_executes_against_surfnet() { + let _guard = test_lock(); + let surfnet = Surfnet::start().await.unwrap(); + let cheats = surfnet.cheatcodes(); + let client = rpc_client(&cheats); + let before = client.get_epoch_info().unwrap(); + let target_epoch = before.epoch + 1; + + let after = cheats.time_travel_to_epoch(target_epoch).unwrap(); + assert!(after.epoch >= target_epoch); +} + +#[tokio::test(flavor = "multi_thread")] +async fn time_travel_to_timestamp_executes_against_surfnet() { + let _guard = test_lock(); + let surfnet = Surfnet::start().await.unwrap(); + let cheats = surfnet.cheatcodes(); + let client = rpc_client(&cheats); + let before = client.get_epoch_info().unwrap(); + let target_timestamp_ms = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_millis() as u64 + + 5_000; + + let after = cheats.time_travel_to_timestamp(target_timestamp_ms).unwrap(); + assert!(after.absolute_slot > before.absolute_slot); +} + +#[tokio::test(flavor = "multi_thread")] +async fn reset_account_builder_executes_against_surfnet() { + let _guard = test_lock(); + let surfnet = Surfnet::start().await.unwrap(); + let cheats = surfnet.cheatcodes(); + let client = rpc_client(&cheats); + let address = Pubkey::new_unique(); + let owner = Pubkey::new_unique(); + + cheats + .set_account(&address, 77_777, &[1, 2, 3], &owner) + .unwrap(); + assert_eq!(client.get_balance(&address).unwrap(), 77_777); + + cheats + .execute(ResetAccount::new(address).include_owned_accounts(false)) + .unwrap(); + + assert_eq!(client.get_balance(&address).unwrap(), 0); + assert!(client.get_account(&address).is_err()); +} + +#[tokio::test(flavor = "multi_thread")] +async fn stream_account_builder_executes_against_surfnet() { + let _guard = test_lock(); + let surfnet = Surfnet::start().await.unwrap(); + let cheats = surfnet.cheatcodes(); + let client = rpc_client(&cheats); + let address = Pubkey::new_unique(); + + cheats + .execute(StreamAccount::new(address).include_owned_accounts(true)) + .unwrap(); + + let response = client + .send::( + solana_client::rpc_request::RpcRequest::Custom { + method: "surfnet_getStreamedAccounts", + }, + serde_json::json!([]), + ) + .unwrap(); + + let accounts = response + .get("value") + .and_then(|value| value.get("accounts")) + .and_then(|value| value.as_array()) + .cloned() + .unwrap_or_default(); + + assert!(accounts.iter().any(|account| { + account.get("pubkey").and_then(|value| value.as_str()) == Some(&address.to_string()) + && account + .get("includeOwnedAccounts") + .and_then(|value| value.as_bool()) + == Some(true) + })); +} + +#[test] +fn get_ata_matches_associated_token_derivation() { + let owner = Pubkey::new_unique(); + let mint = Pubkey::new_unique(); + let cheats = Cheatcodes::new("http://127.0.0.1:8899"); + + let derived = cheats.get_ata(&owner, &mint, None); + let expected = + get_associated_token_address_with_program_id(&owner, &mint, &spl_token_program_id()); + + assert_eq!(derived, expected); +} From a8fd31c89be80b6ab803359e4fa3a648320c6065 Mon Sep 17 00:00:00 2001 From: Micaiah Reid Date: Fri, 3 Apr 2026 14:59:46 -0400 Subject: [PATCH 11/13] chore: update sdk docs --- crates/sdk/src/cheatcodes/builders/mod.rs | 30 ++++++ .../src/cheatcodes/builders/reset_account.rs | 22 +++++ .../src/cheatcodes/builders/set_account.rs | 35 +++++++ .../cheatcodes/builders/set_token_account.rs | 32 +++++++ .../src/cheatcodes/builders/stream_account.rs | 22 +++++ crates/sdk/src/cheatcodes/mod.rs | 95 ++++++++++++++++++- crates/sdk/src/lib.rs | 2 +- 7 files changed, 232 insertions(+), 6 deletions(-) diff --git a/crates/sdk/src/cheatcodes/builders/mod.rs b/crates/sdk/src/cheatcodes/builders/mod.rs index 22944eb7..114663f8 100644 --- a/crates/sdk/src/cheatcodes/builders/mod.rs +++ b/crates/sdk/src/cheatcodes/builders/mod.rs @@ -1,8 +1,38 @@ +//! Builder types for constructing Surfnet cheatcode RPC payloads. +//! +//! These builders are useful when tests need to express optional parameters +//! incrementally and then execute the request through +//! [`crate::Cheatcodes::execute`]. +//! +//! ```rust,no_run +//! use surfpool_sdk::{Pubkey, Surfnet}; +//! use surfpool_sdk::cheatcodes::builders::set_account::SetAccount; +//! +//! # async fn example() { +//! let surfnet = Surfnet::start().await.unwrap(); +//! let cheats = surfnet.cheatcodes(); +//! let address = Pubkey::new_unique(); +//! let owner = Pubkey::new_unique(); +//! +//! cheats +//! .execute( +//! SetAccount::new(address) +//! .lamports(1_000_000) +//! .owner(owner) +//! .data(vec![1, 2, 3, 4]), +//! ) +//! .unwrap(); +//! # } +//! ``` pub mod set_account; pub mod set_token_account; pub mod reset_account; pub mod stream_account; +/// Trait implemented by typed cheatcode builders. +/// +/// `METHOD` is the target Surfnet RPC method, and [`Self::build`] returns +/// the JSON-RPC parameter array for that method. pub trait CheatcodeBuilder { const METHOD: &'static str; fn build(self) -> serde_json::Value; diff --git a/crates/sdk/src/cheatcodes/builders/reset_account.rs b/crates/sdk/src/cheatcodes/builders/reset_account.rs index d80cb1b6..cb443314 100644 --- a/crates/sdk/src/cheatcodes/builders/reset_account.rs +++ b/crates/sdk/src/cheatcodes/builders/reset_account.rs @@ -2,12 +2,32 @@ use solana_pubkey::Pubkey; use crate::cheatcodes::builders::CheatcodeBuilder; +/// Builder for `surfnet_resetAccount`. +/// +/// This builder starts with the required account address and exposes +/// additional reset options as fluent setters. +/// +/// ```rust,no_run +/// use surfpool_sdk::{Pubkey, Surfnet}; +/// use surfpool_sdk::cheatcodes::builders::reset_account::ResetAccount; +/// +/// # async fn example() { +/// let surfnet = Surfnet::start().await.unwrap(); +/// let cheats = surfnet.cheatcodes(); +/// let address = Pubkey::new_unique(); +/// +/// cheats +/// .execute(ResetAccount::new(address).include_owned_accounts(true)) +/// .unwrap(); +/// # } +/// ``` pub struct ResetAccount { address: Pubkey, include_owned_accounts: Option, } impl ResetAccount { + /// Create a reset-account builder for the given address. pub fn new(address: Pubkey) -> Self { Self { address, @@ -15,6 +35,7 @@ impl ResetAccount { } } + /// Include accounts owned by the target account in the reset operation. pub fn include_owned_accounts(mut self, include_owned_accounts: bool) -> Self { self.include_owned_accounts = Some(include_owned_accounts); self @@ -24,6 +45,7 @@ impl ResetAccount { impl CheatcodeBuilder for ResetAccount { const METHOD: &'static str = "surfnet_resetAccount"; + /// Build the JSON-RPC parameter array for `surfnet_resetAccount`. fn build(self) -> serde_json::Value { let mut params = vec![self.address.to_string().into()]; diff --git a/crates/sdk/src/cheatcodes/builders/set_account.rs b/crates/sdk/src/cheatcodes/builders/set_account.rs index 0e3bfa72..b77f12aa 100644 --- a/crates/sdk/src/cheatcodes/builders/set_account.rs +++ b/crates/sdk/src/cheatcodes/builders/set_account.rs @@ -2,6 +2,31 @@ use solana_pubkey::Pubkey; use crate::cheatcodes::builders::CheatcodeBuilder; +/// Builder for `surfnet_setAccount`. +/// +/// This builder starts with the required account address and lets tests add +/// whichever optional account fields they want to override before execution. +/// +/// ```rust,no_run +/// use surfpool_sdk::{Pubkey, Surfnet}; +/// use surfpool_sdk::cheatcodes::builders::set_account::SetAccount; +/// +/// # async fn example() { +/// let surfnet = Surfnet::start().await.unwrap(); +/// let cheats = surfnet.cheatcodes(); +/// let address = Pubkey::new_unique(); +/// let owner = Pubkey::new_unique(); +/// +/// cheats +/// .execute( +/// SetAccount::new(address) +/// .lamports(500_000) +/// .owner(owner) +/// .data(vec![0xAA, 0xBB]), +/// ) +/// .unwrap(); +/// # } +/// ``` pub struct SetAccount { address: Pubkey, lamports: Option, @@ -12,6 +37,7 @@ pub struct SetAccount { } impl SetAccount { + /// Create a new account-update builder for the given address. pub fn new(address: Pubkey) -> Self { Self { address, @@ -23,26 +49,33 @@ impl SetAccount { } } + /// Set the target lamport balance for the account. pub fn lamports(mut self, lamports: u64) -> Self { self.lamports = Some(lamports); self } + /// Set raw account data bytes. + /// + /// The builder hex-encodes these bytes when producing the final RPC payload. pub fn data(mut self, data: Vec) -> Self { self.data = Some(data); self } + /// Set the owning program for the account. pub fn owner(mut self, owner: Pubkey) -> Self { self.owner = Some(owner); self } + /// Set the account rent epoch. pub fn rent_epoch(mut self, rent_epoch: u64) -> Self { self.rent_epoch = Some(rent_epoch); self } + /// Set whether the account is executable. pub fn executable(mut self, executable: bool) -> Self { self.executable = Some(executable); self @@ -51,6 +84,8 @@ impl SetAccount { impl CheatcodeBuilder for SetAccount { const METHOD: &'static str = "surfnet_setAccount"; + + /// Build the JSON-RPC parameter array for `surfnet_setAccount`. fn build(self) -> serde_json::Value { let mut account_info = serde_json::Map::new(); if let Some(lamports) = self.lamports { diff --git a/crates/sdk/src/cheatcodes/builders/set_token_account.rs b/crates/sdk/src/cheatcodes/builders/set_token_account.rs index a2242f6f..8d9e694b 100644 --- a/crates/sdk/src/cheatcodes/builders/set_token_account.rs +++ b/crates/sdk/src/cheatcodes/builders/set_token_account.rs @@ -2,6 +2,26 @@ use solana_pubkey::Pubkey; use crate::cheatcodes::builders::CheatcodeBuilder; +/// Builder for `surfnet_setTokenAccount`. +/// +/// The required inputs are the token owner wallet and mint. Optional methods +/// can then be used to set token-account fields before execution. +/// +/// ```rust,no_run +/// use surfpool_sdk::{Pubkey, Surfnet}; +/// use surfpool_sdk::cheatcodes::builders::set_token_account::SetTokenAccount; +/// +/// # async fn example() { +/// let surfnet = Surfnet::start().await.unwrap(); +/// let cheats = surfnet.cheatcodes(); +/// let owner = Pubkey::new_unique(); +/// let mint = Pubkey::new_unique(); +/// +/// cheats +/// .execute(SetTokenAccount::new(owner, mint).amount(1_000)) +/// .unwrap(); +/// # } +/// ``` pub struct SetTokenAccount { owner: Pubkey, mint: Pubkey, @@ -14,6 +34,7 @@ pub struct SetTokenAccount { } impl SetTokenAccount { + /// Create a new token-account update builder for the given owner and mint. pub fn new(owner: Pubkey, mint: Pubkey) -> Self { Self { owner, @@ -27,41 +48,51 @@ impl SetTokenAccount { } } + /// Set the token amount for the associated token account. pub fn amount(mut self, amount: u64) -> Self { self.amount = Some(amount); self } + /// Set a delegate authority on the token account. pub fn delegate(mut self, delegate: Pubkey) -> Self { self.delegate = Some(Some(delegate)); self } + /// Clear any existing delegate authority. pub fn clear_delegate(mut self) -> Self { self.delegate = Some(None); self } + /// Set the token account state string expected by Surfnet RPC. pub fn state(mut self, state: impl Into) -> Self { self.state = Some(state.into()); self } + /// Set the delegated token amount. pub fn delegated_amount(mut self, delegated_amount: u64) -> Self { self.delegated_amount = Some(delegated_amount); self } + /// Set the close authority on the token account. pub fn close_authority(mut self, close_authority: Pubkey) -> Self { self.close_authority = Some(Some(close_authority)); self } + /// Clear any existing close authority. pub fn clear_close_authority(mut self) -> Self { self.close_authority = Some(None); self } + /// Override the token program id. + /// + /// If omitted, the classic SPL Token program is used. pub fn token_program(mut self, token_program: Pubkey) -> Self { self.token_program = Some(token_program); self @@ -71,6 +102,7 @@ impl SetTokenAccount { impl CheatcodeBuilder for SetTokenAccount { const METHOD: &'static str = "surfnet_setTokenAccount"; + /// Build the JSON-RPC parameter array for `surfnet_setTokenAccount`. fn build(self) -> serde_json::Value { let mut update = serde_json::Map::new(); if let Some(amount) = self.amount { diff --git a/crates/sdk/src/cheatcodes/builders/stream_account.rs b/crates/sdk/src/cheatcodes/builders/stream_account.rs index 1f2890fd..bc517c25 100644 --- a/crates/sdk/src/cheatcodes/builders/stream_account.rs +++ b/crates/sdk/src/cheatcodes/builders/stream_account.rs @@ -2,12 +2,32 @@ use solana_pubkey::Pubkey; use crate::cheatcodes::builders::CheatcodeBuilder; +/// Builder for `surfnet_streamAccount`. +/// +/// This builder starts with the required account address and can be extended +/// with optional streaming configuration before execution. +/// +/// ```rust,no_run +/// use surfpool_sdk::{Pubkey, Surfnet}; +/// use surfpool_sdk::cheatcodes::builders::stream_account::StreamAccount; +/// +/// # async fn example() { +/// let surfnet = Surfnet::start().await.unwrap(); +/// let cheats = surfnet.cheatcodes(); +/// let address = Pubkey::new_unique(); +/// +/// cheats +/// .execute(StreamAccount::new(address).include_owned_accounts(true)) +/// .unwrap(); +/// # } +/// ``` pub struct StreamAccount { address: Pubkey, include_owned_accounts: Option, } impl StreamAccount { + /// Create a stream-account builder for the given address. pub fn new(address: Pubkey) -> Self { Self { address, @@ -15,6 +35,7 @@ impl StreamAccount { } } + /// Include owned accounts when registering the streamed account. pub fn include_owned_accounts(mut self, include_owned_accounts: bool) -> Self { self.include_owned_accounts = Some(include_owned_accounts); self @@ -24,6 +45,7 @@ impl StreamAccount { impl CheatcodeBuilder for StreamAccount { const METHOD: &'static str = "surfnet_streamAccount"; + /// Build the JSON-RPC parameter array for `surfnet_streamAccount`. fn build(self) -> serde_json::Value { let mut params = vec![self.address.to_string().into()]; diff --git a/crates/sdk/src/cheatcodes/mod.rs b/crates/sdk/src/cheatcodes/mod.rs index 93bf9cea..43e1e33b 100644 --- a/crates/sdk/src/cheatcodes/mod.rs +++ b/crates/sdk/src/cheatcodes/mod.rs @@ -14,7 +14,8 @@ use builders::CheatcodeBuilder; /// perfect for test setup (funding wallets, minting tokens, etc.). /// /// ```rust,no_run -/// use surfpool_sdk::{Surfnet, Pubkey}; +/// use surfpool_sdk::{Pubkey, Surfnet}; +/// use surfpool_sdk::cheatcodes::builders::set_account::SetAccount; /// /// # async fn example() { /// let surfnet = Surfnet::start().await.unwrap(); @@ -27,6 +28,18 @@ use builders::CheatcodeBuilder; /// // Fund a token account with 1000 USDC /// let usdc_mint: Pubkey = "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v".parse().unwrap(); /// cheats.fund_token(&alice, &usdc_mint, 1_000_000_000, None).unwrap(); +/// +/// // Or build a typed cheatcode request: +/// let custom = Pubkey::new_unique(); +/// let owner = Pubkey::new_unique(); +/// cheats +/// .execute( +/// SetAccount::new(custom) +/// .lamports(42) +/// .owner(owner) +/// .data(vec![1, 2, 3]), +/// ) +/// .unwrap(); /// # } /// ``` pub struct Cheatcodes<'a> { @@ -42,7 +55,19 @@ impl<'a> Cheatcodes<'a> { RpcClient::new(self.rpc_url) } - /// Set the SOL balance (in lamports) for an account. + /// Set the SOL balance for an account in lamports. + /// + /// ```rust,no_run + /// use surfpool_sdk::{Pubkey, Surfnet}; + /// + /// # async fn example() { + /// let surfnet = Surfnet::start().await.unwrap(); + /// let cheats = surfnet.cheatcodes(); + /// let recipient = Pubkey::new_unique(); + /// + /// cheats.fund_sol(&recipient, 1_000_000_000).unwrap(); + /// # } + /// ``` pub fn fund_sol(&self, address: &Pubkey, lamports: u64) -> SurfnetResult<()> { let params = serde_json::json!([ address.to_string(), @@ -51,7 +76,22 @@ impl<'a> Cheatcodes<'a> { self.call_cheatcode("surfnet_setAccount", params) } - /// Set arbitrary account data. + /// Set arbitrary account state for a single account. + /// + /// This helper updates lamports, owner, and raw account data in one RPC call. + /// + /// ```rust,no_run + /// use surfpool_sdk::{Pubkey, Surfnet}; + /// + /// # async fn example() { + /// let surfnet = Surfnet::start().await.unwrap(); + /// let cheats = surfnet.cheatcodes(); + /// let address = Pubkey::new_unique(); + /// let owner = Pubkey::new_unique(); + /// + /// cheats.set_account(&address, 500, &[1, 2, 3], &owner).unwrap(); + /// # } + /// ``` pub fn set_account( &self, address: &Pubkey, @@ -73,6 +113,19 @@ impl<'a> Cheatcodes<'a> { /// Fund a token account (creates the ATA if needed). /// /// Uses `spl_token` program by default. Pass `token_program` to use Token-2022. + /// + /// ```rust,no_run + /// use surfpool_sdk::{Pubkey, Surfnet}; + /// + /// # async fn example() { + /// let surfnet = Surfnet::start().await.unwrap(); + /// let cheats = surfnet.cheatcodes(); + /// let owner = Pubkey::new_unique(); + /// let mint = Pubkey::new_unique(); + /// + /// cheats.fund_token(&owner, &mint, 1_000, None).unwrap(); + /// # } + /// ``` pub fn fund_token( &self, owner: &Pubkey, @@ -90,7 +143,9 @@ impl<'a> Cheatcodes<'a> { self.call_cheatcode("surfnet_setTokenAccount", params) } - /// Set a token account balance for a specific ATA address. + /// Set the token balance for a wallet/mint pair. + /// + /// This is an alias for [`Self::fund_token`]. pub fn set_token_balance( &self, owner: &Pubkey, @@ -102,12 +157,26 @@ impl<'a> Cheatcodes<'a> { } /// Get the associated token address for a wallet/mint pair. + /// + /// ```rust,no_run + /// use surfpool_sdk::{Pubkey, Surfnet}; + /// + /// # async fn example() { + /// let surfnet = Surfnet::start().await.unwrap(); + /// let cheats = surfnet.cheatcodes(); + /// let owner = Pubkey::new_unique(); + /// let mint = Pubkey::new_unique(); + /// + /// let ata = cheats.get_ata(&owner, &mint, None); + /// println!("{ata}"); + /// # } + /// ``` pub fn get_ata(&self, owner: &Pubkey, mint: &Pubkey, token_program: Option<&Pubkey>) -> Pubkey { let program = token_program.copied().unwrap_or(spl_token_program_id()); get_associated_token_address_with_program_id(owner, mint, &program) } - /// Fund multiple accounts with SOL in one call. + /// Fund multiple accounts with SOL using repeated `surfnet_setAccount` calls. pub fn fund_sol_many(&self, accounts: &[(&Pubkey, u64)]) -> SurfnetResult<()> { for (address, lamports) in accounts { self.fund_sol(address, *lamports)?; @@ -144,10 +213,25 @@ impl<'a> Cheatcodes<'a> { self.time_travel(serde_json::json!([{ "absoluteTimestamp": timestamp }])) } + /// Execute a typed cheatcode builder. + /// + /// ```rust,no_run + /// use surfpool_sdk::{Pubkey, Surfnet}; + /// use surfpool_sdk::cheatcodes::builders::reset_account::ResetAccount; + /// + /// # async fn example() { + /// let surfnet = Surfnet::start().await.unwrap(); + /// let cheats = surfnet.cheatcodes(); + /// let address = Pubkey::new_unique(); + /// + /// cheats.execute(ResetAccount::new(address)).unwrap(); + /// # } + /// ``` pub fn execute(&self, builder: B) -> SurfnetResult<()> { self.call_cheatcode(B::METHOD, builder.build()) } + /// Internal helper for `surfnet_timeTravel` requests that return [`EpochInfo`]. fn time_travel(&self, params: serde_json::Value) -> SurfnetResult { let client = self.rpc_client(); client @@ -160,6 +244,7 @@ impl<'a> Cheatcodes<'a> { .map_err(|e| SurfnetError::Cheatcode(format!("surfnet_timeTravel: {e}"))) } + /// Internal helper for cheatcodes that return `()`. fn call_cheatcode(&self, method: &'static str, params: serde_json::Value) -> SurfnetResult<()> { let client = self.rpc_client(); client diff --git a/crates/sdk/src/lib.rs b/crates/sdk/src/lib.rs index 471bb8b2..3273c3dc 100644 --- a/crates/sdk/src/lib.rs +++ b/crates/sdk/src/lib.rs @@ -39,7 +39,7 @@ mod error; pub mod report; mod surfnet; -pub use cheatcodes::Cheatcodes; +pub use cheatcodes::{Cheatcodes, builders}; pub use error::{SurfnetError, SurfnetResult}; // Re-export key Solana types for convenience pub use solana_keypair::Keypair; From 25b9ed3416e2677acb1c0b6e3cfc5bc0f510a609 Mon Sep 17 00:00:00 2001 From: Micaiah Reid Date: Fri, 3 Apr 2026 15:50:30 -0400 Subject: [PATCH 12/13] feat: add program deployment patterns to sdk --- .../src/cheatcodes/builders/deploy_program.rs | 121 ++++++++++++++++++ crates/sdk/src/cheatcodes/builders/mod.rs | 1 + crates/sdk/src/cheatcodes/mod.rs | 79 ++++++++++++ crates/sdk/src/cheatcodes/tests.rs | 94 +++++++++++++- 4 files changed, 293 insertions(+), 2 deletions(-) create mode 100644 crates/sdk/src/cheatcodes/builders/deploy_program.rs diff --git a/crates/sdk/src/cheatcodes/builders/deploy_program.rs b/crates/sdk/src/cheatcodes/builders/deploy_program.rs new file mode 100644 index 00000000..fa622c05 --- /dev/null +++ b/crates/sdk/src/cheatcodes/builders/deploy_program.rs @@ -0,0 +1,121 @@ +use solana_pubkey::Pubkey; +use std::path::{Path, PathBuf}; + +use crate::{ + error::{SurfnetError, SurfnetResult}, + cheatcodes::read_keypair_pubkey, +}; + +/// Builder for deploying a program to Surfnet. +/// +/// Unlike single-RPC cheatcode builders, deployment is a compound operation: +/// it writes the program bytes first and then optionally registers an IDL. +/// +/// ```rust,no_run +/// use surfpool_sdk::{Pubkey, Surfnet}; +/// use surfpool_sdk::cheatcodes::builders::deploy_program::DeployProgram; +/// +/// # async fn example() { +/// let surfnet = Surfnet::start().await.unwrap(); +/// let cheats = surfnet.cheatcodes(); +/// let program_id = Pubkey::new_unique(); +/// +/// cheats +/// .deploy( +/// DeployProgram::new(program_id) +/// .so_path("target/deploy/my_program.so") +/// .idl_path("target/idl/my_program.json"), +/// ) +/// .unwrap(); +/// # } +/// ``` +pub struct DeployProgram { + program_id: Pubkey, + so_path: Option, + so_bytes: Option>, + idl_path: Option, +} + +impl DeployProgram { + /// Create a deployment builder from a known program id. + pub fn new(program_id: Pubkey) -> Self { + Self { + program_id, + so_path: None, + so_bytes: None, + idl_path: None, + } + } + + /// Create a deployment builder from a Solana keypair file. + /// + /// The program id is derived from the keypair public key. + pub fn from_keypair_path(path: impl AsRef) -> SurfnetResult { + let path = path.as_ref(); + let program_id = read_keypair_pubkey(path)?; + Ok(Self::new(program_id)) + } + + /// Set the path to the `.so` artifact to deploy. + pub fn so_path(mut self, path: impl Into) -> Self { + self.so_path = Some(path.into()); + self + } + + /// Set the raw `.so` bytes directly. + pub fn so_bytes(mut self, bytes: Vec) -> Self { + self.so_bytes = Some(bytes); + self + } + + /// Set the path to an Anchor IDL JSON file to register after deployment. + pub fn idl_path(mut self, path: impl Into) -> Self { + self.idl_path = Some(path.into()); + self + } + + /// Set the IDL path only if the file exists. + pub(crate) fn idl_path_if_exists(mut self, path: impl Into) -> Self { + let path = path.into(); + if path.exists() { + self.idl_path = Some(path); + } + self + } + + /// Return the program id that will be deployed. + pub(crate) fn program_id(&self) -> Pubkey { + self.program_id + } + + /// Resolve the program bytes from either an explicit path or inline bytes. + pub(crate) fn load_so_bytes(&self) -> SurfnetResult> { + match (&self.so_bytes, &self.so_path) { + (Some(bytes), _) => Ok(bytes.clone()), + (None, Some(path)) => std::fs::read(path).map_err(|e| { + SurfnetError::Cheatcode(format!( + "failed to read program bytes from {}: {e}", + path.display() + )) + }), + (None, None) => Err(SurfnetError::Cheatcode( + "deploy program requires either so_path or so_bytes".to_string(), + )), + } + } + + /// Resolve and parse the optional IDL file. + pub(crate) fn load_idl(&self) -> SurfnetResult> { + let Some(path) = &self.idl_path else { + return Ok(None); + }; + + let contents = std::fs::read_to_string(path).map_err(|e| { + SurfnetError::Cheatcode(format!("failed to read IDL from {}: {e}", path.display())) + })?; + let idl = serde_json::from_str(&contents).map_err(|e| { + SurfnetError::Cheatcode(format!("failed to parse IDL from {}: {e}", path.display())) + })?; + Ok(Some(idl)) + } +} diff --git a/crates/sdk/src/cheatcodes/builders/mod.rs b/crates/sdk/src/cheatcodes/builders/mod.rs index 114663f8..16932694 100644 --- a/crates/sdk/src/cheatcodes/builders/mod.rs +++ b/crates/sdk/src/cheatcodes/builders/mod.rs @@ -24,6 +24,7 @@ //! .unwrap(); //! # } //! ``` +pub mod deploy_program; pub mod set_account; pub mod set_token_account; pub mod reset_account; diff --git a/crates/sdk/src/cheatcodes/mod.rs b/crates/sdk/src/cheatcodes/mod.rs index 43e1e33b..9ec5363a 100644 --- a/crates/sdk/src/cheatcodes/mod.rs +++ b/crates/sdk/src/cheatcodes/mod.rs @@ -1,12 +1,16 @@ use solana_client::rpc_request::RpcRequest; use solana_epoch_info::EpochInfo; +use solana_keypair::{EncodableKey, Keypair}; use solana_pubkey::Pubkey; use solana_rpc_client::rpc_client::RpcClient; +use solana_signer::Signer; use spl_associated_token_account_interface::address::get_associated_token_address_with_program_id; +use std::path::{Path, PathBuf}; use crate::error::{SurfnetError, SurfnetResult}; pub mod builders; use builders::CheatcodeBuilder; +use builders::deploy_program::DeployProgram; /// Direct state manipulation helpers for a running Surfnet. /// @@ -213,6 +217,45 @@ impl<'a> Cheatcodes<'a> { self.time_travel(serde_json::json!([{ "absoluteTimestamp": timestamp }])) } + /// Deploy a program from local workspace artifacts. + /// + /// This looks for: + /// - `target/deploy/{program_name}.so` + /// - `target/deploy/{program_name}-keypair.json` + /// - `target/idl/{program_name}.json` (optional) + /// + /// If an IDL file exists, it is registered after the program bytes are written. + pub fn deploy_program(&self, program_name: &str) -> SurfnetResult<()> { + let deploy_dir = PathBuf::from("target/deploy"); + let idl_dir = PathBuf::from("target/idl"); + let so_path = deploy_dir.join(format!("{program_name}.so")); + let keypair_path = deploy_dir.join(format!("{program_name}-keypair.json")); + let idl_path = idl_dir.join(format!("{program_name}.json")); + + let builder = DeployProgram::from_keypair_path(&keypair_path)? + .so_path(so_path) + .idl_path_if_exists(idl_path); + + self.deploy(builder) + } + + /// Deploy a program described by a [`DeployProgram`] builder. + /// + /// This writes the program bytes with `surfnet_writeProgram` and, when present, + /// registers the parsed IDL with `surfnet_registerIdl`. + pub fn deploy(&self, builder: DeployProgram) -> SurfnetResult<()> { + let program_id = builder.program_id(); + let program_bytes = builder.load_so_bytes()?; + self.write_program(&program_id, &program_bytes)?; + + if let Some(mut idl) = builder.load_idl()? { + idl.address = program_id.to_string(); + self.register_idl(&idl)?; + } + + Ok(()) + } + /// Execute a typed cheatcode builder. /// /// ```rust,no_run @@ -244,6 +287,31 @@ impl<'a> Cheatcodes<'a> { .map_err(|e| SurfnetError::Cheatcode(format!("surfnet_timeTravel: {e}"))) } + fn write_program(&self, program_id: &Pubkey, data: &[u8]) -> SurfnetResult<()> { + const PROGRAM_CHUNK_BYTES: usize = 15 * 1024 * 1024; + + for (index, chunk) in data.chunks(PROGRAM_CHUNK_BYTES).enumerate() { + let offset = index * PROGRAM_CHUNK_BYTES; + let params = serde_json::json!([program_id.to_string(), hex::encode(chunk), offset,]); + self.call_cheatcode("surfnet_writeProgram", params)?; + } + + Ok(()) + } + + fn register_idl(&self, idl: &surfpool_types::Idl) -> SurfnetResult<()> { + let client = self.rpc_client(); + client + .send::( + RpcRequest::Custom { + method: "surfnet_registerIdl", + }, + serde_json::json!([idl]), + ) + .map_err(|e| SurfnetError::Cheatcode(format!("surfnet_registerIdl: {e}")))?; + Ok(()) + } + /// Internal helper for cheatcodes that return `()`. fn call_cheatcode(&self, method: &'static str, params: serde_json::Value) -> SurfnetResult<()> { let client = self.rpc_client(); @@ -259,5 +327,16 @@ fn spl_token_program_id() -> Pubkey { Pubkey::from_str_const("TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA") } +fn read_keypair_pubkey(path: &Path) -> SurfnetResult { + Keypair::read_from_file(path) + .map(|keypair| keypair.pubkey()) + .map_err(|e| { + SurfnetError::Cheatcode(format!( + "failed to read deploy keypair from {}: {e}", + path.display() + )) + }) +} + #[cfg(test)] mod tests; diff --git a/crates/sdk/src/cheatcodes/tests.rs b/crates/sdk/src/cheatcodes/tests.rs index f309f3f6..badb7a7c 100644 --- a/crates/sdk/src/cheatcodes/tests.rs +++ b/crates/sdk/src/cheatcodes/tests.rs @@ -1,13 +1,19 @@ use solana_program_pack::Pack; +use solana_signer::Signer; use solana_pubkey::Pubkey; use solana_rpc_client::rpc_client::RpcClient; use spl_associated_token_account_interface::address::get_associated_token_address_with_program_id; use spl_token_interface::state::{Account as TokenAccount, Mint}; -use std::sync::{Mutex, MutexGuard, OnceLock}; +use std::{ + fs, + sync::{Mutex, MutexGuard, OnceLock}, +}; +use tempfile::tempdir; use super::{ Cheatcodes, builders::{ + deploy_program::DeployProgram, reset_account::ResetAccount, set_account::SetAccount, set_token_account::SetTokenAccount, stream_account::StreamAccount, }, @@ -41,7 +47,27 @@ fn rpc_client(cheats: &Cheatcodes<'_>) -> RpcClient { fn test_lock() -> MutexGuard<'static, ()> { static LOCK: OnceLock> = OnceLock::new(); - LOCK.get_or_init(|| Mutex::new(())).lock().unwrap() + LOCK.get_or_init(|| Mutex::new(())) + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) +} + +fn sample_idl(program_id: &Pubkey) -> serde_json::Value { + serde_json::json!({ + "address": program_id.to_string(), + "metadata": { + "name": "test_program", + "version": "0.1.0", + "spec": "0.1.0", + "description": "Created with Anchor" + }, + "instructions": [], + "accounts": [], + "types": [], + "events": [], + "errors": [], + "constants": [] + }) } #[tokio::test(flavor = "multi_thread")] @@ -285,6 +311,70 @@ async fn stream_account_builder_executes_against_surfnet() { })); } +#[tokio::test(flavor = "multi_thread")] +async fn deploy_program_builder_executes_against_surfnet() { + let _guard = test_lock(); + let surfnet = Surfnet::start().await.unwrap(); + let cheats = surfnet.cheatcodes(); + let client = rpc_client(&cheats); + let program_id = Pubkey::new_unique(); + let temp = tempdir().unwrap(); + let idl_path = temp.path().join("program.json"); + + fs::write( + &idl_path, + serde_json::to_vec(&sample_idl(&program_id)).unwrap(), + ) + .unwrap(); + + cheats + .deploy( + DeployProgram::new(program_id) + .so_bytes(vec![1, 2, 3, 4, 5, 6]) + .idl_path(&idl_path), + ) + .unwrap(); + + let account = client.get_account(&program_id).unwrap(); + assert!(account.executable); +} + +#[tokio::test(flavor = "multi_thread")] +async fn deploy_program_discovers_workspace_artifacts() { + let _guard = test_lock(); + let surfnet = Surfnet::start().await.unwrap(); + let cheats = surfnet.cheatcodes(); + let client = rpc_client(&cheats); + let temp = tempdir().unwrap(); + let previous_dir = std::env::current_dir().unwrap(); + let deploy_dir = temp.path().join("target/deploy"); + let idl_dir = temp.path().join("target/idl"); + let keypair = crate::Keypair::new(); + let program_name = "fixture_program"; + + fs::create_dir_all(&deploy_dir).unwrap(); + fs::create_dir_all(&idl_dir).unwrap(); + fs::write(deploy_dir.join(format!("{program_name}.so")), vec![9, 8, 7, 6]).unwrap(); + fs::write( + deploy_dir.join(format!("{program_name}-keypair.json")), + serde_json::to_vec(&keypair.to_bytes().to_vec()).unwrap(), + ) + .unwrap(); + fs::write( + idl_dir.join(format!("{program_name}.json")), + serde_json::to_vec(&sample_idl(&keypair.pubkey())).unwrap(), + ) + .unwrap(); + + std::env::set_current_dir(temp.path()).unwrap(); + let result = cheats.deploy_program(program_name); + std::env::set_current_dir(previous_dir).unwrap(); + result.unwrap(); + + let account = client.get_account(&keypair.pubkey()).unwrap(); + assert!(account.executable); +} + #[test] fn get_ata_matches_associated_token_derivation() { let owner = Pubkey::new_unique(); From 2f86fbc57dc8afe189736432f405c074ae79b7b1 Mon Sep 17 00:00:00 2001 From: Micaiah Reid Date: Fri, 3 Apr 2026 16:39:00 -0400 Subject: [PATCH 13/13] chore: bring sdk-node to parity with sdk --- Cargo.lock | 1 + crates/sdk-node/Cargo.toml | 1 + crates/sdk-node/src/lib.rs | 161 ++++++++++++++++++++++++++ crates/sdk-node/surfpool-sdk/index.ts | 51 +++++++- 4 files changed, 212 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index c1e03d85..29e8e9b4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -11420,6 +11420,7 @@ dependencies = [ "napi-build", "napi-derive", "serde_json", + "solana-epoch-info", "solana-keypair", "solana-pubkey 3.0.0", "solana-signer", diff --git a/crates/sdk-node/Cargo.toml b/crates/sdk-node/Cargo.toml index 59ffdfb7..5af812e8 100644 --- a/crates/sdk-node/Cargo.toml +++ b/crates/sdk-node/Cargo.toml @@ -15,6 +15,7 @@ napi-derive = "2.16" hiro-system-kit = { workspace = true } surfpool-sdk = { workspace = true } solana-keypair = { workspace = true } +solana-epoch-info = { workspace = true } solana-pubkey = { workspace = true } solana-signer = { workspace = true } serde_json = { workspace = true } diff --git a/crates/sdk-node/src/lib.rs b/crates/sdk-node/src/lib.rs index 8012dc15..5901cc8a 100644 --- a/crates/sdk-node/src/lib.rs +++ b/crates/sdk-node/src/lib.rs @@ -95,6 +95,35 @@ impl Surfnet { .map_err(|e| Error::new(Status::GenericFailure, e.to_string())) } + /// Fund multiple SOL accounts with explicit lamport balances. + #[napi] + pub fn fund_sol_many(&self, accounts: Vec) -> Result<()> { + let parsed_accounts = accounts + .iter() + .map(|account| { + account + .address + .parse::() + .map(|pubkey| (pubkey, account.lamports as u64)) + .map_err(|e| { + Error::new( + Status::InvalidArg, + format!("Invalid address {}: {e}", account.address), + ) + }) + }) + .collect::>>()?; + let account_refs = parsed_accounts + .iter() + .map(|(pubkey, lamports)| (pubkey, *lamports)) + .collect::>(); + + self.inner + .cheatcodes() + .fund_sol_many(&account_refs) + .map_err(|e| Error::new(Status::GenericFailure, e.to_string())) + } + /// Fund a token account (creates the ATA if needed). /// Uses spl_token program by default. Pass token_program for Token-2022. #[napi] @@ -125,6 +154,70 @@ impl Surfnet { .map_err(|e| Error::new(Status::GenericFailure, e.to_string())) } + /// Set the token balance for a wallet/mint pair. + #[napi] + pub fn set_token_balance( + &self, + owner: String, + mint: String, + amount: f64, + token_program: Option, + ) -> Result<()> { + let owner_pk: Pubkey = owner + .parse() + .map_err(|e| Error::new(Status::InvalidArg, format!("Invalid owner: {e}")))?; + let mint_pk: Pubkey = mint + .parse() + .map_err(|e| Error::new(Status::InvalidArg, format!("Invalid mint: {e}")))?; + let tp = token_program + .map(|s| { + s.parse::().map_err(|e| { + Error::new(Status::InvalidArg, format!("Invalid token program: {e}")) + }) + }) + .transpose()?; + + self.inner + .cheatcodes() + .set_token_balance(&owner_pk, &mint_pk, amount as u64, tp.as_ref()) + .map_err(|e| Error::new(Status::GenericFailure, e.to_string())) + } + + /// Fund multiple wallets with the same token and amount. + #[napi] + pub fn fund_token_many( + &self, + owners: Vec, + mint: String, + amount: f64, + token_program: Option, + ) -> Result<()> { + let owner_pubkeys = owners + .iter() + .map(|owner| { + owner.parse::().map_err(|e| { + Error::new(Status::InvalidArg, format!("Invalid owner {owner}: {e}")) + }) + }) + .collect::>>()?; + let owner_refs = owner_pubkeys.iter().collect::>(); + let mint_pk: Pubkey = mint + .parse() + .map_err(|e| Error::new(Status::InvalidArg, format!("Invalid mint: {e}")))?; + let tp = token_program + .map(|s| { + s.parse::().map_err(|e| { + Error::new(Status::InvalidArg, format!("Invalid token program: {e}")) + }) + }) + .transpose()?; + + self.inner + .cheatcodes() + .fund_token_many(&owner_refs, &mint_pk, amount as u64, tp.as_ref()) + .map_err(|e| Error::new(Status::GenericFailure, e.to_string())) + } + /// Set arbitrary account data. #[napi] pub fn set_account( @@ -146,6 +239,45 @@ impl Surfnet { .map_err(|e| Error::new(Status::GenericFailure, e.to_string())) } + /// Move Surfnet time forward to an absolute epoch. + #[napi] + pub fn time_travel_to_epoch(&self, epoch: f64) -> Result { + self.inner + .cheatcodes() + .time_travel_to_epoch(epoch as u64) + .map(EpochInfoValue::from) + .map_err(|e| Error::new(Status::GenericFailure, e.to_string())) + } + + /// Move Surfnet time forward to an absolute slot. + #[napi] + pub fn time_travel_to_slot(&self, slot: f64) -> Result { + self.inner + .cheatcodes() + .time_travel_to_slot(slot as u64) + .map(EpochInfoValue::from) + .map_err(|e| Error::new(Status::GenericFailure, e.to_string())) + } + + /// Move Surfnet time forward to an absolute Unix timestamp in milliseconds. + #[napi] + pub fn time_travel_to_timestamp(&self, timestamp: f64) -> Result { + self.inner + .cheatcodes() + .time_travel_to_timestamp(timestamp as u64) + .map(EpochInfoValue::from) + .map_err(|e| Error::new(Status::GenericFailure, e.to_string())) + } + + /// Deploy a program by discovering local Anchor/Agave artifacts. + #[napi] + pub fn deploy_program(&self, program_name: String) -> Result<()> { + self.inner + .cheatcodes() + .deploy_program(&program_name) + .map_err(|e| Error::new(Status::GenericFailure, e.to_string())) + } + /// Get the associated token address for a wallet/mint pair. #[napi] pub fn get_ata( @@ -201,3 +333,32 @@ pub struct KeypairInfo { pub public_key: String, pub secret_key: Vec, } + +#[napi(object)] +pub struct SolAccountFunding { + pub address: String, + pub lamports: f64, +} + +#[napi(object)] +pub struct EpochInfoValue { + pub epoch: f64, + pub slot_index: f64, + pub slots_in_epoch: f64, + pub absolute_slot: f64, + pub block_height: f64, + pub transaction_count: Option, +} + +impl From for EpochInfoValue { + fn from(value: solana_epoch_info::EpochInfo) -> Self { + Self { + epoch: value.epoch as f64, + slot_index: value.slot_index as f64, + slots_in_epoch: value.slots_in_epoch as f64, + absolute_slot: value.absolute_slot as f64, + block_height: value.block_height as f64, + transaction_count: value.transaction_count.map(|count| count as f64), + } + } +} diff --git a/crates/sdk-node/surfpool-sdk/index.ts b/crates/sdk-node/surfpool-sdk/index.ts index 1582134b..308bd37d 100644 --- a/crates/sdk-node/surfpool-sdk/index.ts +++ b/crates/sdk-node/surfpool-sdk/index.ts @@ -2,9 +2,11 @@ import { Surfnet as SurfnetInner, SurfnetConfig, KeypairInfo, + EpochInfoValue, + SolAccountFunding, } from "./internal"; -export { SurfnetConfig, KeypairInfo } from "./internal"; +export { SurfnetConfig, KeypairInfo, EpochInfoValue, SolAccountFunding } from "./internal"; /** * A running Surfpool instance with RPC/WS endpoints on dynamic ports. @@ -52,7 +54,7 @@ export class Surfnet { /** The pre-funded payer secret key as a 64-byte Uint8Array. */ get payerSecretKey(): Uint8Array { - return this.inner.payerSecretKey; + return Uint8Array.from(this.inner.payerSecretKey); } /** Fund a SOL account with lamports. */ @@ -60,6 +62,11 @@ export class Surfnet { this.inner.fundSol(address, lamports); } + /** Fund multiple SOL accounts with explicit lamport balances. */ + fundSolMany(accounts: SolAccountFunding[]): void { + this.inner.fundSolMany(accounts); + } + /** * Fund a token account (creates the ATA if needed). * Uses spl_token program by default. Pass tokenProgram for Token-2022. @@ -73,6 +80,26 @@ export class Surfnet { this.inner.fundToken(owner, mint, amount, tokenProgram ?? null); } + /** Set the token balance for a wallet/mint pair. */ + setTokenBalance( + owner: string, + mint: string, + amount: number, + tokenProgram?: string + ): void { + this.inner.setTokenBalance(owner, mint, amount, tokenProgram ?? null); + } + + /** Fund multiple wallets with the same token and amount. */ + fundTokenMany( + owners: string[], + mint: string, + amount: number, + tokenProgram?: string + ): void { + this.inner.fundTokenMany(owners, mint, amount, tokenProgram ?? null); + } + /** Set arbitrary account data. */ setAccount( address: string, @@ -83,6 +110,26 @@ export class Surfnet { this.inner.setAccount(address, lamports, Array.from(data), owner); } + /** Move Surfnet time forward to an absolute epoch. */ + timeTravelToEpoch(epoch: number): EpochInfoValue { + return this.inner.timeTravelToEpoch(epoch); + } + + /** Move Surfnet time forward to an absolute slot. */ + timeTravelToSlot(slot: number): EpochInfoValue { + return this.inner.timeTravelToSlot(slot); + } + + /** Move Surfnet time forward to an absolute Unix timestamp in milliseconds. */ + timeTravelToTimestamp(timestamp: number): EpochInfoValue { + return this.inner.timeTravelToTimestamp(timestamp); + } + + /** Deploy a program by discovering local Anchor/Agave artifacts. */ + deployProgram(programName: string): void { + this.inner.deployProgram(programName); + } + /** Get the associated token address for a wallet/mint pair. */ getAta(owner: string, mint: string, tokenProgram?: string): string { return this.inner.getAta(owner, mint, tokenProgram ?? null);