solana-wasi 0.1.0

Solana primitives that actually compile to wasm32-wasip2: pubkeys, PDAs, JSON-RPC over a swappable transport, SPL Token / Token-2022 account parsing, and unsigned v0 transaction construction. No solana-sdk, no C toolchain, no async runtime.
Documentation
//! How a JSON-RPC request leaves the component.
//!
//! Everything above this module is pure: [`crate::rpc::RpcClient`] builds a
//! request string, hands it to a [`Transport`], and parses the string that
//! comes back. That single seam is what lets the entire crate — and every
//! plugin built on it — run under a plain host `cargo test` with no wasm
//! toolchain and no live network.
//!
//! Two implementations ship here:
//!
//! * [`WakiTransport`] — `wasi:http` via the blocking `waki` client. Compiled
//!   only for `target_family = "wasm"`, so a host test build never sees it.
//! * [`MockTransport`] — a canned-response map for tests, available on every
//!   target because plugin test suites need it too.

use std::cell::RefCell;
use std::collections::HashMap;

use crate::error::{Error, Result};

/// A blocking JSON-over-HTTPS round trip.
///
/// One method on purpose: a WIT component with the `http_client` permission
/// gets exactly this much reach, and nothing in this crate should be able to
/// ask for more.
pub trait Transport {
    /// POST `body` to `url` as `application/json` and return the response body.
    ///
    /// Implementations must treat a non-2xx status as an error rather than
    /// returning the error page as a body: a JSON-RPC parse failure two layers
    /// up is a much worse diagnostic than "HTTP 429".
    fn post_json(&self, url: &str, body: &str) -> Result<String>;
}

impl<T: Transport + ?Sized> Transport for &T {
    fn post_json(&self, url: &str, body: &str) -> Result<String> {
        (**self).post_json(url, body)
    }
}

impl<T: Transport + ?Sized> Transport for Box<T> {
    fn post_json(&self, url: &str, body: &str) -> Result<String> {
        (**self).post_json(url, body)
    }
}

#[cfg(target_family = "wasm")]
mod waki_transport {
    use super::{Error, Result, Transport};
    use std::time::Duration;

    /// `wasi:http` transport. TLS is performed host-side by the ZeroClaw
    /// runtime; the component never sees a certificate or a socket.
    pub struct WakiTransport {
        connect_timeout: Duration,
    }

    impl WakiTransport {
        /// Default 10s connect timeout.
        pub fn new() -> Self {
            Self {
                connect_timeout: Duration::from_secs(10),
            }
        }

        /// Override the connect timeout.
        pub fn with_timeout(secs: u64) -> Self {
            Self {
                connect_timeout: Duration::from_secs(secs),
            }
        }
    }

    impl Default for WakiTransport {
        fn default() -> Self {
            Self::new()
        }
    }

    impl Transport for WakiTransport {
        fn post_json(&self, url: &str, body: &str) -> Result<String> {
            let resp = waki::Client::new()
                .post(url)
                .connect_timeout(self.connect_timeout)
                .header("Content-Type", "application/json")
                .body(body.as_bytes().to_vec())
                .send()
                .map_err(|e| Error::Transport(e.to_string()))?;

            let status = resp.status_code();
            let bytes = resp.body().map_err(|e| Error::Transport(e.to_string()))?;

            if !(200..300).contains(&status) {
                // Surface the status, not the provider's HTML error page.
                return Err(Error::Transport(format!("HTTP {status}")));
            }
            String::from_utf8(bytes).map_err(|_| Error::Transport("response was not UTF-8".into()))
        }
    }
}

#[cfg(target_family = "wasm")]
pub use waki_transport::WakiTransport;

/// Deterministic transport for host tests.
///
/// Responses are keyed by JSON-RPC method name. `MockTransport` parses the
/// outgoing request far enough to read `method`, records the full request for
/// assertions, and wraps the canned value in a JSON-RPC envelope.
///
/// This lives in the published crate rather than behind `#[cfg(test)]` because
/// every plugin built on `solana-wasi` needs it to satisfy ZeroClaw's
/// "host-run tests, no live network" requirement.
#[derive(Default)]
pub struct MockTransport {
    results: HashMap<String, serde_json::Value>,
    errors: HashMap<String, (i64, String)>,
    requests: RefCell<Vec<serde_json::Value>>,
    fail_with: Option<String>,
}

impl MockTransport {
    /// An empty mock. Any method not registered returns an error, so a test
    /// can never silently pass against a call it forgot to stub.
    pub fn new() -> Self {
        Self::default()
    }

    /// Register the `result` member returned for `method`.
    pub fn on(mut self, method: &str, result: serde_json::Value) -> Self {
        self.results.insert(method.to_string(), result);
        self
    }

    /// Register a JSON-RPC `error` member returned for `method`.
    pub fn on_error(mut self, method: &str, code: i64, message: &str) -> Self {
        self.errors
            .insert(method.to_string(), (code, message.to_string()));
        self
    }

    /// Make every call fail at the transport layer (connection refused, 429…).
    pub fn failing(mut self, message: &str) -> Self {
        self.fail_with = Some(message.to_string());
        self
    }

    /// Every request body observed so far, in order.
    pub fn requests(&self) -> Vec<serde_json::Value> {
        self.requests.borrow().clone()
    }

    /// How many requests were made. Guards against N+1 RPC regressions, which
    /// are the difference between a 400ms tool call and a rate-limited one.
    pub fn call_count(&self) -> usize {
        self.requests.borrow().len()
    }

    /// The `params` of the last request made for `method`, if any.
    pub fn last_params(&self, method: &str) -> Option<serde_json::Value> {
        self.requests
            .borrow()
            .iter()
            .rev()
            .find(|r| r.get("method").and_then(|m| m.as_str()) == Some(method))
            .and_then(|r| r.get("params").cloned())
    }
}

impl Transport for MockTransport {
    fn post_json(&self, _url: &str, body: &str) -> Result<String> {
        let parsed: serde_json::Value = serde_json::from_str(body)
            .map_err(|e| Error::Transport(format!("mock got invalid JSON: {e}")))?;
        self.requests.borrow_mut().push(parsed.clone());

        if let Some(msg) = &self.fail_with {
            return Err(Error::Transport(msg.clone()));
        }

        let method = parsed
            .get("method")
            .and_then(|m| m.as_str())
            .unwrap_or_default()
            .to_string();
        let id = parsed.get("id").cloned().unwrap_or(serde_json::json!(1));

        if let Some((code, message)) = self.errors.get(&method) {
            return Ok(serde_json::json!({
                "jsonrpc": "2.0",
                "id": id,
                "error": { "code": code, "message": message }
            })
            .to_string());
        }

        match self.results.get(&method) {
            Some(result) => Ok(serde_json::json!({
                "jsonrpc": "2.0",
                "id": id,
                "result": result
            })
            .to_string()),
            None => Err(Error::Transport(format!(
                "mock has no response registered for `{method}`"
            ))),
        }
    }
}