diff --git a/.github/actions/test-report/action.yml b/.github/actions/test-report/action.yml new file mode 100644 index 00000000..aa5303d8 --- /dev/null +++ b/.github/actions/test-report/action.yml @@ -0,0 +1,149 @@ +name: Surfpool 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-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 }} diff --git a/Cargo.lock b/Cargo.lock index 95cc1068..29e8e9b4 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", @@ -11296,6 +11373,60 @@ dependencies = [ "tracing", ] +[[package]] +name = "surfpool-sdk" +version = "1.1.1" +dependencies = [ + "chrono", + "crossbeam-channel", + "hex", + "hiro-system-kit", + "log 0.4.29", + "reqwest 0.12.28", + "serde", + "serde_json", + "solana-account 3.3.0", + "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", + "solana-signer", + "solana-system-interface 2.0.0", + "solana-transaction", + "spl-associated-token-account-interface", + "spl-token-interface", + "surfpool-core", + "surfpool-types", + "tempfile", + "thiserror 2.0.17", + "tokio", + "uuid", + "zip", +] + +[[package]] +name = "surfpool-sdk-node" +version = "0.1.0" +dependencies = [ + "hiro-system-kit", + "napi", + "napi-build", + "napi-derive", + "serde_json", + "solana-epoch-info", + "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 652fe03b..4a13ae42 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,6 +15,8 @@ members = [ "crates/core", "crates/db", "crates/mcp", + "crates/sdk", + "crates/sdk-node", "crates/studio", "crates/types", ] @@ -160,6 +162,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-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..5af812e8 --- /dev/null +++ b/crates/sdk-node/Cargo.toml @@ -0,0 +1,24 @@ +[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-epoch-info = { 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..9191390e --- /dev/null +++ b/crates/sdk-node/package.json @@ -0,0 +1,36 @@ +{ + "name": "surfpool-sdk", + "version": "0.1.0", + "description": "Embed a Surfpool Solana runtime in your Node.js tests", + "main": "surfpool-sdk/internal.js", + "types": "surfpool-sdk/internal.d.ts", + "files": [ + "dist/", + "surfpool-sdk/" + ], + "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..5901cc8a --- /dev/null +++ b/crates/sdk-node/src/lib.rs @@ -0,0 +1,364 @@ +#[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 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] + 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 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( + &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())) + } + + /// 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( + &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, +} + +#[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/.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..308bd37d --- /dev/null +++ b/crates/sdk-node/surfpool-sdk/index.ts @@ -0,0 +1,142 @@ +import { + Surfnet as SurfnetInner, + SurfnetConfig, + KeypairInfo, + EpochInfoValue, + SolAccountFunding, +} from "./internal"; + +export { SurfnetConfig, KeypairInfo, EpochInfoValue, SolAccountFunding } 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 Uint8Array.from(this.inner.payerSecretKey); + } + + /** Fund a SOL account with lamports. */ + fundSol(address: string, lamports: number): void { + 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. + */ + fundToken( + owner: string, + mint: string, + amount: number, + tokenProgram?: string + ): void { + 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, + lamports: number, + data: Uint8Array, + owner: string + ): void { + 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); + } + + /** 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"] +} diff --git a/crates/sdk/Cargo.toml b/crates/sdk/Cargo.toml new file mode 100644 index 00000000..99014db8 --- /dev/null +++ b/crates/sdk/Cargo.toml @@ -0,0 +1,53 @@ +[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] +chrono = { workspace = true } +crossbeam-channel = { workspace = true } +hex = { 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 } +solana-epoch-info = { 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 } +uuid = { workspace = true, features = ["v4"] } + +surfpool-core = { workspace = true } +surfpool-types = { workspace = true } + +[build-dependencies] +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/build.rs b/crates/sdk/build.rs new file mode 100644 index 00000000..04b2827d --- /dev/null +++ b/crates/sdk/build.rs @@ -0,0 +1,143 @@ +use std::{ + env, fs, + io::{Cursor, Read}, + path::{Path, PathBuf}, +}; + +const DEFAULT_RELEASE_OWNER: &str = "solana-foundation"; +const DEFAULT_RELEASE_REPO: &str = "surfpool-web-ui"; +const DEFAULT_RELEASE_ASSET: &str = "surfpool-report-viewer.zip"; +const OUTPUT_DIR_NAME: &str = "surfpool-report-viewer"; + +fn main() { + println!("cargo:rerun-if-env-changed=SURFPOOL_REPORT_UI_DIR"); + println!("cargo:rerun-if-env-changed=SURFPOOL_REPORT_UI_URL"); + + let out_dir = PathBuf::from(env::var("OUT_DIR").expect("OUT_DIR not set")); + let asset_dir = out_dir.join(OUTPUT_DIR_NAME); + println!("cargo:warning=surfpool-sdk: preparing embedded report viewer at {}", asset_dir.display()); + + if asset_dir.exists() { + fs::remove_dir_all(&asset_dir).expect("failed to clear existing report viewer assets"); + } + fs::create_dir_all(&asset_dir).expect("failed to create report viewer asset dir"); + + if let Ok(dir) = env::var("SURFPOOL_REPORT_UI_DIR") { + println!("cargo:warning=surfpool-sdk: using local report viewer dist from {dir}"); + copy_local_dist(Path::new(&dir), &asset_dir); + println!("cargo:warning=surfpool-sdk: embedded local report viewer into {}", asset_dir.display()); + return; + } + + let source_url = env::var("SURFPOOL_REPORT_UI_URL").unwrap_or_else(|_| default_release_url()); + println!("cargo:warning=surfpool-sdk: downloading report viewer from {source_url}"); + download_and_extract_release(&source_url, &asset_dir); + println!("cargo:warning=surfpool-sdk: embedded downloaded report viewer into {}", asset_dir.display()); +} + +fn default_release_url() -> String { + let version = env::var("CARGO_PKG_VERSION").expect("CARGO_PKG_VERSION not set"); + format!( + "https://github.com/{DEFAULT_RELEASE_OWNER}/{DEFAULT_RELEASE_REPO}/releases/download/v{version}/{DEFAULT_RELEASE_ASSET}" + ) +} + +fn copy_local_dist(source_dir: &Path, output_dir: &Path) { + let index_path = source_dir.join("index.html"); + if !index_path.exists() { + panic!( + "SURFPOOL_REPORT_UI_DIR must point to a built dist directory containing index.html: {}", + source_dir.display() + ); + } + println!("cargo:rerun-if-changed={}", index_path.display()); + + let source = fs::read_to_string(&index_path).unwrap_or_else(|error| { + panic!( + "failed to read report viewer index at {}: {error}", + index_path.display() + ) + }); + if source.contains(r#""#) { + panic!( + "SURFPOOL_REPORT_UI_DIR must point to the built report-viewer dist directory, not the app source directory: {}", + source_dir.display() + ); + } + + copy_dir(source_dir, output_dir).expect("failed to copy local report viewer dist"); +} + +fn copy_dir(source_dir: &Path, output_dir: &Path) -> std::io::Result<()> { + for entry in fs::read_dir(source_dir)? { + let entry = entry?; + let entry_path = entry.path(); + let destination_path = output_dir.join(entry.file_name()); + + if entry_path.is_dir() { + fs::create_dir_all(&destination_path)?; + copy_dir(&entry_path, &destination_path)?; + } else { + fs::copy(&entry_path, &destination_path)?; + } + } + + Ok(()) +} + +fn download_and_extract_release(url: &str, output_dir: &Path) { + let response = reqwest::blocking::Client::new() + .get(url) + .header( + reqwest::header::USER_AGENT, + "surfpool-sdk-report-viewer-build (github.com/solana-foundation/surfpool)", + ) + .send() + .unwrap_or_else(|error| { + panic!("failed to download report viewer asset from {url}: {error}") + }) + .error_for_status() + .unwrap_or_else(|error| panic!("failed to fetch report viewer asset from {url}: {error}")); + + let bytes = response.bytes().unwrap_or_else(|error| { + panic!("failed to read report viewer asset bytes from {url}: {error}") + }); + println!( + "cargo:warning=surfpool-sdk: downloaded {} bytes for embedded report viewer", + bytes.len() + ); + + let mut archive = zip::ZipArchive::new(Cursor::new(bytes)) + .expect("failed to open report viewer release asset as zip"); + + for index in 0..archive.len() { + let mut file = archive + .by_index(index) + .expect("failed to read file from report viewer zip"); + let out_path = output_dir.join(file.mangled_name()); + + if file.is_dir() { + fs::create_dir_all(&out_path) + .expect("failed to create directory from report viewer zip"); + continue; + } + + if let Some(parent) = out_path.parent() { + fs::create_dir_all(parent) + .expect("failed to create file parent directory from report viewer zip"); + } + + let mut content = Vec::new(); + file.read_to_end(&mut content) + .expect("failed to read report viewer zip entry"); + fs::write(&out_path, content).expect("failed to write report viewer zip entry"); + } + + let index_path = output_dir.join("index.html"); + if !index_path.exists() { + panic!( + "downloaded report viewer asset did not contain index.html at {}", + index_path.display() + ); + } +} 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 new file mode 100644 index 00000000..16932694 --- /dev/null +++ b/crates/sdk/src/cheatcodes/builders/mod.rs @@ -0,0 +1,40 @@ +//! 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 deploy_program; +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 new file mode 100644 index 00000000..cb443314 --- /dev/null +++ b/crates/sdk/src/cheatcodes/builders/reset_account.rs @@ -0,0 +1,60 @@ +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, + include_owned_accounts: None, + } + } + + /// 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 + } +} + +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()]; + + 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..b77f12aa --- /dev/null +++ b/crates/sdk/src/cheatcodes/builders/set_account.rs @@ -0,0 +1,109 @@ +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, + data: Option>, + owner: Option, + rent_epoch: Option, + executable: Option, +} + +impl SetAccount { + /// Create a new account-update builder for the given address. + pub fn new(address: Pubkey) -> Self { + Self { + address, + lamports: None, + data: None, + owner: None, + rent_epoch: None, + executable: None, + } + } + + /// 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 + } +} + +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 { + 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..8d9e694b --- /dev/null +++ b/crates/sdk/src/cheatcodes/builders/set_token_account.rs @@ -0,0 +1,146 @@ +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, + amount: Option, + delegate: Option>, + state: Option, + delegated_amount: Option, + close_authority: Option>, + token_program: Option, +} + +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, + mint, + amount: None, + delegate: None, + state: None, + delegated_amount: None, + close_authority: None, + token_program: None, + } + } + + /// 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 + } +} + +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 { + 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..bc517c25 --- /dev/null +++ b/crates/sdk/src/cheatcodes/builders/stream_account.rs @@ -0,0 +1,60 @@ +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, + include_owned_accounts: None, + } + } + + /// 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 + } +} + +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()]; + + 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/mod.rs b/crates/sdk/src/cheatcodes/mod.rs new file mode 100644 index 00000000..9ec5363a --- /dev/null +++ b/crates/sdk/src/cheatcodes/mod.rs @@ -0,0 +1,342 @@ +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. +/// +/// 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::{Pubkey, Surfnet}; +/// use surfpool_sdk::cheatcodes::builders::set_account::SetAccount; +/// +/// # 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(); +/// +/// // 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> { + 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 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(), + { "lamports": lamports } + ]); + self.call_cheatcode("surfnet_setAccount", params) + } + + /// 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, + lamports: u64, + data: &[u8], + owner: &Pubkey, + ) -> SurfnetResult<()> { + let params = serde_json::json!([ + address.to_string(), + { + "lamports": lamports, + "data": hex::encode(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. + /// + /// ```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, + 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 the token balance for a wallet/mint pair. + /// + /// This is an alias for [`Self::fund_token`]. + 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. + /// + /// ```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 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)?; + } + 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(()) + } + + /// 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 }])) + } + + /// 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 + /// 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 + .send::( + RpcRequest::Custom { + method: "surfnet_timeTravel", + }, + params, + ) + .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(); + 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") +} + +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 new file mode 100644 index 00000000..badb7a7c --- /dev/null +++ b/crates/sdk/src/cheatcodes/tests.rs @@ -0,0 +1,389 @@ +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::{ + 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, + }, + 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_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")] +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) + })); +} + +#[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(); + 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); +} diff --git a/crates/sdk/src/error.rs b/crates/sdk/src/error.rs new file mode 100644 index 00000000..008d7aea --- /dev/null +++ b/crates/sdk/src/error.rs @@ -0,0 +1,34 @@ +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), + /// A report operation failed + Report(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}"), + SurfnetError::Report(msg) => write!(f, "report error: {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..3273c3dc --- /dev/null +++ b/crates/sdk/src/lib.rs @@ -0,0 +1,50 @@ +//! # 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); +//! } +//! ``` +//! +//! ## 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::{generate, generate_default, SurfpoolReport, SurfpoolReportOptions}; +//! +//! // After tests complete: +//! let report = SurfpoolReport::from_directory("target/surfpool-reports").unwrap(); +//! report.write_html("target/surfpool-report.html").unwrap(); +//! +//! // Or use the explicit one-shot API: +//! generate_default().unwrap(); +//! generate(SurfpoolReportOptions::default()).unwrap(); +//! ``` + +mod cheatcodes; +mod error; +pub mod report; +mod surfnet; + +pub use cheatcodes::{Cheatcodes, builders}; +pub use error::{SurfnetError, SurfnetResult}; +// 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-template.dev.html b/crates/sdk/src/report-template.dev.html new file mode 100644 index 00000000..2be4f431 --- /dev/null +++ b/crates/sdk/src/report-template.dev.html @@ -0,0 +1,12 @@ + + + + + + Surfpool Report + + + +
+ + diff --git a/crates/sdk/src/report.rs b/crates/sdk/src/report.rs new file mode 100644 index 00000000..d6f8f02f --- /dev/null +++ b/crates/sdk/src/report.rs @@ -0,0 +1,517 @@ +use std::path::{Path, PathBuf}; + +use serde::{Deserialize, Serialize}; + +use crate::error::{SurfnetError, SurfnetResult}; + +const DEFAULT_REPORT_DIR: &str = "target/surfpool-reports"; +const DEFAULT_OUTPUT_PATH: &str = "target/surfpool-report.html"; +const REPORT_DATA_PLACEHOLDER: &str = "__SURFPOOL_REPORT_DATA_PLACEHOLDER__"; +#[cfg(rust_analyzer)] +const REPORT_TEMPLATE: &str = include_str!("report-template.dev.html"); +#[cfg(not(rust_analyzer))] +const REPORT_TEMPLATE: &str = include_str!(concat!( + env!("OUT_DIR"), + "/surfpool-report-viewer/index.html" +)); + +/// 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, matching the static report viewer bundle. +#[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 report covering all Surfnet instances from a test suite. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SurfpoolReport { + pub instances: Vec, + pub generated_at: String, +} + +/// Options for consolidating per-instance report JSON and writing the static HTML report. +#[derive(Debug, Clone)] +pub struct SurfpoolReportOptions { + pub report_dir: PathBuf, + pub output_path: PathBuf, +} + +impl Default for SurfpoolReportOptions { + fn default() -> Self { + Self { + report_dir: PathBuf::from(DEFAULT_REPORT_DIR), + output_path: PathBuf::from(DEFAULT_OUTPUT_PATH), + } + } +} + +/// Read report JSON from `options.report_dir` and write the consolidated HTML report to +/// `options.output_path`. +pub fn generate(options: SurfpoolReportOptions) -> SurfnetResult { + let report = SurfpoolReport::from_directory(&options.report_dir)?; + report.write_html(&options.output_path) +} + +/// Generate the Surfpool report using the default report and output paths. +pub fn generate_default() -> SurfnetResult { + generate(SurfpoolReportOptions::default()) +} + +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(|entry| entry.ok()) + .filter(|entry| entry.path().extension().is_some_and(|ext| ext == "json")) + .collect(); + + entries.sort_by_key(|entry| entry.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(Self { + instances, + generated_at: chrono::Utc::now().to_rfc3339(), + }) + } + + pub fn total_transactions(&self) -> usize { + self.instances + .iter() + .map(|instance| instance.transactions.len()) + .sum() + } + + pub fn failed_transactions(&self) -> usize { + self.instances + .iter() + .flat_map(|instance| &instance.transactions) + .filter(|transaction| transaction.error.is_some()) + .count() + } + + pub fn total_compute_units(&self) -> u64 { + self.instances + .iter() + .flat_map(|instance| &instance.transactions) + .map(|transaction| { + transaction + .profile_json_parsed + .as_ref() + .and_then(|profile| profile.get("transactionProfile")) + .and_then(|profile| profile.get("computeUnitsConsumed")) + .and_then(|value| value.as_u64()) + .unwrap_or(0) + }) + .sum() + } + + /// Generate the HTML report as a string using the embedded report-viewer bundle. + pub fn generate_html(&self) -> SurfnetResult { + if !REPORT_TEMPLATE.contains(REPORT_DATA_PLACEHOLDER) { + return Err(SurfnetError::Report( + "embedded report viewer template is missing the report data placeholder".into(), + )); + } + + let serialized = serde_json::to_string(self) + .map_err(|e| SurfnetError::Report(format!("failed to serialize report: {e}")))?; + let escaped_json = escape_json_for_script_tag(&serialized); + + Ok(REPORT_TEMPLATE.replacen(REPORT_DATA_PLACEHOLDER, &escaped_json, 1)) + } + + /// 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(path.to_path_buf()) + } +} + +fn escape_json_for_script_tag(json: &str) -> String { + json.replace(", + ) -> TransactionReportEntry { + TransactionReportEntry { + signature: signature.into(), + slot, + error: error.map(ToOwned::to_owned), + logs: vec![ + "Program 11111111111111111111111111111111 invoke [1]".into(), + "Program log: transfer complete".into(), + ], + profile_json_parsed: Some(serde_json::json!({ + "slot": slot, + "instructionProfiles": [ + { + "computeUnitsConsumed": 1200, + "logMessages": [ + "Program 11111111111111111111111111111111 invoke [1]", + "Program log: transfer complete" + ], + "errorMessage": error, + "accountStates": { + "Sender1111111111111111111111111111111111111": { + "type": "writable", + "accountChange": { + "type": "update", + "data": [ + { + "lamports": 5000000000u64, + "owner": "11111111111111111111111111111111", + "executable": false, + "rentEpoch": 0, + "space": 0, + "data": ["" , "base64"] + }, + { + "lamports": 3750000000u64, + "owner": "11111111111111111111111111111111", + "executable": false, + "rentEpoch": 0, + "space": 0, + "data": ["" , "base64"] + } + ] + } + }, + "Recipient11111111111111111111111111111111111": { + "type": "writable", + "accountChange": { + "type": "update", + "data": [ + { + "lamports": 1000000000u64, + "owner": "11111111111111111111111111111111", + "executable": false, + "rentEpoch": 0, + "space": 0, + "data": ["" , "base64"] + }, + { + "lamports": 2250000000u64, + "owner": "11111111111111111111111111111111", + "executable": false, + "rentEpoch": 0, + "space": 0, + "data": ["" , "base64"] + } + ] + } + } + } + } + ], + "transactionProfile": { + "computeUnitsConsumed": 1200, + "accountStates": {}, + "logMessages": [ + "Program 11111111111111111111111111111111 invoke [1]", + "Program log: transfer complete" + ], + "errorMessage": error + }, + "readonlyAccountStates": { + "11111111111111111111111111111111": { + "lamports": 1, + "data": ["", "base64"], + "owner": "NativeLoader1111111111111111111111111111111", + "executable": true, + "rentEpoch": 0, + "space": 0 + } + } + })), + profile_base64: Some(serde_json::json!({ + "slot": slot, + "instructionProfiles": [ + { + "computeUnitsConsumed": 1200, + "logMessages": [ + "Program 11111111111111111111111111111111 invoke [1]", + "Program log: transfer complete" + ], + "errorMessage": error, + "accountStates": { + "Sender1111111111111111111111111111111111111": { + "type": "writable", + "accountChange": { + "type": "update", + "data": [ + { + "lamports": 5000000000u64, + "owner": "11111111111111111111111111111111", + "executable": false, + "rentEpoch": 0, + "space": 0, + "data": ["", "base64"] + }, + { + "lamports": 3750000000u64, + "owner": "11111111111111111111111111111111", + "executable": false, + "rentEpoch": 0, + "space": 0, + "data": ["", "base64"] + } + ] + } + }, + "Recipient11111111111111111111111111111111111": { + "type": "writable", + "accountChange": { + "type": "update", + "data": [ + { + "lamports": 1000000000u64, + "owner": "11111111111111111111111111111111", + "executable": false, + "rentEpoch": 0, + "space": 0, + "data": ["", "base64"] + }, + { + "lamports": 2250000000u64, + "owner": "11111111111111111111111111111111", + "executable": false, + "rentEpoch": 0, + "space": 0, + "data": ["", "base64"] + } + ] + } + } + } + } + ], + "transactionProfile": { + "computeUnitsConsumed": 1200, + "accountStates": {}, + "logMessages": [ + "Program 11111111111111111111111111111111 invoke [1]", + "Program log: transfer complete" + ], + "errorMessage": error + }, + "readonlyAccountStates": { + "11111111111111111111111111111111": { + "lamports": 1, + "data": ["", "base64"], + "owner": "NativeLoader1111111111111111111111111111111", + "executable": true, + "rentEpoch": 0, + "space": 0 + } + } + })), + } + } + + fn sample_instance( + instance_id: &str, + test_name: &str, + transactions: Vec, + ) -> SurfnetReportData { + SurfnetReportData { + instance_id: instance_id.into(), + test_name: Some(test_name.into()), + rpc_url: "http://127.0.0.1:8899".into(), + transactions, + timestamp: "2026-04-03T12:00:00Z".into(), + } + } + + #[test] + fn options_default_to_expected_paths() { + let options = SurfpoolReportOptions::default(); + assert_eq!( + options.report_dir, + std::path::PathBuf::from(DEFAULT_REPORT_DIR) + ); + assert_eq!( + options.output_path, + std::path::PathBuf::from(DEFAULT_OUTPUT_PATH) + ); + } + + #[test] + fn from_directory_sorts_instances_by_filename() { + let dir = tempdir().unwrap(); + let alpha = sample_instance("alpha", "alpha_test", vec![]); + let beta = sample_instance("beta", "beta_test", vec![]); + + fs::write( + dir.path().join("b.json"), + serde_json::to_string(&beta).unwrap(), + ) + .unwrap(); + fs::write( + dir.path().join("a.json"), + serde_json::to_string(&alpha).unwrap(), + ) + .unwrap(); + + let report = SurfpoolReport::from_directory(dir.path()).unwrap(); + assert_eq!(report.instances[0].instance_id, "alpha"); + assert_eq!(report.instances[1].instance_id, "beta"); + } + + #[test] + fn generate_html_injects_script_safe_json() { + let report = SurfpoolReport { + instances: vec![sample_instance( + "alpha", + "danger_test", + vec![sample_transaction( + "signature", + 1, + Some(r#"closing tag "#), + )], + )], + generated_at: "2026-04-03T12:00:00Z".into(), + }; + + let html = report.generate_html().unwrap(); + + assert!(!html.contains(REPORT_DATA_PLACEHOLDER)); + assert!(html.contains(r#"<\/script>