Skip to main content

ud_cli/
solana.rs

1//! Fetch a Solana on-chain program and return its raw ELF
2//! bytes. Strips the loader-state header so the resulting bytes
3//! feed straight into [`ud_translate::decompile::decompile`].
4//!
5//! Solana stores SBF programs under one of three loaders, each
6//! with its own account-data layout:
7//!
8//! * **`BPFLoader2111…`** (non-upgradeable, legacy). The
9//!   program's account data IS the ELF — no header to strip.
10//! * **`BPFLoaderUpgradeab1e…`** (today's default). Two
11//!   accounts: the *Program* account holds a 36-byte
12//!   `UpgradeableLoaderState::Program` (a Borsh enum tag of 2
13//!   plus a 32-byte pubkey pointing at the *ProgramData*
14//!   account). The ProgramData account starts with an
15//!   `UpgradeableLoaderState::ProgramData` header (45 bytes
16//!   when an upgrade authority is set, 13 bytes when it's
17//!   `None`) followed by the ELF.
18//! * **`LoaderV411…`** (Agave's newer loader). A
19//!   `LoaderV4State` of 48 bytes (slot + authority pubkey +
20//!   8-byte status) followed by the ELF.
21//!
22//! All three are recognised here. The strip routine validates
23//! that the bytes immediately after the header carry the ELF
24//! magic (`\x7fELF`), so a mismatched layout fails loudly
25//! rather than producing garbage.
26//!
27//! Fetching uses Solana's standard JSON-RPC `getAccountInfo`
28//! method against a public mainnet endpoint by default; the
29//! caller can override with `--rpc`. Fetched ELFs are cached
30//! under `~/.cache/univdreams/solana/` keyed by program ID
31//! (and, for upgradeable programs, the ProgramData slot —
32//! upgrades invalidate the cache).
33
34use std::fs;
35use std::path::PathBuf;
36use std::time::Duration;
37
38use anyhow::{bail, Context, Result};
39use base64::engine::general_purpose::STANDARD as BASE64;
40use base64::Engine;
41use serde::Deserialize;
42use ud_format::solana::{self as solana_layout, LoaderKind};
43
44/// Default RPC endpoint when `--rpc` isn't given. Solana's
45/// public mainnet endpoint — heavily rate-limited but the
46/// universally-known fallback.
47pub const DEFAULT_RPC: &str = "https://api.mainnet-beta.solana.com";
48
49/// Fetch the raw ELF bytes for `program_id`, caching under
50/// `~/.cache/univdreams/solana/`. With `use_cache = false`, the
51/// cached copy is ignored and overwritten.
52pub fn fetch_program_elf(program_id: &str, rpc_url: &str, use_cache: bool) -> Result<Vec<u8>> {
53    validate_pubkey(program_id)?;
54
55    // We don't yet know the loader / slot, so the cache lookup
56    // is by program ID alone. Upgradeable programs encode their
57    // slot inside the cached ELF's filename suffix for clarity,
58    // but for the cache-key lookup we use the bare program ID.
59    let cache_dir = cache_dir()?;
60    let cache_path = cache_dir.join(format!("{program_id}.elf"));
61    if use_cache && cache_path.is_file() {
62        return fs::read(&cache_path)
63            .with_context(|| format!("read cache {}", cache_path.display()));
64    }
65
66    // Step 1: fetch the program account itself. The owner field
67    // identifies which loader manages this program.
68    let account = rpc_get_account(rpc_url, program_id)
69        .with_context(|| format!("getAccountInfo {program_id}"))?;
70    let owner = account.owner.clone();
71    let data = account.decoded_data()?;
72
73    let elf = match solana_layout::classify_loader(owner.as_str()) {
74        LoaderKind::BpfLoader2 => solana_layout::strip_bpf_loader_v2(&data)
75            .with_context(|| format!("{program_id}: BPFLoader2 strip"))?
76            .to_vec(),
77        LoaderKind::Upgradeable => fetch_upgradeable_elf(rpc_url, &data, program_id)?,
78        LoaderKind::LoaderV4 => solana_layout::strip_loader_v4(&data)
79            .with_context(|| format!("{program_id}: LoaderV4 strip"))?
80            .to_vec(),
81        LoaderKind::Unknown => bail!(
82            "{program_id}: unknown loader {owner} — supported: \
83             BPFLoader2, BPFLoaderUpgradeable, LoaderV4"
84        ),
85    };
86
87    // Cache the stripped ELF for the next run.
88    if let Err(e) = fs::create_dir_all(&cache_dir) {
89        eprintln!(
90            "warning: couldn't create cache dir {}: {e}",
91            cache_dir.display()
92        );
93    } else if let Err(e) = fs::write(&cache_path, &elf) {
94        eprintln!(
95            "warning: couldn't write cache {}: {e}",
96            cache_path.display()
97        );
98    }
99
100    Ok(elf)
101}
102
103/// Two-step fetch for upgradeable programs: the Program
104/// account points at a ProgramData account that carries the
105/// actual ELF.
106fn fetch_upgradeable_elf(
107    rpc_url: &str,
108    program_account_data: &[u8],
109    program_id: &str,
110) -> Result<Vec<u8>> {
111    let pd_pubkey_bytes = solana_layout::programdata_pubkey(program_account_data)
112        .with_context(|| format!("{program_id}: Program account"))?;
113    let programdata_address = bs58::encode(pd_pubkey_bytes).into_string();
114
115    let pd = rpc_get_account(rpc_url, &programdata_address)
116        .with_context(|| format!("getAccountInfo (ProgramData) {programdata_address}"))?;
117    let pd_data = pd.decoded_data()?;
118
119    let stripped = solana_layout::strip_bpf_loader_upgradeable(&pd_data)
120        .with_context(|| format!("{programdata_address}: ProgramData strip"))?;
121    Ok(stripped.to_vec())
122}
123
124fn cache_dir() -> Result<PathBuf> {
125    // We resolve `$XDG_CACHE_HOME` first, falling back to
126    // `$HOME/.cache` on Unix-likes and `%LOCALAPPDATA%` on
127    // Windows. Avoids pulling in the `dirs` crate for one
128    // function.
129    if let Ok(xdg) = std::env::var("XDG_CACHE_HOME") {
130        return Ok(PathBuf::from(xdg).join("univdreams").join("solana"));
131    }
132    if let Ok(home) = std::env::var("HOME") {
133        return Ok(PathBuf::from(home)
134            .join(".cache")
135            .join("univdreams")
136            .join("solana"));
137    }
138    if let Ok(local_app_data) = std::env::var("LOCALAPPDATA") {
139        return Ok(PathBuf::from(local_app_data)
140            .join("univdreams")
141            .join("cache")
142            .join("solana"));
143    }
144    bail!(
145        "can't determine cache directory: neither $XDG_CACHE_HOME, $HOME, nor $LOCALAPPDATA is set"
146    )
147}
148
149// ============================================================
150// JSON-RPC plumbing
151// ============================================================
152
153#[derive(Debug, Deserialize)]
154struct RpcResponse<T> {
155    result: Option<T>,
156    error: Option<RpcError>,
157}
158
159#[derive(Debug, Deserialize)]
160struct RpcError {
161    code: i64,
162    message: String,
163}
164
165#[derive(Debug, Deserialize)]
166struct GetAccountInfoResult {
167    value: Option<AccountInfo>,
168}
169
170#[derive(Debug, Deserialize)]
171struct AccountInfo {
172    owner: String,
173    /// `[base64, "base64"]` when we ask for base64 encoding.
174    data: (String, String),
175    #[allow(dead_code)]
176    executable: bool,
177}
178
179impl AccountInfo {
180    fn decoded_data(&self) -> Result<Vec<u8>> {
181        let (payload, encoding) = (&self.data.0, &self.data.1);
182        if encoding != "base64" {
183            bail!("unexpected account-data encoding {encoding:?}");
184        }
185        BASE64
186            .decode(payload.as_bytes())
187            .context("base64-decode account data")
188    }
189}
190
191fn rpc_get_account(rpc_url: &str, pubkey: &str) -> Result<AccountInfo> {
192    let req = serde_json::json!({
193        "jsonrpc": "2.0",
194        "id": 1,
195        "method": "getAccountInfo",
196        "params": [
197            pubkey,
198            { "encoding": "base64" }
199        ],
200    });
201    let agent = ureq::AgentBuilder::new()
202        .timeout(Duration::from_secs(30))
203        .build();
204    let serialized = serde_json::to_string(&req).context("serialize RPC request")?;
205    let body = agent
206        .post(rpc_url)
207        .set("Content-Type", "application/json")
208        .send_string(&serialized)
209        .with_context(|| format!("POST {rpc_url}"))?
210        .into_string()
211        .context("read RPC response body")?;
212    let parsed: RpcResponse<GetAccountInfoResult> =
213        serde_json::from_str(&body).with_context(|| format!("parse RPC response: {body}"))?;
214    if let Some(err) = parsed.error {
215        bail!("RPC error {}: {}", err.code, err.message);
216    }
217    let result = parsed
218        .result
219        .ok_or_else(|| anyhow::anyhow!("RPC response missing `result`"))?;
220    result
221        .value
222        .ok_or_else(|| anyhow::anyhow!("account {pubkey} does not exist"))
223}
224
225fn validate_pubkey(s: &str) -> Result<()> {
226    let bytes = bs58::decode(s)
227        .into_vec()
228        .with_context(|| format!("decode base58 pubkey {s}"))?;
229    if bytes.len() != 32 {
230        bail!(
231            "invalid pubkey {s}: decoded to {} bytes (expected 32)",
232            bytes.len()
233        );
234    }
235    Ok(())
236}