solana-program-dumper 0.1.0

Dump a Solana program's executable (.so) and every account it owns, in one command.
use std::{path::PathBuf, str::FromStr, time::Duration};

use clap::{Parser, ValueEnum};
use solana_address::Address;
use solana_client::rpc_config::CommitmentConfig;

/// Dump a Solana program's executable and every account it owns.
///
/// Requires the `solana` CLI to be installed and on your PATH.
#[derive(Parser, Debug)]
#[command(version, about, long_about = None)]
pub struct Args {
    /// Program address (base58).
    #[arg(short, long, value_parser = parse_address)]
    pub program: Address,

    /// RPC endpoint URL, or a moniker — `mainnet-beta`/`m`, `devnet`/`d`,
    /// `testnet`/`t`, `localhost`/`l` — also in attached form as `-ud`/`-um`
    #[arg(short, long, value_parser = resolve_url)]
    pub url: String,

    /// Directory to write the dump into. Created if missing.
    #[arg(short, long, default_value = "soldump")]
    pub dir: PathBuf,

    /// Skip accounts whose data length matches. Repeatable.
    ///
    /// Applied after fetching, so the accounts still cross the wire. Use
    /// `--only-size` when you want the RPC node to do the filtering.
    #[arg(short, long)]
    pub skip_size: Vec<usize>,

    /// Fetch only accounts with exactly this data length.
    ///
    /// Sent to the RPC node as a `dataSize` filter, which is often what makes
    /// `getProgramAccounts` viable against a program with many accounts.
    #[arg(short, long)]
    pub only_size: Option<u64>,

    /// Commitment level.
    #[arg(short, long, value_enum, default_value_t = Commitment::Confirmed)]
    pub commitment: Commitment,

    /// RPC request timeout, in seconds.
    ///
    /// Applies to the account dump. `getProgramAccounts` against a program with
    /// a lot of state routinely needs longer than the 30s default.
    #[arg(short, long = "timeout", default_value_t = 30, value_name = "SECONDS")]
    pub timeout_secs: u64,
}

impl Args {
    pub fn timeout(&self) -> Duration {
        Duration::from_secs(self.timeout_secs)
    }
}

#[derive(Copy, Clone, Debug, PartialEq, Eq, ValueEnum)]
pub enum Commitment {
    Processed,
    Confirmed,
    Finalized,
}

impl Commitment {
    /// The spelling the `solana` CLI expects for `--commitment`.
    pub fn as_str(self) -> &'static str {
        match self {
            Commitment::Processed => "processed",
            Commitment::Confirmed => "confirmed",
            Commitment::Finalized => "finalized",
        }
    }

    pub fn config(self) -> CommitmentConfig {
        match self {
            Commitment::Processed => CommitmentConfig::processed(),
            Commitment::Confirmed => CommitmentConfig::confirmed(),
            Commitment::Finalized => CommitmentConfig::finalized(),
        }
    }
}

fn parse_address(s: &str) -> Result<Address, String> {
    Address::from_str(s).map_err(|e| format!("invalid program address `{s}`: {e}"))
}

/// Expand a cluster moniker to a URL, mirroring the `solana` CLI's monikers.
///
/// Monikers are resolved here rather than passed through because the two
/// consumers disagree: `solana program dump` understands them, `RpcClient` does
/// not. Resolving up front keeps both halves pointed at the same endpoint.
/// Anything that is not a moniker must already be a URL — a bare hostname is
/// rejected at parse time rather than failing later inside a request.
fn resolve_url(s: &str) -> Result<String, String> {
    let url = match s {
        "m" | "mainnet-beta" => "https://api.mainnet-beta.solana.com",
        "d" | "devnet" => "https://api.devnet.solana.com",
        "t" | "testnet" => "https://api.testnet.solana.com",
        "l" | "localhost" => "http://localhost:8899",
        other if other.starts_with("http://") || other.starts_with("https://") => other,
        other => {
            return Err(format!(
                "`{other}` is neither a moniker (mainnet-beta, devnet, testnet, localhost) \
                 nor an http(s) URL"
            ))
        }
    };
    Ok(url.to_string())
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn resolve_url_rejects_anything_that_is_not_a_moniker_or_url() {
        // `solana program dump` accepts a bare hostname, RpcClient does not.
        // Letting one through yields a .so, no accounts, and a zero exit code.
        assert!(resolve_url("api.devnet.solana.com").is_err());
        assert!(resolve_url("mainnet").is_err());
        assert!(resolve_url("d").is_ok());
        assert!(resolve_url("https://x.example/rpc").is_ok());
    }

    #[test]
    fn verify_cli() {
        use clap::CommandFactory;
        Args::command().debug_assert();
    }
}