Skip to main content

palinurus_core/
rpc.rs

1//! Solana JSON-RPC 2.0 client over `waki` (blocking `wasi:http`).
2//!
3//! The host grants the `http_client` capability; TLS is handled host-side. Only
4//! the methods our plugins call: `getLatestBlockhash`, `getAccountInfo`,
5//! `getSignaturesForAddress`, `sendTransaction`.
6//!
7//! ## Testable seam
8//!
9//! The JSON-RPC request-building ([`rpc_request`]) and response-parsing
10//! ([`rpc_result`], [`parse_blockhash`], [`parse_account_info`],
11//! [`parse_signatures`], [`parse_send_tx`]) logic is **pure** and fully
12//! host-testable via [`MockRpc`] (scripted raw JSON-RPC responses → typed Rust).
13//! [`WakiRpc`] shares the same parse layer and only differs in transport
14//! (waki POST) — it is exercised live inside a wasm32-wasip2 component with a
15//! wasi:http runtime, never on host. No live network in `cargo test`.
16//!
17//! ## API-key auth
18//!
19//! `waki` supports custom headers (`RequestBuilder::header`), so an RPC API key
20//! is sent as `Authorization: Bearer <key>` when `api_key` is `Some`. Providers
21//! that use URL-path auth (Helius, QuickNode) just embed the key in the
22//! `endpoint` URL and leave `api_key` = `None`. Endpoint + key come from plugin
23//! config (`config_read`), never hardcoded.
24
25use crate::base58::Pubkey;
26use crate::versioned_tx::Blockhash;
27use base64::prelude::{BASE64_STANDARD, Engine as _};
28use serde_json::{json, Value};
29use std::cell::RefCell;
30use std::collections::VecDeque;
31use std::str::FromStr;
32use std::sync::atomic::{AtomicU64, Ordering};
33
34/// `getLatestBlockhash` result.
35#[derive(Clone, PartialEq, Eq, Debug)]
36pub struct BlockhashInfo {
37    pub blockhash: Blockhash,
38    pub last_valid_block_height: u64,
39}
40
41/// `getAccountInfo` result (the account exists). `data` is the raw decoded
42/// account bytes (base64-decoded from the RPC `[<b64>, "base64"]` form).
43#[derive(Clone, PartialEq, Eq, Debug)]
44pub struct AccountInfo {
45    pub data: Vec<u8>,
46    pub owner: Pubkey,
47    pub lamports: u64,
48    pub executable: bool,
49}
50
51/// One entry from `getSignaturesForAddress`.
52#[derive(Clone, PartialEq, Eq, Debug)]
53pub struct TxSummary {
54    pub signature: String,
55    /// `null` on success; a string/object describing the failure on a failed tx.
56    pub err: Option<String>,
57    pub slot: u64,
58    pub block_time: Option<i64>,
59}
60
61#[derive(Clone, PartialEq, Eq, Debug)]
62pub enum RpcError {
63    /// waki / wasi:http transport failure.
64    Transport(String),
65    /// serde_json (de)serialization failure.
66    Json(String),
67    /// A JSON-RPC `error` object returned by the RPC server.
68    Rpc { code: i64, message: String },
69    /// Response envelope missing `result`/`error`, or a required field absent.
70    Unexpected(String),
71    /// A base58 string in the response didn't decode to a 32-byte pubkey/blockhash.
72    Base58(String),
73    /// A base64 account-data blob didn't decode.
74    Base64(String),
75}
76
77/// The Solana JSON-RPC trait. Blocking. Mockable via [`MockRpc`].
78pub trait Rpc {
79    fn get_latest_blockhash(&self) -> Result<BlockhashInfo, RpcError>;
80    fn get_account_info(&self, pubkey: &Pubkey) -> Result<Option<AccountInfo>, RpcError>;
81    fn get_signatures_for_address(&self, pubkey: &Pubkey, limit: usize) -> Result<Vec<TxSummary>, RpcError>;
82    /// Submit a signed transaction (raw wire bytes). Returns the transaction signature.
83    /// T1 plugins don't call this (they return unsigned tx bytes); T2 does.
84    fn send_transaction(&self, tx: &[u8]) -> Result<String, RpcError>;
85}
86
87// ---- Pure JSON-RPC builders / parsers (host-testable) ----
88
89/// Build a JSON-RPC 2.0 request envelope.
90pub fn rpc_request(id: u64, method: &str, params: Value) -> Value {
91    json!({ "jsonrpc": "2.0", "id": id, "method": method, "params": params })
92}
93
94/// Extract the `result` from a JSON-RPC response, or surface the `error` object.
95pub fn rpc_result(v: &Value) -> Result<&Value, RpcError> {
96    if let Some(err) = v.get("error") {
97        return Err(RpcError::Rpc {
98            code: err.get("code").and_then(|c| c.as_i64()).unwrap_or(0),
99            message: err.get("message").and_then(|m| m.as_str()).unwrap_or("").to_string(),
100        });
101    }
102    v.get("result")
103        .ok_or_else(|| RpcError::Unexpected("missing `result` or `error` in JSON-RPC response".to_string()))
104}
105
106/// Parse a `getLatestBlockhash` `result` value.
107pub fn parse_blockhash(v: &Value) -> Result<BlockhashInfo, RpcError> {
108    let value = v
109        .get("value")
110        .ok_or_else(|| RpcError::Unexpected("missing result.value".to_string()))?;
111    let bh_str = value
112        .get("blockhash")
113        .and_then(|b| b.as_str())
114        .ok_or_else(|| RpcError::Unexpected("missing blockhash".to_string()))?;
115    let blockhash = Pubkey::from_str(bh_str)
116        .map_err(|e| RpcError::Base58(e.to_string()))?
117        .to_bytes();
118    let last_valid_block_height = value
119        .get("lastValidBlockHeight")
120        .and_then(|h| h.as_u64())
121        .ok_or_else(|| RpcError::Unexpected("missing lastValidBlockHeight".to_string()))?;
122    Ok(BlockhashInfo { blockhash, last_valid_block_height })
123}
124
125/// Parse a `getAccountInfo` `result` value. `value: null` → `Ok(None)`.
126pub fn parse_account_info(v: &Value) -> Result<Option<AccountInfo>, RpcError> {
127    let value = match v.get("value") {
128        None => return Err(RpcError::Unexpected("missing result.value".to_string())),
129        Some(Value::Null) => return Ok(None),
130        Some(v) => v,
131    };
132    let data_arr = value
133        .get("data")
134        .ok_or_else(|| RpcError::Unexpected("missing data".to_string()))?;
135    let b64 = data_arr
136        .get(0)
137        .and_then(|d| d.as_str())
138        .ok_or_else(|| RpcError::Unexpected("data[0] not a string".to_string()))?;
139    let data = BASE64_STANDARD
140        .decode(b64)
141        .map_err(|e| RpcError::Base64(e.to_string()))?;
142    let owner_str = value
143        .get("owner")
144        .and_then(|o| o.as_str())
145        .ok_or_else(|| RpcError::Unexpected("missing owner".to_string()))?;
146    let owner = Pubkey::from_str(owner_str).map_err(|e| RpcError::Base58(e.to_string()))?;
147    let lamports = value
148        .get("lamports")
149        .and_then(|l| l.as_u64())
150        .ok_or_else(|| RpcError::Unexpected("missing lamports".to_string()))?;
151    let executable = value.get("executable").and_then(|e| e.as_bool()).unwrap_or(false);
152    Ok(Some(AccountInfo { data, owner, lamports, executable }))
153}
154
155/// Parse a `getSignaturesForAddress` `result` array.
156pub fn parse_signatures(v: &Value) -> Result<Vec<TxSummary>, RpcError> {
157    let arr = v
158        .as_array()
159        .ok_or_else(|| RpcError::Unexpected("signatures result not an array".to_string()))?;
160    let mut out = Vec::with_capacity(arr.len());
161    for item in arr {
162        let signature = item
163            .get("signature")
164            .and_then(|s| s.as_str())
165            .ok_or_else(|| RpcError::Unexpected("missing signature".to_string()))?
166            .to_string();
167        let err = match item.get("err") {
168            None | Some(Value::Null) => None,
169            Some(e) => Some(if let Some(s) = e.as_str() { s.to_string() } else { e.to_string() }),
170        };
171        let slot = item
172            .get("slot")
173            .and_then(|s| s.as_u64())
174            .ok_or_else(|| RpcError::Unexpected("missing slot".to_string()))?;
175        let block_time = item.get("blockTime").and_then(|b| b.as_i64());
176        out.push(TxSummary { signature, err, slot, block_time });
177    }
178    Ok(out)
179}
180
181/// Parse a `sendTransaction` `result` (the signature string).
182pub fn parse_send_tx(v: &Value) -> Result<String, RpcError> {
183    v.as_str()
184        .map(|s| s.to_string())
185        .ok_or_else(|| RpcError::Unexpected("sendTransaction result not a string".to_string()))
186}
187
188// ---- WakiRpc: the waki transport ----
189
190/// A Solana JSON-RPC client backed by `waki` (blocking `wasi:http`).
191pub struct WakiRpc {
192    endpoint: String,
193    api_key: Option<String>,
194    id: AtomicU64,
195}
196
197impl WakiRpc {
198    /// Construct with an RPC endpoint URL and an optional API key. The key, if
199    /// present, is sent as `Authorization: Bearer <key>`. For providers that
200    /// embed the key in the URL path (Helius/QuickNode), leave `api_key` = None.
201    pub fn new(endpoint: String, api_key: Option<String>) -> Self {
202        Self { endpoint, api_key, id: AtomicU64::new(0) }
203    }
204    pub fn endpoint(&self) -> &str { &self.endpoint }
205    pub fn api_key(&self) -> Option<&str> { self.api_key.as_deref() }
206
207    fn next_id(&self) -> u64 { self.id.fetch_add(1, Ordering::Relaxed) + 1 }
208
209    fn post_json(&self, req: &Value) -> Result<Value, RpcError> {
210        // `.json()` sets Content-Type: application/json + serializes the body.
211        let mut builder = waki::Client::new().post(&self.endpoint);
212        if let Some(key) = &self.api_key {
213            let auth = format!("Bearer {key}");
214            builder = builder.header("Authorization", auth.as_str());
215        }
216        let resp = builder
217            .json(req)
218            .send()
219            .map_err(|e| RpcError::Transport(e.to_string()))?;
220        resp.json::<Value>().map_err(|e| RpcError::Json(e.to_string()))
221    }
222}
223
224impl Rpc for WakiRpc {
225    fn get_latest_blockhash(&self) -> Result<BlockhashInfo, RpcError> {
226        let req = rpc_request(self.next_id(), "getLatestBlockhash", json!([]));
227        let resp = self.post_json(&req)?;
228        parse_blockhash(rpc_result(&resp)?)
229    }
230    fn get_account_info(&self, pubkey: &Pubkey) -> Result<Option<AccountInfo>, RpcError> {
231        let req = rpc_request(
232            self.next_id(),
233            "getAccountInfo",
234            json!([pubkey.to_string(), { "encoding": "base64" }]),
235        );
236        let resp = self.post_json(&req)?;
237        parse_account_info(rpc_result(&resp)?)
238    }
239    fn get_signatures_for_address(&self, pubkey: &Pubkey, limit: usize) -> Result<Vec<TxSummary>, RpcError> {
240        let req = rpc_request(
241            self.next_id(),
242            "getSignaturesForAddress",
243            json!([pubkey.to_string(), { "limit": limit }]),
244        );
245        let resp = self.post_json(&req)?;
246        parse_signatures(rpc_result(&resp)?)
247    }
248    fn send_transaction(&self, tx: &[u8]) -> Result<String, RpcError> {
249        let b64 = BASE64_STANDARD.encode(tx);
250        let req = rpc_request(self.next_id(), "sendTransaction", json!([b64, { "encoding": "base64" }]));
251        let resp = self.post_json(&req)?;
252        parse_send_tx(rpc_result(&resp)?)
253    }
254}
255
256// ---- MockRpc: scripted responses for host tests ----
257
258/// A mock `Rpc` that returns scripted raw JSON-RPC responses in FIFO order.
259/// Each trait call consumes one response and runs it through the same parse
260/// layer as `WakiRpc`, so the full response→typed path is exercised. Returns
261/// `RpcError::Unexpected` once the queue is empty.
262pub struct MockRpc {
263    responses: RefCell<VecDeque<Value>>,
264}
265
266impl MockRpc {
267    pub fn new(responses: Vec<Value>) -> Self {
268        Self { responses: RefCell::new(responses.into()) }
269    }
270    fn next_response(&self) -> Result<Value, RpcError> {
271        self.responses
272            .borrow_mut()
273            .pop_front()
274            .ok_or_else(|| RpcError::Unexpected("MockRpc: no more scripted responses".to_string()))
275    }
276}
277
278impl Rpc for MockRpc {
279    fn get_latest_blockhash(&self) -> Result<BlockhashInfo, RpcError> {
280        let resp = self.next_response()?;
281        parse_blockhash(rpc_result(&resp)?)
282    }
283    fn get_account_info(&self, _pubkey: &Pubkey) -> Result<Option<AccountInfo>, RpcError> {
284        let resp = self.next_response()?;
285        parse_account_info(rpc_result(&resp)?)
286    }
287    fn get_signatures_for_address(&self, _pubkey: &Pubkey, _limit: usize) -> Result<Vec<TxSummary>, RpcError> {
288        let resp = self.next_response()?;
289        parse_signatures(rpc_result(&resp)?)
290    }
291    fn send_transaction(&self, _tx: &[u8]) -> Result<String, RpcError> {
292        let resp = self.next_response()?;
293        parse_send_tx(rpc_result(&resp)?)
294    }
295}