Skip to main content

cu_profiler_core/
bench.rs

1//! Declarative fixtures for the turnkey real-CU `bench` path.
2//!
3//! A [`BenchPlan`] describes, as plain data, the instruction(s) to execute against
4//! a compiled Solana program so a live backend (Mollusk) can measure real compute
5//! units — no hand-written Rust harness required. This module owns only the
6//! **schema, parsing and validation**; it pulls in no Solana crates and runs
7//! everywhere the core does (including Windows). Converting a validated plan into
8//! `solana-instruction`/`solana-account` types and executing it lives in the
9//! Linux-only `cu-profiler-mollusk` integration crate.
10//!
11//! ```toml
12//! # bench.toml
13//! [[instruction]]
14//! scenario   = "swap_exact_in"
15//! program_id = "SwapPRogram1111111111111111111111111111"
16//! data       = "01ab"          # hex-encoded instruction data
17//!
18//!   [[instruction.account]]
19//!   pubkey   = "11111111111111111111111111111111"
20//!   signer   = true
21//!   writable = true
22//!   lamports = 1000000
23//! ```
24
25use serde::{Deserialize, Serialize};
26
27use crate::error::{Error, Result};
28
29/// A set of instruction fixtures to benchmark, parsed from a `bench.toml`.
30#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
31#[serde(deny_unknown_fields)]
32pub struct BenchPlan {
33    /// One entry per instruction to execute and measure.
34    #[serde(default, rename = "instruction")]
35    pub instructions: Vec<InstructionFixture>,
36}
37
38/// A single instruction to execute against the program under test.
39#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
40#[serde(deny_unknown_fields)]
41pub struct InstructionFixture {
42    /// The scenario name this instruction measures (keys it to a `[scenario.<name>]`).
43    pub scenario: String,
44    /// The program's base58 address.
45    pub program_id: String,
46    /// Hex-encoded instruction data (empty string for a no-arg instruction).
47    #[serde(default)]
48    pub data: String,
49    /// Accounts passed to the instruction, in order.
50    #[serde(default, rename = "account")]
51    pub accounts: Vec<AccountFixture>,
52}
53
54/// One account in an instruction's account list.
55#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
56#[serde(deny_unknown_fields)]
57pub struct AccountFixture {
58    /// The account's base58 address.
59    pub pubkey: String,
60    /// Whether the account signs the transaction.
61    #[serde(default)]
62    pub signer: bool,
63    /// Whether the instruction may write to the account.
64    #[serde(default)]
65    pub writable: bool,
66    /// Starting lamport balance.
67    #[serde(default)]
68    pub lamports: u64,
69    /// Owning program (base58), if the account should be pre-owned.
70    #[serde(default, skip_serializing_if = "Option::is_none")]
71    pub owner: Option<String>,
72    /// Hex-encoded initial account data, if any.
73    #[serde(default, skip_serializing_if = "Option::is_none")]
74    pub data: Option<String>,
75}
76
77impl BenchPlan {
78    /// Parse and validate a plan from TOML.
79    ///
80    /// # Errors
81    /// Returns [`Error::Config`] for malformed TOML, an unknown key, a non-base58
82    /// program/account address, or non-hex instruction/account data.
83    pub fn from_toml(s: &str) -> Result<Self> {
84        let plan: BenchPlan = toml::from_str(s).map_err(|e| Error::Config(e.to_string()))?;
85        plan.validate()?;
86        Ok(plan)
87    }
88
89    /// Validate every fixture's addresses and encodings.
90    ///
91    /// # Errors
92    /// Returns [`Error::Config`] describing the first invalid field found.
93    pub fn validate(&self) -> Result<()> {
94        if self.instructions.is_empty() {
95            return Err(Error::Config(
96                "bench plan has no `[[instruction]]` entries".to_string(),
97            ));
98        }
99        for ix in &self.instructions {
100            ix.validate()?;
101        }
102        Ok(())
103    }
104}
105
106impl InstructionFixture {
107    fn validate(&self) -> Result<()> {
108        let ctx = format!("instruction `{}`", self.scenario);
109        if self.scenario.is_empty() {
110            return Err(Error::Config(
111                "an instruction has an empty `scenario`".to_string(),
112            ));
113        }
114        validate_base58(&self.program_id, &format!("{ctx}: program_id"))?;
115        validate_hex(&self.data, &format!("{ctx}: data"))?;
116        for acc in &self.accounts {
117            validate_base58(&acc.pubkey, &format!("{ctx}: account pubkey"))?;
118            if let Some(owner) = &acc.owner {
119                validate_base58(owner, &format!("{ctx}: account owner"))?;
120            }
121            if let Some(data) = &acc.data {
122                validate_hex(data, &format!("{ctx}: account data"))?;
123            }
124        }
125        Ok(())
126    }
127}
128
129/// The base58 alphabet Solana uses (Bitcoin alphabet: no `0`, `O`, `I`, `l`).
130const BASE58_ALPHABET: &[u8] = b"123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz";
131
132/// Validate that `s` looks like a base58-encoded 32-byte Solana address.
133///
134/// This checks the alphabet and the length window a 32-byte value encodes to
135/// (32–44 characters); it does not decode (which would need a base58 dependency).
136fn validate_base58(s: &str, what: &str) -> Result<()> {
137    if !(32..=44).contains(&s.len()) {
138        return Err(Error::Config(format!(
139            "{what}: `{s}` is not a 32-byte base58 address (length {})",
140            s.len()
141        )));
142    }
143    if let Some(bad) = s.bytes().find(|b| !BASE58_ALPHABET.contains(b)) {
144        return Err(Error::Config(format!(
145            "{what}: `{s}` contains a non-base58 character `{}`",
146            bad as char
147        )));
148    }
149    Ok(())
150}
151
152/// Validate that `s` is valid hex (even length, hex digits only). Empty is allowed.
153fn validate_hex(s: &str, what: &str) -> Result<()> {
154    if s.len() % 2 != 0 {
155        return Err(Error::Config(format!(
156            "{what}: hex string has an odd length ({})",
157            s.len()
158        )));
159    }
160    if let Some(bad) = s.bytes().find(|b| !b.is_ascii_hexdigit()) {
161        return Err(Error::Config(format!(
162            "{what}: `{s}` contains a non-hex character `{}`",
163            bad as char
164        )));
165    }
166    Ok(())
167}
168
169#[cfg(test)]
170mod tests {
171    use super::*;
172
173    const SYS: &str = "11111111111111111111111111111111";
174
175    fn plan_toml() -> String {
176        format!(
177            "[[instruction]]\nscenario=\"swap\"\nprogram_id=\"{SYS}\"\ndata=\"01ab\"\n\
178             [[instruction.account]]\npubkey=\"{SYS}\"\nsigner=true\nwritable=true\nlamports=1000000\n"
179        )
180    }
181
182    #[test]
183    fn parses_and_validates_a_plan() {
184        let plan = BenchPlan::from_toml(&plan_toml()).unwrap();
185        assert_eq!(plan.instructions.len(), 1);
186        let ix = &plan.instructions[0];
187        assert_eq!(ix.scenario, "swap");
188        assert_eq!(ix.data, "01ab");
189        assert_eq!(ix.accounts.len(), 1);
190        assert!(ix.accounts[0].signer && ix.accounts[0].writable);
191    }
192
193    #[test]
194    fn empty_plan_is_rejected() {
195        assert!(BenchPlan::from_toml("").is_err());
196    }
197
198    #[test]
199    fn rejects_unknown_keys() {
200        let toml = format!("[[instruction]]\nscenario=\"s\"\nprogram_id=\"{SYS}\"\nbogus=1\n");
201        assert!(BenchPlan::from_toml(&toml).is_err());
202    }
203
204    #[test]
205    fn rejects_bad_base58_program_id() {
206        let toml = "[[instruction]]\nscenario=\"s\"\nprogram_id=\"not-base58-0OIl\"\n";
207        let err = BenchPlan::from_toml(toml).unwrap_err().to_string();
208        assert!(err.contains("base58"), "{err}");
209    }
210
211    #[test]
212    fn rejects_odd_and_nonhex_data() {
213        let odd = format!("[[instruction]]\nscenario=\"s\"\nprogram_id=\"{SYS}\"\ndata=\"abc\"\n");
214        assert!(
215            BenchPlan::from_toml(&odd)
216                .unwrap_err()
217                .to_string()
218                .contains("odd")
219        );
220        let nonhex =
221            format!("[[instruction]]\nscenario=\"s\"\nprogram_id=\"{SYS}\"\ndata=\"zz\"\n");
222        assert!(
223            BenchPlan::from_toml(&nonhex)
224                .unwrap_err()
225                .to_string()
226                .contains("non-hex")
227        );
228    }
229}